Showing preview only (346K chars total). Download the full file or copy to clipboard to get everything.
Repository: harmtemolder/koreader-calibre-plugin
Branch: main
Commit: 439b2a89f982
Files: 49
Total size: 329.0 KB
Directory structure:
gitextract_dvork362/
├── .editorconfig
├── .gitattributes
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ ├── 1-bug_report.yml
│ │ ├── 2-feature_request.yml
│ │ └── config.yml
│ └── workflows/
│ ├── develop-pre-release.yml
│ ├── main-ci.yml
│ └── main-release.yml
├── .gitignore
├── .pylintrc
├── .run/
│ └── pydevd_pycharm.run.xml
├── .scripts/
│ └── md-to-bb.py
├── .version
├── GEMINI.md
├── LICENSE
├── Makefile
├── Pipfile
├── README.md
├── TODO.md
├── __init__.py
├── about.txt
├── action.py
├── config.py
├── dummy_device/
│ ├── .driveinfo.calibre
│ ├── .metadata.calibre
│ ├── Carroll, Lewis/
│ │ ├── Alice's Adventures in Wonderland - Lewis Carroll.epub
│ │ └── Alice's Adventures in Wonderland - Lewis Carroll.sdr/
│ │ ├── metadata.epub.lua
│ │ └── metadata.epub.lua.old
│ └── Thoreau, Henry David/
│ └── Walden, and On The Duty Of Civil Disobedience - Henry David Thoreau.epub
├── dummy_library/
│ ├── Henry David Thoreau/
│ │ └── Walden, and On The Duty Of Civil Disobedience (3)/
│ │ ├── Walden, and On The Duty Of Civil Disobedie - Henry David Thoreau.epub
│ │ └── metadata.opf
│ ├── Lewis Carroll/
│ │ └── Alice's Adventures in Wonderland (4)/
│ │ ├── Alice's Adventures in Wonderland - Lewis Carroll.epub
│ │ └── metadata.opf
│ └── metadata_db_prefs_backup.json
├── plugin-import-name-koreader.txt
├── pluginIndexKOReaderSync.txt
├── pytest.ini
├── slpp.py
└── tests/
├── __init__.py
├── conftest.py
├── integration/
│ ├── test_docker_path_resolution.py
│ ├── test_integration.py
│ ├── test_issue_143_fix.py
│ ├── test_issue_143_repro.py
│ └── test_uuid_mismatch.py
└── unit/
├── test_bookmarks.py
├── test_md5_logic.py
└── test_version.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 80
tab_width = 2
trim_trailing_whitespace = true
[*.py]
indent_size = 4
tab_width = 4
[Makefile]
indent_style = tab
================================================
FILE: .gitattributes
================================================
# Handle line endings automatically for files detected as text
# and leave binary files untouched.
* text=auto
# Force bash scripts and python files to always use LF
*.sh text eol=lf
*.py text eol=lf
*.txt text eol=lf
*.md text eol=lf
Makefile text eol=lf
.editorconfig text eol=lf
.gitignore text eol=lf
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
ko_fi: kyxap
================================================
FILE: .github/ISSUE_TEMPLATE/1-bug_report.yml
================================================
name: Bug Report
description: File a bug report.
title: "[Bug] "
labels: ["bug", "triage"]
assignees: []
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: input
id: plugin-version
attributes:
label: KOreader Sync plugin version
description: What plugin version are you using? Is this the latest version? If not, please update and try to reproduce the issue.
placeholder: Provide information here
validations:
required: true
- type: input
id: koreader-version
attributes:
label: KOreader version
description: What KOreader version do you have installed on your device? If it's older than 2024.04, please update and try to reproduce the issue.
placeholder: Provide information here
validations:
required: true
- type: input
id: device
attributes:
label: Device
description: What device are you using? (e.g. Kobo Clara BW, PocketBook Era, etc.)
placeholder: Provide information here
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating System
description: What OS is used to run Calibre?
multiple: true
options:
- Linux
- Windows
- MacOS
validations:
required: true
- type: dropdown
id: connection-type
attributes:
label: Connection type
description: How do you connect/sync your device with calibre? Never mix and match. You must use only wireless or wired but no both for the same device.
multiple: true
options:
- Wireless (over wifi)
- Wired (over usb cable)
validations:
required: true
- type: textarea
id: describe-bug
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is.
placeholder: Type your description here
validations:
required: true
- type: textarea
id: reproduce-steps
attributes:
label: How to reproduce
description: Steps to reproduce the behavior. Provide as much detail as possible.
placeholder: Steps to reproduce the issue
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen.
placeholder: Describe what you expected to happen
validations:
required: true
- type: textarea
id: show-details
attributes:
label: Provide details output from plugin pop-up window
description: Get detailed output by clicking the "Show details" button and copy the output here.
placeholder: Provide information here
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem.
placeholder: Upload screenshots here, for most of the browser simple copy and paste in the input field will upload the screen shot automatically.
validations:
required: false
- type: textarea
id: additional-info
attributes:
label: Any additional info
description: Any additional information if want to add to the bug report
placeholder: If you want add something extra
validations:
required: false
================================================
FILE: .github/ISSUE_TEMPLATE/2-feature_request.yml
================================================
name: Feature Request
description: Suggest an idea for a new feature
title: "[FEATURE] "
labels: ["enhancement", "triage"]
assignees: []
body:
- type: markdown
attributes:
value: |
**Thank you for suggesting a feature! Please fill out the following details to help us understand and evaluate your request.**
- type: input
id: feature-summary
attributes:
label: Feature Summary
description: A clear and concise summary of the feature you are requesting.
placeholder: Briefly describe the feature
validations:
required: true
- type: textarea
id: motivation
attributes:
label: Motivation
description: Explain why this feature is needed and how it will benefit the project or users.
placeholder: Describe the motivation for this feature
validations:
required: true
- type: textarea
id: use-cases
attributes:
label: Use Cases
description: Provide specific scenarios or use cases where this feature would be useful.
placeholder: Provide detailed use cases
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives
description: Describe any alternative solutions or features that you considered.
placeholder: Describe any alternative solutions
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context or information that may help in understanding and evaluating the request.
placeholder: Provide additional context here
validations:
required: false
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
================================================
FILE: .github/workflows/develop-pre-release.yml
================================================
name: Pre-release CI
on:
push:
branches:
- develop
permissions:
contents: write
issues: write
jobs:
pre-release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Build Plugin
id: build
run: |
VERSION=$(head -n 1 .version)
PRE_VERSION="${VERSION}-pre"
echo "version=${PRE_VERSION}" >> $GITHUB_OUTPUT
# 1. Create the dev marker so the Makefile uses the correct ZIP filename
mkdir -p dist
echo "${PRE_VERSION}" > dist/.version-dev
# 2. Patch the files manually for this artifact build
sed -i "s/^[[:space:]]*version_string = .*/ version_string = \"${PRE_VERSION}\"/" __init__.py
sed -i "s/Version: [^;]*;/Version: ${PRE_VERSION};/" pluginIndexKOReaderSync.txt
# 3. Create the ZIP (bypassing the 'build' target which would reset these changes)
make zip
- name: Create or Update Pre Release
id: create_release
uses: softprops/action-gh-release@v2
with:
tag_name: pre-release
name: "Upcoming Release (v${{ steps.build.outputs.version }})"
body: |
This is a **community pre-release** containing the latest fixes and features planned for the next official update.
We invite everyone to collaborate:
- 🧪 **Test**: Download and try this build.
- 💬 **Feedback**: Let us know if the fixes work for you!
- 🚀 **Contribute**: Help us ensure the best possible stable release.
**Note:** This build is experimental and updated automatically on every change to the `develop` branch.
Last updated: ${{ github.event.head_commit.timestamp }}
Commit: ${{ github.sha }}
prerelease: true
files: dist/*.zip
make_latest: false
generate_release_notes: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Notify Issues
run: |
# Extract issue numbers from commit message (e.g., #123)
COMMIT_MSG=$(git log -1 --pretty=%B)
ISSUE_NUMBERS=$(echo "$COMMIT_MSG" | grep -oP '#\K\d+' | sort -u)
if [ -n "$ISSUE_NUMBERS" ]; then
for ISSUE in $ISSUE_NUMBERS; do
echo "Notifying issue #$ISSUE"
gh issue comment "$ISSUE" --body "A potential fix has been pushed to the **develop** branch.
You can download the latest pre-release for testing here: [Pre-release (Rolling)](https://github.com/${{ github.repository }}/releases/tag/pre-release)
*(Note: This is an automated notification)*"
done
else
echo "No issue numbers found in commit message."
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
================================================
FILE: .github/workflows/main-ci.yml
================================================
name: Quality Check
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
lint:
name: Linting (Pylint)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.9'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pylint
if [ -f Pipfile ]; then pip install pipenv && pipenv install --system --dev; fi
- name: Run Linting
run: make lint
test:
name: Unit Tests (Pytest)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.9'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
if [ -f Pipfile ]; then pip install pipenv && pipenv install --system --dev; fi
- name: Run Tests
run: make test
================================================
FILE: .github/workflows/main-release.yml
================================================
name: Stable Release CI
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Build Plugin
run: make build
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v1
with:
files: dist/*.zip
generate_release_notes: true
draft: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
================================================
FILE: .gitignore
================================================
# PyCharm & VSCodium
.idea
.vscode
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# Release and distribution
dist/
release/
*.zip
# Versioning
.version-dev
# scripts extras
.scripts/output.forumbb
.scripts/input.md
# Temporary files
.temp/
================================================
FILE: .pylintrc
================================================
[MASTER]
ignore-patterns=slpp.py
fail-under=9.5
[MESSAGES CONTROL]
# Disable some of the more "opinionated" or hard-to-fix-quickly checks
disable=raw-checker-failed,
bad-inline-option,
locally-disabled,
file-ignored,
suppressed-message,
useless-suppression,
deprecated-pragma,
use-symbolic-message-instead,
import-error,
no-name-in-module,
attribute-defined-outside-init,
missing-class-docstring,
missing-function-docstring,
unnecessary-lambda,
broad-except,
bare-except,
# Relaxing these for the "big boost"
too-many-instance-attributes,
too-many-locals,
too-many-arguments,
too-many-statements,
too-many-branches,
too-many-lines,
too-many-positional-arguments,
duplicate-code,
line-too-long,
fixme,
inconsistent-return-statements
[REPORTS]
# Increased error weight from 5 to 50 so a single E fails the 9.5 threshold
evaluation=10.0 - ((float(50 * error + warning + refactor + convention) / statement) * 10)
score=yes
[VARIABLES]
# Calibre/Qt globals that show up as "undefined"
additional-builtins=_,get_icons,get_resources
[FORMAT]
max-line-length=140
max-module-lines=2000
[BASIC]
# Allow some common shorter names
good-names=i,j,k,ex,Run,_,db,f,qs,ok
[DESIGN]
# Loosen these up a bit
max-args=10
max-attributes=15
max-locals=25
max-statements=100
max-branches=20
min-public-methods=0
================================================
FILE: .run/pydevd_pycharm.run.xml
================================================
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="pydevd_pycharm" type="PyRemoteDebugConfigurationType" factoryName="Python Remote Debug" editBeforeRun="true">
<module name="koreader-calibre-plugin" />
<option name="PORT" value="5678" />
<option name="HOST" value="localhost" />
<PathMappingSettings>
<option name="pathMappings">
<list>
<mapping local-root="$PROJECT_DIR$/__init__.py" remote-root="/home/harm/git/koreader-calibre-plugin/calibre_plugins.koreader.__init__" />
<mapping local-root="$PROJECT_DIR$/action.py" remote-root="/home/harm/git/koreader-calibre-plugin/calibre_plugins.koreader.action" />
<mapping local-root="$PROJECT_DIR$/config.py" remote-root="/home/harm/git/koreader-calibre-plugin/calibre_plugins.koreader.config" />
</list>
</option>
</PathMappingSettings>
<option name="REDIRECT_OUTPUT" value="true" />
<option name="SUSPEND_AFTER_CONNECT" value="true" />
<method v="2" />
</configuration>
</component>
================================================
FILE: .scripts/md-to-bb.py
================================================
import sys
import re
import os
def markdown_to_bbcode(text):
# Remove all line breaks to preserve full lines in the output
text = re.sub(r'(?m)^# (.+)', r'[b][SIZE="7"]\1[/SIZE][/b]', text) # H1
text = re.sub(r'(?m)^## (.+)', r'[b][SIZE="3"]\1[/SIZE][/b]', text) # H2
text = re.sub(r'(?m)^### (.+)', r'[b][SIZE="3"]\1[/SIZE][/b]', text) # H3
# Convert Markdown lists to BBCode
text = re.sub(r'(?m)^\* (.+)', r'[list][*]\1[/list]', text) # Unordered list
text = re.sub(r'(?m)^\d+\. (.+)', r'[list][*]\1[/list]', text) # Ordered list
# Convert Markdown links to BBCode
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'[url=\2]\1[/url]', text)
# Convert Markdown bold and italic to BBCode
text = re.sub(r'\*\*(.+?)\*\*', r'[b]\1[/b]', text)
text = re.sub(r'\*(.+?)\*', r'[i]\1[/i]', text)
return text
def main():
if len(sys.argv) != 3:
print("Usage: python markdown_to_bbcode.py <input_file> <output_file>")
sys.exit(1)
input_file = sys.argv[1]
output_file = sys.argv[2]
# Compute the absolute path to the version file
script_dir = os.path.dirname(os.path.abspath(__file__))
version_file = os.path.join(script_dir, '..', '.version')
# Read the version from the version file
if not os.path.exists(version_file):
print(f"Error: Version file '{version_file}' not found.")
sys.exit(1)
with open(version_file, 'r', encoding='utf-8') as vf:
version = vf.read().strip()
# Format the version as BBCode
version_bbcode = f'[b][SIZE="5"]v{version}[/SIZE][/b]\n\n'
# Read and convert the Markdown input
with open(input_file, 'r', encoding='utf-8') as f:
markdown_text = f.read()
bbcode_text = markdown_to_bbcode(markdown_text)
# Combine version and BBCode content
full_bbcode_text = version_bbcode + bbcode_text
# Write the combined BBCode to the output file
with open(output_file, 'w', encoding='utf-8') as f:
f.write(full_bbcode_text)
print(f"Converting {input_file} to {output_file}")
print(f"Version: {version}")
if __name__ == "__main__":
main()
================================================
FILE: .version
================================================
0.8.2
================================================
FILE: GEMINI.md
================================================
# KOReader Calibre Plugin - AI Context & Architecture
This file provides critical architectural context and known limitations for AI assistants working on this codebase.
## 🏗 Core Architecture
The plugin facilitates metadata synchronization between **KOReader** (on E-ink devices) and **Calibre**.
### Connection Methods & Capabilities
| Method | Reading Metadata | Writing Sidecars (`.sdr`) | Notes |
| :--- | :--- | :--- | :--- |
| **USB Cable** | ✅ Supported | ✅ Supported | Device mounted as a local filesystem. |
| **Calibre WiFi** | ✅ Supported | ❌ Not Supported | Uses `SMART_DEVICE_APP` driver. `put_file()` is not available for arbitrary sidecars. |
| **Sync Server** | ✅ Supported | ✅ Supported | Communicates via REST API. Identifies books by MD5 hash instead of UUID. |
### Metadata Schema & Mapping
The plugin maps KOReader's Lua sidecar data to Calibre custom columns. Key fields include:
- **Percent Read:** Supported as both Floating Point (`{:.0%}`) and Integer.
- **Status:** Maps KOReader statuses (*Finished, Reading, On hold*) to Calibre (*complete, reading, abandoned*).
- **Annotations/Highlights:** Stored as Markdown in a Long Text column.
- **MD5 Hash:** **Critical** for ProgressSync server functionality.
- **Date Sidecar Modified:** Only available via wired (USB) connections.
## 🛠 Key Implementation Details
### Wireless "Sync Missing" Logic
- Because Calibre's Wireless driver cannot write sidecar files, the "Sync Missing to KOReader" feature is gracefully disabled for wireless connections to prevent `AttributeError`.
- **Existence Probing:** The method `device_path_exists` uses `device.get_file()` as a probe. On wireless connections, this is fast (~0.05s per book) and much more reliable than `os.path.exists()`.
### Metadata Extraction
- **Sidecar Parsing:** Sidecar files (`.lua`) are parsed into Python dicts using the internal `slpp.py` (Lua-in-Python parser).
- **Robustness:** Always use `.get()` or check for the existence of the `summary` key. Older or corrupted KOReader files may lack this key, which previously caused crashes.
- **Large Annotations:** To avoid `apsw.TooBigError` (Issue #114), the metadata extraction loop for hidden attributes must be O(N) relative to the number of highlights. Never iterate over the entire bookmark collection inside an individual annotation loop.
- **Renamed Fields:** Be aware that `bookmarks` was renamed to `annotations` in newer sidecar formats.
### Book Identification
- **UUID vs. MD5:** Direct device sync uses Calibre's internal UUID. Server-based sync relies on an MD5 hash stored in a custom column.
- **Sync Loop:** The internal sync loop (in `sync_to_calibre`) uses a list of `(uuid, path)` tuples. This allows handling multiple books that might be missing UUIDs without overwriting data in a dictionary.
## ⚠️ Known Limitations & Constraints
- **Hidden Folders:** The plugin is configured to ignore any book paths containing hidden directories (e.g., `.stfolder`, `.stversions`) to avoid "None Book" entries in the UI.
- **Python Versioning:** The plugin aims for compatibility with Python 3.12+. All regex patterns MUST use raw strings (`r"..."`) to avoid `SyntaxWarning` for invalid escape sequences.
## 🧪 Development & Debugging
- Use `make dev` to build and install the development version.
- Timing logs for device operations are prefixed with `KoreaderAction:device_path_exists:` in the debug output.
================================================
FILE: LICENSE
================================================
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
================================================
FILE: Makefile
================================================
# Read the version from the .version file
version := $(shell head -n 1 .version 2>/dev/null || echo 0.0.1)
# Dev version components
DATE := $(shell date -u +%Y%m%d)
SHA := $(shell git rev-parse --short HEAD 2>/dev/null || echo local)
DEV_VERSION := $(DATE)-$(SHA)-dev
DEV_TUPLE := (0, 0, 0)
# Check for a dev version file in dist, otherwise use the release version
ifeq ($(wildcard dist/.version-dev),)
effective_version := $(version)
else
effective_version := $(shell head -n 1 dist/.version-dev)
endif
zip_file = KOReader_Sync_v$(effective_version).zip
zip_contents = about.txt LICENSE plugin-import-name-koreader.txt *.py *.md images/
plugin_index_file_to_upd = pluginIndexKOReaderSync.txt
init_file_to_upd = __init__.py
dist_dir = dist
# Convert the version to tuple format (only take numeric parts for the tuple)
version_tuple := $(shell echo $(version) | sed 's/-.*//' | awk -F. '{print "("$$1", "$$2", "$$3")"}')
# Flatpak support: set FLATPAK=1 to use Flatpak commands
# e.g., make release FLATPAK=1
ifdef FLATPAK
ifneq ($(shell uname -s 2>/dev/null),Linux)
$(error FLATPAK=1 is only supported on Linux. For other platforms, please install Calibre natively and run make without FLATPAK=1)
endif
ifeq ($(shell command -v flatpak 2>/dev/null),)
$(error The 'flatpak' command was not found. Please install flatpak or run make without FLATPAK=1)
endif
ifeq ($(shell flatpak info com.calibre_ebook.calibre >/dev/null 2>&1; echo $$?),1)
$(error Calibre Flatpak (com.calibre_ebook.calibre) is not installed. Please install it or run make without FLATPAK=1)
endif
CALIBRE_CUSTOMIZE = flatpak run --command=calibre-customize com.calibre_ebook.calibre
CALIBRE_DEBUG = flatpak run --command=calibre-debug com.calibre_ebook.calibre
else
CALIBRE_CUSTOMIZE = calibre-customize
CALIBRE_DEBUG = calibre-debug
endif
# Main targets
# Always clean dev metadata before a formal release
build: clean_dev
@$(MAKE) update_version
@$(MAKE) zip
release: lint test
@if [ "$$(git rev-parse --abbrev-ref HEAD)" != "main" ]; then \
echo "Error: You must be on the 'main' branch to release."; \
exit 1; \
fi
@$(MAKE) build
@$(MAKE) tag
# Preparation for a release: creates a branch, updates versions, and commits.
# Allows .version to be dirty so you can edit it before running.
prep-release:
@if [ -n "$$(git status --short | grep -v ' .version$$')" ]; then \
echo "Working directory has uncommitted changes (other than .version). Please commit or stash them first."; \
exit 1; \
fi
@echo "Preparing release for version $(version)"
@git checkout -b "release-prep-$(version)"
@$(MAKE) update_version
@$(MAKE) lint
@$(MAKE) test
@git add .version $(init_file_to_upd) $(plugin_index_file_to_upd)
@git commit -m "chore: Prepare release $(version)"
@echo "Release preparation branch 'release-prep-$(version)' created."
@echo "Review the changes and then merge to main. Finally, run 'make release' on main."
# Quality tools
test:
@echo "Running tests..."
@if [ -d "tests" ]; then \
pytest tests/; \
else \
echo "No tests directory found."; \
exit 1; \
fi
lint:
@echo "Running linting (pylint)..."
@pylint __init__.py action.py config.py --rcfile=.pylintrc --fail-on=E,F --output-format=colorized --msg-template="{path}:{line}: [{category}] {msg} ({symbol})" || \
(echo -e "\n\033[0;31m[!!!] CRITICAL ERRORS FOUND - FIX THESE FIRST:\033[0m" && \
pylint __init__.py action.py config.py --rcfile=.pylintrc --errors-only && \
exit 1)
# Helper targets to bump version in .version file
bump-patch:
@awk -F. '{print $$1"."$$2"."$$3+1}' .version > .version.tmp && mv .version.tmp .version
@echo "Version bumped to $$(cat .version)"
bump-minor:
@awk -F. '{print $$1"."$$2+1".0"}' .version > .version.tmp && mv .version.tmp .version
@echo "Version bumped to $$(cat .version)"
bump-major:
@awk -F. '{print $$1+1".0.0"}' .version > .version.tmp && mv .version.tmp .version
@echo "Version bumped to $$(cat .version)"
pre: build
pre_version:
@echo "Pre-version patching is now handled by CI/CD."
zip: $(dist_dir)
@echo "Creating new $(dist_dir)/$(zip_file)"
@mkdir -p "$(dist_dir)" && zip -r "$(dist_dir)/$(zip_file)" $(zip_contents)
# Loads current src content, use this if doing dev changes
dev: dev_version
@$(MAKE) zip
@$(MAKE) load
dev_version:
@mkdir -p "$(dist_dir)"
@echo "$(DEV_VERSION)" > "$(dist_dir)/.version-dev"
@sed -i 's/^\([[:space:]]*\)version = ([0-9, ]*)/\1version = $(DEV_TUPLE)/' $(init_file_to_upd)
@if grep -q "^[[:space:]]*version_string =" $(init_file_to_upd); then \
sed -i "s/^\([[:space:]]*\)version_string = .*/\1version_string = '$(DEV_VERSION)'/" $(init_file_to_upd); \
else \
sed -i "/^[[:space:]]*version = /a \ version_string = '$(DEV_VERSION)'" $(init_file_to_upd); \
fi
@sed -i 's/Version: [^;]*;/Version: $(DEV_VERSION);/' $(plugin_index_file_to_upd)
@echo "Dev version set to $(DEV_VERSION)"
# Install the plugin into Calibre
install: zip
@$(CALIBRE_CUSTOMIZE) -a "$(dist_dir)/$(zip_file)"
# Install and then launch Calibre in debug mode
load: install
@$(CALIBRE_DEBUG) -g
update_version: update_version_plugin_index update_version_init
@echo "Versions updated in all files."
update_version_plugin_index:
@echo "Updating version in $(plugin_index_file_to_upd) to $(version)"
@sed -i 's/Version: [^;]*;/Version: $(version);/' $(plugin_index_file_to_upd)
@echo "Version updated in $(plugin_index_file_to_upd)"
update_version_init:
@echo "Updating version in $(init_file_to_upd) to $(version_tuple)"
@sed -i 's/^[[:space:]]*version = .*/ version = $(version_tuple)/' $(init_file_to_upd)
@sed -i "s/^[[:space:]]*version_string = .*/ version_string = \"$(version)\"/" $(init_file_to_upd)
@echo "Version updated in $(init_file_to_upd)"
clean_dev:
@rm -f "$(dist_dir)/.version-dev"
@echo "Dev version metadata removed from $(dist_dir)."
clean: clean_dev
@rm -rf "$(dist_dir)"
@echo "Cleaned $(dist_dir) directory"
$(dist_dir):
@mkdir -p $(dist_dir)
@echo "Created $(dist_dir) directory"
debug_version:
@echo "Read version: $(version)"
@echo "Effective version: $(effective_version)"
@echo "Version tuple: $(version_tuple)"
@echo "Zip file: $(zip_file)"
tag:
@echo "Tagging version v$(version) and pushing to the repository"
@if git rev-parse "v$(version)" >/dev/null 2>&1; then \
echo "Tag v$(version) already exists."; \
else \
git tag -a "v$(version)" -m "Version $(version)"; \
git push origin "v$(version)"; \
fi
md_to_bb:
@echo "Converting input.md to output.forumbb"
@python .scripts/md-to-bb.py .scripts/input.md .scripts/output.forumbb
@echo "Done:"
@cat .scripts/output.forumbb
.PHONY: build release zip dev install load update_version update_version_plugin_index update_version_init debug_version tag md_to_bb dev_version clean_dev clean prep-release test lint bump-patch bump-minor bump-major
================================================
FILE: Pipfile
================================================
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
pyqt6 = "*"
pyqt5 = "*"
lxml = "*"
[dev-packages]
css-parser = "*"
html5-parser = "*"
mechanize = "*"
regex = "*"
zeroconf = "*"
apsw = "*"
lxml = "*"
msgpack = "*"
python-dateutil = "*"
beautifulsoup4 = "*"
markdown = "*"
netifaces = "*"
[requires]
python_version = "3.9"
================================================
FILE: README.md
================================================
# KOReader calibre plugin
[](https://github.com/kyxap/koreader-calibre-plugin/actions/workflows/main-ci.yml)
[](https://github.com/kyxap/koreader-calibre-plugin/actions/workflows/main-release.yml)
[](https://github.com/kyxap/koreader-calibre-plugin/releases)
[](https://github.com/kyxap/koreader-calibre-plugin/actions/workflows/develop-pre-release.yml)
[](https://github.com/kyxap/koreader-calibre-plugin/releases/tag/pre-release)
[](https://github.com/kyxap/koreader-calibre-plugin/blob/main/LICENSE)
A calibre plugin to synchronize metadata from KOReader to calibre.
[KOReader](https://koreader.rocks/) creates sidecar files that hold read
progress and annotations.
This plugin reads the data from those sidecar files and updates calibre's
metadata based on them. It is inspired
by [the Kobo Utilities plugin](https://www.mobileread.com/forums/showthread.php?t=215339),
that synchronizes reading progress between the original Kobo firmware ("Nickel")
and custom columns in calibre.
Note that at the moment the sync is primarily one-way—from the KOReader device
to calibre, and only works for USB
and [wireless](https://github.com/koreader/koreader/wiki/Calibre-wireless-connection)
devices. For best experience please use the latest
KOReader [release](https://github.com/koreader/koreader/releases)
Releases will also be uploaded
to [plugin thread](https://www.mobileread.com/forums/showthread.php?t=362706) on
the MobileRead Forums.
If you are on there as well, please let me know what you think of the plugin in
that thread.
## Using this plugin
### Download and install
1. Go to your calibre's _Preferences_ > _Plugins_ > _Get new plugins_ and search
for _KOReader Sync_
2. Click _Install_
3. Restart calibre
#### Alternatively
1. Download the latest release
from [here](https://github.com/kyxap/koreader-calibre-plugin/releases).
2. Go to your calibre's _Preferences_ > _Plugins_ > _Load plugin from file_ and
point it to the downloaded ZIP file
3. Restart calibre
### Setup
1. Pick and choose the metadata you would like to sync and create the
appropriate columns in calibre. The plugin makes this easy, simply select
the **create new columns** option in the config dropdowns.
These are your options:
- A _Floating point numbers_ column to store the **current percent read**,
with _Format for numbers_ set to `{:.0%}`.
- An _Integers_ column to store the **current percent read**.
- A regular _Text_ column to store the **location you last stopped reading at**
- A _Rating_ column to store your **rating** of the book, as entered on the
book's status page.
- A _Long text_ column to store your **review** of the book, as entered on
the book's status page.
- A regular _Text_ column to store the **reading status** of the book, as
entered on the book status page (_Finished_, _Reading_, _On hold_).
Translates to complete, reading, and abandoned respectively in calibre.
- A _Yes/No_ column to store the **reading status** of the book, as a
boolean (_Yes_ = _Finished_, _No_ = everything else).
- A _Long text_ column to store your **bookmarks and highlights** of the
book, with _Interpret this column as_ set to _Plain text formatted using
markdown_. (Highlights are an unordered list with their metadata in an
HTML comment.)
- A regular _Text_ column to store the **MD5 hash** KOReader uses to sync
progress to a [KOReader Sync
Server](https://github.com/koreader/koreader-sync-server#koreader-sync-server)
(_Progress sync_ in the KOReader app). This allows for syncing
progress and location to calibre without having to connect your KOReader device.
- A _Date_ column to store **when the last sync was performed**.
- A _Date_ column to store **when the sidecar file was last modified**. Works
for wired connection only, wireless will be always empty.
- A _Date_ column to store **when the book status was first marked reading**.
- A _Date_ column to store **when the book status was first marked finished**.
- A _Long text_ column to store the **contents of the metadata sidecar** as
HTML, with _Interpret this column as_ set to _HTML_.
There are additional settings for:
- Sync only if changes are more recent: Checks retrieved **Last Sync Date** against date on file.
- No sync if book has already been finished: If **percent read** is _100_ or if **reading status** is _finished_ don't update data.
- Automatic Sync on device connection: Silently sync's from KOReader when device is connected
1. Add _KOReader Sync_ to _main toolbar when a device is connected_, if it
isn't there already.
2. Right-click the _KOReader Sync_ icon and _Configure_.
3. Map the metadata you want to sync to the newly created calibre columns.
4. Click _OK_ to save your mapping.
5. From now on just click the _KOReader Sync_ icon to sync all mapped metadata
for all books on the connected device to calibre.
**Note:** Some field are depreciated and removed from plugin since they are
changed/removed from `sidecar_contents` data structure:
- `first_bookmark` removed
- `last_bookmark` removed
- `bookmarks` renamed to `annotations`
- `rating` KOreader uses 5-point but calibre 10-point scale (whole starts, not half stars)
- `date_sidecar_modified` seems to be present in `calculated` only if connected via
cable (not wireless)
### ProgressSync
This plugin supports use of a [KOReader Sync
Server](https://github.com/koreader/koreader-sync-server#koreader-sync-server)
(_Progress sync_ in the KOReader app) in order to update **current percent read**
(both float and int) and **location you last stopped reading at** wirelessly.\
You must also have the **MD5 hash** column enabled.\
Add the server and user credentials in the plugin config to use this function.
The user password is stored as a hash, not plain text.\
You can have calibre fetch updated data on a daily schedule.
### Things to consider
- The plugin overwrites existing metadata in Calibre without asking. That
usually isn’t a problem, because you will probably only add to KOReader’s
metadata. But be aware that you might lose data in calibre if you’re not
careful.
- Pushing sidecars back to KOReader currently only happens for sidecars which
are missing. For now, manually delete the `<bookname>.sdr` folder from the
device before attempting to push the sidecars back to KOReader for any books
you would like to overwrite the current metadata with Calibre's metadata.
- When pushing missing sidecars to the device, no attempt is made to convert
Calibre's metadata to account for changes in KOReader's sidecar format. Old
metadata may work unpredictably if it's from a different version of KOReader.
### Supported devices
This plugin has been tested successfully with:
- Kobo Clara BW/Colour connected over USB or KOreader wireless driver
- Kobo Aura/Touch connected over USB (`KOBO` and `KOBOTOUCH` drivers)
- Kobo Aura H2O over USB (`KOBOTOUCHEXTENDED` driver)
- All devices connected wirelessly via the `SMART_DEVICE_APP` driver (e.g., KOReader wireless connection)
- PocketBook devices using `POCKETBOOK_IMPROVED`, `POCKETBOOK632`, `POCKETBOOK626`, or `POCKETBOOK622` drivers
- Kindle Keyboard (`KINDLE2`)
- Tolino Vision 4 HD (`TOLINO`)
- A connected folder (`FOLDER_DEVICE`)
- Manually defined devices using the `USER_DEFINED` driver
This plugin is not compatible with:
- `MTP_DEVICE` (Android devices connected via MTP)
### Star History
[](https://star-history.com/#kyxap/koreader-calibre-plugin&Date)
### Issues
If you encounter any issues with the plugin, please submit
them [here](https://github.com/kyxap/koreader-calibre-plugin/issues).
## Acknowledgements
- Multiple tweaks and bug fixes by [Glen Sawyer](https://git.sr.ht/~snelg)
- Additional functionality by [Charles Taylor](https://github.com/charlesangus/)
- Contains [SirAnthony's SLPP](https://github.com/SirAnthony/slpp) to parse Lua
in Python.
- Some code borrowed from--and heavily inspired by--the
great [Kobo Utilities](https://www.mobileread.com/forums/showthread.php?t=215339)
calibre plugin.
- Some code borrowed from--and heavily inspired by--the
great [Goodreads Sync](https://www.mobileread.com/forums/showthread.php?t=123281)
calibre plugin.
## Contributing to this plugin
### Notes & Tips
- My first attempt was actually to sync calibre with KOReader's read progress
through the progress sync plugin and
a [sync server](https://github.com/koreader/koreader-sync-server).
Read [here](https://github.com/koreader/koreader/issues/6399#issuecomment-721826362)
why that did not work. This plugin might actually make that possible now by
allowing you to store KOReader's MD5 hash in calibre...
- calibre allows you to auto-connect to a folder device on boot, which greatly
speeds up your workflow when testing. You can find this under "
Preferences" > "Tweaks", search for `auto_connect_to_folder`. Point that to
the `dummy_device` folder in this repository. (I have included royalty free
EPUBs for your and my convenience.)
- If you're testing and don't actually want to update any metadata,
set `DRY_RUN` to `True` in `__init__.py`.
- I work in PyCharm, which offers a remote debugging server. To enable that in
this plugin, set `PYDEVD` to `True` in `__init__.py`.You might need to
change `sys.path.append` in `action.py`.
- The supported device drivers can be found
in [the `SUPPORTED_DEVICES` list in `config.py`](https://github.com/kyxap/koreader-calibre-plugin/blob/main/config.py).
Adding a new type here is the first step to adding support, but make sure all
features are tested thoroughly before releasing a version with an added device
### Testing in calibre
Use make to load the plugin into calibre and launch it:
```shell
make dev
```
For Linux users with a Flatpak installation of Calibre, use the `FLATPAK=1`
flag. This is necessary because Flatpak runs Calibre in a
[sandboxed environment](https://docs.flatpak.org/en/latest/sandbox-permissions.html),
requiring specific commands to interact with it:
```shell
make dev FLATPAK=1
```
> **Note:** `FLATPAK=1` is only supported on Linux. On Windows and macOS,
> please install Calibre natively and run `make` without this flag.
### Makefile Targets
#### Main
| Target | Description |
|----------------|------------------------------------------------------------------------------------------------------------------|
| `test` | Run unit and integration tests using `pytest` (includes Calibre environment mocks) |
| `lint` | Run static analysis using `pylint` (enforces 9.5/10 score and zero Errors) |
| `dev` | Load plugin source directly into Calibre and launch in debug mode |
| `pre` | Patch internal version with `-pre` and build a community pre-release ZIP |
| `bump-patch` | Increment the patch version in `.version` (e.g., 0.8.0 -> 0.8.1) |
| `bump-minor` | Increment the minor version in `.version` (e.g., 0.8.0 -> 0.9.0) |
| `bump-major` | Increment the major version in `.version` (e.g., 0.8.0 -> 1.0.0) |
| `prep-release` | Create a `release-prep-<version>` branch, update files, and commit |
| `release` | Tag the current version and push to trigger GitHub Release, do this after updated version already pushed to main |
| `md_to_bb` | Convert input.md to output.forumbb (BBCode) for MobileRead forum posts |
#### Extra
| Target | Description |
|---------------|---------------------------------------------------------------------|
| `install` | Install ZIP into Calibre without launching the GUI |
| `zip` | Create plugin ZIP file in `dist/` directory |
| `load` | Install ZIP from `dist/` and launch Calibre in debug mode |
| `build` | Full build workflow: update versions from `.version` and create ZIP |
| `dev_version` | Update all code files with the current version from `.version` |
| `clean` | Remove all build artifacts and temporary files |
| `clean_dev` | Clean up development-specific temporary files |
| `tag` | Create and push git tag for current version |
### Development & Release Cycle
The project uses a structured workflow to ensure both rapid updates and stable releases:
1. **Develop Branch (`develop`)**: This is the primary work-in-progress branch.
- Experimental fixes and new features are merged here first.
- Every push to this branch triggers an automated **Pre-release build**.
- Users can download the latest community pre-release from the [Upcoming Release](https://github.com/kyxap/koreader-calibre-plugin/releases/tag/pre-release) page.
2. **Main Branch (`main`)**: This branch contains the stable, production-ready code.
- Only merge `develop` into `main` when a milestone is reached.
- Running `make release` on this branch automatically cleans the version string, tags the commit, and triggers the official GitHub Release.
### Quality Assurance
The project enforces high code quality standards through automated checks:
- **Unit Testing:** Run `make test`. We use `pytest` along with a mocking layer (`tests/conftest.py`) that simulates the Calibre environment. This allows you to test plugin logic without having Calibre installed.
- **Linting:** Run `make lint`. We use `pylint` with a custom configuration (`.pylintrc`).
- **Threshold:** The project requires a minimum score of **9.5/10**.
- **Strictness:** The build will **instantly fail** if any **Fatal (F)** or **Error (E)** messages are found, regardless of the total score.
These checks run automatically on every Pull Request via GitHub Actions.
The project uses GitHub Actions to automate releases. When a tag `v*` is pushed, a GitHub Release is created automatically with the built plugin ZIP.
1. **Prepare the version:**
- Manually edit `.version` OR run `make bump-patch` / `make bump-minor`.
2. **Run preparation:**
- Run `make prep-release`. This creates a new branch (e.g., `release-prep-x.x.x`), updates all version strings in the code, and commits them.
3. **Review and Merge:**
- Review the changes in the new branch, then merge it into `main`.
4. **Publish:**
- On the `main` branch, run `make release`. This will tag the commit and push it.
- The GitHub Action will pick up the tag, build the plugin, and create a GitHub Release with the ZIP attached.
### Debugging a release
1. Download the required release
from [here](https://github.com/kyxap/koreader-calibre-plugin/releases)
1. Add it to calibre by running this in your
terminal: `calibre-customize -a "KOReader_Sync_vX.X.X.zip"`, where `X.X.X`
refers to the version you downloaded
1. Start calibre in debug mode with `calibre-debug -g`
1. Configure the KOReader plugin as
described [here](https://github.com/kyxap/koreader-calibre-plugin#setup)
1. Connect your device
1. Run the sync by clicking the KOReader icon in your toolbar
1. Check the details of the message when it's done if any/all books have been
synced correctly
1. Check your (custom) columns for one of those books to see if their contents
are what they should be
1. Check the output in your terminal for lines containing `koreader` to see what
it did
================================================
FILE: TODO.md
================================================
# TODO
## 🚀 High Priority (0.8.x Release)
- [x] Fix `apsw.TooBigError` for books with 900+ highlights [#114](https://github.com/kyxap/koreader-calibre-plugin/issues/114)
- [x] Implement robust UUID matching: Parse `calibre:` prefix in sidecar identifiers to fix mismatches [#115](https://github.com/kyxap/koreader-calibre-plugin/issues/115), [#99](https://github.com/kyxap/koreader-calibre-plugin/issues/99)
- [x] Add support for `POCKETBOOK_IMPROVED` driver [#63](https://github.com/kyxap/koreader-calibre-plugin/issues/63)
- [x] Fix sidecar write regression for USB/Folder devices (`_io.BytesIO` error) [#143](https://github.com/kyxap/koreader-calibre-plugin/issues/143)
## 🐛 Bug Fixes
- [x] Fix MD5 computation for Kobo devices using custom folders [#98](https://github.com/kyxap/koreader-calibre-plugin/issues/98)
- [x] Fix sidecar directory resolution in Docker (linuxserver/calibre) environments [#73](https://github.com/kyxap/koreader-calibre-plugin/issues/73)
- [x] Resolve Windows "Access Denied" error for Kobo sidecar folder creation [#68](https://github.com/kyxap/koreader-calibre-plugin/issues/68)
## ✨ Enhancements & Features
- [ ] Add support for highlights and bookmarks into the native `annotations` table in calibre's `metadata.db` [#95](https://github.com/kyxap/koreader-calibre-plugin/issues/95)
- [ ] **Architectural Change**: Stop converting Lua to JSON for storage. Store raw Lua in the "Raw Sidecar" column to prevent lossy conversions and fix sync-back bugs [#65](https://github.com/kyxap/koreader-calibre-plugin/issues/65), [#58](https://github.com/kyxap/koreader-calibre-plugin/issues/58).
- [ ] Implement metadata merging (union) instead of overwriting for multi-device sync [#76](https://github.com/kyxap/koreader-calibre-plugin/issues/76)
- [ ] Support custom sidecar locations (global .sdr folder) [#57](https://github.com/kyxap/koreader-calibre-plugin/issues/57)
- [ ] Implement 2-way wireless sidecar modification [#100](https://github.com/kyxap/koreader-calibre-plugin/issues/100)
- [ ] Add template support for customizing the appearance of imported highlights [#1](https://github.com/kyxap/koreader-calibre-plugin/issues/1)
## 🛠 Project Health & Maintenance
- [ ] Fix remaining pylint errors and warnings (currently 9.59/10)
- [ ] Standardize Python code style with Black/Ruff [#81](https://github.com/kyxap/koreader-calibre-plugin/issues/81)
- [ ] Add `last_page` support for PDFs
- [ ] Investigate `calibre.devices.usbms.cli.CLI.list()` for better sidecar discovery
## ✅ Completed
- [x] Add an `.editorconfig` and `.pylintrc` to define code layout
- [x] Add support for highlights and bookmarks into a metadata field
- [x] Make the warning about synced metadata more informative
- [x] Add support for `KINDLE2` devices
- [x] ~~Add support for `MTP_DEVICE` devices~~
- [x] ~~Add support for multiple storages (i.e. SD cards) for `MTP_DEVICES`~~
================================================
FILE: __init__.py
================================================
#!/usr/bin/env python3
"""KOReader Sync Plugin for Calibre."""
import os
from functools import partial
from calibre.constants import DEBUG as _DEBUG
from calibre.constants import numeric_version
from calibre.customize import InterfaceActionBase
from calibre.devices.usbms.driver import debug_print as root_debug_print
from calibre.utils.config import JSONConfig
__license__ = 'GNU GPLv3'
__copyright__ = '2021, harmtemolder <mail at harmtemolder.com>'
__modified_by__ = 'kyxap kyxappp@gmail.com'
__modification_date__ = '2024'
__docformat__ = 'restructuredtext en'
DEBUG = _DEBUG
DRY_RUN = False # Used during debugging to skip the actual updating of metadata
PYDEVD = False # Used during debugging to connect to PyCharm's remote debugging
if numeric_version >= (5, 5, 0):
module_debug_print = partial(
root_debug_print,
' koreader:__init__:',
sep=''
)
else:
module_debug_print = partial(root_debug_print, 'koreader:__init__:')
class KoreaderSync(InterfaceActionBase):
name = 'KOReader Sync'
description = 'Get metadata from a connected KOReader device'
author = 'harmtemolder & others, currently maintaining by: kyxap'
version = (0, 8, 2)
version_string = "0.8.2"
minimum_calibre_version = (5, 0, 1) # Because Python 3
config = JSONConfig(os.path.join('plugins', 'KOReader Sync.json'))
actual_plugin = 'calibre_plugins.koreader.action:KoreaderAction'
def is_customizable(self):
return True
def config_widget(self):
if self.actual_plugin_:
from calibre_plugins.koreader.config import \
ConfigWidget # pylint: disable=import-error, disable=import-outside-toplevel
return ConfigWidget(self.actual_plugin_)
return None
def save_settings(self, config_widget):
config_widget.save_settings()
def clean_bookmarks(bookmarks):
"""Transforms KOReader's bookmark metadata into text that can be stored
in calibre. I assume that all bookmarks have a `note` attribute, which I
use as the main text of the bookmark. All other attributes are stored in a
HTML comment.
:param bookmarks: dict with numbered keys and annotations dict values
:return: HTML-formatted str of the all bookmarks and highlights
"""
debug_print = partial(root_debug_print, 'clean_bookmarks:')
# Dictionary to store highlights grouped by chapter
highlights_by_chapter = {}
for annotation in bookmarks.values():
if 'note' not in annotation:
debug_print('annotation does not have `note`', annotation)
else:
debug_print('annotation has `note`', annotation)
# Extracting all attributes to save as hidden text
hidden_attributes = ''
if len(bookmarks) > 0:
hidden_attributes += ' <!-- '
for attr, val in annotation.items():
hidden_attributes += f'{attr}: {val}, '
hidden_attributes = hidden_attributes[:-2] + ' -->'
hidden_attributes += '\n'
# Extracting attributes that will be used in html
chapter = annotation.get("chapter", "Unknown Chapter")
reader_note = annotation.get("note", "no notes")
highlighted_text = annotation.get("text", "Unknown Highlighted Text")
datetime = annotation.get("datetime", "Unknown Datetime")
# Create highlight dictionary
highlight = {
"chapter": chapter,
"reader_note": reader_note,
"highlighted_text": highlighted_text,
"datetime": datetime,
"hidden_attributes": hidden_attributes
}
# Add highlight to the corresponding chapter
if chapter not in highlights_by_chapter:
highlights_by_chapter[chapter] = []
highlights_by_chapter[chapter].append(highlight)
# Generate HTML content for each chapter
html_content = ('<!DOCTYPE html>\n<html>\n<head>\n'
'<title>Book Highlights and Notes</title>\n'
'</head>\n<body>\n')
highlight_count = 0
for chapter, chapter_highlights in highlights_by_chapter.items():
if chapter.strip() == '':
chapter = 'Unknown'
html_content += f'<div>\n<h3>Chapter: <u>{chapter}</u></h3>\n'
html_content += '<blockquote>'
for highlight in chapter_highlights:
highlight_count += 1
html_content += (f'<p><strong>{highlight_count}. Highlight</strong'
f'> - {highlight["datetime"]} '
f'<br/>{highlight["highlighted_text"]}\n')
html_content += '<br><br>\n'
html_content += ('<strong>Note:</strong> <i>'
f'{highlight["reader_note"]}</i></p>\n')
html_content += f'{highlight["hidden_attributes"]}\n'
html_content += "</div>\n"
html_content += '</blockquote>'
html_content += "</body>\n</html>"
return html_content.strip()
================================================
FILE: about.txt
================================================
<h2>About KOReader Sync</h2>
<p>A calibre plugin to synchronize metadata from KOReader to calibre.</p>
<p>The source code of this plugin can be found <a href="https://github.com/harmtemolder/koreader-calibre-plugin">on GitHub</a>.</p>
<p>If you encounter any issues with the plugin, please submit them <a href="https://github.com/harmtemolder/koreader-calibre-plugin/issues">here</a>.</p>
================================================
FILE: action.py
================================================
#!/usr/bin/env python3
"""KOReader Sync Plugin for Calibre."""
from datetime import datetime
from functools import partial
import io
import json
import os
import re
import sys
import importlib.util
import time
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
import ssl
from PyQt5.Qt import (
QUrl,
QTimer,
QTime,
QTableWidget,
QTableWidgetItem,
QHBoxLayout,
QVBoxLayout,
QDialog,
QLabel,
QIcon,
QPushButton,
QScrollArea,
QProgressBar,
QApplication,
Qt,
QThread,
pyqtSignal,
)
from calibre_plugins.koreader.slpp import slpp as lua
from calibre_plugins.koreader.config import (
SUPPORTED_DEVICES,
UNSUPPORTED_DEVICES,
CUSTOM_COLUMN_DEFAULTS as COLUMNS,
CONFIG,
)
from calibre_plugins.koreader import (
DEBUG,
DRY_RUN,
PYDEVD,
KoreaderSync,
)
from calibre.utils.iso8601 import utc_tz, local_tz
from calibre.gui2.dialogs.message_box import MessageBox
from calibre.gui2.actions import InterfaceAction
from calibre.gui2.device import device_signals
from calibre.gui2 import (
error_dialog,
warning_dialog,
open_url,
)
from calibre.devices.usbms.driver import debug_print as root_debug_print, USBMS
from calibre.constants import numeric_version
from enum import Enum, auto
__license__ = 'GNU GPLv3'
__copyright__ = '2021, harmtemolder <mail at harmtemolder.com>'
__modified_by__ = 'kyxap kyxappp@gmail.com'
__modification_date__ = '2024'
__docformat__ = 'restructuredtext en'
if numeric_version >= (5, 5, 0):
module_debug_print = partial(root_debug_print, ' koreader:action:', sep='')
else:
module_debug_print = partial(root_debug_print, 'koreader:action:')
if DEBUG and PYDEVD:
try:
sys.path.append(
# '/Applications/PyCharm.app/Contents/debug-eggs/pydevd-pycharm.egg' # macOS
'/opt/pycharm-professional/debug-eggs/pydevd-pycharm.egg'
# Manjaro Linux
)
import pydevd_pycharm
pydevd_pycharm.settrace(
'localhost', stdoutToServer=True, stderrToServer=True,
suspend=False
)
except Exception as e:
module_debug_print('could not start pydevd_pycharm, e = ', e)
PYDEVD = False
class GetSidecarStatus(Enum):
PATH_NOT_FOUND = auto()
DECODE_FAILED = auto()
class OperationStatus(Enum):
PASS = auto()
FAIL = auto()
SKIP = auto()
def is_system_path(path):
"""
KOreader user may have some files in the root which we want to skip to
avoid showing warning message
:param path: path to sidecar file (*.lua)
:return: true/false if partial match found
"""
to_ignore = ['kfmon.sdr', 'koreader.sdr']
return any(substring in path for substring in to_ignore)
def append_results(results, title, status_msg, book_uuid, sidecar_path):
debug_print = partial(
module_debug_print,
'KoreaderAction:append_results:'
)
debug_print(f'{sidecar_path} - {status_msg}')
return results.append(
{
'title': title,
'result': status_msg,
'book_uuid': book_uuid,
'sidecar_path': sidecar_path,
}
)
def parse_sidecar_lua(sidecar_lua):
"""Parses a sidecar Lua file into a Python dict
:param sidecar_lua: the contents of a sidecar Lua as a str
:return: a dict of those contents
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:parse_sidecar_lua:'
)
try:
clean_lua = re.sub(r'^[^{]*', '', sidecar_lua).strip()
decoded_lua = lua.decode(clean_lua)
except:
debug_print('could not decode sidecar_lua')
decoded_lua = None
if 'bookmarks' in decoded_lua:
if isinstance(decoded_lua['bookmarks'], list):
decoded_lua['bookmarks'] = {
# Starts from 1
i+1: bookmark for i, bookmark in enumerate(decoded_lua['bookmarks'])}
debug_print('calculating first and last bookmark dates')
bookmark_dates = [
datetime.strptime(
bookmark['datetime'],
'%Y-%m-%d %H:%M:%S'
).replace(tzinfo=utc_tz)
for bookmark in decoded_lua['bookmarks'].values()
]
if len(bookmark_dates) > 0:
decoded_lua['calculated'] = {
'first_bookmark': min(bookmark_dates),
'last_bookmark': max(bookmark_dates),
}
return decoded_lua
class KoreaderAction(InterfaceAction):
name = KoreaderSync.name
action_spec = (name, 'edit-redo.png', KoreaderSync.description, None)
action_add_menu = True
action_menu_clone_qaction = 'Sync from KOReader'
dont_add_to = frozenset(
[
'context-menu', 'context-menu-device', 'menubar',
'menubar-device', 'context-menu-cover-browser',
'context-menu-split']
)
dont_remove_from = frozenset()
action_type = 'current'
def genesis(self):
debug_print = partial(module_debug_print, 'KoreaderAction:genesis:')
debug_print('start')
base = self.interface_action_base_plugin
if hasattr(base, 'version_string') and base.version_string:
self.version = f'{base.name} (v{base.version_string})'
else:
self.version = f'{base.name} (v{".".join(map(str, base.version))})'
self.extension_callback = None
# Overwrite icon with actual KOReader logo
icon = get_icons(
'images/icon.png'
)
self.qaction.setIcon(icon)
# Left-click action
self.qaction.triggered.connect(self.exec_main_action)
# Right-click menu (already includes left-click action)
# TODO: Sync calibre to KOReader is disabled see more in #8
self.create_menu_action(
self.qaction.menu(),
'Sync missing to KOReader',
'Sync missing to KOReader',
icon='edit-undo.png',
description='If calibre has an entry in the "Raw sidecar column", '
'but KOReader does not have a sidecar file, push the '
'metadata from calibre to a new sidecar file.',
triggered=self.sync_missing_sidecars_to_koreader
)
self.create_menu_action(
self.qaction.menu(),
'Sync from ProgressSync',
'Sync from ProgressSync',
icon='convert.png',
description="Use KOReader's built in ProgressSync Plugin "
"to update percentRead int or float.",
triggered=self.sync_progress_from_progresssync
)
self.qaction.menu().addSeparator()
self.create_menu_action(
self.qaction.menu(),
'Configure KOReader Sync',
'Configure',
icon='config.png',
description='Configure KOReader Sync',
triggered=self.show_config
)
self.qaction.menu().addSeparator()
self.create_menu_action(
self.qaction.menu(),
'Readme for KOReader Sync',
'Readme',
icon='dialog_question.png',
description='Readme for KOReader Sync',
triggered=self.show_readme
)
self.create_menu_action(
self.qaction.menu(),
'About KOReader Sync',
'About',
icon='dialog_information.png',
description='About KOReader Sync',
triggered=self.show_about
)
# Start the scheduled progress sync if enabled
if CONFIG["checkbox_enable_scheduled_progressync"]:
self.scheduled_progress_sync()
# Start the device connection watcher if enabled
if CONFIG["checkbox_enable_automatic_sync"]:
device_signals.device_metadata_available.connect(
self._on_device_metadata_available)
basedir = os.path.dirname(base.plugin_path)
for filename in os.listdir(basedir):
if filename.startswith("KOSync_extension") and filename.endswith(".py"):
filepath = os.path.join(basedir, filename)
try:
spec = importlib.util.spec_from_file_location(
"KOSync_extension", filepath)
extension = importlib.util.module_from_spec(spec)
spec.loader.exec_module(extension)
if hasattr(extension, "onItemUpdate"):
self.extension_callback = extension.onItemUpdate
print(f"Loaded onItemUpdate from {filename}")
return
except Exception as e:
print(f"Failed to load extension: {e}")
def is_usb_device(self, device):
"""Returns True if the device is connected via USB Mass Storage or Folder Device."""
return isinstance(device, USBMS) or device.__class__.__name__ == 'FOLDER_DEVICE'
def exec_main_action(self) -> None:
# Execute main action defined by user
main_button = CONFIG['main_action']
if main_button == 'KOReader Sync':
self.sync_to_calibre()
elif main_button == 'Progress Sync':
self.sync_progress_from_progresssync()
else:
self.sync_to_calibre()
def show_config(self):
self.interface_action_base_plugin.do_user_config(self.gui)
def show_readme(self):
debug_print = partial(module_debug_print,
'KoreaderAction:show_readme:')
debug_print('start')
readme_url = QUrl(
'https://github.com/harmtemolder/koreader-calibre-plugin#readme'
)
open_url(readme_url)
def show_about(self):
debug_print = partial(module_debug_print, 'KoreaderAction:show_about:')
debug_print('start')
text = get_resources('about.txt').decode(
'utf-8'
)
if DEBUG:
text += '\n\nRunning in debug mode'
icon = get_icons(
'images/icon.png'
)
about_dialog = MessageBox(
MessageBox.INFO,
f'About {self.version}',
text,
det_msg='',
q_icon=icon,
show_copy_button=False,
parent=None,
)
return about_dialog.exec_()
def apply_settings(self):
debug_print = partial(
module_debug_print,
'KoreaderAction:apply_settings:'
)
debug_print('start')
def get_connected_device(self):
"""Tries to get the connected device, if any
:return: the connected device object or None
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:get_connected_device:'
)
try:
is_device_present = self.gui.device_manager.is_device_present
except:
is_device_present = False
if not is_device_present:
debug_print('is_device_present = ', is_device_present)
error_dialog(
self.gui,
'No device found',
'No device found',
det_msg='',
show=True,
show_copy_button=False
)
return None
try:
connected_device = self.gui.device_manager.connected_device
connected_device_type = connected_device.__class__.__name__
except:
debug_print('could not get connected_device')
error_dialog(
self.gui,
'Could not connect to device',
'Could not connect to device',
det_msg='',
show=True,
show_copy_button=False
)
return None
debug_print('connected_device_type = ', connected_device_type)
return connected_device
def _on_device_metadata_available(self):
self.sync_to_calibre(silent=not DEBUG)
def get_paths(self, device):
"""Retrieves paths to sidecars of all books in calibre's library
on the device
:param device: a device object
:return: a list of (uuid, path) tuples to sidecars
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:get_paths:'
)
debug_print(
f'found {len(device.books())} paths to books:\n\t',
'\n\t'.join([book.path for book in device.books()])
)
for book in device.books():
debug_print(f'uuid to path: {book.uuid} - {book.path}')
paths = []
for book in device.books():
# Ignore hidden folders (issue #101)
if any(part.startswith('.') for part in book.path.replace('\\\\', '/').split('/')):
debug_print(f'Ignoring book in hidden folder: {book.path}')
continue
sidecar_path = re.sub(
r'\.([^./\\]+)$', r'.sdr/metadata.\1.lua', book.path
)
paths.append((book.uuid, sidecar_path))
debug_print(
f'generated {len(paths)} path(s) to sidecar Lua files:\n\t',
'\n\t'.join([p[1] for p in paths])
)
return paths
def get_sidecar(self, device, path):
"""Requests the given path from the given device and returns the
contents of a sidecar Lua as Python dict
:param device: a device object
:param path: a path to a sidecar Lua on the device
:return: dict or None
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:get_sidecar:'
)
with io.BytesIO() as outfile:
try:
device.get_file(path, outfile)
except:
debug_print('could not get ', path)
return GetSidecarStatus.PATH_NOT_FOUND
contents = outfile.getvalue()
try:
decoded_contents = contents.decode()
except UnicodeDecodeError:
debug_print('could not decode ', contents)
return GetSidecarStatus.DECODE_FAILED
debug_print(f'Parsing: {path}')
parsed_contents = parse_sidecar_lua(decoded_contents)
parsed_contents['calculated'] = {}
# Ensure 'summary' exists to avoid KeyError later (#117)
if 'summary' not in parsed_contents:
debug_print(f"Warning: 'summary' key missing in sidecar for {path}")
parsed_contents['summary'] = {'status': 'unknown', 'modified': datetime.now().strftime("%Y-%m-%d")}
# Define metadata extraction tasks
is_usb = self.is_usb_device(device)
metadata_tasks = [
('date_synced', lambda: datetime.now().replace(tzinfo=local_tz)),
('date_status_changed', lambda: datetime.strptime(
parsed_contents['summary'].get('modified', datetime.now().strftime("%Y-%m-%d")), "%Y-%m-%d").replace(tzinfo=local_tz)),
('date_sidecar_modified', lambda: datetime.fromtimestamp(
os.path.getmtime(path) if is_usb and os.path.exists(path) else time.time()).replace(tzinfo=local_tz))
]
for key, task in metadata_tasks:
try:
parsed_contents['calculated'][key] = task()
except Exception as error:
debug_print(f'Failed to set {key}: {error}')
return parsed_contents
def get_calibre_uuid_from_sidecar(self, sidecar_contents):
"""Extracts the calibre UUID from sidecar identifiers if present.
(Issue #115)
"""
if not isinstance(sidecar_contents, dict):
return None
stats = sidecar_contents.get('stats', {})
identifiers_str = stats.get('identifiers', '')
if not identifiers_str:
return None
# KOReader uses both space and \ as separators in some versions
parts = re.split(r'[\s\\]+', identifiers_str)
for part in parts:
if part.startswith('calibre:'):
return part.replace('calibre:', '').strip()
return None
def update_metadata(self, uuid, db, keys_values_to_update):
"""Update multiple metadata columns for the given book.
:param uuid: identifier for the book
:param keys_values_to_update: a dict of keys to update with values
:return: a dict of values that can be used to report back to the user
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:update_metadata:'
)
try:
debug_print('Looking for uuid in calibre db: ', uuid)
book_id = db.lookup_by_uuid(uuid)
except:
book_id = None
if not book_id:
debug_print(f'could not find {uuid} in calibre\'s library')
return OperationStatus.SKIP, {
'result': 'could not find uuid in calibre\'s library, have you deleted this book from library?'}
# Get the current metadata for the book from the library
metadata = db.get_metadata(book_id)
# Dict for use in logging
updateLog = {}
read_percent_key = CONFIG['column_percent_read'] or CONFIG['column_percent_read_int']
# Check config to sync only if data is more recent
if CONFIG['checkbox_sync_if_more_recent']:
date_modified_key = CONFIG['column_date_sidecar_modified']
current_date_modified = metadata.get(date_modified_key)
new_date_modified = keys_values_to_update.get(date_modified_key)
if current_date_modified is not None and new_date_modified is not None:
if current_date_modified.timestamp() >= new_date_modified.timestamp():
debug_print(
f'book {book_id} date_modified {new_date_modified} older than current {current_date_modified}')
return OperationStatus.SKIP, {
'result': 'skipped, data in calibre is newer',
}
# Fallback if no 'Date Modified Column' is set or not obtainable (wireless)
elif new_date_modified is None:
current_read_percent = metadata.get(read_percent_key)
new_read_percent = keys_values_to_update.get(read_percent_key)
if current_read_percent is not None and new_read_percent is not None:
if current_read_percent >= new_read_percent:
debug_print(
f'book {book_id} read_percent {new_read_percent} lower or equal than current {current_read_percent}')
return OperationStatus.SKIP, {
'result': 'skipped, read Percent is lower or equal to the one stored in calibre',
'book_id': book_id,
}
elif current_read_percent is not None and new_read_percent is None:
debug_print(
f'book {book_id} read_percent is None but existing is {current_read_percent}')
return OperationStatus.SKIP, {
'result': 'skipped, no new read percent found',
}
# Check config to sync only if the book is not yet finished
status_key = CONFIG['column_status']
if CONFIG['checkbox_no_sync_if_finished']:
current_read_percent = metadata.get(read_percent_key)
current_status = metadata.get(status_key)
if current_read_percent is not None and current_read_percent >= 100 \
or current_status is not None and current_status == "complete":
debug_print(f'book {book_id} was already finished')
return OperationStatus.SKIP, {
'result': 'skipped, book already finished',
}
# Check and correct reading status if required
if status_key:
new_status = keys_values_to_update.get(status_key)
if not new_status:
new_read_percent = keys_values_to_update.get(read_percent_key)
current_status = metadata.get(status_key)
if new_read_percent and current_status != "abandoned":
if new_read_percent > 0 and new_read_percent < 100 and current_status != "reading":
debug_print(
f'book {book_id} set column_status to reading')
keys_values_to_update[status_key] = "reading"
status_bool_key = CONFIG['column_status_bool']
if status_bool_key:
keys_values_to_update[status_bool_key] = False
elif new_read_percent >= 100 and current_status != "complete":
debug_print(
f'book {book_id} set column_status to complete')
keys_values_to_update[status_key] = "complete"
status_bool_key = CONFIG['column_status_bool']
if status_bool_key:
keys_values_to_update[status_bool_key] = True
# Call the extension callback if it exists
if self.extension_callback:
try:
updateLog = self.extension_callback(
self=self,
metadata=metadata,
keys_values_to_update=keys_values_to_update,
updateLog=updateLog,
CONFIG=CONFIG,
book_id=book_id
)
except Exception as e:
debug_print(f'Error in extension onItemUpdate: {e}')
updates = []
# Update that metadata locally
for key, new_value in keys_values_to_update.items():
old_value = metadata.get(key)
if new_value != old_value:
updates.append(key)
metadata.set(key, new_value)
updateLog[key] = f'{old_value} >> {new_value}'
else:
if DEBUG:
updateLog[key] = f'{old_value} -- {new_value}'
# Write the updated metadata back to the library
if len(updates) == 0:
updateLog['result'] = 'no updates needed'
debug_print(
'no changed metadata for uuid = ', uuid,
', id = ', book_id
)
elif DEBUG and DRY_RUN:
debug_print(
'would have updated the following fields for uuid = ',
uuid, ', id = ', book_id, ': ', updates
)
else:
db.set_metadata(
book_id, metadata, set_title=False,
set_authors=False
)
debug_print(
'updated the following fields for uuid = ', uuid,
', id = ', book_id, ': ', updates
)
return OperationStatus.PASS, {
'result': 'success',
**updateLog
}
def check_device(self, device):
"""Return .
:param device: The connected device.
:return: False if device is specifically not supported,
otherwise True
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:check_device:'
)
if not device:
return False
device_class = device.__class__.__name__
if device_class in UNSUPPORTED_DEVICES:
debug_print('unsupported device, device_class = ', device_class)
error_dialog(
self.gui,
'Device not supported',
f'Devices of the type {device_class} are not supported by this plugin. I '
f'have tried to get it working, but couldn’t. Sorry.',
det_msg='',
show=True,
show_copy_button=False
)
return False
if device_class in SUPPORTED_DEVICES:
return True
debug_print(
'not yet supported device, device_class = ',
device_class
)
warning_dialog(
self.gui,
'Device not yet supported',
f'Devices of the type {device_class} are not yet supported by this plugin. '
f'Please check if there already is a feature request for this '
f'<a href="https://github.com/harmtemolder/koreader-calibre-plugin/issues">'
f'here</a>. If not, feel free to create one. I\'ll try to sync anyway.',
det_msg='',
show=True,
show_copy_button=False
)
return True
def device_path_exists(self, device, path):
"""Checks if a path exists on the device, with timing debug logs."""
debug_print = partial(
module_debug_print,
'KoreaderAction:device_path_exists:'
)
start_time = time.time()
exists = False
method = "unknown"
# 1. Try native driver exists() if available
if hasattr(device, 'exists'):
try:
exists = device.exists(path)
method = "driver.exists"
except:
pass
# 2. Try local filesystem (for USB)
if not exists and self.is_usb_device(device):
try:
if os.path.exists(path):
exists = True
method = "os.path.exists"
except:
pass
# 3. Try get_file (for Wireless) - this is the "expensive" fallback
if not exists and method == "unknown":
try:
with io.BytesIO() as dummy:
device.get_file(path, dummy)
exists = True
method = "device.get_file"
except:
exists = False
method = "device.get_file (failed)"
end_time = time.time()
debug_print(f"Path: {path} | Exists: {exists} | Method: {method} | Time: {end_time - start_time:.4f}s")
return exists
def push_metadata_to_koreader_sidecar(self, device, book_uuid, path):
"""Create a sidecar file for the given book.
:param device: The connected device object
:param book_uuid: Calibre's uuid for the book
:param path: path to sidecar file to create
:return: tuple of bool and result dict
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:push_metadata_to_koreader_sidecar:'
)
try:
db = self.gui.current_db.new_api
book_id = db.lookup_by_uuid(book_uuid)
debug_print(f"Book id is {book_id}")
except:
book_id = None
if not book_id:
debug_print(f'could not find {book_uuid} in calibre’s library')
return "failure", {
'result': f"Could not find uuid {book_uuid} in Calibre's "
f"library."
}
# Get the current metadata for the book from the library
metadata = db.get_metadata(book_id)
sidecar_metadata = metadata.get(CONFIG["column_sidecar"])
if not sidecar_metadata:
return "no_metadata", {
'result': f'No KOReader metadata for book_id {book_id}, no '
f'need to push.'
}
sidecar_dict = json.loads(sidecar_metadata)
sidecar_lua = lua.encode(sidecar_dict)
# Lua -> JSON -> Lua conversion is lossy, because JSON does not support integer
# keys. This means that a key like [1] will end up as ["1"] after the round
# trip. The following regex strips the quotes from any Lua object key that consists of
# only digits. This is not entirely correct because it now converts keys with
# only digits that were originally string keys as well, but it doesn't seem that
# KOReader uses those.
sidecar_lua = re.sub(r'\["(\d+)"\]', r'[\1]', sidecar_lua)
sidecar_lua_formatted = f"-- we can read Lua syntax here!\nreturn {sidecar_lua}\n"
# Create parent directory for USB devices (Issue #68 / #73)
is_usb = self.is_usb_device(device)
if is_usb:
try:
parent_dir = os.path.dirname(path)
if not os.path.exists(parent_dir):
debug_print(f"Creating directory: {parent_dir}")
os.makedirs(parent_dir, exist_ok=True)
except OSError as os_e:
debug_print(f"Failed to create directory {parent_dir}: {os_e}")
return "failure", {
'result': f'Unable to create directory at: {path} due to {os_e}',
}
# Use direct file writing for USB/Folder devices to avoid driver-specific put_file issues (#143)
try:
with open(path, "wb") as f:
debug_print(f"Writing directly to {path}")
f.write(sidecar_lua_formatted.encode('utf-8'))
return "success", {
'result': 'success',
}
except Exception as e:
debug_print(f"Failed to write directly to {path}: {e}")
return "failure", {
'result': f'Failed to write directly to device: {e}',
}
# Use device.put_file to support wireless devices (#122)
# Check if driver supports writing arbitrary files
if not hasattr(device, 'put_file'):
debug_print(f"Device driver {device.__class__.__name__} does not support writing sidecar files wirelessly.")
return "failure", {
'result': 'Wireless write not supported by this device driver. Please use USB or Sync Server.',
}
try:
with io.BytesIO(sidecar_lua_formatted.encode('utf-8')) as f:
device.put_file(path, f)
except Exception as e:
debug_print(f"Failed to push metadata to {path}: {e}")
return "failure", {
'result': f'Failed to write to device: {e}',
}
return "success", {
'result': 'success',
}
def sync_missing_sidecars_to_koreader(self, silent=False):
"""Push the content of Calibre's raw metadata column to KOReader
for any files which are missing in KOReader. Does not touch existing
metadata sidecars on KOReader.
Intended for e.g. setting up a new device and syncing to it for the first
time.
:return:
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:sync_missing_sidecars_to_koreader:'
)
if CONFIG["column_sidecar"] == '':
error_dialog(
self.gui,
'Failure',
'Raw metadata column not mapped, impossible to push metadata to sidecars',
show=True,
show_copy_button=False
)
return None
device = self.get_connected_device()
if not self.check_device(device):
return None
sidecar_paths = self.get_paths(device)
debug_print('sidecar_paths: ', sidecar_paths)
results = []
num_processed = 0
num_success = 0
num_no_metadata = 0
num_fail = 0
num_skipped_existing = 0
for book_uuid, path in sidecar_paths:
# Check if exists first (issue #122 revisited)
if self.device_path_exists(device, path):
debug_print(f"Skipping existing sidecar: {path}")
num_skipped_existing += 1
continue
num_processed += 1
result, details = self.push_metadata_to_koreader_sidecar(device, book_uuid,
path)
if result == "success":
num_success += 1
results.append(
{
**details,
'book_uuid': book_uuid,
'sidecar_path': path,
}
)
elif result == "failure":
num_fail += 1
results.append(
{
**details,
'book_uuid': book_uuid,
'sidecar_path': path,
}
)
elif result == "no_metadata":
num_no_metadata += 1
results.append(
{
**details,
'book_uuid': book_uuid,
'sidecar_path': path,
}
)
if not silent:
results_message = (
f'{len(sidecar_paths)} books on device.\n'
f'{num_skipped_existing} books already have sidecars (skipped).\n'
f'Sidecar creation succeeded for {num_success}.\n'
f'Sidecar creation failed for {num_fail}.\n'
f'No attempt made for {num_no_metadata} (no metadata in Calibre to push).\n'
f'See below for details.'
)
if num_success > 0 and num_fail > 0:
SyncCompletionDialog(
self.gui,
'Results',
results_message,
results,
'warn'
)
elif num_success > 0 or num_no_metadata > 0: # and num_fail == 0
SyncCompletionDialog(
self.gui,
'Success',
results_message,
results,
'info'
)
else:
SyncCompletionDialog(
self.gui,
'Failure',
results_message,
results,
'error'
)
def sync_progress_from_progresssync(self, silent=False):
"""Use KOReader's ProgressSync Server to update Calibre metadata rather than a manual sync.
Intended to easily update Calibre with the latest reading progress from KOReader.
:return:
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:sync_progress_from_progresssync:'
)
md5_column = CONFIG["column_md5"]
if md5_column == '':
error_dialog(
self.gui,
'Failure',
'MD5 column not mapped, impossible to get metadata from Progress Sync Server',
show=True,
show_copy_button=False
)
return None
if CONFIG["progress_sync_password"] == '':
error_dialog(
self.gui,
'Failure',
'Progress Sync Account is not logged in, add credentials in plugin settings',
show=True,
show_copy_button=False
)
return None
status_key = CONFIG['column_status']
read_percent_key = CONFIG['column_percent_read_int'] or CONFIG['column_percent_read']
if read_percent_key == '' or status_key == '':
error_dialog(
self.gui,
'Failure',
'This feature needs a KOReader Progress (int or float) and Status Text column.\n'
'Add those in plugin settings and try again.',
show=True,
show_copy_button=False
)
return None
'Get list of books with MD5 column'
db = self.gui.current_db.new_api
books_with_md5 = db.search(f'{md5_column}:!''')
results = []
num_success = 0
num_skip = 0
headers = {
'x-auth-user': CONFIG["progress_sync_username"],
'x-auth-key': CONFIG["progress_sync_password"],
'Accept': 'application/vnd.koreader.v1+json',
'Connection': 'keep-alive',
'Cache-Control': 'no-cache',
'User-Agent': f'CalibreKOReaderSync/{self.version}'
}
# Create SSL context based on user preference
ssl_context = ssl.create_default_context()
if CONFIG['checkbox_skip_ssl_verification']:
# Skip SSL verification for custom servers with self-signed certificates
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
for book_id in books_with_md5:
metadata = db.get_metadata(book_id)
md5_value = metadata.get(md5_column)
book_uuid = metadata.get('uuid')
title = metadata.get('title')
# Only get sync status if curr progress < 100 and status = reading or if curr_progress/status is not set yet
metadata_status = metadata.get(status_key)
metadata_read_percent = metadata.get(read_percent_key)
if (metadata_status is None or metadata_status == "reading") and (metadata_read_percent is None or metadata_read_percent < 100):
try:
url = f'{CONFIG["progress_sync_url"]}/syncs/progress/{md5_value}'
request = Request(url, headers=headers)
with urlopen(request, timeout=20, context=ssl_context) as response:
response_data = response.read()
if response_data == b'{}':
results.append({
'md5_value': md5_value,
'error': 'No ProgressSync entry for md5 hash'
})
num_skip += 1
continue
progress_data = json.loads(response_data.decode('utf-8'))
# Kinda Janky edge case handling
if len(str(progress_data)) < 8:
continue
# List of keys to check
ProgressSync_Columns = [
'column_percent_read', 'column_percent_read_int', 'column_last_read_location', 'column_date_synced', 'column_device_name', 'column_device_id']
# Map of progress_data keys to match each config key
progress_mapping = {
'column_percent_read': progress_data['percentage'] if not CONFIG["checkbox_percent_read_100"] else progress_data['percentage']*100,
'column_percent_read_int': round(progress_data['percentage']*100),
'column_last_read_location': progress_data['progress'],
'column_date_synced': datetime.fromtimestamp(progress_data['timestamp']/1000, tz=local_tz),
'column_device_name': progress_data['device'],
'column_device_id': progress_data['device_id']
}
# Change percentage to be human readable on summary screen
if CONFIG["checkbox_percent_read_100"]:
progress_data['percentage']*=100
# Dictionary to store values to be updated
keys_values_to_update = {}
# Set column_date_book_started if this is the first sync and column not already filled
date_book_started_key = CONFIG.get('column_date_book_started')
if date_book_started_key is not None:
if metadata.get(date_book_started_key) is None:
keys_values_to_update[date_book_started_key] = progress_mapping['column_date_synced']
# Set column_date_book_finished if this book is finished and column not already filled
if progress_mapping['column_percent_read_int'] >= 100:
date_book_finished_key = CONFIG.get('column_date_book_finished')
if date_book_finished_key is not None:
if metadata.get(date_book_finished_key) is None:
keys_values_to_update[date_book_finished_key] = progress_mapping['column_date_synced']
for key in ProgressSync_Columns:
# Get internal column name from CONFIG
internal_column = CONFIG.get(key, '')
if not internal_column: # Skip if internal column name is blank
continue
# Get current value from metadata
current_value = metadata.get(internal_column)
remote_value = progress_mapping[key]
# Compare current and remote values
if current_value != remote_value:
keys_values_to_update[internal_column] = remote_value
# TODO This is redundant isn't it? I can remove a whole chunk of this ngl.
# Update only if there are differences
if keys_values_to_update:
operation_status, result = self.update_metadata(
book_uuid, db, keys_values_to_update)
else:
result = {}
results.append({
**result,
'title': title,
'book_uuid': book_uuid,
'md5_value': md5_value,
**progress_data
})
num_success += 1
except (HTTPError, URLError) as e:
msg = f'Failed to make progress sync query: {url}, error: {str(e)}'
debug_print(msg)
results.append({
'title': title,
'book_uuid': book_uuid,
'md5_value': md5_value,
'error': 'No data received'
})
num_skip += 1
else:
results.append({
'title': title,
'book_uuid': book_uuid,
'md5_value': md5_value,
'error': 'Book has already been read'
})
num_skip += 1
if not silent:
results_message = (
f'Total books with MD5 values: {len(books_with_md5)}\n\n'
f'Successful syncs: {num_success}\n'
f'Failed/Skipped syncs: {num_skip}\n\n'
)
if num_success > 0 and num_skip == 0:
SyncCompletionDialog(
self.gui,
'Progress sync finished',
results_message + 'All looks good!\n\n',
results,
'info'
)
elif num_skip > 0:
SyncCompletionDialog(
self.gui,
'Some syncs failed',
results_message + 'There were some errors during the sync process!\n'
'Please investigate and report if it looks like a bug\n\n',
results,
'warn'
)
else:
SyncCompletionDialog(
self.gui,
'No successful syncs',
results_message + 'No successful syncs\n'
'Please investigate and report if it looks like a bug\n\n',
results,
'error'
)
def scheduled_progress_sync(self):
def scheduledTask():
# Set another timer for the next day and order sync
QTimer.singleShot(24 * 3600 * 1000, scheduledTask)
self.sync_progress_from_progresssync(
silent=True if not DEBUG else False)
def main():
# Get current local time
currentTime = QTime.currentTime()
# Set target time to user inputted time
targetTime = QTime(
CONFIG["scheduleSyncHour"], CONFIG["scheduleSyncMinute"])
# Calculate the time difference
timeDiff = currentTime.msecsTo(targetTime)
# If target time has already passed today, set the target time for tomorrow
if timeDiff < 0:
timeDiff = timeDiff + 86400000
# Create a QTimer to trigger the task at the desired time
QTimer.singleShot(timeDiff, scheduledTask)
main() # Runs scheduled_progress_sync
def sync_to_calibre(self, silent=False):
"""This plugin’s main purpose. It syncs the contents of
KOReader’s metadata sidecar files into calibre’s metadata.
:return:
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:sync_to_calibre:'
)
device = self.get_connected_device()
if not self.check_device(device):
return None
sidecar_paths = self.get_paths(device)
debug_print('sidecar_paths:', sidecar_paths)
class KOSyncWorker(QThread):
progress_update = pyqtSignal(int, str)
finished_signal = pyqtSignal(dict)
def __init__(self, action, db, sidecar_paths):
super().__init__()
self.action = action
self.db = db
self.sidecar_paths = sidecar_paths
def run(self):
results = []
num_success = 0
num_fail = 0
num_skip = 0
for idx, (book_uuid, sidecar_path) in enumerate(self.sidecar_paths):
debug_print('Trying to get sidecar from ', device,
', with sidecar_path: ', sidecar_path)
# pre-checks before parsing
if book_uuid is None:
status = 'skipped, no UUID'
append_results(results, None, status,
book_uuid, sidecar_path)
num_skip += 1
continue
sidecar_contents = self.action.get_sidecar(
device, sidecar_path)
debug_print("sidecar_contents:", sidecar_contents)
try:
book_id = db.lookup_by_uuid(book_uuid)
if not book_id:
# Try to find a better UUID in the sidecar (Issue #115)
better_uuid = self.action.get_calibre_uuid_from_sidecar(sidecar_contents)
if better_uuid:
debug_print(f"Found alternative UUID in sidecar: {better_uuid}")
book_id = db.lookup_by_uuid(better_uuid)
if book_id:
book_uuid = better_uuid # Use the one that worked
if not book_id:
raise Exception("Book not found")
metadata = db.get_metadata(book_id)
title = metadata.get('title')
except Exception as e:
debug_print(f"Failed to lookup book {book_uuid}: {e}")
status = 'skipped, could not find in library'
append_results(results, "Unknown", status,
book_uuid, sidecar_path)
num_skip += 1
continue
self.progress_update.emit(idx + 1, title)
if DEBUG: # Add time delay when debugging
time.sleep(.4)
if sidecar_contents is GetSidecarStatus.PATH_NOT_FOUND:
status = ('skipped, sidecar does not exist '
'(seems like book is never opened)')
append_results(results, title, status,
book_uuid, sidecar_path)
num_skip += 1
continue
if sidecar_contents is GetSidecarStatus.DECODE_FAILED:
status = 'decoding is failed see debug for more details'
append_results(results, title, status,
book_uuid, sidecar_path)
num_fail += 1
continue
debug_print('sidecar_contents is found!')
keys_values_to_update = {}
for config_name, column in COLUMNS.items():
target = CONFIG[config_name]
if target == '':
# No column mapped, so do not sync
continue
# Special handling for date started/finished
# Safety check for 'summary' key (#117)
summary = sidecar_contents.get('summary', {})
if config_name == 'column_date_book_started':
if metadata.get(target) is None and summary.get('status') == 'reading':
sidecar_contents['calculated']['date_book_started'] = sidecar_contents['calculated'].get('date_status_changed')
if config_name == 'column_date_book_finished':
if metadata.get(target) is None and summary.get('status') == 'complete':
sidecar_contents['calculated']['date_book_finished'] = sidecar_contents['calculated'].get('date_status_changed')
data_location = column['data_location']
value = sidecar_contents
for subproperty in data_location:
if value and subproperty in value:
value = value[subproperty]
else:
debug_print(
f'subproperty "{subproperty}" not found in value')
value = None
break
# Fallback for MD5 (Issue #98)
if config_name == 'column_md5' and value is None:
value = sidecar_contents.get('stats', {}).get('md5')
if value:
debug_print('Found MD5 in fallback location (stats.md5)')
if value is None:
continue
# Transform value if required
if 'transform' in column:
debug_print('transforming value for ', target)
value = column['transform'](value)
keys_values_to_update[target] = value
operation_status, result = self.action.update_metadata(
book_uuid, db, keys_values_to_update)
results.append(
{
**result,
'title': title,
'book_uuid': book_uuid,
'sidecar_path': sidecar_path,
**({'updated': json.dumps(keys_values_to_update, default=str)} if DEBUG else {})
}
)
if operation_status == OperationStatus.PASS:
num_success += 1
elif operation_status == OperationStatus.FAIL:
num_fail += 1
elif operation_status == OperationStatus.SKIP:
num_skip += 1
self.finished_signal.emit(
{'results': results, 'num_success': num_success, 'num_fail': num_fail, 'num_skip': num_skip})
db = self.gui.current_db.new_api
startTime = time.perf_counter()
self.koSyncWorker = KOSyncWorker(self, db, sidecar_paths)
progress_dialog = None
if not silent and len(sidecar_paths) > 10:
progress_dialog = ProgressDialog(
self.gui, "Syncing Sidecars...", len(sidecar_paths))
progress_dialog.show()
self.koSyncWorker.progress_update.connect(progress_dialog.setValue)
def on_finished(res):
if not silent:
if progress_dialog:
progress_dialog.close()
results_message = (
f"Total targets found: {len(sidecar_paths)}\n\n"
f"Metadata sync succeeded for: {res['num_success']}\n"
f"Metadata sync skipped for: {res['num_skip']}\n"
f"Metadata sync failed for: {res['num_fail']}\n"
f"Time taken: {time.perf_counter() - startTime:.4f} seconds.\n\n"
)
# Sort by if error, then # of changes
res['results'].sort(key=lambda row: (
not row.get('error', False), -len(row)))
if res['num_success'] > 0 and res['num_fail'] == 0:
SyncCompletionDialog(
self.gui,
'Metadata sync finished',
results_message + 'All looks good!\n\n',
res['results'],
'info'
)
elif res['num_fail'] > 0:
SyncCompletionDialog(
self.gui,
'Some sync failed',
results_message + 'There was some error during sync process!\n'
'Please investigate and report if it looks '
'like a bug\n\n',
res['results'],
'error'
)
elif res['num_success'] == 0 and res['num_fail'] == 0:
SyncCompletionDialog(
self.gui,
'No errors but not successful syncs',
results_message + 'No errors but no successful syncs\n'
'Do you have book(s) which are ready to be '
'sync?\n'
'Please investigate and report if it looks '
'like a bug\n\n',
res['results'],
'warn'
)
else:
error_dialog(
self.gui,
'Edge case',
results_message + 'Seems like and bug, please report ASAP\n\n',
det_msg=json.dumps(res['results'], indent=2),
show=True,
show_copy_button=False
)
self.koSyncWorker.finished_signal.connect(on_finished)
self.koSyncWorker.start()
class ProgressDialog(QDialog):
def __init__(self, parent, title: str, count: int):
super().__init__(parent)
self.setWindowTitle(title)
self.setWindowModality(Qt.WindowModal)
layout = QVBoxLayout(self)
self.progressBar = QProgressBar(self)
self.progressBar.setMinimum(0)
self.progressBar.setMaximum(count)
self.progressBar.setFormat("%v of %m")
layout.addWidget(self.progressBar)
self.currBook = QLabel('Beginning Sync')
layout.addWidget(self.currBook)
def setValue(self, idx: int, bookTitle: str):
self.progressBar.setValue(idx)
self.currBook.setText(bookTitle)
class SyncCompletionDialog(QDialog):
def __init__(self, parent=None, title="", msg="", results=None, type=None):
super().__init__(parent)
self.setWindowTitle(title)
self.setMinimumWidth(800)
self.setMinimumHeight(800)
layout = QVBoxLayout(self)
layout.setSpacing(10)
# Main Message Area
mainMessageLayout = QHBoxLayout()
type_icon = {
'info': 'dialog_information',
'error': 'dialog_error',
'warn': 'dialog_warning',
}.get(type)
if type_icon is not None:
icon = QIcon.ic(f'{type_icon}.png')
self.setWindowIcon(icon)
icon_widget = QLabel(self)
icon_widget.setPixmap(icon.pixmap(64, 64))
mainMessageLayout.addWidget(icon_widget)
message_label = QLabel(msg)
mainMessageLayout.addWidget(message_label)
mainMessageLayout.addStretch() # Left align the message/text
layout.addLayout(mainMessageLayout)
# Table in scrollable area if results are provided
if results:
self.table_area = QScrollArea(self)
self.table_area.setWidgetResizable(True)
table = self.create_results_table(results)
self.table_area.setWidget(table)
layout.addWidget(self.table_area)
# Bottom Buttons
bottomButtonLayout = QHBoxLayout()
if results:
copy_button = QPushButton("COPY", self)
copy_button.setFixedWidth(200)
copy_button.setIcon(QIcon.ic('edit-copy.png'))
copy_button.clicked.connect(lambda: (
QApplication.clipboard().setText(str(results)),
copy_button.setText('Copied')
))
bottomButtonLayout.addWidget(copy_button)
bottomButtonLayout.addStretch() # Right align the rest of this layout
ok_button = QPushButton("OK", self)
ok_button.setFixedWidth(200)
ok_button.setIcon(QIcon.ic('ok.png'))
ok_button.clicked.connect(self.accept)
ok_button.setDefault(True)
bottomButtonLayout.addWidget(ok_button)
layout.addLayout(bottomButtonLayout)
self.show()
def create_results_table(self, results):
# Get all possible headers from results and save as set
all_headers = {key for result in results for key in result.keys()}
headers = []
custom_columns = sorted(h for h in all_headers
if h not in ('title', 'book_uuid', 'result', 'error'))
if 'title' in all_headers:
headers.append('title')
if 'book_uuid' in all_headers:
headers.append('book_uuid')
if 'result' in all_headers:
headers.append('result')
if 'error' in all_headers:
headers.append('error')
if custom_columns:
headers.extend(custom_columns)
table = QTableWidget()
table.setRowCount(len(results))
table.setColumnCount(len(headers))
table.setHorizontalHeaderLabels(headers)
for row, result in enumerate(results):
for col, header in enumerate(headers):
item = QTableWidgetItem(str(result.get(header, "")))
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
# Set the tooltip to the full text
item.setToolTip(item.text())
table.setItem(row, col, item)
max_lines = 1
for col, header in enumerate(headers):
words, line, lines, col_len_limit = header.split(
), "", [], max(table.columnWidth(col) // 7, 10)
for word in words:
line = f"{line} {word}".strip()
if len(line) > col_len_limit:
lines.append(line.rsplit(' ', 1)[0])
line = word if ' ' in line else ''
lines.append(line)
max_lines = max(len(lines), max_lines)
wrapped = '\n'.join(lines)
table.setHorizontalHeaderItem(col, QTableWidgetItem(wrapped))
table.horizontalHeader().setFixedHeight(20 * max_lines) # Default = 20
return table
================================================
FILE: config.py
================================================
#!/usr/bin/env python3
"""Config for KOReader Sync plugin for Calibre."""
import os
import json
from functools import partial
from PyQt5.Qt import (
QComboBox,
QCheckBox,
QGroupBox,
QPushButton,
QLabel,
QLineEdit,
QHBoxLayout,
QVBoxLayout,
QFormLayout,
QWidget,
QSpinBox,
QFrame,
QDialog,
Qt,
)
from PyQt5.QtGui import QPixmap
from calibre.constants import numeric_version
from calibre.devices.usbms.driver import debug_print as root_debug_print
from calibre.utils.config import JSONConfig
from calibre_plugins.koreader import clean_bookmarks
from calibre.gui2 import show_restart_warning
__license__ = 'GNU GPLv3'
__copyright__ = '2021, harmtemolder <mail at harmtemolder.com>'
__modified_by__ = 'kyxap kyxappp@gmail.com'
__modification_date__ = '2024'
__docformat__ = 'restructuredtext en'
SUPPORTED_DEVICES = [
'FOLDER_DEVICE',
'KINDLE2',
'KOBO',
'KOBOTOUCH',
'KOBOTOUCHEXTENDED',
'POCKETBOOK622',
'POCKETBOOK626',
'POCKETBOOK632',
'POCKETBOOK_IMPROVED',
'SMART_DEVICE_APP',
'TOLINO',
'USER_DEFINED',
]
UNSUPPORTED_DEVICES = [
'MTP_DEVICE',
]
try:
from calibre.gui2.preferences.create_custom_column import CreateNewCustomColumn
SUPPORTS_CREATE_CUSTOM_COLUMN = True
except ImportError:
SUPPORTS_CREATE_CUSTOM_COLUMN = False
"""
Each entry in the below dict has the following keys:
Each entry is keyed by the name of the config item used to store the selected column's lookup name
first_in_group (optional): If present and true, a separator will be added before this item in the Config UI.
If this is a string a QLabel with bolded string value will be added below the separator.
column_heading: Default custom column heading
datatype: Custom column datatype
is_multiple (optional): For text columns, specified as a tuple (default_multiple, only_multiple_in_dropdown)
additional_params (optional): Additional parameters for the custom column display parameter as specified in the calibre API as a dictionary.
https://github.com/kovidgoyal/calibre/blob/bc29562c0c8534b349c9d330ac9aec72eef2be99/src/calibre/gui2/preferences/create_custom_column.py#L901
description: Default custom column description
default_lookup_name: The suggested column lookup string in calibre (e.g. "#ko_progfloat")
config_label: Label for the item in the Config UI
config_tool_tip: Tooltip for the item in the Config UI
data_source: Source of the data; 'sidecar' is the KOReader sidecar file.
data_location: List of keys used to locate the data in the data_source dictionary
transform (optional): lambda expression to format the value
"""
CUSTOM_COLUMN_DEFAULTS = {
'column_percent_read': {
'column_heading': _("KOReader Precise Progress"),
'datatype': 'float',
'additional_params': {'number_format': "{:.2%}"},
'description': _("Reading progress for the book with decimal precision."),
'default_lookup_name': '#ko_progfloat',
'config_label': _('Percent read column (float):'),
'config_tool_tip': _('A "Floating point numbers" column to store the current\n'
'percent read, with "Format for numbers" set to 0.00%.'),
'data_source': 'sidecar',
'data_location': ['percent_finished'],
'transform': (lambda value: float(value)),
},
'column_percent_read_int': {
'column_heading': _("KOReader Progress"),
'datatype': 'int',
'additional_params': {'number_format': "{}%"},
'description': _("Reading progress for the book."),
'default_lookup_name': '#ko_progint',
'config_label': _('Percent read column (int):'),
'config_tool_tip': _('An "Integers" column to store the current percent read.'),
'data_source': 'sidecar',
'data_location': ['percent_finished'],
'transform': (lambda value: round(float(value) * 100)),
},
'column_status': {
'column_heading': _("KOReader Book Status"),
'datatype': 'text',
'description': _("Reading status of the book, either Finished, Reading, or On hold."),
'default_lookup_name': '#ko_status',
'config_label': _('Reading status column (text):'),
'config_tool_tip': _('A regular "Text" column to store the reading status of the\n'
'book, as entered on the book status page ("Finished",\n'
'"Reading", "On hold").'),
'data_source': 'sidecar',
'data_location': ['summary', 'status'],
},
'column_status_bool': {
'column_heading': _("KOReader Book Status Y/N"),
'datatype': 'bool',
'description': _("Yes if the book is marked as finished in KOReader, otherwise No."),
'default_lookup_name': '#ko_statusbool',
'config_label': _('Reading status column (yes/no):'),
'config_tool_tip': _('A "Yes/No" column to store the reading status of the book,\n'
'as a boolean ("Yes" = "Finished", "No" = everything else).'),
'data_source': 'sidecar',
'data_location': ['summary', 'status'],
'transform': (lambda val: bool(val == 'complete')),
},
'column_last_read_location': {
'column_heading': _("KOReader Last Location"),
'datatype': 'text',
'description': _("Last location you stopped reading at in the book."),
'default_lookup_name': '#ko_loc',
'config_label': _('Last read location column:'),
'config_tool_tip': _('A regular "Text" column to store the location you last\n'
'stopped reading at.'),
'data_source': 'sidecar',
'data_location': ['last_xpointer'],
},
'column_date_book_started': {
'column_heading': _("Date KOReader Started"),
'datatype': 'datetime',
'description': _("Date when the book was started."),
'default_lookup_name': '#ko_start',
'config_label': _('Date Book Started column:'),
'config_tool_tip': _('A "Date" column to store when the book was started. '
'Will only be set once when synced with reading status.'),
'data_source': 'sidecar',
'data_location': ['calculated', 'date_book_started'],
},
'column_date_book_finished': {
'column_heading': _("Date KOReader Finished"),
'datatype': 'datetime',
'description': _("Date when the book was finished."),
'default_lookup_name': '#ko_finish',
'config_label': _('Date Book Finished column:'),
'config_tool_tip': _('A "Date" column to store when the book was finished. '
'Will only be set once when synced with finished status.'),
'data_source': 'sidecar',
'data_location': ['calculated', 'date_book_finished'],
},
'column_rating': {
'first_in_group': True,
'column_heading': _("KOReader Rating"),
'datatype': 'rating',
'description': _("Rating for the book."),
'default_lookup_name': '#ko_rating',
'config_label': _('Rating column:'),
'config_tool_tip': _('A "Rating" column to store your rating of the book,\n'
'as entered on the book’s status page.'),
'data_source': 'sidecar',
'data_location': ['summary', 'rating'],
# calibre uses a 10-point scale,
'transform': (lambda value: value * 2),
},
'column_review': { # Unsure about Interpret this column as
'column_heading': _("KOReader Review"),
'datatype': 'comments',
'description': _("Review of book."),
'default_lookup_name': '#ko_review',
'config_label': _('Review column:'),
'config_tool_tip': _('A "Long text" column to store your review of the book,\n'
'as entered on the book’s status page.'),
'data_source': 'sidecar',
'data_location': ['summary', 'note'],
},
'column_bookmarks': {
'column_heading': _("KOReader Bookmarks"),
'datatype': 'comments',
'description': _("All the bookmarks and highlights from KOReader."),
'default_lookup_name': '#ko_bookmarks',
'config_label': _('Bookmarks column:'),
'config_tool_tip': _('A "Long text" column to store your bookmarks and highlights.'),
'data_source': 'sidecar',
'data_location': ['annotations'],
'transform': clean_bookmarks,
},
'column_md5': {
'first_in_group': True,
'column_heading': _("KOReader MD5"),
'datatype': 'text',
'description': _("MD5 hash used by KOReader, allowed for ProgressSync Support."),
'default_lookup_name': '#ko_md5',
'config_label': _('MD5 hash column:'),
'config_tool_tip': _('A regular "Text" column to store the MD5 hash KOReader uses\n'
'to sync progress to a KOReader Sync Server. ("Progress sync"\n'
'in the KOReader app.)'),
'data_source': 'sidecar',
'data_location': ['partial_md5_checksum'],
},
'column_device_name': {
'column_heading': _("KOReader Device Name"),
'datatype': 'text',
'description': _("Last Synced Device Name from ProgressSync."),
'default_lookup_name': '#ko_device_name',
'config_label': _('ProgressSync Device Name:'),
'config_tool_tip': _('A regular "Text" column to store the last device name used\n'
'to sync progress via ProgressSync.'),
'data_source': 'progresssync',
'data_location': ['device'],
},
'column_device_id': {
'column_heading': _("KOReader Device ID"),
'datatype': 'text',
'description': _("Last Synced Device ID from ProgressSync."),
'default_lookup_name': '#ko_device_id',
'config_label': _('ProgressSync Device ID:'),
'config_tool_tip': _('A regular "Text" column to store the last device id used\n'
'to sync progress via ProgressSync.'),
'data_source': 'progresssync',
'data_location': ['device'],
},
'column_date_synced': {
'column_heading': _("Date KOReader Synced"),
'datatype': 'datetime',
'description': _("Date when the book was last synced from KOReader."),
'default_lookup_name': '#ko_lastsync',
'config_label': _('Date Synced column:'),
'config_tool_tip': _('A "Date" column to store when the last sync was performed.'),
'data_source': 'sidecar',
'data_location': ['calculated', 'date_synced'],
},
'column_date_sidecar_modified': {
'column_heading': _("Date KOReader Modified"),
'datatype': 'datetime',
'description': _("Date when the book was last modified in KOReader. Wired sync only."),
'default_lookup_name': '#ko_lastmod',
'config_label': _('Date Modified column:'),
'config_tool_tip': _('A "Date" column to store when the sidecar file was last '
'modified. Works for wired connection only, wireless will be '
'always empty'),
'data_source': 'sidecar',
'data_location': ['calculated', 'date_sidecar_modified'],
},
'column_sidecar': { # Unsure about Interpret this column as
'column_heading': _("KOReader Raw Sidecar"),
'datatype': 'comments',
'description': _("Raw sidecar data directly from KOReader. Allows sync to KOReader, also serves as a backup."),
'default_lookup_name': '#ko_sidecar',
'config_label': _('Raw sidecar column:'),
'config_tool_tip': _('A "Long text" column to store the contents of the\n'
'metadata sidecar as JSON, with "Interpret this column as" set to\n'
'"Plain text". This is required to sync metadata back to KOReader sidecars.'),
'data_source': 'sidecar',
'data_location': [], # [] gives the entire sidecar dict
'transform': (lambda d: json.dumps(
{k: d[k] for k in d if k != 'calculated'},
skipkeys=True,
indent=2,
default=str
)),
},
}
CHECKBOXES = { # Each entry in the below dict is keyed with config_name
'checkbox_percent_read_100': {
'config_label': 'Percent read column (float) range 0.0-100.0',
'config_tool_tip': 'Default the range is 0.0-1.0\n'
'Checking this option the float value is multiplied by 100 to be in range 0.0-100.0',
},
'checkbox_sync_if_more_recent': {
'config_label': 'Sync only if changes are more recent',
'config_tool_tip': 'Sync book only if the metadata is more recent. Requires\n'
'"Date Modified Column" or "Percent read column" to be synced',
},
'checkbox_no_sync_if_finished': {
'config_label': 'No sync if book has already been finished',
'config_tool_tip': 'Do not sync book if it has already been finished. Requires\n'
'"Percent read column" or "Reading status column" to be synced',
},
'checkbox_enable_automatic_sync': {
'config_label': 'Automatic Sync on device connection',
'config_tool_tip': 'Sync from KOReader automatically on device connection. \n'
'Restart calibre to apply this setting',
},
'checkbox_enable_scheduled_progressync': {
'config_label': 'Daily ProgressSync',
'config_tool_tip': 'Enable daily sync of reading progress and location using \n'
'KOReader\'s ProgressSync server.',
},
'checkbox_skip_ssl_verification': {
'config_label': 'Skip SSL certificate verification for ProgressSync',
'config_tool_tip': 'Disable SSL certificate verification when connecting to ProgressSync server.\n'
'Enable this if you use a custom server with self-signed certificates or IP addresses.\n'
'Warning: This reduces security. Only use with trusted servers.',
},
}
CONFIG = JSONConfig(os.path.join('plugins', 'KOReader Sync.json'))
for this_column in CUSTOM_COLUMN_DEFAULTS:
CONFIG.defaults[this_column] = ''
for this_checkbox in CHECKBOXES:
CONFIG.defaults[this_checkbox] = False
CONFIG.defaults['checkbox_skip_ssl_verification'] = False
CONFIG.defaults['progress_sync_url'] = 'https://sync.koreader.rocks:443'
CONFIG.defaults['progress_sync_username'] = ''
CONFIG.defaults['progress_sync_password'] = ''
CONFIG.defaults['scheduleSyncHour'] = 4
CONFIG.defaults['scheduleSyncMinute'] = 0
CONFIG.defaults['main_action'] = 'KOReader Sync'
if numeric_version >= (5, 5, 0):
module_debug_print = partial(root_debug_print, ' koreader:config:', sep='')
else:
module_debug_print = partial(root_debug_print, 'koreader:config:')
def create_separator():
separator = QFrame()
separator.setFrameShape(QFrame.HLine)
separator.setFrameShadow(QFrame.Sunken)
return separator
class ConfigWidget(QWidget): # https://doc.qt.io/qt-5/qwidget.html
def __init__(self, plugin_action):
QWidget.__init__(self)
debug_print = partial(module_debug_print, 'ConfigWidget:__init__:')
debug_print('start')
self.action = plugin_action
self.must_restart = False
# Set up main layout
layout = QVBoxLayout()
self.setLayout(layout)
# Add icon and title
title_layout = TitleLayout(
self,
'images/icon.png',
f'Configure {self.action.version}',
)
layout.addLayout(title_layout)
# Add custom column dropdowns
self._get_create_new_custom_column_instance = None
self.sync_custom_columns = {}
bottom_options_layout = QHBoxLayout()
layout.addLayout(bottom_options_layout)
columns_group_box = QGroupBox(
_('Synchronisable Custom Columns:'), self)
bottom_options_layout.addWidget(columns_group_box)
columns_group_box_layout = QHBoxLayout()
columns_group_box.setLayout(columns_group_box_layout)
columns_group_box_layout2 = QFormLayout()
columns_group_box_layout.addLayout(columns_group_box_layout2)
columns_group_box_layout.addStretch()
for config_name, metadata in CUSTOM_COLUMN_DEFAULTS.items():
self.sync_custom_columns[config_name] = {'current_columns': self.get_custom_columns(
metadata['datatype'], metadata.get('is_multiple', (False, False))[1])}
self._column_combo = self.create_custom_column_controls(
columns_group_box_layout2, config_name)
metadata['comboBox'] = self._column_combo
self._column_combo.populate_combo(
self.sync_custom_columns[config_name]['current_columns'],
CONFIG[config_name]
)
# Main action combobox
main_action_layout = QHBoxLayout()
main_action_layout.setAlignment(Qt.AlignLeft)
self.main_action_box_label = QLabel('Main button:')
tooltip = 'Select which action will be used for the main button on calibre GUI'
self.main_action_box_label.setToolTip(tooltip)
self.main_action_combo = QComboBox()
self.main_action_combo.setToolTip(tooltip)
self.main_action_combo.setMinimumWidth(200)
self.main_action_combo.addItems({'KOReader Sync', 'Progress Sync'})
self.main_action_combo.model().sort(0)
self.main_action_box_label.setBuddy(self.main_action_combo)
main_action_layout.addWidget(self.main_action_box_label)
main_action_layout.addWidget(self.main_action_combo)
self.main_action_combo.setCurrentText(CONFIG['main_action'])
layout.addLayout(main_action_layout)
# Add custom checkboxes
layout.addLayout(self.add_checkbox('checkbox_percent_read_100'))
layout.addLayout(self.add_checkbox('checkbox_sync_if_more_recent'))
layout.addLayout(self.add_checkbox('checkbox_no_sync_if_finished'))
layout.addLayout(self.add_checkbox('checkbox_enable_automatic_sync'))
# Progress Sync Section
layout.addWidget(create_separator())
ps_header_label = QLabel(
"This plugin supports use of KOReader's built-in ProgressSync server to update reading progress and location without the device connected. "
"You must have an MD5 column mapped and use Binary matching in KOReader's ProgressSync Settings (default).\n"
"You also need a reading progress column and status text column.\n"
"This functionality can optionally be scheduled into a daily sync from within calibre. "
"Enter scheduled time in military time, default is 4 AM local time. You must restart calibre after making changes to scheduled sync settings. "
)
ps_header_label.setWordWrap(True)
layout.addWidget(ps_header_label)
# Add SSL verification checkbox
layout.addLayout(self.add_checkbox('checkbox_skip_ssl_verification'))
# Add scheduled sync options
scheduled_sync_layout = QHBoxLayout()
scheduled_sync_layout.setAlignment(Qt.AlignLeft)
scheduled_sync_layout.addLayout(self.add_checkbox(
'checkbox_enable_scheduled_progressync'))
scheduled_sync_layout.addWidget(QLabel('Scheduled Time:'))
self.schedule_hour_input = QSpinBox()
self.schedule_hour_input.setRange(0, 23)
self.schedule_hour_input.setValue(CONFIG['scheduleSyncHour'])
self.schedule_hour_input.setSuffix('h')
self.schedule_hour_input.wheelEvent = lambda event: event.ignore()
scheduled_sync_layout.addWidget(self.schedule_hour_input)
scheduled_sync_layout.addWidget(QLabel(':'))
self.schedule_minute_input = QSpinBox()
self.schedule_minute_input.setRange(0, 59)
self.schedule_minute_input.setValue(CONFIG['scheduleSyncMinute'])
self.schedule_minute_input.setSuffix('m')
self.schedule_minute_input.wheelEvent = lambda event: event.ignore()
scheduled_sync_layout.addWidget(self.schedule_minute_input)
layout.addLayout(scheduled_sync_layout)
# Add ProgressSync Account button
progress_sync_button = QPushButton('Add ProgressSync Account', self)
progress_sync_button.clicked.connect(self.show_progress_sync_popup)
layout.addWidget(progress_sync_button)
def show_progress_sync_popup(self):
self.progress_sync_popup = ProgressSyncPopup(self)
self.progress_sync_popup.show()
def save_settings(self):
debug_print = partial(module_debug_print,
'ConfigWidget:save_settings:')
debug_print('old CONFIG = ', CONFIG)
# Check relevant settings for changes in order to show restart warning
needRestart = (self.must_restart or # Custom Column Addition
CONFIG['checkbox_enable_automatic_sync'] != (CHECKBOXES['checkbox_enable_automatic_sync']['checkbox'].checkState() == Qt.Checked) or
CONFIG['checkbox_enable_scheduled_progressync'] != (CHECKBOXES['checkbox_enable_scheduled_progressync']['checkbox'].checkState() == Qt.Checked) or
CONFIG['scheduleSyncHour'] != self.schedule_hour_input.value() or
CONFIG['scheduleSyncMinute'] != self.schedule_minute_input.value()
)
# Save Column Settings
for config_name, metadata in CUSTOM_COLUMN_DEFAULTS.items():
CONFIG[config_name] = metadata['comboBox'].get_selected_column()
# Save Checkbox Settings
for config_name in CHECKBOXES:
CONFIG[config_name] = CHECKBOXES[config_name]['checkbox'].checkState(
) == Qt.Checked
# Save main action Settings
CONFIG['main_action'] = self.main_action_combo.currentText()
# Save Scheduled ProgressSync Settings
CONFIG['scheduleSyncHour'] = self.schedule_hour_input.value()
CONFIG['scheduleSyncMinute'] = self.schedule_minute_input.value()
# NOTE: Server/Credentials are saved by the ProgressSyncPopup
debug_print('new CONFIG = ', CONFIG)
if needRestart and show_restart_warning('Changes have been made that require a restart to take effect.\nRestart now?'):
self.action.gui.quit(restart=True)
def add_checkbox(self, checkboxKey):
layout = QHBoxLayout()
checkboxMeta = CHECKBOXES[checkboxKey]
checkbox = QCheckBox()
checkbox.setCheckState(
Qt.Checked if CONFIG[checkboxKey] else Qt.Unchecked)
label = QLabel(checkboxMeta['config_label'])
label.setToolTip(checkboxMeta['config_tool_tip'])
label.setBuddy(checkbox)
label.mousePressEvent = lambda event, checkbox=checkbox: checkbox.toggle()
layout.addWidget(checkbox)
layout.addWidget(label)
layout.addStretch()
CHECKBOXES[checkboxKey]['checkbox'] = checkbox
return layout
def create_custom_column_controls(self, columns_group_box_layout, custom_col_name, min_width=300):
if fig := CUSTOM_COLUMN_DEFAULTS[custom_col_name].get('first_in_group', False):
columns_group_box_layout.addRow(create_separator())
if isinstance(fig, str):
columns_group_box_layout.addRow(QLabel(f'<b>{fig}</b>', self))
current_Location_label = QLabel(
CUSTOM_COLUMN_DEFAULTS[custom_col_name]['config_label'], self)
current_Location_label.setToolTip(
CUSTOM_COLUMN_DEFAULTS[custom_col_name]['config_tool_tip'])
create_column_callback = partial(
self.create_custom_column, custom_col_name) if SUPPORTS_CREATE_CUSTOM_COLUMN else None
avail_columns = self.sync_custom_columns[custom_col_name]['current_columns']
custom_column_combo = CustomColumnComboBox(
self, avail_columns, create_column_callback=create_column_callback)
custom_column_combo.setMinimumWidth(min_width)
current_Location_label.setBuddy(custom_column_combo)
columns_group_box_layout.addRow(
current_Location_label, custom_column_combo)
self.sync_custom_columns[custom_col_name]['combo_box'] = custom_column_combo
return custom_column_combo
def create_custom_column(self, lookup_name=None):
if not lookup_name or lookup_name not in CUSTOM_COLUMN_DEFAULTS:
return False
column_meta = CUSTOM_COLUMN_DEFAULTS[lookup_name]
display_params = {
'description': column_meta['description'],
**column_meta.get('additional_params', {})
}
datatype = column_meta['datatype']
column_heading = column_meta['column_heading']
is_multiple = column_meta.get('is_multiple', (False, False))
# Get the create column instance
create_new_custom_column_instance = self.get_create_new_custom_column_instance
if not create_new_custom_column_instance:
return False
result = create_new_custom_column_instance.create_column(
column_meta['default_lookup_name'], column_heading, datatype, is_multiple[0], display=display_params, generate_unused_lookup_name=True, freeze_lookup_name=False)
if result and result[0] == CreateNewCustomColumn.Result.COLUMN_ADDED:
self.sync_custom_columns[lookup_name]['current_columns'][result[1]] = {
'name': column_heading}
self.sync_custom_columns[lookup_name]['combo_box'].populate_combo(
self.sync_custom_columns[lookup_name]['current_columns'],
result[1]
)
self.must_restart = True
return True
return False
@property
def get_create_new_custom_column_instance(self):
if self._get_create_new_custom_column_instance is None and SUPPORTS_CREATE_CUSTOM_COLUMN:
self._get_create_new_custom_column_instance = CreateNewCustomColumn(
self.action.gui)
return self._get_create_new_custom_column_instance
def get_custom_columns(self, datatype, only_is_multiple=False):
if SUPPORTS_CREATE_CUSTOM_COLUMN:
custom_columns = self.get_create_new_custom_column_instance.current_columns()
else:
custom_columns = self.action.gui.library_view.model().custom_columns
available_columns = {}
for key, column in custom_columns.items():
typ = column['datatype']
if typ == datatype:
available_columns[key] = column
if datatype == 'rating': # Add rating column if requested
ratings_column_name = self.action.gui.library_view.model(
).orig_headers['rating']
available_columns['rating'] = {'name': ratings_column_name}
if only_is_multiple: # If user requests only is_multiple columns check and filter
available_columns = {
key: column for key, column in available_columns.items()
if column.get('is_multiple', False) != {}
}
return available_columns
class ProgressSyncPopup(QDialog):
def __init__(self, parent):
QDialog.__init__(self, parent)
self.setWindowTitle('Add ProgressSync Account')
self.setGeometry(100, 100, 400, 200)
layout = QVBoxLayout()
self.setLayout(layout)
self.url_label = QLabel('ProgressSync Server URL:', self)
self.url_input = QLineEdit(self)
self.url_input.setText(CONFIG['progress_sync_url'])
layout.addWidget(self.url_label)
layout.addWidget(self.url_input)
self.username_label = QLabel('Username:', self)
self.username_input = QLineEdit(self)
self.username_input.setText(CONFIG['progress_sync_username'])
layout.addWidget(self.username_label)
layout.addWidget(self.username_input)
self.password_label = QLabel('Password:', self)
self.password_input = QLineEdit(self)
self.password_input.setEchoMode(QLineEdit.Password)
layout.addWidget(self.password_label)
layout.addWidget(self.password_input)
self.note_label = QLabel(
'Enter any custom server or leave the default filled in.\n'
'Enter your username and password. Then click log in, this does not validate your account so make sure you enter the correct info.\n'
'Make sure you have one or more of the following columns set up: column_percent_read, column_percent_read_int, column_last_read_location\n'
'You must have a percent read (int or float) and status text column.',
self
)
self.note_label.setWordWrap(True)
layout.addWidget(self.note_label)
self.login_button = QPushButton('Log In', self)
self.login_button.clicked.connect(self.save_progress_sync_settings)
layout.addWidget(self.login_button)
def save_progress_sync_settings(self):
CONFIG['progress_sync_url'] = self.url_input.text()
CONFIG['progress_sync_username'] = self.username_input.text()
CONFIG['progress_sync_password'] = self.hash_password(
self.password_input.text())
self.accept()
def hash_password(self, password):
import hashlib
return hashlib.md5(password.encode()).hexdigest()
class TitleLayout(QHBoxLayout):
"""A sub-layout to the main layout used in ConfigWidget that contains an
icon and title.
"""
def __init__(self, parent, icon, title):
QHBoxLayout.__init__(self)
# Add icon
icon_label = QLabel(parent)
pixmap = QPixmap()
pixmap.loadFromData(get_resources(icon))
icon_label.setPixmap(pixmap)
icon_label.setMaximumSize(64, 64)
icon_label.setScaledContents(True)
self.addWidget(icon_label)
# Add title
title_label = QLabel(f'<h2>{title}</h2>', parent)
title_label.setContentsMargins(10, 0, 10, 0)
self.addWidget(title_label)
# Add empty space
self.addStretch()
# Add Readme hyperlink
readme_label = QLabel('<a href="#">Readme</a>', parent)
readme_label.setTextInteractionFlags(
Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard)
readme_label.linkActivated.connect(parent.action.show_readme)
self.addWidget(readme_label)
# Add About hyperlink
about_label = QLabel('<a href="#">About</a>', parent)
about_label.setTextInteractionFlags(
Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard)
about_label.linkActivated.connect(parent.action.show_about)
self.addWidget(about_label)
class CustomColumnComboBox(QComboBox):
def __init__(self, parent, custom_columns=None, selected_column='', create_column_callback=None):
super().__init__(parent)
if custom_columns is None:
custom_columns = {}
self.create_column_callback = create_column_callback
if create_column_callback is not None:
self.currentTextChanged.connect(self.current_text_changed)
self.populate_combo(custom_columns, selected_column)
def populate_combo(self, custom_columns, selected_column, show_lookup_name=True):
self.blockSignals(True)
self.clear()
self.column_names = []
if self.create_column_callback is not None:
self.column_names.append('Create new column')
self.addItem('Create new column')
self.column_names.append('do not sync')
self.addItem('do not sync')
selected_idx = 1
for key in sorted(custom_columns.keys()):
self.column_names.append(key)
display_name = '%s (%s)' % (
key, custom_columns[key]['name']) if show_lookup_name else custom_columns[key]['name']
self.addItem(display_name)
if key == selected_column:
selected_idx = len(self.column_names) - 1
self.setCurrentIndex(selected_idx)
self.current_index = selected_idx
self.blockSignals(False)
def get_selected_column(self):
selected_column = self.column_names[self.currentIndex()]
if selected_column == 'Create new column' or selected_column == 'do not sync':
selected_column = ''
return selected_column
def current_text_changed(self, new_text):
if new_text == 'Create new column':
result = self.create_column_callback()
if not result:
self.setCurrentIndex(self.current_index)
else:
self.current_index = self.currentIndex()
def wheelEvent(self, event): # Prevents the mouse wheel from changing the selected item
event.ignore()
================================================
FILE: dummy_device/.driveinfo.calibre
================================================
{"device_store_uuid": "94894e7d-f6d4-4aa7-88a2-5654287cdc86", "device_name": "Folder Device", "location_code": "main", "last_library_uuid": "f6727444-b3ca-4bb0-bcaa-c75333f6b171", "calibre_version": "6.16.0", "date_last_connected": "2023-05-22T09:26:00.387289+00:00", "prefix": "/home/harm/git/koreader-calibre-plugin/dummy_device/"}
================================================
FILE: dummy_device/.metadata.calibre
================================================
[
{
"application_id": 4,
"rights": null,
"lpath": "Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.epub",
"rating": null,
"tags": [
"Fantasy fiction",
"Children's stories",
"Imaginary places -- Juvenile fiction",
"Alice (Fictitious character from Carroll) -- Juvenile fiction"
],
"series_index": null,
"book_producer": null,
"publication_type": null,
"timestamp": "2020-11-16T21:46:44+00:00",
"last_modified": "2023-05-22T09:25:57.211107+00:00",
"title_sort": "Alice's Adventures in Wonderland",
"series": null,
"identifiers": {
"uri": "http://www.gutenberg.org/11"
},
"languages": [
"eng"
],
"publisher": null,
"size": 253703,
"title": "Alice's Adventures in Wonderland",
"author_sort": "Carroll, Lewis",
"authors": [
"Lewis Carroll"
],
"thumbnail": [
49,
68,
"/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCABEADEDASIAAhEBAxEB/8QAHAAAAQQDAQAAAAAAAAAAAAAAAAIFBgcBAwQI/8QANBAAAgEDAwIEBAQFBQAAAAAAAQIDAAQRBRIhMUEGE1FhFCIycSNCgaEkM0OCwZGSo7Hw/8QAGQEAAwEBAQAAAAAAAAAAAAAAAAIDBAEF/8QAJREAAwACAAUDBQAAAAAAAAAAAAECAxESEyExcQSh0QVBUWGB/9oADAMBAAIRAxEAPwB61AXvxEPw8ZeEY8wKQD9SnufQMP7hWm1XUBPGbhZNoLbvmBGOMd/vTxUF07xVpl1dQ2Vzq+pRXL4Vn2oI/MJA2j5SRz3Ix71mlbL09D4kWsfAD8QC4VgQGYcjacg/r/iiUauDJ5Z7jbyPUc/b6uvtTkdHYSBDql7uIyBvTJH+2oH4a8RXuq609pfaiYIQjEMWCnIIwMkGmaSWznFvoSyFtRS5t2kEhiCDzVyOWwff7URHVjPH5n0iRQ3I5XkN+wBHua49buotPhg+G1x5ZZXA2qyuQvdsAdqddHmafSopHlMrF3XzCMFgHIBx9hSKlS2hvvocttFY4opRhVefZ7FnnkfeACxOK9BVVssmljS7c/CyLKoXzJFjPfqc459aeb4en5EuHSbTS17+BjstHvY3jnS+kiuE5RkJ+XHvmlaPol3LqT/xDQ+V/MkU4PPYfeprpOg2lzpiytcSyrM7BZFcLtAJx+wrntdJuJrSzntFmlkWV0lztVSoBLEcZPGD+lQ5t7aNObFh4E8a0+/k0pFZ29wzPdo0wwSZpMt/7Hapd4fAOiQFSCN8mCOh/EaoJDbwzareRhVeYsAQ6Mw2gDGe3ap34eUpoUCMApDSAgDAHzt27U2Ps/4ZI77HTmij9aKcqLqrIdSihs7a2iiVpptqs7LnaDxVp1U4hs57e2ikhR5mRRveTpxngLz/AKkfaluU56hO+ZOh7W5SOyhWGOMlIwp/DwQQeFyRkkev71YmmW0drbW7XqJGx3Z3Nnlt2QTk89utVxD5VvJC0iM0Ue1nCDHAPOPc5qxX1vRZBFMbtlEZDjajAnA6Hj3rHhaabPU+o4eBzMr2+BgsfCcPlrcrcyIZCGkV4+d3AP8A1it+jR+XpcaD8sko/wCRqjOt6hrV1fTWsaO9pMwltzjbhc8EYxzz39vapJoCvHokCSZ3qzhsnJzvar4Za22zzKmVrS0OWTRRRVzguop4e8LQv4divbd8alLArRTTE7YzweAPbjPWpXTOlhJa2sdmms3CRRoI1XZHwABxnb6YqeSaqdSxoamtsi81lrw1K4jk03zJXILGBcRucAZDEAe/Na9N0+d/G+nPd2U8EYL43rhWdQTx2I6HNS+KCZQQmuT9Mk7Iicev01l0mVlaTXJdyggFoocjpn8n2qc4KXbXb9/Bov1bqFDfReDq1UtvAnbFu/yhRIBu9iDyM9Mg1zaIu3SYl44eQcdPrbpWiTSzdAxvrF0x6HGwH17L7U42dotlZx26OzKmfmbqcnP+abFieNdWQu1T2jdRWcUVUQVSDDEWLGNCT1JHWiiugY8iIf0k6Y+kfag28J6xIf7RRRRsAEEQOREmfXaKWaKKADFFFFcA/9k="
],
"link_maps": {},
"comments": null,
"author_sort_map": {
"Lewis Carroll": "Carroll, Lewis"
},
"uuid": "43bd8264-96fa-461a-a05e-1d1cb245d34f",
"user_categories": {},
"db_id": null,
"user_metadata": {
"#formats": {
"table": "custom_column_1",
"column": "value",
"datatype": "composite",
"is_multiple": null,
"kind": "field",
"name": "Formats",
"search_terms": [
"#formats"
],
"label": "formats",
"colnum": 1,
"display": {
"contains_html": false,
"make_category": false,
"composite_sort": "text",
"use_decorations": 0,
"composite_template": "{:'re(approximate_formats(), ',', ', ')'}",
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 22,
"#value#": "EPUB",
"#extra#": null,
"is_multiple2": {}
},
"#gr_rating": {
"table": "custom_column_2",
"column": "value",
"datatype": "rating",
"is_multiple": null,
"kind": "field",
"name": "Goodreads Rating",
"search_terms": [
"#gr_rating"
],
"label": "gr_rating",
"colnum": 2,
"display": {
"description": "",
"allow_half_stars": false
},
"is_custom": true,
"is_category": true,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 23,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
},
"#gr_review": {
"table": "custom_column_3",
"column": "value",
"datatype": "comments",
"is_multiple": null,
"kind": "field",
"name": "Goodreads Review",
"search_terms": [
"#gr_review"
],
"label": "gr_review",
"colnum": 3,
"display": {
"heading_position": "hide",
"interpret_as": "long-text",
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 24,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
},
"#gr_shelf": {
"table": "custom_column_4",
"column": "value",
"datatype": "text",
"is_multiple": null,
"kind": "field",
"name": "Goodreads Shelf",
"search_terms": [
"#gr_shelf"
],
"label": "gr_shelf",
"colnum": 4,
"display": {
"use_decorations": 0,
"description": ""
},
"is_custom": true,
"is_category": true,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 25,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
},
"#ko_annotations": {
"table": "custom_column_13",
"column": "value",
"datatype": "comments",
"is_multiple": null,
"kind": "field",
"name": "KOReader Annotations",
"search_terms": [
"#ko_annotations"
],
"label": "ko_annotations",
"colnum": 13,
"display": {
"heading_position": "above",
"interpret_as": "markdown",
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 26,
"#value#": "- in CHAPTER VI. Pig and Pepper <!-- datetime: 2020-11-16 18:52:11, page: /body/DocFragment[8]/body/div/h2/text()[1].0, chapter: CHAPTER VI. Pig and Pepper -->\n- \u201cYou!\u201d said the Caterpillar contemptuously. \u201cWho are you?\u201d <!-- text: Higlight with note: Page 48 \u201cYou!\u201d said the Caterpillar contemptuously. \u201cWho are you?\u201d @ 2020-11-16 18:51:04, page: /body/DocFragment[7]/body/div/p[12]/text()[1].0, chapter: CHAPTER V. Advice from a Caterpillar, highlighted: True, datetime: 2020-11-16 18:51:04, pos0: /body/DocFragment[7]/body/div/p[12]/text()[1].0, pos1: /body/DocFragment[7]/body/div/p[12]/text()[2].1 -->\n- CHAPTER V. Advice from a Caterpillar <!-- page: /body/DocFragment[7]/body/div/h2/text()[1].0, chapter: CHAPTER V. Advice from a Caterpillar, highlighted: True, datetime: 2020-11-16 18:50:53, pos0: /body/DocFragment[7]/body/div/h2/text()[1].0, pos1: /body/DocFragment[7]/body/div/h2/text()[2].26 -->",
"#extra#": null,
"is_multiple2": {}
},
"#ko_md5": {
"table": "custom_column_5",
"column": "value",
"datatype": "text",
"is_multiple": null,
"kind": "field",
"name": "KOReader Sync Server MD5 Hash",
"search_terms": [
"#ko_md5"
],
"label": "ko_md5",
"colnum": 5,
"display": {
"use_decorations": 0,
"description": ""
},
"is_custom": true,
"is_category": true,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 27,
"#value#": "9c928bc227e2011e85f931ca159ff710",
"#extra#": null,
"is_multiple2": {}
},
"#ko_mod": {
"table": "custom_column_20",
"column": "value",
"datatype": "datetime",
"is_multiple": null,
"kind": "field",
"name": "KOReader Last Modified",
"search_terms": [
"#ko_mod"
],
"label": "ko_mod",
"colnum": 20,
"display": {
"date_format": null,
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 28,
"#value#": "2021-11-22T06:47:26+00:00",
"#extra#": null,
"is_multiple2": {}
},
"#ko_sidecar": {
"table": "custom_column_6",
"column": "value",
"datatype": "comments",
"is_multiple": null,
"kind": "field",
"name": "KOReader Sidecar",
"search_terms": [
"#ko_sidecar"
],
"label": "ko_sidecar",
"colnum": 6,
"display": {
"heading_position": "above",
"interpret_as": "long-text",
"description": "The entire dict from KOReader\u2019s metadata.epub.lua"
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 29,
"#value#": "{\n \"bookmarks\": {\n \"1\": {\n \"chapter\": \"CHAPTER VI. Pig and Pepper\",\n \"datetime\": \"2020-11-16 18:52:11\",\n \"page\": \"/body/DocFragment[8]/body/div/h2/text()[1].0\"\n },\n \"2\": {\n \"chapter\": \"CHAPTER V. Advice from a Caterpillar\",\n \"datetime\": \"2020-11-16 18:51:04\",\n \"highlighted\": true,\n \"page\": \"/body/DocFragment[7]/body/div/p[12]/text()[1].0\",\n \"pos0\": \"/body/DocFragment[7]/body/div/p[12]/text()[1].0\",\n \"pos1\": \"/body/DocFragment[7]/body/div/p[12]/text()[2].1\",\n \"text\": \"Higlight with note: Page 48 \\u201cYou!\\u201d said the Caterpillar contemptuously. \\u201cWho are you?\\u201d @ 2020-11-16 18:51:04\"\n },\n \"3\": {\n \"chapter\": \"CHAPTER V. Advice from a Caterpillar\",\n \"datetime\": \"2020-11-16 18:50:53\",\n \"highlighted\": true,\n \"page\": \"/body/DocFragment[7]/body/div/h2/text()[1].0\",\n \"pos0\": \"/body/DocFragment[7]/body/div/h2/text()[1].0\",\n \"pos1\": \"/body/DocFragment[7]/body/div/h2/text()[2].26\"\n }\n },\n \"bookmarks_sorted\": true,\n \"config_panel_index\": 1,\n \"copt_b_page_margin\": 15,\n \"copt_block_rendering_mode\": 3,\n \"copt_embedded_css\": 1,\n \"copt_embedded_fonts\": 1,\n \"copt_font_gamma\": 15,\n \"copt_font_hinting\": 2,\n \"copt_font_kerning\": 3,\n \"copt_font_size\": 22,\n \"copt_font_weight\": 0,\n \"copt_h_page_margins\": {\n \"1\": 10,\n \"2\": 10\n },\n \"copt_line_spacing\": 100,\n \"copt_nightmode_images\": 1,\n \"copt_render_dpi\": 96,\n \"copt_rotation_mode\": 0,\n \"copt_smooth_scaling\": 0,\n \"copt_status_line\": 1,\n \"copt_sync_t_b_page_margins\": 0,\n \"copt_t_page_margin\": 15,\n \"copt_view_mode\": 0,\n \"copt_visible_pages\": 1,\n \"copt_word_expansion\": 0,\n \"copt_word_spacing\": {\n \"1\": 95,\n \"2\": 75\n },\n \"cre_dom_version\": 20200824,\n \"css\": \"./data/epub.css\",\n \"disable_fuzzy_search\": false,\n \"doc_pages\": 156,\n \"doc_props\": {\n \"authors\": \"Lewis Carroll\",\n \"description\": \"\",\n \"keywords\": \"Fantasy fiction\\\\\\nChildren's stories\\\\\\nImaginary places\\n-- Juvenile fiction\\\\\\nAlice (Fictitious character from Carroll) -- Juvenile fiction\",\n \"language\": \"en\",\n \"series\": \"\",\n \"title\": \"Alice's Adventures in Wonderland\"\n },\n \"embedded_css\": true,\n \"embedded_fonts\": true,\n \"floating_punctuation\": 0,\n \"font_embolden\": 0,\n \"font_face\": \"Noto Serif\",\n \"font_hinting\": 2,\n \"font_kerning\": 3,\n \"font_size\": 22,\n \"gamma\": 1,\n \"gamma_index\": 15,\n \"header_font_face\": \"Noto Sans\",\n \"highlight\": {\n \"47\": {\n \"1\": {\n \"chapter\": \"CHAPTER V. Advice from a Caterpillar\",\n \"datetime\": \"2020-11-16 18:50:53\",\n \"drawer\": \"lighten\",\n \"pos0\": \"/body/DocFragment[7]/body/div/h2/text()[1].0\",\n \"pos1\": \"/body/DocFragment[7]/body/div/h2/text()[2].26\",\n \"text\": \"CHAPTER V. Advice from a Caterpillar\"\n }\n },\n \"48\": {\n \"1\": {\n \"chapter\": \"CHAPTER V. Advice from a Caterpillar\",\n \"datetime\": \"2020-11-16 18:51:04\",\n \"drawer\": \"lighten\",\n \"pos0\": \"/body/DocFragment[7]/body/div/p[12]/text()[1].0\",\n \"pos1\": \"/body/DocFragment[7]/body/div/p[12]/text()[2].1\",\n \"text\": \"\\u201cYou!\\u201d said the Caterpillar contemptuously. \\u201cWho are you?\\u201d\"\n }\n }\n },\n \"highlight_disabled\": false,\n \"highlight_drawer\": \"lighten\",\n \"highlights_imported\": true,\n \"hyph_force_algorithmic\": false,\n \"hyph_soft_hyphens_only\": false,\n \"hyph_trust_soft_hyphens\": false,\n \"hyphenation\": true,\n \"inverse_reading_order\": false,\n \"last_xpointer\": \"/body/DocFragment[9]/body/div/h2/text()[1].0\",\n \"line_space_percent\": 100,\n \"nightmode_images\": true,\n \"page_overlap_style\": \"dim\",\n \"partial_md5_checksum\": \"9c928bc227e2011e85f931ca159ff710\",\n \"percent_finished\": 0.45512820512821,\n \"readermenu_tab_index\": 4,\n \"render_dpi\": 96,\n \"render_mode\": 0,\n \"rotation_mode\": 0,\n \"show_overlap_enable\": false,\n \"smooth_scaling\": false,\n \"stats\": {\n \"authors\": \"Lewis Carroll\",\n \"highlights\": 2,\n \"language\": \"en\",\n \"md5\": \"9c928bc227e2011e85f931ca159ff710\",\n \"notes\": 0,\n \"pages\": 156,\n \"series\": \"\",\n \"title\": \"Alice's Adventures in Wonderland\"\n },\n \"text_lang\": \"en-US\",\n \"text_lang_embedded_langs\": true,\n \"visible_pages\": 1,\n \"word_expansion\": 0,\n \"word_spacing\": {\n \"1\": 95,\n \"2\": 75\n }\n}",
"#extra#": null,
"is_multiple2": {}
},
"#ko_sync": {
"table": "custom_column_19",
"column": "value",
"datatype": "datetime",
"is_multiple": null,
"kind": "field",
"name": "KOReader La
gitextract_dvork362/
├── .editorconfig
├── .gitattributes
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ ├── 1-bug_report.yml
│ │ ├── 2-feature_request.yml
│ │ └── config.yml
│ └── workflows/
│ ├── develop-pre-release.yml
│ ├── main-ci.yml
│ └── main-release.yml
├── .gitignore
├── .pylintrc
├── .run/
│ └── pydevd_pycharm.run.xml
├── .scripts/
│ └── md-to-bb.py
├── .version
├── GEMINI.md
├── LICENSE
├── Makefile
├── Pipfile
├── README.md
├── TODO.md
├── __init__.py
├── about.txt
├── action.py
├── config.py
├── dummy_device/
│ ├── .driveinfo.calibre
│ ├── .metadata.calibre
│ ├── Carroll, Lewis/
│ │ ├── Alice's Adventures in Wonderland - Lewis Carroll.epub
│ │ └── Alice's Adventures in Wonderland - Lewis Carroll.sdr/
│ │ ├── metadata.epub.lua
│ │ └── metadata.epub.lua.old
│ └── Thoreau, Henry David/
│ └── Walden, and On The Duty Of Civil Disobedience - Henry David Thoreau.epub
├── dummy_library/
│ ├── Henry David Thoreau/
│ │ └── Walden, and On The Duty Of Civil Disobedience (3)/
│ │ ├── Walden, and On The Duty Of Civil Disobedie - Henry David Thoreau.epub
│ │ └── metadata.opf
│ ├── Lewis Carroll/
│ │ └── Alice's Adventures in Wonderland (4)/
│ │ ├── Alice's Adventures in Wonderland - Lewis Carroll.epub
│ │ └── metadata.opf
│ └── metadata_db_prefs_backup.json
├── plugin-import-name-koreader.txt
├── pluginIndexKOReaderSync.txt
├── pytest.ini
├── slpp.py
└── tests/
├── __init__.py
├── conftest.py
├── integration/
│ ├── test_docker_path_resolution.py
│ ├── test_integration.py
│ ├── test_issue_143_fix.py
│ ├── test_issue_143_repro.py
│ └── test_uuid_mismatch.py
└── unit/
├── test_bookmarks.py
├── test_md5_logic.py
└── test_version.py
SYMBOL INDEX (100 symbols across 14 files)
FILE: .scripts/md-to-bb.py
function markdown_to_bbcode (line 6) | def markdown_to_bbcode(text):
function main (line 26) | def main():
FILE: __init__.py
class KoreaderSync (line 34) | class KoreaderSync(InterfaceActionBase):
method is_customizable (line 44) | def is_customizable(self):
method config_widget (line 47) | def config_widget(self):
method save_settings (line 54) | def save_settings(self, config_widget):
function clean_bookmarks (line 58) | def clean_bookmarks(bookmarks):
FILE: action.py
class GetSidecarStatus (line 95) | class GetSidecarStatus(Enum):
class OperationStatus (line 100) | class OperationStatus(Enum):
function is_system_path (line 106) | def is_system_path(path):
function append_results (line 118) | def append_results(results, title, status_msg, book_uuid, sidecar_path):
function parse_sidecar_lua (line 134) | def parse_sidecar_lua(sidecar_lua):
class KoreaderAction (line 176) | class KoreaderAction(InterfaceAction):
method genesis (line 190) | def genesis(self):
method is_usb_device (line 290) | def is_usb_device(self, device):
method exec_main_action (line 294) | def exec_main_action(self) -> None:
method show_config (line 304) | def show_config(self):
method show_readme (line 307) | def show_readme(self):
method show_about (line 316) | def show_about(self):
method apply_settings (line 340) | def apply_settings(self):
method get_connected_device (line 347) | def get_connected_device(self):
method _on_device_metadata_available (line 392) | def _on_device_metadata_available(self):
method get_paths (line 395) | def get_paths(self, device):
method get_sidecar (line 435) | def get_sidecar(self, device, path):
method get_calibre_uuid_from_sidecar (line 490) | def get_calibre_uuid_from_sidecar(self, sidecar_contents):
method update_metadata (line 508) | def update_metadata(self, uuid, db, keys_values_to_update):
method check_device (line 658) | def check_device(self, device):
method device_path_exists (line 709) | def device_path_exists(self, device, path):
method push_metadata_to_koreader_sidecar (line 751) | def push_metadata_to_koreader_sidecar(self, device, book_uuid, path):
method sync_missing_sidecars_to_koreader (line 847) | def sync_missing_sidecars_to_koreader(self, silent=False):
method sync_progress_from_progresssync (line 960) | def sync_progress_from_progresssync(self, silent=False):
method scheduled_progress_sync (line 1177) | def scheduled_progress_sync(self):
method sync_to_calibre (line 1204) | def sync_to_calibre(self, silent=False):
class ProgressDialog (line 1436) | class ProgressDialog(QDialog):
method __init__ (line 1437) | def __init__(self, parent, title: str, count: int):
method setValue (line 1450) | def setValue(self, idx: int, bookTitle: str):
class SyncCompletionDialog (line 1455) | class SyncCompletionDialog(QDialog):
method __init__ (line 1456) | def __init__(self, parent=None, title="", msg="", results=None, type=N...
method create_results_table (line 1513) | def create_results_table(self, results):
FILE: config.py
function create_separator (line 331) | def create_separator():
class ConfigWidget (line 338) | class ConfigWidget(QWidget): # https://doc.qt.io/qt-5/qwidget.html
method __init__ (line 339) | def __init__(self, plugin_action):
method show_progress_sync_popup (line 448) | def show_progress_sync_popup(self):
method save_settings (line 452) | def save_settings(self):
method add_checkbox (line 486) | def add_checkbox(self, checkboxKey):
method create_custom_column_controls (line 502) | def create_custom_column_controls(self, columns_group_box_layout, cust...
method create_custom_column (line 523) | def create_custom_column(self, lookup_name=None):
method get_create_new_custom_column_instance (line 555) | def get_create_new_custom_column_instance(self):
method get_custom_columns (line 561) | def get_custom_columns(self, datatype, only_is_multiple=False):
class ProgressSyncPopup (line 583) | class ProgressSyncPopup(QDialog):
method __init__ (line 584) | def __init__(self, parent):
method save_progress_sync_settings (line 624) | def save_progress_sync_settings(self):
method hash_password (line 631) | def hash_password(self, password):
class TitleLayout (line 636) | class TitleLayout(QHBoxLayout):
method __init__ (line 641) | def __init__(self, parent, icon, title):
class CustomColumnComboBox (line 676) | class CustomColumnComboBox(QComboBox):
method __init__ (line 677) | def __init__(self, parent, custom_columns=None, selected_column='', cr...
method populate_combo (line 686) | def populate_combo(self, custom_columns, selected_column, show_lookup_...
method get_selected_column (line 711) | def get_selected_column(self):
method current_text_changed (line 717) | def current_text_changed(self, new_text):
method wheelEvent (line 725) | def wheelEvent(self, event): # Prevents the mouse wheel from changing...
FILE: slpp.py
function sequential (line 35) | def sequential(lst):
class ParseError (line 46) | class ParseError(Exception):
class SLPP (line 50) | class SLPP(object):
method __init__ (line 52) | def __init__(self):
method decode (line 64) | def decode(self, text):
method encode (line 74) | def encode(self, obj):
method __encode (line 78) | def __encode(self, obj):
method white (line 115) | def white(self):
method comment (line 123) | def comment(self):
method next_is (line 141) | def next_is(self, value):
method prev_is (line 146) | def prev_is(self, value):
method next_chr (line 151) | def next_chr(self):
method value (line 159) | def value(self):
method string (line 173) | def string(self, end=None):
method object (line 194) | def object(self):
method word (line 248) | def word(self):
method number (line 258) | def number(self):
method digit (line 295) | def digit(self):
method hex (line 302) | def hex(self):
FILE: tests/conftest.py
class MockInterfaceActionBase (line 22) | class MockInterfaceActionBase:
class MockUSBMS (line 30) | class MockUSBMS:
class MockInterfaceAction (line 50) | class MockInterfaceAction:
method __init__ (line 51) | def __init__(self, parent, site_customization):
FILE: tests/integration/test_docker_path_resolution.py
function test_wireless_device_avoids_local_filesystem (line 6) | def test_wireless_device_avoids_local_filesystem():
function test_usb_device_triggers_makedirs (line 48) | def test_usb_device_triggers_makedirs():
function test_wireless_device_skips_makedirs (line 79) | def test_wireless_device_skips_makedirs():
FILE: tests/integration/test_integration.py
function test_dummy_data_consistency (line 8) | def test_dummy_data_consistency():
function test_get_paths_with_dummy_device (line 26) | def test_get_paths_with_dummy_device():
FILE: tests/integration/test_issue_143_fix.py
function test_fix_issue_143_usb_direct_write (line 8) | def test_fix_issue_143_usb_direct_write():
function test_wireless_still_uses_put_file (line 43) | def test_wireless_still_uses_put_file():
FILE: tests/integration/test_issue_143_repro.py
function test_reproduce_issue_143 (line 7) | def test_reproduce_issue_143():
FILE: tests/integration/test_uuid_mismatch.py
function test_get_calibre_uuid_from_sidecar (line 7) | def test_get_calibre_uuid_from_sidecar():
function test_uuid_mismatch_resolution_integration (line 34) | def test_uuid_mismatch_resolution_integration():
FILE: tests/unit/test_bookmarks.py
function test_clean_bookmarks_large_payload (line 5) | def test_clean_bookmarks_large_payload():
function test_clean_bookmarks_empty (line 40) | def test_clean_bookmarks_empty():
FILE: tests/unit/test_md5_logic.py
function test_sidecar_path_regex_robustness (line 7) | def test_sidecar_path_regex_robustness():
function test_is_system_path (line 20) | def test_is_system_path():
FILE: tests/unit/test_version.py
function test_version_match (line 4) | def test_version_match():
function test_version_tuple_match (line 28) | def test_version_tuple_match():
Condensed preview — 49 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (357K chars).
[
{
"path": ".editorconfig",
"chars": 238,
"preview": "[*]\ncharset = utf-8\nend_of_line = lf\nindent_size = 2\nindent_style = space\ninsert_final_newline = true\nmax_line_length = "
},
{
"path": ".gitattributes",
"chars": 306,
"preview": "# Handle line endings automatically for files detected as text\n# and leave binary files untouched.\n* text=auto\n\n# Force "
},
{
"path": ".github/FUNDING.yml",
"chars": 60,
"preview": "# These are supported funding model platforms\n\nko_fi: kyxap\n"
},
{
"path": ".github/ISSUE_TEMPLATE/1-bug_report.yml",
"chars": 3437,
"preview": "name: Bug Report\ndescription: File a bug report.\ntitle: \"[Bug] \"\nlabels: [\"bug\", \"triage\"]\nassignees: []\n\nbody:\n - type"
},
{
"path": ".github/ISSUE_TEMPLATE/2-feature_request.yml",
"chars": 1679,
"preview": "name: Feature Request\ndescription: Suggest an idea for a new feature\ntitle: \"[FEATURE] \"\nlabels: [\"enhancement\", \"triage"
},
{
"path": ".github/ISSUE_TEMPLATE/config.yml",
"chars": 28,
"preview": "blank_issues_enabled: false\n"
},
{
"path": ".github/workflows/develop-pre-release.yml",
"chars": 2955,
"preview": "name: Pre-release CI\n\non:\n push:\n branches:\n - develop\n\npermissions:\n contents: write\n issues: write\n\njobs:\n "
},
{
"path": ".github/workflows/main-ci.yml",
"chars": 1132,
"preview": "name: Quality Check\n\non:\n push:\n branches: [ main ]\n pull_request:\n branches: [ main ]\n\njobs:\n lint:\n name: "
},
{
"path": ".github/workflows/main-release.yml",
"chars": 539,
"preview": "name: Stable Release CI\n\non:\n push:\n tags:\n - 'v*'\n\njobs:\n release:\n runs-on: ubuntu-latest\n steps:\n "
},
{
"path": ".gitignore",
"chars": 2230,
"preview": "# PyCharm & VSCodium\n.idea\n.vscode\n\n# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C exten"
},
{
"path": ".pylintrc",
"chars": 1512,
"preview": "[MASTER]\nignore-patterns=slpp.py\nfail-under=9.5\n\n\n[MESSAGES CONTROL]\n# Disable some of the more \"opinionated\" or hard-to"
},
{
"path": ".run/pydevd_pycharm.run.xml",
"chars": 1061,
"preview": "<component name=\"ProjectRunConfigurationManager\">\n <configuration default=\"false\" name=\"pydevd_pycharm\" type=\"PyRemoteD"
},
{
"path": ".scripts/md-to-bb.py",
"chars": 2148,
"preview": "import sys\nimport re\nimport os\n\n\ndef markdown_to_bbcode(text):\n # Remove all line breaks to preserve full lines in th"
},
{
"path": ".version",
"chars": 6,
"preview": "0.8.2\n"
},
{
"path": "GEMINI.md",
"chars": 3418,
"preview": "# KOReader Calibre Plugin - AI Context & Architecture\n\nThis file provides critical architectural context and known limit"
},
{
"path": "LICENSE",
"chars": 35149,
"preview": " GNU GENERAL PUBLIC LICENSE\n Version 3, 29 June 2007\n\n Copyright (C) 2007 Free "
},
{
"path": "Makefile",
"chars": 6905,
"preview": "# Read the version from the .version file\nversion := $(shell head -n 1 .version 2>/dev/null || echo 0.0.1)\n\n# Dev versio"
},
{
"path": "Pipfile",
"chars": 362,
"preview": "[[source]]\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\nname = \"pypi\"\n\n[packages]\npyqt6 = \"*\"\npyqt5 = \"*\"\nlxml = \"*"
},
{
"path": "README.md",
"chars": 16747,
"preview": "# KOReader calibre plugin\n\n[\n- [x] Fix `apsw.TooBigError` for books with 900+ highlights [#114](https://gi"
},
{
"path": "__init__.py",
"chars": 4995,
"preview": "#!/usr/bin/env python3\n\n\"\"\"KOReader Sync Plugin for Calibre.\"\"\"\n\nimport os\nfrom functools import partial\n\nfrom calibre.c"
},
{
"path": "about.txt",
"chars": 389,
"preview": "<h2>About KOReader Sync</h2>\n<p>A calibre plugin to synchronize metadata from KOReader to calibre.</p>\n<p>The source cod"
},
{
"path": "action.py",
"chars": 60474,
"preview": "#!/usr/bin/env python3\n\n\"\"\"KOReader Sync Plugin for Calibre.\"\"\"\n\nfrom datetime import datetime\nfrom functools import par"
},
{
"path": "config.py",
"chars": 32844,
"preview": "#!/usr/bin/env python3\n\n\"\"\"Config for KOReader Sync plugin for Calibre.\"\"\"\n\nimport os\nimport json\nfrom functools import "
},
{
"path": "dummy_device/.driveinfo.calibre",
"chars": 333,
"preview": "{\"device_store_uuid\": \"94894e7d-f6d4-4aa7-88a2-5654287cdc86\", \"device_name\": \"Folder Device\", \"location_code\": \"main\", \""
},
{
"path": "dummy_device/.metadata.calibre",
"chars": 38786,
"preview": "[\n {\n \"application_id\": 4,\n \"rights\": null,\n \"lpath\": \"Carroll, Lewis/Alice's Adventures in Wonderland - Lewis"
},
{
"path": "dummy_device/Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.sdr/metadata.epub.lua",
"chars": 5283,
"preview": "-- we can read Lua syntax here!\nreturn {\n [\"highlights_imported\"] = true,\n [\"render_mode\"] = 0,\n [\"embedded_css"
},
{
"path": "dummy_device/Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.sdr/metadata.epub.lua.old",
"chars": 109,
"preview": "-- we can read Lua syntax here!\nreturn {\n [\"partial_md5_checksum\"] = \"9c928bc227e2011e85f931ca159ff710\"\n}\n"
},
{
"path": "dummy_library/Henry David Thoreau/Walden, and On The Duty Of Civil Disobedience (3)/metadata.opf",
"chars": 12827,
"preview": "<?xml version='1.0' encoding='utf-8'?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" unique-identifier=\"uuid_id\" version"
},
{
"path": "dummy_library/Lewis Carroll/Alice's Adventures in Wonderland (4)/metadata.opf",
"chars": 29265,
"preview": "<?xml version='1.0' encoding='utf-8'?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" unique-identifier=\"uuid_id\" version"
},
{
"path": "dummy_library/metadata_db_prefs_backup.json",
"chars": 37122,
"preview": "{\n \"bools_are_tristate\": true,\n \"user_categories\": {},\n \"saved_searches\": {},\n \"grouped_search_terms\": {},\n \"tag_br"
},
{
"path": "plugin-import-name-koreader.txt",
"chars": 0,
"preview": ""
},
{
"path": "pluginIndexKOReaderSync.txt",
"chars": 316,
"preview": "[*][URL=\"https://www.mobileread.com/forums/showthread.php?t=362706\"]KOReader Sync[/URL]\n[I]Synchronize metadata (e.g. re"
},
{
"path": "pytest.ini",
"chars": 191,
"preview": "[pytest]\ntestpaths = tests\npython_files = test_*.py\npython_classes = Test*\npython_functions = test_*\n# Prevent pytest fr"
},
{
"path": "slpp.py",
"chars": 10383,
"preview": "\"\"\"Copyright (c) 2010, 2011, 2012 SirAnthony <anthony at adsorbtion.org>\n\nPermission is hereby granted, free of charge, "
},
{
"path": "tests/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "tests/conftest.py",
"chars": 4090,
"preview": "import sys\nimport os\nimport builtins\nimport types\nimport importlib.util\nfrom unittest.mock import MagicMock\n\n# 1. Mock g"
},
{
"path": "tests/integration/test_docker_path_resolution.py",
"chars": 3774,
"preview": "\nimport os\nfrom unittest.mock import MagicMock, patch\nfrom action import KoreaderAction\n\ndef test_wireless_device_avoids"
},
{
"path": "tests/integration/test_integration.py",
"chars": 2213,
"preview": "\nimport os\nimport sqlite3\nimport pytest\nfrom unittest.mock import MagicMock\nfrom action import KoreaderAction\n\ndef test_"
},
{
"path": "tests/integration/test_issue_143_fix.py",
"chars": 2270,
"preview": "\nimport os\nimport io\nimport pytest\nfrom unittest.mock import MagicMock, patch\nfrom action import KoreaderAction\n\ndef tes"
},
{
"path": "tests/integration/test_issue_143_repro.py",
"chars": 1385,
"preview": "\nimport io\nimport pytest\nfrom unittest.mock import MagicMock\nfrom action import KoreaderAction\n\ndef test_reproduce_issue"
},
{
"path": "tests/integration/test_uuid_mismatch.py",
"chars": 2826,
"preview": "\nimport os\nimport pytest\nfrom unittest.mock import MagicMock\nfrom action import KoreaderAction\n\ndef test_get_calibre_uui"
},
{
"path": "tests/unit/test_bookmarks.py",
"chars": 1435,
"preview": "\nimport pytest\nfrom __init__ import clean_bookmarks\n\ndef test_clean_bookmarks_large_payload():\n # Simulate a 1.8MB me"
},
{
"path": "tests/unit/test_md5_logic.py",
"chars": 1124,
"preview": "\nimport pytest\nimport re\nfrom unittest.mock import MagicMock\nfrom action import KoreaderAction\n\ndef test_sidecar_path_re"
},
{
"path": "tests/unit/test_version.py",
"chars": 1666,
"preview": "import os\nimport re\n\ndef test_version_match():\n \"\"\"Check if version in .version matches version in __init__.py\"\"\"\n "
}
]
// ... and 4 more files (download for full content)
About this extraction
This page contains the full source code of the harmtemolder/koreader-calibre-plugin GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 49 files (329.0 KB), approximately 87.4k tokens, and a symbol index with 100 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.