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 ================================================ ================================================ 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 ") 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. 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. Copyright (C) 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 . 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: Copyright (C) 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 . 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 . ================================================ 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 [![Quality Check](https://github.com/kyxap/koreader-calibre-plugin/actions/workflows/main-ci.yml/badge.svg)](https://github.com/kyxap/koreader-calibre-plugin/actions/workflows/main-ci.yml) [![Stable Release CI](https://github.com/kyxap/koreader-calibre-plugin/actions/workflows/main-release.yml/badge.svg)](https://github.com/kyxap/koreader-calibre-plugin/actions/workflows/main-release.yml) [![Latest Stable Release](https://img.shields.io/github/v/release/kyxap/koreader-calibre-plugin?label=latest%20stable&color=green)](https://github.com/kyxap/koreader-calibre-plugin/releases) [![Pre-release CI](https://github.com/kyxap/koreader-calibre-plugin/actions/workflows/develop-pre-release.yml/badge.svg)](https://github.com/kyxap/koreader-calibre-plugin/actions/workflows/develop-pre-release.yml) [![Upcoming Release](https://img.shields.io/github/v/tag/kyxap/koreader-calibre-plugin/pre-release?label=upcoming%20release&color=orange)](https://github.com/kyxap/koreader-calibre-plugin/releases/tag/pre-release) [![License](https://img.shields.io/github/license/kyxap/koreader-calibre-plugin?color=blue)](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 `.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 [![Star History Chart](https://api.star-history.com/svg?repos=kyxap/koreader-calibre-plugin&type=Date)](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-` 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 ' __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 += ' ' 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 = ('\n\n\n' 'Book Highlights and Notes\n' '\n\n') highlight_count = 0 for chapter, chapter_highlights in highlights_by_chapter.items(): if chapter.strip() == '': chapter = 'Unknown' html_content += f'
\n

Chapter: {chapter}

\n' html_content += '
' for highlight in chapter_highlights: highlight_count += 1 html_content += (f'

{highlight_count}. Highlight - {highlight["datetime"]} ' f'
{highlight["highlighted_text"]}\n') html_content += '

\n' html_content += ('Note: ' f'{highlight["reader_note"]}

\n') html_content += f'{highlight["hidden_attributes"]}\n' html_content += "
\n" html_content += '' html_content += "\n" return html_content.strip() ================================================ FILE: about.txt ================================================

About KOReader Sync

A calibre plugin to synchronize metadata from KOReader to calibre.

The source code of this plugin can be found on GitHub.

If you encounter any issues with the plugin, please submit them here.

================================================ 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 ' __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'' f'here. 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 ' __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'{fig}', 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'

{title}

', parent) title_label.setContentsMargins(10, 0, 10, 0) self.addWidget(title_label) # Add empty space self.addStretch() # Add Readme hyperlink readme_label = QLabel('Readme', 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('About', 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 \n- \u201cYou!\u201d said the Caterpillar contemptuously. \u201cWho are you?\u201d \n- CHAPTER V. Advice from a Caterpillar ", "#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 Last Sync", "search_terms": [ "#ko_sync" ], "label": "ko_sync", "colnum": 19, "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": 30, "#value#": "2023-05-22T08:56:46+00:00", "#extra#": null, "is_multiple2": {} }, "#mm_annotations": { "table": "custom_column_7", "column": "value", "datatype": "comments", "is_multiple": null, "kind": "field", "name": "Kobo Annotations", "search_terms": [ "#mm_annotations" ], "label": "mm_annotations", "colnum": 7, "display": { "heading_position": "above", "interpret_as": "html", "description": "" }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 31, "#value#": null, "#extra#": null, "is_multiple2": {} }, "#read_first": { "table": "custom_column_8", "column": "value", "datatype": "datetime", "is_multiple": null, "kind": "field", "name": "Read First", "search_terms": [ "#read_first" ], "label": "read_first", "colnum": 8, "display": { "description": "The date on which I added this book to my Goodreads Currently Reading shelf", "date_format": null }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 32, "#value#": "2020-11-16T18:50:53+00:00", "#extra#": null, "is_multiple2": {} }, "#read_last": { "table": "custom_column_9", "column": "value", "datatype": "datetime", "is_multiple": null, "kind": "field", "name": "Read Last", "search_terms": [ "#read_last" ], "label": "read_last", "colnum": 9, "display": { "description": "The date on which I last opened this book on my e-reader", "date_format": null }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 33, "#value#": "2020-11-16T18:52:11+00:00", "#extra#": null, "is_multiple2": {} }, "#read_location": { "table": "custom_column_10", "column": "value", "datatype": "text", "is_multiple": null, "kind": "field", "name": "Read Location", "search_terms": [ "#read_location" ], "label": "read_location", "colnum": 10, "display": { "description": "", "use_decorations": 0 }, "is_custom": true, "is_category": true, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 34, "#value#": "/body/DocFragment[9]/body/div/h2/text()[1].0", "#extra#": null, "is_multiple2": {} }, "#read_progress": { "table": "custom_column_11", "column": "value", "datatype": "float", "is_multiple": null, "kind": "field", "name": "Read Progress", "search_terms": [ "#read_progress" ], "label": "read_progress", "colnum": 11, "display": { "description": "", "number_format": "{:.0%}", "decimals": 2 }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 35, "#value#": 0.45512820512821, "#extra#": null, "is_multiple2": {} }, "#read_progress_int": { "table": "custom_column_18", "column": "value", "datatype": "int", "is_multiple": null, "kind": "field", "name": "Read Progress (int)", "search_terms": [ "#read_progress_int" ], "label": "read_progress_int", "colnum": 18, "display": { "number_format": null, "description": "" }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 36, "#value#": 45, "#extra#": null, "is_multiple2": {} }, "#read_status": { "table": "custom_column_12", "column": "value", "datatype": "composite", "is_multiple": null, "kind": "field", "name": "Read Status", "search_terms": [ "#read_status" ], "label": "read_status", "colnum": 12, "display": { "use_decorations": 0, "contains_html": false, "description": "", "composite_template": "{#read_progress:get_read_status()}", "composite_sort": "text", "make_category": true }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 37, "#value#": "\u2610", "#extra#": null, "is_multiple2": {} }, "#read_status_bool": { "table": "custom_column_17", "column": "value", "datatype": "bool", "is_multiple": null, "kind": "field", "name": "Read Status (yes/no)", "search_terms": [ "#read_status_bool" ], "label": "read_status_bool", "colnum": 17, "display": { "bools_show_text": false, "bools_show_icons": true, "description": "" }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 38, "#value#": false, "#extra#": null, "is_multiple2": {} }, "#read_status_text": { "table": "custom_column_16", "column": "value", "datatype": "text", "is_multiple": null, "kind": "field", "name": "Read Status (text)", "search_terms": [ "#read_status_text" ], "label": "read_status_text", "colnum": 16, "display": { "use_decorations": false, "description": "" }, "is_custom": true, "is_category": true, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 39, "#value#": "reading", "#extra#": null, "is_multiple2": {} } }, "cover": null, "mime": null, "pubdate": "2008-06-27T04:00:00+00:00" }, { "application_id": 3, "rights": null, "lpath": "Thoreau, Henry David/Walden, and On The Duty Of Civil Disobedience - Henry David Thoreau.epub", "rating": null, "tags": [ "Civil disobedience", "Authors", "American -- 19th century -- Biography", "Thoreau", "Henry David", "1817-1862 -- Homes and haunts -- Massachusetts -- Walden Woods", "Wilderness areas -- Massachusetts -- Walden Woods", "Natural history -- Massachusetts -- Walden Woods", "Solitude", "Government", "Resistance to", "Walden Woods (Mass.) -- Social life and customs" ], "series_index": null, "book_producer": null, "publication_type": null, "timestamp": "2020-11-16T21:45:41+00:00", "last_modified": "2023-05-22T09:25:57.211107+00:00", "title_sort": "Walden, and On The Duty Of Civil Disobedience", "series": null, "identifiers": { "uri": "http://www.gutenberg.org/205" }, "languages": [ "eng" ], "publisher": null, "size": 612096, "title": "Walden, and On The Duty Of Civil Disobedience", "author_sort": "Thoreau, Henry David", "authors": [ "Henry David Thoreau" ], "thumbnail": [ 47, 68, "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCABEAC8DASIAAhEBAxEB/8QAGwAAAQUBAQAAAAAAAAAAAAAAAwACBQYHAQT/xAAuEAACAgECBAQEBwEAAAAAAAABAgMRAAQhBRIxURNBYZEUcaHwBiJCgbHB0XL/xAAXAQEBAQEAAAAAAAAAAAAAAAABAgAE/8QAGBEBAQEBAQAAAAAAAAAAAAAAAAERAjH/2gAMAwEAAhEDEQA/ANKAwWpillhZIZfCc9Hq6/bHtI6vyiIsKBBv13x0bylSWhKkcu1316+2cEjpBEOpDgmZeQEWCNyKIP1o4MabWhQDqxfKATyDrdk9O22eslnjUPACGamUm6G+/wBB742OWVo2vTlCEBC83U77fQe+ODTgrco5qvzrGtQFnONNMFv4Zr22DDtZ9umKN2kJDxFNgRfnsMLG0awouifkLxjvLuUFCtgUN3hUwb6uGOXw3aj3/rGNTA2oF83KRW1Rnr75xpNRdry12MTf7hJdXHEaNk1djp75GxauRdSAHL836S336e2ISCSyAEyKT/yhGFG+9V88GmqjkmaEEiRVDMpHQG/8wlgnrg0NfnERKEK3c5DT6kfFhC9uf4yW1biPRyP2H95RdPxQNxGV32NstnyAxkapPWcUli49BpnIMbQkL6Hy/jPNqNb8Hp9ZOjgEGlArY+W330yI1Ug12v8AjYpAoRrQmqYV5j53vni1OpV5ZIG5fFeQnnvZTew3+98rErVwri4k/FciczlJI1WqoDbsfU5cLrMs0OrRfxFHOWjjKvXMR+UHuarb1zUuoB2yeoqPDxuRo+C6l0blZVFG63sZTuG+BB8NPUsYOoBmldQFIIYkc3pS9ay/sGaJ1Q0xGx9cqsum1qSOItayrfRlLWfnjz41FWTTScYfVLq4Zonh8IIkoY3zWDXy88oDDl1bOoNCU3265Z9fwB3kJ8aX8+zKN2PoOg+owScClghaSXTtBCkbEFyOZiR1q9sqCo7hkKPxWKQOAHfmWrHnew37dM1M70czvhq6vUSaFoVKsjhnKp5i9+nrX75orHJ6MJTnWjRmsqL7+eLFkw1w6eIm2QE+uNbTws1tGrHuReLFiCGnhGwiSu3L0xzdcWLCmP/Z" ], "link_maps": {}, "comments": null, "author_sort_map": { "Henry David Thoreau": "Thoreau, Henry David" }, "uuid": "3393747a-f0d8-44e1-bfaf-5fad857da3eb", "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#": null, "#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#": null, "#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#": "None", "#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#": null, "#extra#": null, "is_multiple2": {} }, "#ko_sync": { "table": "custom_column_19", "column": "value", "datatype": "datetime", "is_multiple": null, "kind": "field", "name": "KOReader Last Sync", "search_terms": [ "#ko_sync" ], "label": "ko_sync", "colnum": 19, "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": 30, "#value#": "None", "#extra#": null, "is_multiple2": {} }, "#mm_annotations": { "table": "custom_column_7", "column": "value", "datatype": "comments", "is_multiple": null, "kind": "field", "name": "Kobo Annotations", "search_terms": [ "#mm_annotations" ], "label": "mm_annotations", "colnum": 7, "display": { "heading_position": "above", "interpret_as": "html", "description": "" }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 31, "#value#": null, "#extra#": null, "is_multiple2": {} }, "#read_first": { "table": "custom_column_8", "column": "value", "datatype": "datetime", "is_multiple": null, "kind": "field", "name": "Read First", "search_terms": [ "#read_first" ], "label": "read_first", "colnum": 8, "display": { "description": "The date on which I added this book to my Goodreads Currently Reading shelf", "date_format": null }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 32, "#value#": "None", "#extra#": null, "is_multiple2": {} }, "#read_last": { "table": "custom_column_9", "column": "value", "datatype": "datetime", "is_multiple": null, "kind": "field", "name": "Read Last", "search_terms": [ "#read_last" ], "label": "read_last", "colnum": 9, "display": { "description": "The date on which I last opened this book on my e-reader", "date_format": null }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 33, "#value#": "None", "#extra#": null, "is_multiple2": {} }, "#read_location": { "table": "custom_column_10", "column": "value", "datatype": "text", "is_multiple": null, "kind": "field", "name": "Read Location", "search_terms": [ "#read_location" ], "label": "read_location", "colnum": 10, "display": { "description": "", "use_decorations": 0 }, "is_custom": true, "is_category": true, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 34, "#value#": null, "#extra#": null, "is_multiple2": {} }, "#read_progress": { "table": "custom_column_11", "column": "value", "datatype": "float", "is_multiple": null, "kind": "field", "name": "Read Progress", "search_terms": [ "#read_progress" ], "label": "read_progress", "colnum": 11, "display": { "description": "", "number_format": "{:.0%}", "decimals": 2 }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 35, "#value#": null, "#extra#": null, "is_multiple2": {} }, "#read_progress_int": { "table": "custom_column_18", "column": "value", "datatype": "int", "is_multiple": null, "kind": "field", "name": "Read Progress (int)", "search_terms": [ "#read_progress_int" ], "label": "read_progress_int", "colnum": 18, "display": { "number_format": null, "description": "" }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 36, "#value#": null, "#extra#": null, "is_multiple2": {} }, "#read_status": { "table": "custom_column_12", "column": "value", "datatype": "composite", "is_multiple": null, "kind": "field", "name": "Read Status", "search_terms": [ "#read_status" ], "label": "read_status", "colnum": 12, "display": { "use_decorations": 0, "contains_html": false, "description": "", "composite_template": "{#read_progress:get_read_status()}", "composite_sort": "text", "make_category": true }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 37, "#value#": "", "#extra#": null, "is_multiple2": {} }, "#read_status_bool": { "table": "custom_column_17", "column": "value", "datatype": "bool", "is_multiple": null, "kind": "field", "name": "Read Status (yes/no)", "search_terms": [ "#read_status_bool" ], "label": "read_status_bool", "colnum": 17, "display": { "bools_show_text": false, "bools_show_icons": true, "description": "" }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 38, "#value#": null, "#extra#": null, "is_multiple2": {} }, "#read_status_text": { "table": "custom_column_16", "column": "value", "datatype": "text", "is_multiple": null, "kind": "field", "name": "Read Status (text)", "search_terms": [ "#read_status_text" ], "label": "read_status_text", "colnum": 16, "display": { "use_decorations": false, "description": "" }, "is_custom": true, "is_category": true, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 39, "#value#": null, "#extra#": null, "is_multiple2": {} } }, "cover": null, "mime": null, "pubdate": "1995-01-01T03:00:00+00:00" } ] ================================================ FILE: dummy_device/Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.sdr/metadata.epub.lua ================================================ -- we can read Lua syntax here! return { ["highlights_imported"] = true, ["render_mode"] = 0, ["embedded_css"] = true, ["disable_fuzzy_search"] = false, ["embedded_fonts"] = true, ["copt_block_rendering_mode"] = 3, ["smooth_scaling"] = false, ["page_overlap_style"] = "dim", ["nightmode_images"] = true, ["cre_dom_version"] = 20200824, ["last_xpointer"] = "/body/DocFragment[9]/body/div/h2/text()[1].0", ["highlight_disabled"] = false, ["bookmarks"] = { [1] = { ["datetime"] = "2020-11-16 18:52:11", ["page"] = "/body/DocFragment[8]/body/div/h2/text()[1].0", ["chapter"] = "CHAPTER VI. Pig and Pepper", ["notes"] = "in CHAPTER VI. Pig and Pepper" }, [2] = { ["text"] = "Higlight with note: Page 48 “You!” said the Caterpillar contemptuously. “Who are you?” @ 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", ["notes"] = "“You!” said the Caterpillar contemptuously. “Who are you?”" }, [3] = { ["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", ["notes"] = "CHAPTER V. Advice from a Caterpillar" } }, ["inverse_reading_order"] = false, ["line_space_percent"] = 100, ["css"] = "./data/epub.css", ["percent_finished"] = 0.45512820512821, ["rotation_mode"] = 0, ["partial_md5_checksum"] = "9c928bc227e2011e85f931ca159ff710", ["font_size"] = 22, ["copt_embedded_fonts"] = 1, ["copt_embedded_css"] = 1, ["copt_rotation_mode"] = 0, ["copt_render_dpi"] = 96, ["copt_sync_t_b_page_margins"] = 0, ["copt_h_page_margins"] = { [1] = 10, [2] = 10 }, ["copt_t_page_margin"] = 15, ["copt_smooth_scaling"] = 0, ["copt_word_spacing"] = { [1] = 95, [2] = 75 }, ["copt_nightmode_images"] = 1, ["word_spacing"] = { [1] = 95, [2] = 75 }, ["header_font_face"] = "Noto Sans", ["copt_font_size"] = 22, ["font_embolden"] = 0, ["text_lang"] = "en-US", ["copt_font_hinting"] = 2, ["copt_font_kerning"] = 3, ["copt_word_expansion"] = 0, ["copt_line_spacing"] = 100, ["copt_font_gamma"] = 15, ["gamma"] = 1, ["render_dpi"] = 96, ["font_face"] = "Noto Serif", ["visible_pages"] = 1, ["show_overlap_enable"] = false, ["copt_view_mode"] = 0, ["highlight"] = { [47] = { [1] = { ["drawer"] = "lighten", ["chapter"] = "CHAPTER V. Advice from a Caterpillar", ["datetime"] = "2020-11-16 18:50:53", ["pos0"] = "/body/DocFragment[7]/body/div/h2/text()[1].0", ["text"] = "CHAPTER V. Advice from a Caterpillar", ["pos1"] = "/body/DocFragment[7]/body/div/h2/text()[2].26" } }, [48] = { [1] = { ["drawer"] = "lighten", ["chapter"] = "CHAPTER V. Advice from a Caterpillar", ["datetime"] = "2020-11-16 18:51:04", ["pos0"] = "/body/DocFragment[7]/body/div/p[12]/text()[1].0", ["text"] = "“You!” said the Caterpillar contemptuously. “Who are you?”", ["pos1"] = "/body/DocFragment[7]/body/div/p[12]/text()[2].1" } } }, ["copt_status_line"] = 1, ["copt_b_page_margin"] = 15, ["readermenu_tab_index"] = 4, ["copt_font_weight"] = 0, ["bookmarks_sorted"] = true, ["stats"] = { ["authors"] = "Lewis Carroll", ["series"] = "", ["title"] = "Alice's Adventures in Wonderland", ["highlights"] = 2, ["notes"] = 0, ["md5"] = "9c928bc227e2011e85f931ca159ff710", ["language"] = "en", ["pages"] = 156 }, ["hyph_soft_hyphens_only"] = false, ["font_hinting"] = 2, ["doc_pages"] = 156, ["copt_visible_pages"] = 1, ["text_lang_embedded_langs"] = true, ["config_panel_index"] = 1, ["gamma_index"] = 15, ["font_kerning"] = 3, ["hyphenation"] = true, ["hyph_trust_soft_hyphens"] = false, ["doc_props"] = { ["authors"] = "Lewis Carroll", ["series"] = "", ["title"] = "Alice's Adventures in Wonderland", ["description"] = "", ["language"] = "en", ["keywords"] = "Fantasy fiction\ Children's stories\ Imaginary places -- Juvenile fiction\ Alice (Fictitious character from Carroll) -- Juvenile fiction" }, ["hyph_force_algorithmic"] = false, ["floating_punctuation"] = 0, ["word_expansion"] = 0, ["highlight_drawer"] = "lighten" } ================================================ FILE: dummy_device/Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.sdr/metadata.epub.lua.old ================================================ -- we can read Lua syntax here! return { ["partial_md5_checksum"] = "9c928bc227e2011e85f931ca159ff710" } ================================================ FILE: dummy_library/Henry David Thoreau/Walden, and On The Duty Of Civil Disobedience (3)/metadata.opf ================================================ 3 3393747a-f0d8-44e1-bfaf-5fad857da3eb Walden, and On The Duty Of Civil Disobedience Henry David Thoreau calibre (5.5.0) [https://calibre-ebook.com] 1995-01-01T03:00:00+00:00 http://www.gutenberg.org/205 eng Civil disobedience Authors American -- 19th century -- Biography Thoreau Henry David 1817-1862 -- Homes and haunts -- Massachusetts -- Walden Woods Wilderness areas -- Massachusetts -- Walden Woods Natural history -- Massachusetts -- Walden Woods Solitude Government Resistance to Walden Woods (Mass.) -- Social life and customs ================================================ FILE: dummy_library/Lewis Carroll/Alice's Adventures in Wonderland (4)/metadata.opf ================================================ 4 43bd8264-96fa-461a-a05e-1d1cb245d34f Alice's Adventures in Wonderland Lewis Carroll calibre (6.16.0) [https://calibre-ebook.com] 2008-06-27T04:00:00+00:00 http://www.gutenberg.org/11 eng Fantasy fiction Children's stories Imaginary places -- Juvenile fiction Alice (Fictitious character from Carroll) -- Juvenile fiction ================================================ FILE: dummy_library/metadata_db_prefs_backup.json ================================================ { "bools_are_tristate": true, "user_categories": {}, "saved_searches": {}, "grouped_search_terms": {}, "tag_browser_hidden_categories": [ "#read_location", "rating", "news" ], "library_view books view state": { "hidden_columns": [ "size", "rating", "tags", "publisher", "pubdate", "last_modified", "languages", "#read_status" ], "last_modified_injected": true, "languages_injected": true, "sort_history": [ [ "timestamp", false ], [ "authors", true ], [ "series", true ], [ "title", true ], [ "timestamp", false ] ], "column_positions": { "ondevice": 0, "title": 2, "authors": 1, "timestamp": 22, "size": 24, "rating": 23, "tags": 25, "series": 3, "publisher": 26, "pubdate": 27, "last_modified": 28, "languages": 29, "#formats": 15, "#gr_rating": 13, "#gr_review": 14, "#gr_shelf": 12, "#ko_annotations": 17, "#ko_md5": 18, "#ko_mod": 20, "#ko_sidecar": 19, "#ko_sync": 21, "#mm_annotations": 16, "#read_first": 9, "#read_last": 10, "#read_location": 11, "#read_progress": 7, "#read_progress_int": 8, "#read_status": 4, "#read_status_bool": 6, "#read_status_text": 5 }, "column_sizes": { "title": 397, "authors": 214, "timestamp": 111, "size": 0, "rating": 0, "tags": 0, "series": 441, "publisher": 0, "pubdate": 0, "last_modified": 0, "languages": 0, "#formats": 100, "#gr_rating": 100, "#gr_review": 100, "#gr_shelf": 137, "#ko_annotations": 100, "#ko_md5": 100, "#ko_mod": 100, "#ko_sidecar": 100, "#ko_sync": 100, "#mm_annotations": 100, "#read_first": 100, "#read_last": 100, "#read_location": 100, "#read_progress": 100, "#read_progress_int": 100, "#read_status": 0, "#read_status_bool": 100, "#read_status_text": 100 }, "column_alignment": { "#read_progress": "right", "#read_status": "center", "pubdate": "center", "size": "center", "timestamp": "center" } }, "field_metadata": { "authors": { "table": "authors", "column": "name", "link_column": "author", "category_sort": "sort", "datatype": "text", "is_multiple": { "cache_to_list": ",", "ui_to_list": "&", "list_to_ui": " & " }, "kind": "field", "name": "Authors", "search_terms": [ "authors", "author" ], "is_custom": false, "is_category": true, "is_csp": false, "label": "authors", "display": {}, "is_editable": true, "rec_index": 2 }, "languages": { "table": "languages", "column": "lang_code", "link_column": "lang_code", "category_sort": "lang_code", "datatype": "text", "is_multiple": { "cache_to_list": ",", "ui_to_list": ",", "list_to_ui": ", " }, "kind": "field", "name": "Languages", "search_terms": [ "languages", "language" ], "is_custom": false, "is_category": true, "is_csp": false, "label": "languages", "display": {}, "is_editable": true, "rec_index": 21 }, "series": { "table": "series", "column": "name", "link_column": "series", "category_sort": "(title_sort(name))", "datatype": "series", "is_multiple": {}, "kind": "field", "name": "Series", "search_terms": [ "series" ], "is_custom": false, "is_category": true, "is_csp": false, "label": "series", "display": {}, "is_editable": true, "rec_index": 8 }, "formats": { "table": null, "column": null, "datatype": "text", "is_multiple": { "cache_to_list": ",", "ui_to_list": ",", "list_to_ui": ", " }, "kind": "field", "name": "Formats", "search_terms": [ "formats", "format" ], "is_custom": false, "is_category": true, "is_csp": false, "label": "formats", "display": {}, "is_editable": true, "rec_index": 13 }, "publisher": { "table": "publishers", "column": "name", "link_column": "publisher", "category_sort": "name", "datatype": "text", "is_multiple": {}, "kind": "field", "name": "Publisher", "search_terms": [ "publisher" ], "is_custom": false, "is_category": true, "is_csp": false, "label": "publisher", "display": {}, "is_editable": true, "rec_index": 9 }, "rating": { "table": "ratings", "column": "rating", "link_column": "rating", "category_sort": "rating", "datatype": "rating", "is_multiple": {}, "kind": "field", "name": "Rating", "search_terms": [ "rating" ], "is_custom": false, "is_category": true, "is_csp": false, "label": "rating", "display": {}, "is_editable": true, "rec_index": 5 }, "news": { "table": "news", "column": "name", "category_sort": "name", "datatype": null, "is_multiple": {}, "kind": "category", "name": "News", "search_terms": [], "is_custom": false, "is_category": true, "is_csp": false, "label": "news", "display": {}, "is_editable": true }, "tags": { "table": "tags", "column": "name", "link_column": "tag", "category_sort": "name", "datatype": "text", "is_multiple": { "cache_to_list": ",", "ui_to_list": ",", "list_to_ui": ", " }, "kind": "field", "name": "Tags", "search_terms": [ "tags", "tag" ], "is_custom": false, "is_category": true, "is_csp": false, "label": "tags", "display": {}, "is_editable": true, "rec_index": 6 }, "identifiers": { "table": null, "column": null, "datatype": "text", "is_multiple": { "cache_to_list": ",", "ui_to_list": ",", "list_to_ui": ", " }, "kind": "field", "name": "Identifiers", "search_terms": [ "identifiers", "identifier", "isbn" ], "is_custom": false, "is_category": true, "is_csp": true, "label": "identifiers", "display": {}, "is_editable": true, "rec_index": 20 }, "author_sort": { "table": null, "column": null, "datatype": "text", "is_multiple": {}, "kind": "field", "name": "Author sort", "search_terms": [ "author_sort" ], "is_custom": false, "is_category": false, "is_csp": false, "label": "author_sort", "display": {}, "is_editable": true, "rec_index": 12 }, "au_map": { "table": null, "column": null, "datatype": "text", "is_multiple": { "cache_to_list": ",", "ui_to_list": null, "list_to_ui": null }, "kind": "field", "name": null, "search_terms": [], "is_custom": false, "is_category": false, "is_csp": false, "label": "au_map", "display": {}, "is_editable": true, "rec_index": 18 }, "comments": { "table": null, "column": null, "datatype": "text", "is_multiple": {}, "kind": "field", "name": "Comments", "search_terms": [ "comments", "comment" ], "is_custom": false, "is_category": false, "is_csp": false, "label": "comments", "display": {}, "is_editable": true, "rec_index": 7 }, "cover": { "table": null, "column": null, "datatype": "int", "is_multiple": {}, "kind": "field", "name": "Cover", "search_terms": [ "cover" ], "is_custom": false, "is_category": false, "is_csp": false, "label": "cover", "display": {}, "is_editable": true, "rec_index": 17 }, "id": { "table": null, "column": null, "datatype": "int", "is_multiple": {}, "kind": "field", "name": null, "search_terms": [ "id" ], "is_custom": false, "is_category": false, "is_csp": false, "label": "id", "display": {}, "is_editable": true, "rec_index": 0 }, "last_modified": { "table": null, "column": null, "datatype": "datetime", "is_multiple": {}, "kind": "field", "name": "Modified", "search_terms": [ "last_modified" ], "is_custom": false, "is_category": false, "is_csp": false, "label": "last_modified", "display": { "date_format": "dd MMM yyyy" }, "is_editable": true, "rec_index": 19 }, "ondevice": { "table": null, "column": null, "datatype": "text", "is_multiple": {}, "kind": "field", "name": "On device", "search_terms": [ "ondevice" ], "is_custom": false, "is_category": false, "is_csp": false, "label": "ondevice", "display": {}, "is_editable": true, "rec_index": 40 }, "path": { "table": null, "column": null, "datatype": "text", "is_multiple": {}, "kind": "field", "name": "Path", "search_terms": [], "is_custom": false, "is_category": false, "is_csp": false, "label": "path", "display": {}, "is_editable": true, "rec_index": 14 }, "pubdate": { "table": null, "column": null, "datatype": "datetime", "is_multiple": {}, "kind": "field", "name": "Published", "search_terms": [ "pubdate" ], "is_custom": false, "is_category": false, "is_csp": false, "label": "pubdate", "display": { "date_format": "MMM yyyy" }, "is_editable": true, "rec_index": 15 }, "marked": { "table": null, "column": null, "datatype": "text", "is_multiple": {}, "kind": "field", "name": null, "search_terms": [ "marked" ], "is_custom": false, "is_category": false, "is_csp": false, "label": "marked", "display": {}, "is_editable": true, "rec_index": 41 }, "in_tag_browser": { "table": null, "column": null, "datatype": "text", "is_multiple": {}, "kind": "field", "name": null, "search_terms": [ "in_tag_browser" ], "is_custom": false, "is_category": false, "is_csp": false, "label": "in_tag_browser", "display": {}, "is_editable": true, "rec_index": 43 }, "series_index": { "table": null, "column": null, "datatype": "float", "is_multiple": {}, "kind": "field", "name": null, "search_terms": [ "series_index" ], "is_custom": false, "is_category": false, "is_csp": false, "label": "series_index", "display": {}, "is_editable": true, "rec_index": 10 }, "series_sort": { "table": null, "column": null, "datatype": "text", "is_multiple": {}, "kind": "field", "name": "Series sort", "search_terms": [ "series_sort" ], "is_custom": false, "is_category": false, "is_csp": false, "label": "series_sort", "display": {}, "is_editable": true, "rec_index": 42 }, "sort": { "table": null, "column": null, "datatype": "text", "is_multiple": {}, "kind": "field", "name": "Title sort", "search_terms": [ "title_sort" ], "is_custom": false, "is_category": false, "is_csp": false, "label": "sort", "display": {}, "is_editable": true, "rec_index": 11 }, "size": { "table": null, "column": null, "datatype": "float", "is_multiple": {}, "kind": "field", "name": "Size", "search_terms": [ "size" ], "is_custom": false, "is_category": false, "is_csp": false, "label": "size", "display": {}, "is_editable": true, "rec_index": 4 }, "timestamp": { "table": null, "column": null, "datatype": "datetime", "is_multiple": {}, "kind": "field", "name": "Date", "search_terms": [ "date" ], "is_custom": false, "is_category": false, "is_csp": false, "label": "timestamp", "display": { "date_format": "dd MMM yyyy" }, "is_editable": true, "rec_index": 3 }, "title": { "table": null, "column": null, "datatype": "text", "is_multiple": {}, "kind": "field", "name": "Title", "search_terms": [ "title" ], "is_custom": false, "is_category": false, "is_csp": false, "label": "title", "display": {}, "is_editable": true, "rec_index": 1 }, "uuid": { "table": null, "column": null, "datatype": "text", "is_multiple": {}, "kind": "field", "name": null, "search_terms": [ "uuid" ], "is_custom": false, "is_category": false, "is_csp": false, "label": "uuid", "display": {}, "is_editable": true, "rec_index": 16 }, "#formats": { "table": "custom_column_1", "column": "value", "datatype": "composite", "is_multiple": {}, "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 }, "#gr_rating": { "table": "custom_column_2", "column": "value", "datatype": "rating", "is_multiple": {}, "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 }, "#gr_review": { "table": "custom_column_3", "column": "value", "datatype": "comments", "is_multiple": {}, "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 }, "#gr_shelf": { "table": "custom_column_4", "column": "value", "datatype": "text", "is_multiple": {}, "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 }, "#ko_annotations": { "table": "custom_column_13", "column": "value", "datatype": "comments", "is_multiple": {}, "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 }, "#ko_md5": { "table": "custom_column_5", "column": "value", "datatype": "text", "is_multiple": {}, "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 }, "#ko_mod": { "table": "custom_column_20", "column": "value", "datatype": "datetime", "is_multiple": {}, "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 }, "#ko_sidecar": { "table": "custom_column_6", "column": "value", "datatype": "comments", "is_multiple": {}, "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 }, "#ko_sync": { "table": "custom_column_19", "column": "value", "datatype": "datetime", "is_multiple": {}, "kind": "field", "name": "KOReader Last Sync", "search_terms": [ "#ko_sync" ], "label": "ko_sync", "colnum": 19, "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": 30 }, "#mm_annotations": { "table": "custom_column_7", "column": "value", "datatype": "comments", "is_multiple": {}, "kind": "field", "name": "Kobo Annotations", "search_terms": [ "#mm_annotations" ], "label": "mm_annotations", "colnum": 7, "display": { "heading_position": "above", "interpret_as": "html", "description": "" }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 31 }, "#read_first": { "table": "custom_column_8", "column": "value", "datatype": "datetime", "is_multiple": {}, "kind": "field", "name": "Read First", "search_terms": [ "#read_first" ], "label": "read_first", "colnum": 8, "display": { "description": "The date on which I added this book to my Goodreads Currently Reading shelf", "date_format": null }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 32 }, "#read_last": { "table": "custom_column_9", "column": "value", "datatype": "datetime", "is_multiple": {}, "kind": "field", "name": "Read Last", "search_terms": [ "#read_last" ], "label": "read_last", "colnum": 9, "display": { "description": "The date on which I last opened this book on my e-reader", "date_format": null }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 33 }, "#read_location": { "table": "custom_column_10", "column": "value", "datatype": "text", "is_multiple": {}, "kind": "field", "name": "Read Location", "search_terms": [ "#read_location" ], "label": "read_location", "colnum": 10, "display": { "description": "", "use_decorations": 0 }, "is_custom": true, "is_category": true, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 34 }, "#read_progress": { "table": "custom_column_11", "column": "value", "datatype": "float", "is_multiple": {}, "kind": "field", "name": "Read Progress", "search_terms": [ "#read_progress" ], "label": "read_progress", "colnum": 11, "display": { "description": "", "number_format": "{:.0%}", "decimals": 2 }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 35 }, "#read_progress_int": { "table": "custom_column_18", "column": "value", "datatype": "int", "is_multiple": {}, "kind": "field", "name": "Read Progress (int)", "search_terms": [ "#read_progress_int" ], "label": "read_progress_int", "colnum": 18, "display": { "number_format": null, "description": "" }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 36 }, "#read_status": { "table": "custom_column_12", "column": "value", "datatype": "composite", "is_multiple": {}, "kind": "field", "name": "Read Status", "search_terms": [ "#read_status" ], "label": "read_status", "colnum": 12, "display": { "use_decorations": 0, "contains_html": false, "description": "", "composite_template": "{#read_progress:get_read_status()}", "composite_sort": "text", "make_category": true }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 37 }, "#read_status_bool": { "table": "custom_column_17", "column": "value", "datatype": "bool", "is_multiple": {}, "kind": "field", "name": "Read Status (yes/no)", "search_terms": [ "#read_status_bool" ], "label": "read_status_bool", "colnum": 17, "display": { "bools_show_text": false, "bools_show_icons": true, "description": "" }, "is_custom": true, "is_category": false, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 38 }, "#read_status_text": { "table": "custom_column_16", "column": "value", "datatype": "text", "is_multiple": {}, "kind": "field", "name": "Read Status (text)", "search_terms": [ "#read_status_text" ], "label": "read_status_text", "colnum": 16, "display": { "use_decorations": false, "description": "" }, "is_custom": true, "is_category": true, "link_column": "value", "category_sort": "value", "is_csp": false, "is_editable": true, "rec_index": 39 } }, "books view split pane state": { "hidden_columns": [], "column_positions": { "ondevice": 0, "title": 1, "authors": 2, "timestamp": 3, "size": 4, "rating": 5, "tags": 6, "series": 7, "publisher": 8, "pubdate": 9, "last_modified": 10, "languages": 11, "#formats": 12, "#gr_rating": 20, "#gr_review": 14, "#gr_shelf": 16, "#ko_annotations": 24, "#ko_md5": 22, "#ko_mod": 29, "#ko_sidecar": 23, "#ko_sync": 28, "#mm_annotations": 13, "#read_first": 21, "#read_last": 15, "#read_location": 19, "#read_progress": 17, "#read_progress_int": 27, "#read_status": 18, "#read_status_bool": 25, "#read_status_text": 26 }, "column_sizes": { "title": 100, "authors": 100, "timestamp": 100, "size": 100, "rating": 100, "tags": 100, "series": 100, "publisher": 100, "pubdate": 100, "last_modified": 100, "languages": 100, "#formats": 100, "#gr_rating": 100, "#gr_review": 100, "#gr_shelf": 100, "#ko_annotations": 100, "#ko_md5": 100, "#ko_mod": 100, "#ko_sidecar": 100, "#ko_sync": 100, "#mm_annotations": 100, "#read_first": 100, "#read_last": 100, "#read_location": 100, "#read_progress": 100, "#read_progress_int": 100, "#read_status": 100, "#read_status_bool": 100, "#read_status_text": 100 } }, "book_display_fields": [ [ "title", true ], [ "authors", true ], [ "series", true ], [ "identifiers", true ], [ "tags", false ], [ "formats", true ], [ "path", true ], [ "publisher", false ], [ "rating", false ], [ "author_sort", false ], [ "sort", false ], [ "timestamp", false ], [ "uuid", false ], [ "comments", true ], [ "id", false ], [ "pubdate", false ], [ "last_modified", false ], [ "size", false ], [ "languages", true ], [ "#formats", true ], [ "#mm_annotations", true ], [ "#read_status", true ], [ "#gr_review", true ], [ "#read_location", true ], [ "#read_last", true ], [ "#gr_shelf", true ], [ "#read_progress", true ] ], "qv_display_fields": [ [ "title", true ], [ "authors", true ], [ "series", true ], [ "languages", false ], [ "formats", false ], [ "publisher", false ], [ "rating", false ], [ "tags", false ], [ "identifiers", false ], [ "author_sort", false ], [ "id", false ], [ "last_modified", false ], [ "path", false ], [ "pubdate", false ], [ "sort", false ], [ "size", false ], [ "timestamp", false ], [ "uuid", false ], [ "#formats", false ], [ "#gr_shelf", false ], [ "#read_last", false ], [ "#read_location", false ], [ "#read_progress", false ], [ "#read_status", false ] ], "column_color_rules": [], "column_icon_rules": [ [ "icon_only", "#read_status", "program:\n# BasicColorRule():5b226f6b2e706e67222c205b5b2223726561645f737461747573222c20226973222c202272656164225d5d5d\ntest(strcmp(field('#read_status'), \"read\", '', '1', ''), 'ok.png', '');\n" ], [ "icon_only", "#read_status", "program:\n# BasicColorRule():5b22717569636b766965772e706e67222c205b5b2223726561645f737461747573222c20226973222c202263757272656e746c792d72656164696e67225d5d5d\ntest(strcmp(field('#read_status'), \"currently-reading\", '', '1', ''), 'quickview.png', '');\n" ] ], "cover_grid_icon_rules": [], "gui_view_history": [ [ 4, "Alice's Adventures in Wonderland" ], [ 7540, "Out of Africa ; and, Shadows on the grass" ], [ 7539, "Out of Africa & Shadows on the Grass (English, 2.27Mb)" ], [ 7531, "Robinson Crusoe" ], [ 7535, "Out of Africa and Shadows on the Grass" ], [ 7524, "Beyond the Limits: Confronting Global Collapse, Envisioning a Sustainable Future" ], [ 7523, "Schismatrix Plus" ], [ 7513, "Fokke en Sukke [Wed, 05 Aug 2020]" ], [ 2614, "Problemski Hotel" ], [ 542, "Zoenen Met Rommel" ], [ 4224, "Angst" ], [ 4877, "Schindlers List" ], [ 5, "Buonanotte, Signor Lenin" ], [ 1426, "Opuscula Selecta Neerlandicorum De Arte" ], [ 4067, "Encomium Artis Medicae _ De Lof Der Geneeskunde" ] ], "update_all_last_mod_dates_on_start": false, "plugboards": {}, "namespaced:KoboUtilitiesPlugin:settings": { "SchemaVersion": 0.1, "profiles": { "Default": { "customColumnOptions": { "currentReadingLocationColumn": "#read_location", "lastReadColumn": "#read_last", "percentReadColumn": "#read_progress", "ratingColumn": "#gr_rating" }, "forDevice": "*Any Device", "profileName": "Default", "storeOptionsStore": { "doNotStoreIfReopened": true, "promptToStore": true, "storeIfMoreRecent": true, "storeOnConnect": true }, "updateOptionsStore": { "doEarlyFirmwareUpdate": false, "doFirmwareUpdateCheck": false, "firmwareUpdateCheckLastTime": 0 } } }, "readingPositionChangesStore": { "selectBooksInLibrary": false, "updeateGoodreadsProgress": true } }, "user_template_functions": [ [ "get_read_status", "get_read_status(progress_field)\nreturn '\u2610' if `progress_field`< 100, '' if empty, else '\u2611\ufe0e'", 1, "def evaluate(self, formatter, kwargs, mi, locals, progress_field):\n\tif progress_field:\n\t\tif float(progress_field.strip('%').strip()) < 100:\n\t\t\treturn '\u2610'\n\t\treturn '\u2611\ufe0e'\n\treturn ''" ] ], "virtual_libraries": { "On Device": "ondevice:\"True\"", "Want to Read": "formats:\"=EPUB\" and #gr_shelf:\"=to-read\"" }, "virt_libs_hidden": [], "namespaced:FindDuplicatesPlugin:settings": { "SchemaVersion": 1.7, "authorExemptions": [], "bookExemptions": [], "lastLibraryCompare": "" }, "namespaced:ImportListPlugin:settings": { "clipboardRegexes": [], "csvFiles": [], "current": { "clipboard": { "regex": "", "reverseList": false, "text": "" }, "csv": { "columnData": [ { "field": "title", "index": 1 }, { "field": "authors", "index": 2 } ], "delimiter": ",", "file": "", "match_by_identifier": null, "reverseList": false, "skipFirst": true, "unquote": true }, "importType": "web", "readingList": { "clearList": true, "name": "" }, "web": { "categories": [ "Social Websites" ], "encoding": "utf-8", "javascript": true, "reverseList": true, "url": "https://www.goodreads.com/review/list/38080398-harm?shelf=to-read&per_page=100", "xpathData": [ { "field": "rows", "xpath": "//tbody[@id=\"booksBody\"]/tr" }, { "field": "title", "isRegexStrip": true, "regex": "\\([^\\)]+\\)", "xpath": "td[@class=\"field title\"]//a/text()" }, { "field": "authors", "isRegexStrip": true, "regex": "", "xpath": "td[@class=\"field author\"]//a/text()" }, { "field": "series", "isRegexStrip": false, "regex": "\\(([^,\\.#]+)", "xpath": "td[@class=\"field title\"]//a/span/text()" }, { "field": "series_index", "isRegexStrip": false, "regex": "#([\\d\\.]+)", "xpath": "td[@class=\"field title\"]//a/span/text()" }, { "field": "identifier:goodreads", "isRegexStrip": false, "regex": "/review/show/(\\d+)", "xpath": "td[@class=\"field actions\"]//a/@href" } ] } }, "javascriptDelay": 3, "lastCSVSetting": "", "lastClipboardSetting": "", "lastPredefinedSetting": "Goodreads: Shelves: To Read", "lastTab": 4, "lastUserSetting": "Goodreads Want to Read", "lastViewType": "list", "lastWebSetting": "Goodreads Want to Read", "savedSettings": { "Goodreads Want to Read": { "categories": [ "Social Websites" ], "encoding": "utf-8", "importType": "web", "javascript": true, "readingList": { "clearList": true, "name": "" }, "reverseList": true, "url": "https://www.goodreads.com/review/list/38080398-harm?shelf=to-read&per_page=100", "xpathData": [ { "field": "rows", "xpath": "//tbody[@id=\"booksBody\"]/tr" }, { "field": "title", "isRegexStrip": true, "regex": "\\([^\\)]+\\)", "xpath": "td[@class=\"field title\"]//a/text()" }, { "field": "authors", "isRegexStrip": true, "regex": "", "xpath": "td[@class=\"field author\"]//a/text()" }, { "field": "series", "isRegexStrip": false, "regex": "\\(([^,\\.#]+)", "xpath": "td[@class=\"field title\"]//a/span/text()" }, { "field": "series_index", "isRegexStrip": false, "regex": "#([\\d\\.]+)", "xpath": "td[@class=\"field title\"]//a/span/text()" }, { "field": "identifier:goodreads", "isRegexStrip": false, "regex": "/review/show/(\\d+)", "xpath": "td[@class=\"field actions\"]//a/@href" } ] } }, "schemaVersion": 1.2, "webUrls": [ "https://www.goodreads.com/review/list/38080398-harm?shelf=to-read&per_page=100", "https://www.goodreads.com/review/list/38080398-harm?shelf=to-read&per_page=infinite", "https://www.goodreads.com/review/list/38080398-harm?utf8=%E2%9C%93&shelf=to-read&title=harm&per_page=infinite", "https://www.goodreads.com/review/list/38080398-harm?shelf=to-read", "http://www.goodreads.com/shelf/show/to-read" ] }, "news_to_be_synced": [], "tag_browser_category_order": [ "authors", "languages", "series", "formats", "publisher", "rating", "news", "tags", "identifiers", "#gr_rating", "#gr_shelf", "#ko_md5", "#read_location", "#read_status" ] } ================================================ FILE: plugin-import-name-koreader.txt ================================================ ================================================ FILE: pluginIndexKOReaderSync.txt ================================================ [*][URL="https://www.mobileread.com/forums/showthread.php?t=362706"]KOReader Sync[/URL] [I]Synchronize metadata (e.g. read progress and rating) from KOReader to calibre. Version: 0.8.2; Calibre: 5.0.1; Author: harmtemolder & others, currently maintaining by: kyxap; History: Yes; Platforms: Windows, OSX, Linux;[/I] ================================================ FILE: pytest.ini ================================================ [pytest] testpaths = tests python_files = test_*.py python_classes = Test* python_functions = test_* # Prevent pytest from trying to import root __init__.py addopts = -v --ignore=__init__.py ================================================ FILE: slpp.py ================================================ """Copyright (c) 2010, 2011, 2012 SirAnthony Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.""" import re import sys from numbers import Number import six ERRORS = { 'unexp_end_string': u'Unexpected end of string while parsing Lua string.', 'unexp_end_table': u'Unexpected end of table while parsing Lua string.', 'mfnumber_minus': u'Malformed number (no digits after initial minus).', 'mfnumber_dec_point': u'Malformed number (no digits after decimal point).', 'mfnumber_sci': u'Malformed number (bad scientific format).', } def sequential(lst): length = len(lst) if length == 0 or lst[0] != 0: return False for i in range(length): if i + 1 < length: if lst[i] + 1 != lst[i+1]: return False return True class ParseError(Exception): pass class SLPP(object): def __init__(self): self.text = '' self.ch = '' self.at = 0 self.len = 0 self.depth = 0 self.space = re.compile(r'\s', re.M) self.alnum = re.compile(r'\w', re.M) self.newline = '\n' self.tab = '\t' def decode(self, text): if not text or not isinstance(text, six.string_types): return self.text = text self.at, self.ch, self.depth = 0, '', 0 self.len = len(text) self.next_chr() result = self.value() return result def encode(self, obj): self.depth = 0 return self.__encode(obj) def __encode(self, obj): s = '' tab = self.tab newline = self.newline if isinstance(obj, str): s += '"%s"' % obj.replace(r'"', r'\"') elif six.PY2 and isinstance(obj, unicode): s += '"%s"' % obj.encode('utf-8').replace(r'"', r'\"') elif six.PY3 and isinstance(obj, bytes): s += '"{}"'.format(''.join(r'\x{:02x}'.format(c) for c in obj)) elif isinstance(obj, bool): s += str(obj).lower() elif obj is None: s += 'nil' elif isinstance(obj, Number): s += str(obj) elif isinstance(obj, (list, tuple, dict)): self.depth += 1 if len(obj) == 0 or (not isinstance(obj, dict) and len([ x for x in obj if isinstance(x, Number) or (isinstance(x, six.string_types) and len(x) < 10) ]) == len(obj)): newline = tab = '' dp = tab * self.depth s += "%s{%s" % (tab * (self.depth - 2), newline) if isinstance(obj, dict): key_list = ['[%s]' if isinstance(k, Number) else '["%s"]' for k in obj.keys()] contents = [dp + (key + ' = %s') % (k, self.__encode(v)) for (k, v), key in zip(obj.items(), key_list)] s += (',%s' % newline).join(contents) else: s += (',%s' % newline).join( [dp + self.__encode(el) for el in obj]) self.depth -= 1 s += "%s%s}" % (newline, tab * self.depth) return s def white(self): while self.ch: if self.space.match(self.ch): self.next_chr() else: break self.comment() def comment(self): if self.ch == '-' and self.next_is('-'): self.next_chr() # TODO: for fancy comments need to improve multiline = self.next_chr() and self.ch == '[' and self.next_is('[') while self.ch: if multiline: if self.ch == ']' and self.next_is(']'): self.next_chr() self.next_chr() self.white() break # `--` is a comment, skip to next new line elif re.match('\n', self.ch): self.white() break self.next_chr() def next_is(self, value): if self.at >= self.len: return False return self.text[self.at] == value def prev_is(self, value): if self.at < 2: return False return self.text[self.at-2] == value def next_chr(self): if self.at >= self.len: self.ch = None return None self.ch = self.text[self.at] self.at += 1 return True def value(self): self.white() if not self.ch: return if self.ch == '{': return self.object() if self.ch == "[": self.next_chr() if self.ch in ['"', "'", '[']: return self.string(self.ch) if self.ch.isdigit() or self.ch == '-': return self.number() return self.word() def string(self, end=None): s = '' start = self.ch if end == '[': end = ']' if start in ['"', "'", '[']: double = start=='[' and self.prev_is(start) while self.next_chr(): if self.ch == end and (not double or self.next_is(end)): self.next_chr() if start != "[" or self.ch == ']': if double: self.next_chr() return s if self.ch == '\\' and start == end: self.next_chr() if self.ch != end: s += '\\' s += self.ch raise ParseError(ERRORS['unexp_end_string']) def object(self): o = {} k = None idx = 0 numeric_keys = False self.depth += 1 self.next_chr() self.white() if self.ch and self.ch == '}': self.depth -= 1 self.next_chr() return o # Exit here else: while self.ch: self.white() if self.ch == '{': o[idx] = self.object() idx += 1 continue elif self.ch == '}': self.depth -= 1 self.next_chr() if k is not None: o[idx] = k if len([key for key in o if isinstance(key, six.string_types + (float, bool, tuple))]) == 0: so = sorted([key for key in o]) if sequential(so): ar = [] for key in o: ar.insert(key, o[key]) o = ar return o # or here else: if self.ch == ',': self.next_chr() continue else: k = self.value() if self.ch == ']': self.next_chr() self.white() ch = self.ch if ch in ('=', ','): self.next_chr() self.white() if ch == '=': o[k] = self.value() else: o[idx] = k idx += 1 k = None raise ParseError(ERRORS['unexp_end_table']) # Bad exit here words = {'true': True, 'false': False, 'nil': None} def word(self): s = '' if self.ch != '\n': s = self.ch self.next_chr() while self.ch is not None and self.alnum.match(self.ch) and s not in self.words: s += self.ch self.next_chr() return self.words.get(s, s) def number(self): def next_digit(err): n = self.ch self.next_chr() if not self.ch or not self.ch.isdigit(): raise ParseError(err) return n n = '' try: if self.ch == '-': n += next_digit(ERRORS['mfnumber_minus']) n += self.digit() if n == '0' and self.ch in ['x', 'X']: n += self.ch self.next_chr() n += self.hex() else: if self.ch and self.ch == '.': n += next_digit(ERRORS['mfnumber_dec_point']) n += self.digit() if self.ch and self.ch in ['e', 'E']: n += self.ch self.next_chr() if not self.ch or self.ch not in ('+', '-'): raise ParseError(ERRORS['mfnumber_sci']) n += next_digit(ERRORS['mfnumber_sci']) n += self.digit() except ParseError: t, e = sys.exc_info()[:2] print(e) return 0 try: return int(n, 0) except: pass return float(n) def digit(self): n = '' while self.ch and self.ch.isdigit(): n += self.ch self.next_chr() return n def hex(self): n = '' while self.ch and (self.ch in 'ABCDEFabcdef' or self.ch.isdigit()): n += self.ch self.next_chr() return n slpp = SLPP() __all__ = ['slpp'] ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/conftest.py ================================================ import sys import os import builtins import types import importlib.util from unittest.mock import MagicMock # 1. Mock gettext '_' function if not hasattr(builtins, '_'): builtins._ = lambda x: x # Add project root to sys.path sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) # 2. Mock 'calibre' package and submodules calibre = types.ModuleType("calibre") calibre.constants = MagicMock() calibre.constants.numeric_version = (6, 0, 0) calibre.customize = MagicMock() # IMPORTANT: Base classes must be classes, not Mocks class MockInterfaceActionBase: pass calibre.customize.InterfaceActionBase = MockInterfaceActionBase calibre.devices = MagicMock() calibre.devices.usbms = MagicMock() # IMPORTANT: USBMS must be a class for isinstance() to work in action.py class MockUSBMS: pass calibre.devices.usbms.driver = MagicMock() calibre.devices.usbms.driver.USBMS = MockUSBMS # Setup calibre.utils calibre_utils = types.ModuleType("calibre.utils") calibre_utils.__path__ = [] calibre.utils = calibre_utils calibre_utils.config = MagicMock() calibre_utils.iso8601 = MagicMock() from datetime import timezone calibre_utils.iso8601.utc_tz = timezone.utc calibre_utils.iso8601.local_tz = timezone.utc # Setup calibre.gui2 calibre_gui2 = types.ModuleType("calibre.gui2") calibre_gui2.__path__ = [] calibre.gui2 = calibre_gui2 calibre_gui2.actions = MagicMock() class MockInterfaceAction: def __init__(self, parent, site_customization): self.gui = parent self.interface_action_base_plugin = site_customization calibre_gui2.actions.InterfaceAction = MockInterfaceAction calibre_gui2.device = MagicMock() calibre_gui2.show_restart_warning = MagicMock() calibre_gui2.error_dialog = MagicMock() calibre_gui2.warning_dialog = MagicMock() calibre_gui2.open_url = MagicMock() # Setup calibre.gui2.dialogs calibre_gui2_dialogs = types.ModuleType("calibre.gui2.dialogs") calibre_gui2_dialogs.__path__ = [] calibre_gui2.dialogs = calibre_gui2_dialogs sys.modules["calibre.gui2.dialogs"] = calibre_gui2_dialogs calibre_gui2_dialogs.message_box = MagicMock() calibre_gui2_dialogs.message_box.MessageBox = MagicMock() # Assign to sys.modules sys.modules["calibre"] = calibre sys.modules["calibre.constants"] = calibre.constants sys.modules["calibre.customize"] = calibre.customize sys.modules["calibre.devices"] = calibre.devices sys.modules["calibre.devices.usbms"] = calibre.devices.usbms sys.modules["calibre.devices.usbms.driver"] = calibre.devices.usbms.driver sys.modules["calibre.utils"] = calibre.utils sys.modules["calibre.utils.config"] = calibre.utils.config sys.modules["calibre.utils.iso8601"] = calibre.utils.iso8601 sys.modules["calibre.gui2"] = calibre.gui2 sys.modules["calibre.gui2.actions"] = calibre_gui2.actions sys.modules["calibre.gui2.dialogs"] = calibre_gui2.dialogs sys.modules["calibre.gui2.dialogs.message_box"] = calibre_gui2_dialogs.message_box sys.modules["calibre.gui2.device"] = calibre_gui2.device # 3. Create 'calibre_plugins' package calibre_plugins = types.ModuleType("calibre_plugins") sys.modules["calibre_plugins"] = calibre_plugins # 4. Create 'koreader' package koreader_pkg = types.ModuleType("calibre_plugins.koreader") koreader_pkg.__path__ = [] calibre_plugins.koreader = koreader_pkg sys.modules["calibre_plugins.koreader"] = koreader_pkg # 5. Import local modules init_path = os.path.join(os.path.dirname(__file__), '..', '__init__.py') spec = importlib.util.spec_from_file_location("__init__", init_path) __init__ = importlib.util.module_from_spec(spec) sys.modules["__init__"] = __init__ spec.loader.exec_module(__init__) import slpp # 6. Assign submodules to package koreader_pkg.slpp = slpp sys.modules["calibre_plugins.koreader.slpp"] = slpp koreader_pkg.clean_bookmarks = __init__.clean_bookmarks koreader_pkg.DEBUG = __init__.DEBUG koreader_pkg.DRY_RUN = __init__.DRY_RUN koreader_pkg.PYDEVD = __init__.PYDEVD koreader_pkg.KoreaderSync = __init__.KoreaderSync # 7. Import config import config koreader_pkg.config = config sys.modules["calibre_plugins.koreader.config"] = config ================================================ FILE: tests/integration/test_docker_path_resolution.py ================================================ import os from unittest.mock import MagicMock, patch from action import KoreaderAction def test_wireless_device_avoids_local_filesystem(): """ Reproduces Issue #73: Ensure that for non-USB devices (Wireless/Docker), the plugin does NOT use os.path.exists which would look at the local container filesystem. """ # 1. Setup a mock device that is NOT a USBMS instance # In conftest.py, we mocked MockUSBMS. # Here we create a device that does NOT inherit from it. class WirelessDevice: def exists(self, path): return True def get_file(self, path, outfile): pass mock_device = WirelessDevice() # 2. Setup Action mock_parent = MagicMock() mock_site_customization = MagicMock() mock_site_customization.name = 'KOReader Sync' mock_site_customization.version = (0, 8, 0) action = KoreaderAction(mock_parent, mock_site_customization) # Path that looks like a Linux/KOReader path but definitely doesn't exist locally device_path = "/mnt/onboard/Books/Test.sdr/metadata.epub.lua" # 3. Patch os.path.exists to track calls with patch('os.path.exists') as mock_exists: mock_exists.return_value = False # Doesn't exist on host # Trigger the check exists = action.device_path_exists(mock_device, device_path) # ASSERTIONS assert exists is True, "Should have used driver.exists" # CRITICAL REPRODUCTION CHECK: # Before the fix, this would have been called with the device_path. # After the fix, it should NOT be called for non-USB devices. mock_exists.assert_not_called() def test_usb_device_triggers_makedirs(): """ Verifies that for USB devices, the plugin attempts to create the sidecar directory if it doesn't exist. """ from calibre.devices.usbms.driver import USBMS class MyUSBDevice(USBMS): def put_file(self, path, stream): pass mock_device = MyUSBDevice() mock_parent = MagicMock() mock_site_customization = MagicMock() action = KoreaderAction(mock_parent, mock_site_customization) device_path = "E:/Books/NewBook.sdr/metadata.epub.lua" # Mock DB lookup to return metadata action.gui.current_db.new_api.lookup_by_uuid.return_value = 1 mock_metadata = MagicMock() mock_metadata.get.return_value = '{"test": 1}' # dummy sidecar json action.gui.current_db.new_api.get_metadata.return_value = mock_metadata with patch('os.makedirs') as mock_makedirs, \ patch('os.path.exists') as mock_exists: mock_exists.return_value = False action.push_metadata_to_koreader_sidecar(mock_device, "some-uuid", device_path) # This will currently FAIL because I removed makedirs mock_makedirs.assert_called_with(os.path.dirname(device_path), exist_ok=True) def test_wireless_device_skips_makedirs(): """ Verifies that for wireless devices, the plugin does NOT call os.makedirs. """ class WirelessDevice: def put_file(self, path, stream): pass mock_device = WirelessDevice() mock_parent = MagicMock() mock_site_customization = MagicMock() action = KoreaderAction(mock_parent, mock_site_customization) device_path = "/mnt/onboard/Books/NewBook.sdr/metadata.epub.lua" action.gui.current_db.new_api.lookup_by_uuid.return_value = 1 mock_metadata = MagicMock() mock_metadata.get.return_value = '{"test": 1}' action.gui.current_db.new_api.get_metadata.return_value = mock_metadata with patch('os.makedirs') as mock_makedirs: action.push_metadata_to_koreader_sidecar(mock_device, "some-uuid", device_path) mock_makedirs.assert_not_called() ================================================ FILE: tests/integration/test_integration.py ================================================ import os import sqlite3 import pytest from unittest.mock import MagicMock from action import KoreaderAction def test_dummy_data_consistency(): # Verify dummy_library metadata.db conn = sqlite3.connect('dummy_library/metadata.db') cursor = conn.cursor() cursor.execute('SELECT title, uuid FROM books') db_books = {title: uuid for title, uuid in cursor.fetchall()} conn.close() assert "Alice's Adventures in Wonderland" in db_books assert "Walden, and On The Duty Of Civil Disobedience" in db_books # Verify dummy_device paths alice_path = "Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.epub" thoreau_path = "Thoreau, Henry David/Walden, and On The Duty Of Civil Disobedience - Henry David Thoreau.epub" assert os.path.exists(os.path.join('dummy_device', alice_path)) assert os.path.exists(os.path.join('dummy_device', thoreau_path)) def test_get_paths_with_dummy_device(): # Mock book objects as they would come from a real device driver class MockBook: def __init__(self, uuid, path): self.uuid = uuid self.path = path alice_book = MockBook( uuid='43bd8264-96fa-461a-a05e-1d1cb245d34f', path="Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.epub" ) thoreau_book = MockBook( uuid='3393747a-f0d8-44e1-bfaf-5fad857da3eb', path="Thoreau, Henry David/Walden, and On The Duty Of Civil Disobedience - Henry David Thoreau.epub" ) mock_device = MagicMock() mock_device.books.return_value = [alice_book, thoreau_book] # Instantiate action with mocks for parent and site_customization mock_parent = MagicMock() mock_site_customization = MagicMock() mock_site_customization.name = 'KOReader Sync' mock_site_customization.version = (0, 8, 0) action = KoreaderAction(mock_parent, mock_site_customization) paths = action.get_paths(mock_device) assert len(paths) == 2 # Verify Alice's sidecar path generation alice_sidecar = next(p for u, p in paths if u == alice_book.uuid) assert alice_sidecar == "Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.sdr/metadata.epub.lua" ================================================ FILE: tests/integration/test_issue_143_fix.py ================================================ import os import io import pytest from unittest.mock import MagicMock, patch from action import KoreaderAction def test_fix_issue_143_usb_direct_write(): """ Verifies fix for Issue #143: USB/Folder devices use direct open() instead of put_file() to avoid driver crashes. """ from calibre.devices.usbms.driver import USBMS class MyUSBDevice(USBMS): def put_file(self, path, stream): raise Exception("Should not be called for USB") mock_device = MyUSBDevice() mock_parent = MagicMock() mock_site_customization = MagicMock() action = KoreaderAction(mock_parent, mock_site_customization) # Mock DB etc action.gui.current_db.new_api.lookup_by_uuid.return_value = 1 mock_metadata = MagicMock() mock_metadata.get.return_value = '{"test": 1}' action.gui.current_db.new_api.get_metadata.return_value = mock_metadata with patch('builtins.open', MagicMock()) as mock_open, \ patch('os.makedirs') as mock_makedirs, \ patch('os.path.exists') as mock_exists: mock_exists.return_value = True result, details = action.push_metadata_to_koreader_sidecar(mock_device, "uuid", "E:/path.lua") assert result == "success" # Verify open was used mock_open.assert_called() # Verify put_file was NOT used # (Already handled by the exception in MyUSBDevice.put_file) def test_wireless_still_uses_put_file(): """ Ensures wireless devices STILL use put_file(). """ class WirelessDevice: def put_file(self, path, stream): self.called = True mock_device = WirelessDevice() mock_device.called = False mock_parent = MagicMock() mock_site_customization = MagicMock() action = KoreaderAction(mock_parent, mock_site_customization) action.gui.current_db.new_api.lookup_by_uuid.return_value = 1 mock_metadata = MagicMock() mock_metadata.get.return_value = '{"test": 1}' action.gui.current_db.new_api.get_metadata.return_value = mock_metadata result, details = action.push_metadata_to_koreader_sidecar(mock_device, "uuid", "/mnt/path.lua") assert result == "success" assert mock_device.called is True ================================================ FILE: tests/integration/test_issue_143_repro.py ================================================ import io import pytest from unittest.mock import MagicMock from action import KoreaderAction def test_reproduce_issue_143(): """ Reproduces Issue #143: '_io.BytesIO' object has no attribute 'startswith' This happens when a driver's put_file implementation expects a string (likely a file path) but receives a BytesIO object. """ mock_parent = MagicMock() mock_site_customization = MagicMock() action = KoreaderAction(mock_parent, mock_site_customization) # Simulate a driver that might do 'stream.startswith' class CrashingDriver: def put_file(self, path, stream): # This simulates the internal Calibre driver logic that might be crashing if stream.startswith('some_path'): pass mock_device = CrashingDriver() # Mock DB etc to reach the put_file call action.gui.current_db.new_api.lookup_by_uuid.return_value = 1 mock_metadata = MagicMock() mock_metadata.get.return_value = '{"test": 1}' action.gui.current_db.new_api.get_metadata.return_value = mock_metadata # This should return a failure result containing the error message result, details = action.push_metadata_to_koreader_sidecar(mock_device, "uuid", "some/path.lua") assert result == "failure" assert "'_io.BytesIO' object has no attribute 'startswith'" in details['result'] ================================================ FILE: tests/integration/test_uuid_mismatch.py ================================================ import os import pytest from unittest.mock import MagicMock from action import KoreaderAction def test_get_calibre_uuid_from_sidecar(): action = KoreaderAction(MagicMock(), MagicMock()) # Test with valid identifiers sidecar = { 'stats': { 'identifiers': 'uuid:8d62883d calibre:5ac8d90f-7d24-4b65-9f89-ff77df18bee9 isbn:123' } } assert action.get_calibre_uuid_from_sidecar(sidecar) == '5ac8d90f-7d24-4b65-9f89-ff77df18bee9' # Test with newline/backslash separator sidecar = { 'stats': { 'identifiers': 'uuid:abc\\calibre:xyz\\isbn:123' } } assert action.get_calibre_uuid_from_sidecar(sidecar) == 'xyz' # Test with no calibre identifier sidecar = { 'stats': { 'identifiers': 'uuid:abc isbn:123' } } assert action.get_calibre_uuid_from_sidecar(sidecar) is None def test_uuid_mismatch_resolution_integration(): # Setup wrong_uuid = "wrong-uuid-1234-5678" correct_uuid = "43bd8264-96fa-461a-a05e-1d1cb245d34f" sidecar_path = "Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.sdr/metadata.epub.lua" class MockBook: def __init__(self, uuid, path): self.uuid = uuid self.path = path mock_book = MockBook(uuid=wrong_uuid, path=sidecar_path.replace(".sdr/metadata.epub.lua", ".epub")) mock_device = MagicMock() mock_device.books.return_value = [mock_book] def get_file_side_effect(path, outfile): real_path = os.path.join("dummy_device", path) with open(real_path, "rb") as f: outfile.write(f.read()) mock_device.get_file.side_effect = get_file_side_effect mock_site_customization = MagicMock() mock_site_customization.name = 'KOReader Sync' mock_site_customization.version = (0, 8, 0) action = KoreaderAction(MagicMock(), mock_site_customization) # Mock DB mock_db = MagicMock() def lookup_by_uuid_side_effect(uuid): if uuid == correct_uuid: return 4 return None mock_db.lookup_by_uuid.side_effect = lookup_by_uuid_side_effect # Simulate the worker loop logic # 1. Get sidecar sidecar_contents = action.get_sidecar(mock_device, sidecar_path) # 2. Inject the correct identifier (simulating what KOReader would have) sidecar_contents['stats']['identifiers'] = f'uuid:{wrong_uuid} calibre:{correct_uuid}' # 3. Test the fix: if initial lookup fails, try alternative book_id = mock_db.lookup_by_uuid(wrong_uuid) assert book_id is None # Initial failure better_uuid = action.get_calibre_uuid_from_sidecar(sidecar_contents) assert better_uuid == correct_uuid book_id = mock_db.lookup_by_uuid(better_uuid) assert book_id == 4 # Success! ================================================ FILE: tests/unit/test_bookmarks.py ================================================ import pytest from __init__ import clean_bookmarks def test_clean_bookmarks_large_payload(): # Simulate a 1.8MB metadata file scenario: # 900 annotations, each with ~2KB of text/notes. num_annotations = 900 large_text = "A" * 1000 # 1KB of text large_note = "B" * 1000 # 1KB of note bookmarks = {} for i in range(1, num_annotations + 1): bookmarks[i] = { 'chapter': f'Chapter {i // 10}', 'note': f'Note {i}: {large_note}', 'text': f'Highlight {i}: {large_text}', 'datetime': '2023-01-01 12:00:00' } result = clean_bookmarks(bookmarks) # Check that the result is a string assert isinstance(result, str) # With the fix (O(N) complexity): # Total size should be roughly (2KB per highlight * 900) + HTML overhead # 2000 * 900 = 1.8 MB. # We allow some headroom for HTML and hidden attributes. # Calibre's SQLite limit for a single cell (Long Text) is technically 1GB, # but practical performance issues start much earlier (usually around 10-20MB). # 5MB is a very safe upper bound for 900 highlights of this size. assert len(result) < 5 * 1024 * 1024 # Verify content assert 'Chapter 0' in result assert 'Note 1:' in result assert '900. Highlight' in result def test_clean_bookmarks_empty(): assert 'Book Highlights and Notes' in clean_bookmarks({}) ================================================ FILE: tests/unit/test_md5_logic.py ================================================ import pytest import re from unittest.mock import MagicMock from action import KoreaderAction def test_sidecar_path_regex_robustness(): """Verifies that the sidecar path regex handles various extensions correctly.""" def get_sidecar_path(book_path): # Using the NEW regex from action.py return re.sub(r'\.([^./\\]+)$', r'.sdr/metadata.\1.lua', book_path) assert get_sidecar_path("Book.epub") == "Book.sdr/metadata.epub.lua" assert get_sidecar_path("Folder/Book.mobi") == "Folder/Book.sdr/metadata.mobi.lua" assert get_sidecar_path("Book.fb2.zip") == "Book.fb2.sdr/metadata.zip.lua" assert get_sidecar_path("My.Book.With.Dots.epub") == "My.Book.With.Dots.sdr/metadata.epub.lua" # Test with hyphen in extension (rare but possible) assert get_sidecar_path("file.epub-original") == "file.sdr/metadata.epub-original.lua" def test_is_system_path(): from action import is_system_path assert is_system_path("kfmon.sdr/metadata.lua") is True assert is_system_path("koreader.sdr/metadata.lua") is True assert is_system_path("Books/MyBook.sdr/metadata.lua") is False ================================================ FILE: tests/unit/test_version.py ================================================ import os import re def test_version_match(): """Check if version in .version matches version in __init__.py""" with open(".version", "r") as f: version = f.read().strip() # Enforce no '-pre' on main branch releases # GITHUB_REF_NAME is provided by GitHub Actions is_main = os.environ.get('GITHUB_REF_NAME') == 'main' if is_main: assert "-pre" not in version, "Release error: .version file on 'main' branch must not contain '-pre'!" with open("__init__.py", "r") as f: content = f.read() # On main, we expect an exact match. # On other branches, we allow -pre or -dev suffixes (added by 'make pre' or 'make dev') if is_main: pattern = rf'version_string\s*=\s*[\'"]{re.escape(version)}[\'"]' else: pattern = rf'version_string\s*=\s*[\'"]{re.escape(version)}(-pre|-dev)?[\'"]' assert re.search(pattern, content), f"Version string matching '{version}' not found in __init__.py" def test_version_tuple_match(): """Check if version in .version matches version tuple in __init__.py""" with open(".version", "r") as f: version = f.read().strip() # Strip any suffix like -pre or -dev for the numeric tuple # This matches the logic in our Makefile numeric_version = version.split("-")[0] parts = numeric_version.split(".") # format (0, 8, 0) version_tuple = f"({', '.join(parts)})" with open("__init__.py", "r") as f: content = f.read() expected = f"version = {version_tuple}" assert expected in content, f"Expected {expected} not found in __init__.py"