[
  {
    "path": ".editorconfig",
    "content": "[*]\ncharset = utf-8\nend_of_line = lf\nindent_size = 2\nindent_style = space\ninsert_final_newline = true\nmax_line_length = 80\ntab_width = 2\ntrim_trailing_whitespace = true\n\n[*.py]\nindent_size = 4\ntab_width = 4\n\n[Makefile]\nindent_style = tab\n"
  },
  {
    "path": ".gitattributes",
    "content": "# Handle line endings automatically for files detected as text\n# and leave binary files untouched.\n* text=auto\n\n# Force bash scripts and python files to always use LF\n*.sh text eol=lf\n*.py text eol=lf\n*.txt text eol=lf\n*.md text eol=lf\nMakefile text eol=lf\n.editorconfig text eol=lf\n.gitignore text eol=lf\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\nko_fi: kyxap\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/1-bug_report.yml",
    "content": "name: Bug Report\ndescription: File a bug report.\ntitle: \"[Bug] \"\nlabels: [\"bug\", \"triage\"]\nassignees: []\n\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to fill out this bug report!\n\n  - type: input\n    id: plugin-version\n    attributes:\n      label: KOreader Sync plugin version\n      description: What plugin version are you using? Is this the latest version? If not, please update and try to reproduce the issue.\n      placeholder: Provide information here\n    validations:\n      required: true\n\n  - type: input\n    id: koreader-version\n    attributes:\n      label: KOreader version\n      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.\n      placeholder: Provide information here\n    validations:\n      required: true\n\n  - type: input\n    id: device\n    attributes:\n      label: Device\n      description: What device are you using? (e.g. Kobo Clara BW, PocketBook Era, etc.)\n      placeholder: Provide information here\n    validations:\n      required: true\n\n  - type: dropdown\n    id: os\n    attributes:\n      label: Operating System\n      description: What OS is used to run Calibre?\n      multiple: true\n      options:\n        - Linux\n        - Windows\n        - MacOS\n    validations:\n      required: true\n\n  - type: dropdown\n    id: connection-type\n    attributes:\n      label: Connection type\n      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.\n      multiple: true\n      options:\n        - Wireless (over wifi)\n        - Wired (over usb cable)\n    validations:\n      required: true\n\n  - type: textarea\n    id: describe-bug\n    attributes:\n      label: Describe the bug\n      description: A clear and concise description of what the bug is.\n      placeholder: Type your description here\n    validations:\n      required: true\n\n  - type: textarea\n    id: reproduce-steps\n    attributes:\n      label: How to reproduce\n      description: Steps to reproduce the behavior. Provide as much detail as possible.\n      placeholder: Steps to reproduce the issue\n    validations:\n      required: true\n\n  - type: textarea\n    id: expected-behavior\n    attributes:\n      label: Expected behavior\n      description: A clear and concise description of what you expected to happen.\n      placeholder: Describe what you expected to happen\n    validations:\n      required: true\n\n  - type: textarea\n    id: show-details\n    attributes:\n      label: Provide details output from plugin pop-up window\n      description: Get detailed output by clicking the \"Show details\" button and copy the output here.\n      placeholder: Provide information here\n    validations:\n      required: false\n\n  - type: textarea\n    id: screenshots\n    attributes:\n      label: Screenshots\n      description: If applicable, add screenshots to help explain your problem.\n      placeholder: Upload screenshots here, for most of the browser simple copy and paste in the input field will upload the screen shot automatically.\n    validations:\n      required: false\n\n  - type: textarea\n    id: additional-info\n    attributes:\n      label: Any additional info\n      description: Any additional information if want to add to the bug report\n      placeholder: If you want add something extra\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/2-feature_request.yml",
    "content": "name: Feature Request\ndescription: Suggest an idea for a new feature\ntitle: \"[FEATURE] \"\nlabels: [\"enhancement\", \"triage\"]\nassignees: []\n\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        **Thank you for suggesting a feature! Please fill out the following details to help us understand and evaluate your request.**\n\n  - type: input\n    id: feature-summary\n    attributes:\n      label: Feature Summary\n      description: A clear and concise summary of the feature you are requesting.\n      placeholder: Briefly describe the feature\n    validations:\n      required: true\n\n  - type: textarea\n    id: motivation\n    attributes:\n      label: Motivation\n      description: Explain why this feature is needed and how it will benefit the project or users.\n      placeholder: Describe the motivation for this feature\n    validations:\n      required: true\n\n  - type: textarea\n    id: use-cases\n    attributes:\n      label: Use Cases\n      description: Provide specific scenarios or use cases where this feature would be useful.\n      placeholder: Provide detailed use cases\n    validations:\n      required: true\n\n  - type: textarea\n    id: alternatives\n    attributes:\n      label: Alternatives\n      description: Describe any alternative solutions or features that you considered.\n      placeholder: Describe any alternative solutions\n    validations:\n      required: false\n\n  - type: textarea\n    id: additional-context\n    attributes:\n      label: Additional Context\n      description: Add any other context or information that may help in understanding and evaluating the request.\n      placeholder: Provide additional context here\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\n"
  },
  {
    "path": ".github/workflows/develop-pre-release.yml",
    "content": "name: Pre-release CI\n\non:\n  push:\n    branches:\n      - develop\n\npermissions:\n  contents: write\n  issues: write\n\njobs:\n  pre-release:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Build Plugin\n        id: build\n        run: |\n          VERSION=$(head -n 1 .version)\n          PRE_VERSION=\"${VERSION}-pre\"\n          echo \"version=${PRE_VERSION}\" >> $GITHUB_OUTPUT\n          \n          # 1. Create the dev marker so the Makefile uses the correct ZIP filename\n          mkdir -p dist\n          echo \"${PRE_VERSION}\" > dist/.version-dev\n          \n          # 2. Patch the files manually for this artifact build\n          sed -i \"s/^[[:space:]]*version_string = .*/    version_string = \\\"${PRE_VERSION}\\\"/\" __init__.py\n          sed -i \"s/Version: [^;]*;/Version: ${PRE_VERSION};/\" pluginIndexKOReaderSync.txt\n          \n          # 3. Create the ZIP (bypassing the 'build' target which would reset these changes)\n          make zip\n\n      - name: Create or Update Pre Release\n        id: create_release\n        uses: softprops/action-gh-release@v2\n        with:\n          tag_name: pre-release\n          name: \"Upcoming Release (v${{ steps.build.outputs.version }})\"\n          body: |\n            This is a **community pre-release** containing the latest fixes and features planned for the next official update.\n\n            We invite everyone to collaborate:\n            - 🧪 **Test**: Download and try this build.\n            - 💬 **Feedback**: Let us know if the fixes work for you!\n            - 🚀 **Contribute**: Help us ensure the best possible stable release.\n\n            **Note:** This build is experimental and updated automatically on every change to the `develop` branch.\n\n            Last updated: ${{ github.event.head_commit.timestamp }}\n            Commit: ${{ github.sha }}\n          prerelease: true\n          files: dist/*.zip\n          make_latest: false\n          generate_release_notes: false\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Notify Issues\n        run: |\n          # Extract issue numbers from commit message (e.g., #123)\n          COMMIT_MSG=$(git log -1 --pretty=%B)\n          ISSUE_NUMBERS=$(echo \"$COMMIT_MSG\" | grep -oP '#\\K\\d+' | sort -u)\n\n          if [ -n \"$ISSUE_NUMBERS\" ]; then\n            for ISSUE in $ISSUE_NUMBERS; do\n              echo \"Notifying issue #$ISSUE\"\n              gh issue comment \"$ISSUE\" --body \"A potential fix has been pushed to the **develop** branch.\n\n              You can download the latest pre-release for testing here: [Pre-release (Rolling)](https://github.com/${{ github.repository }}/releases/tag/pre-release)\n\n              *(Note: This is an automated notification)*\"\n            done\n          else\n            echo \"No issue numbers found in commit message.\"\n          fi\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/main-ci.yml",
    "content": "name: Quality Check\n\non:\n  push:\n    branches: [ main ]\n  pull_request:\n    branches: [ main ]\n\njobs:\n  lint:\n    name: Linting (Pylint)\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: '3.9'\n\n      - name: Install dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install pylint\n          if [ -f Pipfile ]; then pip install pipenv && pipenv install --system --dev; fi\n\n      - name: Run Linting\n        run: make lint\n\n  test:\n    name: Unit Tests (Pytest)\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: '3.9'\n\n      - name: Install dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install pytest\n          if [ -f Pipfile ]; then pip install pipenv && pipenv install --system --dev; fi\n\n      - name: Run Tests\n        run: make test\n"
  },
  {
    "path": ".github/workflows/main-release.yml",
    "content": "name: Stable Release CI\n\non:\n  push:\n    tags:\n      - 'v*'\n\njobs:\n  release:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Build Plugin\n        run: make build\n\n      - name: Create Release\n        id: create_release\n        uses: softprops/action-gh-release@v1\n        with:\n          files: dist/*.zip\n          generate_release_notes: true\n          draft: true\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# PyCharm & VSCodium\n.idea\n.vscode\n\n# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\ncover/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n#   For a library or package, you might want to ignore these files since the code is\n#   intended to run in multiple environments; otherwise, check them in:\n.python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# Release and distribution\ndist/\nrelease/\n*.zip\n\n# Versioning\n.version-dev\n\n# scripts extras\n.scripts/output.forumbb\n.scripts/input.md\n\n# Temporary files\n.temp/"
  },
  {
    "path": ".pylintrc",
    "content": "[MASTER]\nignore-patterns=slpp.py\nfail-under=9.5\n\n\n[MESSAGES CONTROL]\n# Disable some of the more \"opinionated\" or hard-to-fix-quickly checks\ndisable=raw-checker-failed,\n        bad-inline-option,\n        locally-disabled,\n        file-ignored,\n        suppressed-message,\n        useless-suppression,\n        deprecated-pragma,\n        use-symbolic-message-instead,\n        import-error,\n        no-name-in-module,\n        attribute-defined-outside-init,\n        missing-class-docstring,\n        missing-function-docstring,\n        unnecessary-lambda,\n        broad-except,\n        bare-except,\n        # Relaxing these for the \"big boost\"\n        too-many-instance-attributes,\n        too-many-locals,\n        too-many-arguments,\n        too-many-statements,\n        too-many-branches,\n        too-many-lines,\n        too-many-positional-arguments,\n        duplicate-code,\n        line-too-long,\n        fixme,\n        inconsistent-return-statements\n\n[REPORTS]\n# Increased error weight from 5 to 50 so a single E fails the 9.5 threshold\nevaluation=10.0 - ((float(50 * error + warning + refactor + convention) / statement) * 10)\nscore=yes\n\n[VARIABLES]\n# Calibre/Qt globals that show up as \"undefined\"\nadditional-builtins=_,get_icons,get_resources\n\n[FORMAT]\nmax-line-length=140\nmax-module-lines=2000\n\n[BASIC]\n# Allow some common shorter names\ngood-names=i,j,k,ex,Run,_,db,f,qs,ok\n\n[DESIGN]\n# Loosen these up a bit\nmax-args=10\nmax-attributes=15\nmax-locals=25\nmax-statements=100\nmax-branches=20\nmin-public-methods=0\n"
  },
  {
    "path": ".run/pydevd_pycharm.run.xml",
    "content": "<component name=\"ProjectRunConfigurationManager\">\n  <configuration default=\"false\" name=\"pydevd_pycharm\" type=\"PyRemoteDebugConfigurationType\" factoryName=\"Python Remote Debug\" editBeforeRun=\"true\">\n    <module name=\"koreader-calibre-plugin\" />\n    <option name=\"PORT\" value=\"5678\" />\n    <option name=\"HOST\" value=\"localhost\" />\n    <PathMappingSettings>\n      <option name=\"pathMappings\">\n        <list>\n          <mapping local-root=\"$PROJECT_DIR$/__init__.py\" remote-root=\"/home/harm/git/koreader-calibre-plugin/calibre_plugins.koreader.__init__\" />\n          <mapping local-root=\"$PROJECT_DIR$/action.py\" remote-root=\"/home/harm/git/koreader-calibre-plugin/calibre_plugins.koreader.action\" />\n          <mapping local-root=\"$PROJECT_DIR$/config.py\" remote-root=\"/home/harm/git/koreader-calibre-plugin/calibre_plugins.koreader.config\" />\n        </list>\n      </option>\n    </PathMappingSettings>\n    <option name=\"REDIRECT_OUTPUT\" value=\"true\" />\n    <option name=\"SUSPEND_AFTER_CONNECT\" value=\"true\" />\n    <method v=\"2\" />\n  </configuration>\n</component>"
  },
  {
    "path": ".scripts/md-to-bb.py",
    "content": "import sys\nimport re\nimport os\n\n\ndef markdown_to_bbcode(text):\n    # Remove all line breaks to preserve full lines in the output\n    text = re.sub(r'(?m)^# (.+)', r'[b][SIZE=\"7\"]\\1[/SIZE][/b]', text)  # H1\n    text = re.sub(r'(?m)^## (.+)', r'[b][SIZE=\"3\"]\\1[/SIZE][/b]', text)  # H2\n    text = re.sub(r'(?m)^### (.+)', r'[b][SIZE=\"3\"]\\1[/SIZE][/b]', text)  # H3\n\n    # Convert Markdown lists to BBCode\n    text = re.sub(r'(?m)^\\* (.+)', r'[list][*]\\1[/list]', text)  # Unordered list\n    text = re.sub(r'(?m)^\\d+\\. (.+)', r'[list][*]\\1[/list]', text)  # Ordered list\n\n    # Convert Markdown links to BBCode\n    text = re.sub(r'\\[([^\\]]+)\\]\\(([^)]+)\\)', r'[url=\\2]\\1[/url]', text)\n\n    # Convert Markdown bold and italic to BBCode\n    text = re.sub(r'\\*\\*(.+?)\\*\\*', r'[b]\\1[/b]', text)\n    text = re.sub(r'\\*(.+?)\\*', r'[i]\\1[/i]', text)\n\n    return text\n\n\ndef main():\n    if len(sys.argv) != 3:\n        print(\"Usage: python markdown_to_bbcode.py <input_file> <output_file>\")\n        sys.exit(1)\n\n    input_file = sys.argv[1]\n    output_file = sys.argv[2]\n\n    # Compute the absolute path to the version file\n    script_dir = os.path.dirname(os.path.abspath(__file__))\n    version_file = os.path.join(script_dir, '..', '.version')\n\n    # Read the version from the version file\n    if not os.path.exists(version_file):\n        print(f\"Error: Version file '{version_file}' not found.\")\n        sys.exit(1)\n\n    with open(version_file, 'r', encoding='utf-8') as vf:\n        version = vf.read().strip()\n\n    # Format the version as BBCode\n    version_bbcode = f'[b][SIZE=\"5\"]v{version}[/SIZE][/b]\\n\\n'\n\n    # Read and convert the Markdown input\n    with open(input_file, 'r', encoding='utf-8') as f:\n        markdown_text = f.read()\n\n    bbcode_text = markdown_to_bbcode(markdown_text)\n\n    # Combine version and BBCode content\n    full_bbcode_text = version_bbcode + bbcode_text\n\n    # Write the combined BBCode to the output file\n    with open(output_file, 'w', encoding='utf-8') as f:\n        f.write(full_bbcode_text)\n\n    print(f\"Converting {input_file} to {output_file}\")\n    print(f\"Version: {version}\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": ".version",
    "content": "0.8.2\n"
  },
  {
    "path": "GEMINI.md",
    "content": "# KOReader Calibre Plugin - AI Context & Architecture\n\nThis file provides critical architectural context and known limitations for AI assistants working on this codebase.\n\n## 🏗 Core Architecture\n\nThe plugin facilitates metadata synchronization between **KOReader** (on E-ink devices) and **Calibre**.\n\n### Connection Methods & Capabilities\n\n| Method | Reading Metadata | Writing Sidecars (`.sdr`) | Notes |\n| :--- | :--- | :--- | :--- |\n| **USB Cable** | ✅ Supported | ✅ Supported | Device mounted as a local filesystem. |\n| **Calibre WiFi** | ✅ Supported | ❌ Not Supported | Uses `SMART_DEVICE_APP` driver. `put_file()` is not available for arbitrary sidecars. |\n| **Sync Server** | ✅ Supported | ✅ Supported | Communicates via REST API. Identifies books by MD5 hash instead of UUID. |\n\n### Metadata Schema & Mapping\nThe plugin maps KOReader's Lua sidecar data to Calibre custom columns. Key fields include:\n- **Percent Read:** Supported as both Floating Point (`{:.0%}`) and Integer.\n- **Status:** Maps KOReader statuses (*Finished, Reading, On hold*) to Calibre (*complete, reading, abandoned*).\n- **Annotations/Highlights:** Stored as Markdown in a Long Text column.\n- **MD5 Hash:** **Critical** for ProgressSync server functionality.\n- **Date Sidecar Modified:** Only available via wired (USB) connections.\n\n## 🛠 Key Implementation Details\n\n### Wireless \"Sync Missing\" Logic\n- Because Calibre's Wireless driver cannot write sidecar files, the \"Sync Missing to KOReader\" feature is gracefully disabled for wireless connections to prevent `AttributeError`.\n- **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()`.\n\n### Metadata Extraction\n- **Sidecar Parsing:** Sidecar files (`.lua`) are parsed into Python dicts using the internal `slpp.py` (Lua-in-Python parser).\n- **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.\n- **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.\n- **Renamed Fields:** Be aware that `bookmarks` was renamed to `annotations` in newer sidecar formats.\n\n### Book Identification\n- **UUID vs. MD5:** Direct device sync uses Calibre's internal UUID. Server-based sync relies on an MD5 hash stored in a custom column.\n- **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.\n\n## ⚠️ Known Limitations & Constraints\n\n- **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.\n- **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.\n\n## 🧪 Development & Debugging\n- Use `make dev` to build and install the development version.\n- Timing logs for device operations are prefixed with `KoreaderAction:device_path_exists:` in the debug output.\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n"
  },
  {
    "path": "Makefile",
    "content": "# Read the version from the .version file\nversion := $(shell head -n 1 .version 2>/dev/null || echo 0.0.1)\n\n# Dev version components\nDATE := $(shell date -u +%Y%m%d)\nSHA := $(shell git rev-parse --short HEAD 2>/dev/null || echo local)\nDEV_VERSION := $(DATE)-$(SHA)-dev\nDEV_TUPLE := (0, 0, 0)\n\n# Check for a dev version file in dist, otherwise use the release version\nifeq ($(wildcard dist/.version-dev),)\n    effective_version := $(version)\nelse\n    effective_version := $(shell head -n 1 dist/.version-dev)\nendif\n\nzip_file = KOReader_Sync_v$(effective_version).zip\nzip_contents = about.txt LICENSE plugin-import-name-koreader.txt *.py *.md images/\nplugin_index_file_to_upd = pluginIndexKOReaderSync.txt\ninit_file_to_upd = __init__.py\ndist_dir = dist\n\n# Convert the version to tuple format (only take numeric parts for the tuple)\nversion_tuple := $(shell echo $(version) | sed 's/-.*//' | awk -F. '{print \"(\"$$1\", \"$$2\", \"$$3\")\"}')\n\n# Flatpak support: set FLATPAK=1 to use Flatpak commands\n# e.g., make release FLATPAK=1\nifdef FLATPAK\n    ifneq ($(shell uname -s 2>/dev/null),Linux)\n        $(error FLATPAK=1 is only supported on Linux. For other platforms, please install Calibre natively and run make without FLATPAK=1)\n    endif\n    ifeq ($(shell command -v flatpak 2>/dev/null),)\n        $(error The 'flatpak' command was not found. Please install flatpak or run make without FLATPAK=1)\n    endif\n    ifeq ($(shell flatpak info com.calibre_ebook.calibre >/dev/null 2>&1; echo $$?),1)\n        $(error Calibre Flatpak (com.calibre_ebook.calibre) is not installed. Please install it or run make without FLATPAK=1)\n    endif\n    CALIBRE_CUSTOMIZE = flatpak run --command=calibre-customize com.calibre_ebook.calibre\n    CALIBRE_DEBUG = flatpak run --command=calibre-debug com.calibre_ebook.calibre\nelse\n    CALIBRE_CUSTOMIZE = calibre-customize\n    CALIBRE_DEBUG = calibre-debug\nendif\n\n# Main targets\n# Always clean dev metadata before a formal release\nbuild: clean_dev\n\t@$(MAKE) update_version\n\t@$(MAKE) zip\n\nrelease: lint test\n\t@if [ \"$$(git rev-parse --abbrev-ref HEAD)\" != \"main\" ]; then \\\n\t\techo \"Error: You must be on the 'main' branch to release.\"; \\\n\t\texit 1; \\\n\tfi\n\t@$(MAKE) build\n\t@$(MAKE) tag\n\n# Preparation for a release: creates a branch, updates versions, and commits.\n# Allows .version to be dirty so you can edit it before running.\nprep-release:\n\t@if [ -n \"$$(git status --short | grep -v ' .version$$')\" ]; then \\\n\t\techo \"Working directory has uncommitted changes (other than .version). Please commit or stash them first.\"; \\\n\t\texit 1; \\\n\tfi\n\t@echo \"Preparing release for version $(version)\"\n\t@git checkout -b \"release-prep-$(version)\"\n\t@$(MAKE) update_version\n\t@$(MAKE) lint\n\t@$(MAKE) test\n\t@git add .version $(init_file_to_upd) $(plugin_index_file_to_upd)\n\t@git commit -m \"chore: Prepare release $(version)\"\n\t@echo \"Release preparation branch 'release-prep-$(version)' created.\"\n\t@echo \"Review the changes and then merge to main. Finally, run 'make release' on main.\"\n\n# Quality tools\ntest:\n\t@echo \"Running tests...\"\n\t@if [ -d \"tests\" ]; then \\\n\t\tpytest tests/; \\\n\telse \\\n\t\techo \"No tests directory found.\"; \\\n\t\texit 1; \\\n\tfi\n\nlint:\n\t@echo \"Running linting (pylint)...\"\n\t@pylint __init__.py action.py config.py --rcfile=.pylintrc --fail-on=E,F --output-format=colorized --msg-template=\"{path}:{line}: [{category}] {msg} ({symbol})\" || \\\n\t(echo -e \"\\n\\033[0;31m[!!!] CRITICAL ERRORS FOUND - FIX THESE FIRST:\\033[0m\" && \\\n\t pylint __init__.py action.py config.py --rcfile=.pylintrc --errors-only && \\\n\t exit 1)\n\n# Helper targets to bump version in .version file\nbump-patch:\n\t@awk -F. '{print $$1\".\"$$2\".\"$$3+1}' .version > .version.tmp && mv .version.tmp .version\n\t@echo \"Version bumped to $$(cat .version)\"\n\nbump-minor:\n\t@awk -F. '{print $$1\".\"$$2+1\".0\"}' .version > .version.tmp && mv .version.tmp .version\n\t@echo \"Version bumped to $$(cat .version)\"\n\nbump-major:\n\t@awk -F. '{print $$1+1\".0.0\"}' .version > .version.tmp && mv .version.tmp .version\n\t@echo \"Version bumped to $$(cat .version)\"\n\npre: build\n\npre_version:\n\t@echo \"Pre-version patching is now handled by CI/CD.\"\n\nzip: $(dist_dir)\n\t@echo \"Creating new $(dist_dir)/$(zip_file)\"\n\t@mkdir -p \"$(dist_dir)\" && zip -r \"$(dist_dir)/$(zip_file)\" $(zip_contents)\n\n# Loads current src content, use this if doing dev changes\ndev: dev_version\n\t@$(MAKE) zip\n\t@$(MAKE) load\n\ndev_version:\n\t@mkdir -p \"$(dist_dir)\"\n\t@echo \"$(DEV_VERSION)\" > \"$(dist_dir)/.version-dev\"\n\t@sed -i 's/^\\([[:space:]]*\\)version = ([0-9, ]*)/\\1version = $(DEV_TUPLE)/' $(init_file_to_upd)\n\t@if grep -q \"^[[:space:]]*version_string =\" $(init_file_to_upd); then \\\n\t\tsed -i \"s/^\\([[:space:]]*\\)version_string = .*/\\1version_string = '$(DEV_VERSION)'/\" $(init_file_to_upd); \\\n\telse \\\n\t\tsed -i \"/^[[:space:]]*version = /a \\    version_string = '$(DEV_VERSION)'\" $(init_file_to_upd); \\\n\tfi\n\t@sed -i 's/Version: [^;]*;/Version: $(DEV_VERSION);/' $(plugin_index_file_to_upd)\n\t@echo \"Dev version set to $(DEV_VERSION)\"\n\n# Install the plugin into Calibre\ninstall: zip\n\t@$(CALIBRE_CUSTOMIZE) -a \"$(dist_dir)/$(zip_file)\"\n\n# Install and then launch Calibre in debug mode\nload: install\n\t@$(CALIBRE_DEBUG) -g\n\nupdate_version: update_version_plugin_index update_version_init\n\t@echo \"Versions updated in all files.\"\n\nupdate_version_plugin_index:\n\t@echo \"Updating version in $(plugin_index_file_to_upd) to $(version)\"\n\t@sed -i 's/Version: [^;]*;/Version: $(version);/' $(plugin_index_file_to_upd)\n\t@echo \"Version updated in $(plugin_index_file_to_upd)\"\n\nupdate_version_init:\n\t@echo \"Updating version in $(init_file_to_upd) to $(version_tuple)\"\n\t@sed -i 's/^[[:space:]]*version = .*/    version = $(version_tuple)/' $(init_file_to_upd)\n\t@sed -i \"s/^[[:space:]]*version_string = .*/    version_string = \\\"$(version)\\\"/\" $(init_file_to_upd)\n\t@echo \"Version updated in $(init_file_to_upd)\"\n\nclean_dev:\n\t@rm -f \"$(dist_dir)/.version-dev\"\n\t@echo \"Dev version metadata removed from $(dist_dir).\"\n\nclean: clean_dev\n\t@rm -rf \"$(dist_dir)\"\n\t@echo \"Cleaned $(dist_dir) directory\"\n\n$(dist_dir):\n\t@mkdir -p $(dist_dir)\n\t@echo \"Created $(dist_dir) directory\"\n\ndebug_version:\n\t@echo \"Read version: $(version)\"\n\t@echo \"Effective version: $(effective_version)\"\n\t@echo \"Version tuple: $(version_tuple)\"\n\t@echo \"Zip file: $(zip_file)\"\n\ntag:\n\t@echo \"Tagging version v$(version) and pushing to the repository\"\n\t@if git rev-parse \"v$(version)\" >/dev/null 2>&1; then \\\n\t\techo \"Tag v$(version) already exists.\"; \\\n\telse \\\n\t\tgit tag -a \"v$(version)\" -m \"Version $(version)\"; \\\n\t\tgit push origin \"v$(version)\"; \\\n\tfi\n\nmd_to_bb:\n\t@echo \"Converting input.md to output.forumbb\"\n\t@python .scripts/md-to-bb.py .scripts/input.md .scripts/output.forumbb\n\t@echo \"Done:\"\n\t@cat .scripts/output.forumbb\n\n.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\n"
  },
  {
    "path": "Pipfile",
    "content": "[[source]]\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\nname = \"pypi\"\n\n[packages]\npyqt6 = \"*\"\npyqt5 = \"*\"\nlxml = \"*\"\n\n[dev-packages]\ncss-parser = \"*\"\nhtml5-parser = \"*\"\nmechanize = \"*\"\nregex = \"*\"\nzeroconf = \"*\"\napsw = \"*\"\nlxml = \"*\"\nmsgpack = \"*\"\npython-dateutil = \"*\"\nbeautifulsoup4 = \"*\"\nmarkdown = \"*\"\nnetifaces = \"*\"\n\n[requires]\npython_version = \"3.9\"\n"
  },
  {
    "path": "README.md",
    "content": "# KOReader calibre plugin\n\n[![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)\n[![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)\n[![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)\n\n[![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)\n[![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)\n\n[![License](https://img.shields.io/github/license/kyxap/koreader-calibre-plugin?color=blue)](https://github.com/kyxap/koreader-calibre-plugin/blob/main/LICENSE)\n\n\nA calibre plugin to synchronize metadata from KOReader to calibre.\n\n[KOReader](https://koreader.rocks/) creates sidecar files that hold read\nprogress and annotations.\nThis plugin reads the data from those sidecar files and updates calibre's\nmetadata based on them. It is inspired\nby [the Kobo Utilities plugin](https://www.mobileread.com/forums/showthread.php?t=215339),\nthat synchronizes reading progress between the original Kobo firmware (\"Nickel\")\nand custom columns in calibre.\n\nNote that at the moment the sync is primarily one-way—from the KOReader device\nto calibre, and only works for USB\nand [wireless](https://github.com/koreader/koreader/wiki/Calibre-wireless-connection)\ndevices. For best experience please use the latest\nKOReader [release](https://github.com/koreader/koreader/releases)\n\nReleases will also be uploaded\nto [plugin thread](https://www.mobileread.com/forums/showthread.php?t=362706) on\nthe MobileRead Forums.\nIf you are on there as well, please let me know what you think of the plugin in\nthat thread.\n\n## Using this plugin\n\n### Download and install\n\n1. Go to your calibre's _Preferences_ > _Plugins_ > _Get new plugins_ and search\n   for _KOReader Sync_\n2. Click _Install_\n3. Restart calibre\n\n#### Alternatively\n\n1. Download the latest release\n   from [here](https://github.com/kyxap/koreader-calibre-plugin/releases).\n2. Go to your calibre's _Preferences_ > _Plugins_ > _Load plugin from file_ and\n   point it to the downloaded ZIP file\n3. Restart calibre\n\n### Setup\n\n1. Pick and choose the metadata you would like to sync and create the\n   appropriate columns in calibre. The plugin makes this easy, simply select\n   the **create new columns** option in the config dropdowns.\n\n   These are your options:\n   - A _Floating point numbers_ column to store the **current percent read**,\n     with _Format for numbers_ set to `{:.0%}`.\n   - An _Integers_ column to store the **current percent read**.\n   - A regular _Text_ column to store the **location you last stopped reading at**\n   - A _Rating_ column to store your **rating** of the book, as entered on the\n     book's status page.\n   - A _Long text_ column to store your **review** of the book, as entered on\n     the book's status page.\n   - A regular _Text_ column to store the **reading status** of the book, as\n     entered on the book status page (_Finished_, _Reading_, _On hold_).\n     Translates to complete, reading, and abandoned respectively in calibre.\n   - A _Yes/No_ column to store the **reading status** of the book, as a\n     boolean (_Yes_ = _Finished_, _No_ = everything else).\n   - A _Long text_ column to store your **bookmarks and highlights** of the\n     book, with _Interpret this column as_ set to _Plain text formatted using\n     markdown_. (Highlights are an unordered list with their metadata in an\n     HTML comment.)\n   - A regular _Text_ column to store the **MD5 hash** KOReader uses to sync\n     progress to a [KOReader Sync\n     Server](https://github.com/koreader/koreader-sync-server#koreader-sync-server)\n     (_Progress sync_ in the KOReader app). This allows for syncing\n     progress and location to calibre without having to connect your KOReader device.\n   - A _Date_ column to store **when the last sync was performed**.\n   - A _Date_ column to store **when the sidecar file was last modified**. Works\n     for wired connection only, wireless will be always empty.\n   - A _Date_ column to store **when the book status was first marked reading**.\n   - A _Date_ column to store **when the book status was first marked finished**.\n   - A _Long text_ column to store the **contents of the metadata sidecar** as\n     HTML, with _Interpret this column as_ set to _HTML_.\n\n   There are additional settings for:\n   - Sync only if changes are more recent: Checks retrieved **Last Sync Date** against date on file.\n   - No sync if book has already been finished: If **percent read** is _100_ or if **reading status** is _finished_ don't update data.\n   - Automatic Sync on device connection: Silently sync's from KOReader when device is connected\n\n1.  Add _KOReader Sync_ to _main toolbar when a device is connected_, if it\n    isn't there already.\n2.  Right-click the _KOReader Sync_ icon and _Configure_.\n3.  Map the metadata you want to sync to the newly created calibre columns.\n4.  Click _OK_ to save your mapping.\n5.  From now on just click the _KOReader Sync_ icon to sync all mapped metadata\n    for all books on the connected device to calibre.\n\n**Note:** Some field are depreciated and removed from plugin since they are\nchanged/removed from `sidecar_contents` data structure:\n\n- `first_bookmark` removed\n- `last_bookmark` removed\n- `bookmarks` renamed to `annotations`\n- `rating` KOreader uses 5-point but calibre 10-point scale (whole starts, not half stars)\n- `date_sidecar_modified` seems to be present in `calculated` only if connected via\n  cable (not wireless)\n\n### ProgressSync\n\n  This plugin supports use of a [KOReader Sync\n  Server](https://github.com/koreader/koreader-sync-server#koreader-sync-server)\n  (_Progress sync_ in the KOReader app) in order to update **current percent read**\n  (both float and int) and **location you last stopped reading at** wirelessly.\\\n  You must also have the **MD5 hash** column enabled.\\\n  Add the server and user credentials in the plugin config to use this function.\n  The user password is stored as a hash, not plain text.\\\n  You can have calibre fetch updated data on a daily schedule.\n\n### Things to consider\n\n- The plugin overwrites existing metadata in Calibre without asking. That\n  usually isn’t a problem, because you will probably only add to KOReader’s\n  metadata. But be aware that you might lose data in calibre if you’re not\n  careful.\n- Pushing sidecars back to KOReader currently only happens for sidecars which\n  are missing. For now, manually delete the `<bookname>.sdr` folder from the\n  device before attempting to push the sidecars back to KOReader for any books\n  you would like to overwrite the current metadata with Calibre's metadata.\n- When pushing missing sidecars to the device, no attempt is made to convert\n  Calibre's metadata to account for changes in KOReader's sidecar format. Old\n  metadata may work unpredictably if it's from a different version of KOReader.\n\n### Supported devices\n\nThis plugin has been tested successfully with:\n\n- Kobo Clara BW/Colour connected over USB or KOreader wireless driver\n- Kobo Aura/Touch connected over USB (`KOBO` and `KOBOTOUCH` drivers)\n- Kobo Aura H2O over USB (`KOBOTOUCHEXTENDED` driver)\n- All devices connected wirelessly via the `SMART_DEVICE_APP` driver (e.g., KOReader wireless connection)\n- PocketBook devices using `POCKETBOOK_IMPROVED`, `POCKETBOOK632`, `POCKETBOOK626`, or `POCKETBOOK622` drivers\n- Kindle Keyboard (`KINDLE2`)\n- Tolino Vision 4 HD (`TOLINO`)\n- A connected folder (`FOLDER_DEVICE`)\n- Manually defined devices using the `USER_DEFINED` driver\n\nThis plugin is not compatible with:\n\n- `MTP_DEVICE` (Android devices connected via MTP)\n\n### Star History\n\n[![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)\n\n### Issues\n\nIf you encounter any issues with the plugin, please submit\nthem [here](https://github.com/kyxap/koreader-calibre-plugin/issues).\n\n## Acknowledgements\n\n- Multiple tweaks and bug fixes by [Glen Sawyer](https://git.sr.ht/~snelg)\n- Additional functionality by [Charles Taylor](https://github.com/charlesangus/)\n- Contains [SirAnthony's SLPP](https://github.com/SirAnthony/slpp) to parse Lua\n  in Python.\n- Some code borrowed from--and heavily inspired by--the\n  great [Kobo Utilities](https://www.mobileread.com/forums/showthread.php?t=215339)\n  calibre plugin.\n- Some code borrowed from--and heavily inspired by--the\n  great [Goodreads Sync](https://www.mobileread.com/forums/showthread.php?t=123281)\n  calibre plugin.\n\n## Contributing to this plugin\n\n### Notes & Tips\n\n- My first attempt was actually to sync calibre with KOReader's read progress\n  through the progress sync plugin and\n  a [sync server](https://github.com/koreader/koreader-sync-server).\n  Read [here](https://github.com/koreader/koreader/issues/6399#issuecomment-721826362)\n  why that did not work. This plugin might actually make that possible now by\n  allowing you to store KOReader's MD5 hash in calibre...\n- calibre allows you to auto-connect to a folder device on boot, which greatly\n  speeds up your workflow when testing. You can find this under \"\n  Preferences\" > \"Tweaks\", search for `auto_connect_to_folder`. Point that to\n  the `dummy_device` folder in this repository. (I have included royalty free\n  EPUBs for your and my convenience.)\n- If you're testing and don't actually want to update any metadata,\n  set `DRY_RUN` to `True` in `__init__.py`.\n- I work in PyCharm, which offers a remote debugging server. To enable that in\n  this plugin, set `PYDEVD` to `True` in `__init__.py`.You might need to\n  change `sys.path.append` in `action.py`.\n- The supported device drivers can be found\n  in [the `SUPPORTED_DEVICES` list in `config.py`](https://github.com/kyxap/koreader-calibre-plugin/blob/main/config.py).\n  Adding a new type here is the first step to adding support, but make sure all\n  features are tested thoroughly before releasing a version with an added device\n\n### Testing in calibre\n\nUse make to load the plugin into calibre and launch it:\n\n```shell\nmake dev\n```\n\nFor Linux users with a Flatpak installation of Calibre, use the `FLATPAK=1`\nflag. This is necessary because Flatpak runs Calibre in a\n[sandboxed environment](https://docs.flatpak.org/en/latest/sandbox-permissions.html),\nrequiring specific commands to interact with it:\n\n```shell\nmake dev FLATPAK=1\n```\n\n> **Note:** `FLATPAK=1` is only supported on Linux. On Windows and macOS,\n> please install Calibre natively and run `make` without this flag.\n\n### Makefile Targets\n\n#### Main\n\n\n| Target         | Description                                                                                                      |\n|----------------|------------------------------------------------------------------------------------------------------------------|\n| `test`         | Run unit and integration tests using `pytest` (includes Calibre environment mocks)                               |\n| `lint`         | Run static analysis using `pylint` (enforces 9.5/10 score and zero Errors)                                       |\n| `dev`          | Load plugin source directly into Calibre and launch in debug mode                                                |\n| `pre`          | Patch internal version with `-pre` and build a community pre-release ZIP                                         |\n| `bump-patch`   | Increment the patch version in `.version` (e.g., 0.8.0 -> 0.8.1)                                                 |\n| `bump-minor`   | Increment the minor version in `.version` (e.g., 0.8.0 -> 0.9.0)                                                 |\n| `bump-major`   | Increment the major version in `.version` (e.g., 0.8.0 -> 1.0.0)                                                 |\n| `prep-release` | Create a `release-prep-<version>` branch, update files, and commit                                               |\n| `release`      | Tag the current version and push to trigger GitHub Release, do this after updated version already pushed to main |\n| `md_to_bb`     | Convert input.md to output.forumbb (BBCode) for MobileRead forum posts                                           |\n\n#### Extra\n\n| Target        | Description                                                         |\n|---------------|---------------------------------------------------------------------|\n| `install`     | Install ZIP into Calibre without launching the GUI                  |\n| `zip`         | Create plugin ZIP file in `dist/` directory                         |\n| `load`        | Install ZIP from `dist/` and launch Calibre in debug mode           |\n| `build`       | Full build workflow: update versions from `.version` and create ZIP |\n| `dev_version` | Update all code files with the current version from `.version`      |\n| `clean`       | Remove all build artifacts and temporary files                      |\n| `clean_dev`   | Clean up development-specific temporary files                       |\n| `tag`         | Create and push git tag for current version                         |\n\n### Development & Release Cycle\n\nThe project uses a structured workflow to ensure both rapid updates and stable releases:\n\n1.  **Develop Branch (`develop`)**: This is the primary work-in-progress branch.\n    -   Experimental fixes and new features are merged here first.\n    -   Every push to this branch triggers an automated **Pre-release build**.\n    -   Users can download the latest community pre-release from the [Upcoming Release](https://github.com/kyxap/koreader-calibre-plugin/releases/tag/pre-release) page.\n2.  **Main Branch (`main`)**: This branch contains the stable, production-ready code.\n    -   Only merge `develop` into `main` when a milestone is reached.\n    -   Running `make release` on this branch automatically cleans the version string, tags the commit, and triggers the official GitHub Release.\n\n### Quality Assurance\n\nThe project enforces high code quality standards through automated checks:\n\n- **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.\n- **Linting:** Run `make lint`. We use `pylint` with a custom configuration (`.pylintrc`).\n  - **Threshold:** The project requires a minimum score of **9.5/10**.\n  - **Strictness:** The build will **instantly fail** if any **Fatal (F)** or **Error (E)** messages are found, regardless of the total score.\n\nThese checks run automatically on every Pull Request via GitHub Actions.\n\nThe project uses GitHub Actions to automate releases. When a tag `v*` is pushed, a GitHub Release is created automatically with the built plugin ZIP.\n\n1.  **Prepare the version:**\n    -   Manually edit `.version` OR run `make bump-patch` / `make bump-minor`.\n2.  **Run preparation:**\n    -   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.\n3.  **Review and Merge:**\n    -   Review the changes in the new branch, then merge it into `main`.\n4.  **Publish:**\n    -   On the `main` branch, run `make release`. This will tag the commit and push it.\n    -   The GitHub Action will pick up the tag, build the plugin, and create a GitHub Release with the ZIP attached.\n\n### Debugging a release\n\n1. Download the required release\n   from [here](https://github.com/kyxap/koreader-calibre-plugin/releases)\n1. Add it to calibre by running this in your\n   terminal: `calibre-customize -a \"KOReader_Sync_vX.X.X.zip\"`, where `X.X.X`\n   refers to the version you downloaded\n1. Start calibre in debug mode with `calibre-debug -g`\n1. Configure the KOReader plugin as\n   described [here](https://github.com/kyxap/koreader-calibre-plugin#setup)\n1. Connect your device\n1. Run the sync by clicking the KOReader icon in your toolbar\n1. Check the details of the message when it's done if any/all books have been\n   synced correctly\n1. Check your (custom) columns for one of those books to see if their contents\n   are what they should be\n1. Check the output in your terminal for lines containing `koreader` to see what\n   it did\n"
  },
  {
    "path": "TODO.md",
    "content": "# TODO\n\n## 🚀 High Priority (0.8.x Release)\n- [x] Fix `apsw.TooBigError` for books with 900+ highlights [#114](https://github.com/kyxap/koreader-calibre-plugin/issues/114)\n- [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)\n- [x] Add support for `POCKETBOOK_IMPROVED` driver [#63](https://github.com/kyxap/koreader-calibre-plugin/issues/63)\n- [x] Fix sidecar write regression for USB/Folder devices (`_io.BytesIO` error) [#143](https://github.com/kyxap/koreader-calibre-plugin/issues/143)\n\n## 🐛 Bug Fixes\n- [x] Fix MD5 computation for Kobo devices using custom folders [#98](https://github.com/kyxap/koreader-calibre-plugin/issues/98)\n- [x] Fix sidecar directory resolution in Docker (linuxserver/calibre) environments [#73](https://github.com/kyxap/koreader-calibre-plugin/issues/73)\n- [x] Resolve Windows \"Access Denied\" error for Kobo sidecar folder creation [#68](https://github.com/kyxap/koreader-calibre-plugin/issues/68)\n\n## ✨ Enhancements & Features\n- [ ] 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)\n- [ ] **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).\n- [ ] Implement metadata merging (union) instead of overwriting for multi-device sync [#76](https://github.com/kyxap/koreader-calibre-plugin/issues/76)\n- [ ] Support custom sidecar locations (global .sdr folder) [#57](https://github.com/kyxap/koreader-calibre-plugin/issues/57)\n- [ ] Implement 2-way wireless sidecar modification [#100](https://github.com/kyxap/koreader-calibre-plugin/issues/100)\n- [ ] Add template support for customizing the appearance of imported highlights [#1](https://github.com/kyxap/koreader-calibre-plugin/issues/1)\n\n## 🛠 Project Health & Maintenance\n- [ ] Fix remaining pylint errors and warnings (currently 9.59/10)\n- [ ] Standardize Python code style with Black/Ruff [#81](https://github.com/kyxap/koreader-calibre-plugin/issues/81)\n- [ ] Add `last_page` support for PDFs\n- [ ] Investigate `calibre.devices.usbms.cli.CLI.list()` for better sidecar discovery\n\n## ✅ Completed\n- [x] Add an `.editorconfig` and `.pylintrc` to define code layout\n- [x] Add support for highlights and bookmarks into a metadata field\n- [x] Make the warning about synced metadata more informative\n- [x] Add support for `KINDLE2` devices\n- [x] ~~Add support for `MTP_DEVICE` devices~~\n- [x] ~~Add support for multiple storages (i.e. SD cards) for `MTP_DEVICES`~~\n"
  },
  {
    "path": "__init__.py",
    "content": "#!/usr/bin/env python3\n\n\"\"\"KOReader Sync Plugin for Calibre.\"\"\"\n\nimport os\nfrom functools import partial\n\nfrom calibre.constants import DEBUG as _DEBUG\nfrom calibre.constants import numeric_version\nfrom calibre.customize import InterfaceActionBase\nfrom calibre.devices.usbms.driver import debug_print as root_debug_print\nfrom calibre.utils.config import JSONConfig\n\n__license__ = 'GNU GPLv3'\n__copyright__ = '2021, harmtemolder <mail at harmtemolder.com>'\n__modified_by__ = 'kyxap kyxappp@gmail.com'\n__modification_date__ = '2024'\n__docformat__ = 'restructuredtext en'\n\nDEBUG = _DEBUG\nDRY_RUN = False  # Used during debugging to skip the actual updating of metadata\nPYDEVD = False  # Used during debugging to connect to PyCharm's remote debugging\n\nif numeric_version >= (5, 5, 0):\n    module_debug_print = partial(\n        root_debug_print,\n        ' koreader:__init__:',\n        sep=''\n    )\nelse:\n    module_debug_print = partial(root_debug_print, 'koreader:__init__:')\n\n\nclass KoreaderSync(InterfaceActionBase):\n    name = 'KOReader Sync'\n    description = 'Get metadata from a connected KOReader device'\n    author = 'harmtemolder & others, currently maintaining by: kyxap'\n    version = (0, 8, 2)\n    version_string = \"0.8.2\"\n    minimum_calibre_version = (5, 0, 1)  # Because Python 3\n    config = JSONConfig(os.path.join('plugins', 'KOReader Sync.json'))\n    actual_plugin = 'calibre_plugins.koreader.action:KoreaderAction'\n\n    def is_customizable(self):\n        return True\n\n    def config_widget(self):\n        if self.actual_plugin_:\n            from calibre_plugins.koreader.config import \\\n                ConfigWidget  # pylint: disable=import-error, disable=import-outside-toplevel\n            return ConfigWidget(self.actual_plugin_)\n        return None\n\n    def save_settings(self, config_widget):\n        config_widget.save_settings()\n\n\ndef clean_bookmarks(bookmarks):\n    \"\"\"Transforms KOReader's bookmark metadata into text that can be stored\n    in calibre. I assume that all bookmarks have a `note` attribute, which I\n    use as the main text of the bookmark. All other attributes are stored in a\n    HTML comment.\n\n    :param bookmarks: dict with numbered keys and annotations dict values\n    :return: HTML-formatted str of the all bookmarks and highlights\n    \"\"\"\n    debug_print = partial(root_debug_print, 'clean_bookmarks:')\n\n    # Dictionary to store highlights grouped by chapter\n    highlights_by_chapter = {}\n\n    for annotation in bookmarks.values():\n        if 'note' not in annotation:\n            debug_print('annotation does not have `note`', annotation)\n        else:\n            debug_print('annotation has `note`', annotation)\n\n        # Extracting all attributes to save as hidden text\n        hidden_attributes = ''\n        if len(bookmarks) > 0:\n            hidden_attributes += ' <!-- '\n            for attr, val in annotation.items():\n                hidden_attributes += f'{attr}: {val}, '\n            hidden_attributes = hidden_attributes[:-2] + ' -->'\n        hidden_attributes += '\\n'\n\n        # Extracting attributes that will be used in html\n        chapter = annotation.get(\"chapter\", \"Unknown Chapter\")\n        reader_note = annotation.get(\"note\", \"no notes\")\n        highlighted_text = annotation.get(\"text\", \"Unknown Highlighted Text\")\n        datetime = annotation.get(\"datetime\", \"Unknown Datetime\")\n\n        # Create highlight dictionary\n        highlight = {\n            \"chapter\": chapter,\n            \"reader_note\": reader_note,\n            \"highlighted_text\": highlighted_text,\n            \"datetime\": datetime,\n            \"hidden_attributes\": hidden_attributes\n        }\n\n        # Add highlight to the corresponding chapter\n        if chapter not in highlights_by_chapter:\n            highlights_by_chapter[chapter] = []\n        highlights_by_chapter[chapter].append(highlight)\n\n    # Generate HTML content for each chapter\n    html_content = ('<!DOCTYPE html>\\n<html>\\n<head>\\n'\n                    '<title>Book Highlights and Notes</title>\\n'\n                    '</head>\\n<body>\\n')\n    highlight_count = 0\n    for chapter, chapter_highlights in highlights_by_chapter.items():\n        if chapter.strip() == '':\n            chapter = 'Unknown'\n        html_content += f'<div>\\n<h3>Chapter: <u>{chapter}</u></h3>\\n'\n        html_content += '<blockquote>'\n\n        for highlight in chapter_highlights:\n            highlight_count += 1\n            html_content += (f'<p><strong>{highlight_count}. Highlight</strong'\n                             f'> - {highlight[\"datetime\"]} '\n                             f'<br/>{highlight[\"highlighted_text\"]}\\n')\n            html_content += '<br><br>\\n'\n            html_content += ('<strong>Note:</strong> <i>'\n                             f'{highlight[\"reader_note\"]}</i></p>\\n')\n            html_content += f'{highlight[\"hidden_attributes\"]}\\n'\n\n        html_content += \"</div>\\n\"\n        html_content += '</blockquote>'\n\n    html_content += \"</body>\\n</html>\"\n    return html_content.strip()\n"
  },
  {
    "path": "about.txt",
    "content": "<h2>About KOReader Sync</h2>\n<p>A calibre plugin to synchronize metadata from KOReader to calibre.</p>\n<p>The source code of this plugin can be found <a href=\"https://github.com/harmtemolder/koreader-calibre-plugin\">on GitHub</a>.</p>\n<p>If you encounter any issues with the plugin, please submit them <a href=\"https://github.com/harmtemolder/koreader-calibre-plugin/issues\">here</a>.</p>\n"
  },
  {
    "path": "action.py",
    "content": "#!/usr/bin/env python3\n\n\"\"\"KOReader Sync Plugin for Calibre.\"\"\"\n\nfrom datetime import datetime\nfrom functools import partial\nimport io\nimport json\nimport os\nimport re\nimport sys\nimport importlib.util\nimport time\n\nfrom urllib.request import Request, urlopen\nfrom urllib.error import URLError, HTTPError\nimport ssl\n\nfrom PyQt5.Qt import (\n    QUrl,\n    QTimer,\n    QTime,\n    QTableWidget,\n    QTableWidgetItem,\n    QHBoxLayout,\n    QVBoxLayout,\n    QDialog,\n    QLabel,\n    QIcon,\n    QPushButton,\n    QScrollArea,\n    QProgressBar,\n    QApplication,\n    Qt,\n    QThread,\n    pyqtSignal,\n)\n\nfrom calibre_plugins.koreader.slpp import slpp as lua\nfrom calibre_plugins.koreader.config import (\n    SUPPORTED_DEVICES,\n    UNSUPPORTED_DEVICES,\n    CUSTOM_COLUMN_DEFAULTS as COLUMNS,\n    CONFIG,\n)\nfrom calibre_plugins.koreader import (\n    DEBUG,\n    DRY_RUN,\n    PYDEVD,\n    KoreaderSync,\n)\n\nfrom calibre.utils.iso8601 import utc_tz, local_tz\nfrom calibre.gui2.dialogs.message_box import MessageBox\nfrom calibre.gui2.actions import InterfaceAction\nfrom calibre.gui2.device import device_signals\nfrom calibre.gui2 import (\n    error_dialog,\n    warning_dialog,\n    open_url,\n)\nfrom calibre.devices.usbms.driver import debug_print as root_debug_print, USBMS\nfrom calibre.constants import numeric_version\nfrom enum import Enum, auto\n\n__license__ = 'GNU GPLv3'\n__copyright__ = '2021, harmtemolder <mail at harmtemolder.com>'\n__modified_by__ = 'kyxap kyxappp@gmail.com'\n__modification_date__ = '2024'\n__docformat__ = 'restructuredtext en'\n\nif numeric_version >= (5, 5, 0):\n    module_debug_print = partial(root_debug_print, ' koreader:action:', sep='')\nelse:\n    module_debug_print = partial(root_debug_print, 'koreader:action:')\n\nif DEBUG and PYDEVD:\n    try:\n        sys.path.append(\n            # '/Applications/PyCharm.app/Contents/debug-eggs/pydevd-pycharm.egg'  # macOS\n            '/opt/pycharm-professional/debug-eggs/pydevd-pycharm.egg'\n            # Manjaro Linux\n        )\n        import pydevd_pycharm\n\n        pydevd_pycharm.settrace(\n            'localhost', stdoutToServer=True, stderrToServer=True,\n            suspend=False\n        )\n    except Exception as e:\n        module_debug_print('could not start pydevd_pycharm, e = ', e)\n        PYDEVD = False\n\n\nclass GetSidecarStatus(Enum):\n    PATH_NOT_FOUND = auto()\n    DECODE_FAILED = auto()\n\n\nclass OperationStatus(Enum):\n    PASS = auto()\n    FAIL = auto()\n    SKIP = auto()\n\n\ndef is_system_path(path):\n    \"\"\"\n    KOreader user may have some files in the root which we want to skip to\n    avoid showing warning message\n\n    :param path: path to sidecar file (*.lua)\n    :return: true/false if partial match found\n    \"\"\"\n    to_ignore = ['kfmon.sdr', 'koreader.sdr']\n    return any(substring in path for substring in to_ignore)\n\n\ndef append_results(results, title, status_msg, book_uuid, sidecar_path):\n    debug_print = partial(\n        module_debug_print,\n        'KoreaderAction:append_results:'\n    )\n    debug_print(f'{sidecar_path} - {status_msg}')\n    return results.append(\n        {\n            'title': title,\n            'result': status_msg,\n            'book_uuid': book_uuid,\n            'sidecar_path': sidecar_path,\n        }\n    )\n\n\ndef parse_sidecar_lua(sidecar_lua):\n    \"\"\"Parses a sidecar Lua file into a Python dict\n\n    :param sidecar_lua: the contents of a sidecar Lua as a str\n    :return: a dict of those contents\n    \"\"\"\n    debug_print = partial(\n        module_debug_print,\n        'KoreaderAction:parse_sidecar_lua:'\n    )\n\n    try:\n        clean_lua = re.sub(r'^[^{]*', '', sidecar_lua).strip()\n        decoded_lua = lua.decode(clean_lua)\n    except:\n        debug_print('could not decode sidecar_lua')\n        decoded_lua = None\n\n    if 'bookmarks' in decoded_lua:\n        if isinstance(decoded_lua['bookmarks'], list):\n            decoded_lua['bookmarks'] = {\n                # Starts from 1\n                i+1: bookmark for i, bookmark in enumerate(decoded_lua['bookmarks'])}\n\n        debug_print('calculating first and last bookmark dates')\n        bookmark_dates = [\n            datetime.strptime(\n                bookmark['datetime'],\n                '%Y-%m-%d %H:%M:%S'\n            ).replace(tzinfo=utc_tz)\n            for bookmark in decoded_lua['bookmarks'].values()\n        ]\n\n        if len(bookmark_dates) > 0:\n            decoded_lua['calculated'] = {\n                'first_bookmark': min(bookmark_dates),\n                'last_bookmark': max(bookmark_dates),\n            }\n\n    return decoded_lua\n\n\nclass KoreaderAction(InterfaceAction):\n    name = KoreaderSync.name\n    action_spec = (name, 'edit-redo.png', KoreaderSync.description, None)\n    action_add_menu = True\n    action_menu_clone_qaction = 'Sync from KOReader'\n    dont_add_to = frozenset(\n        [\n            'context-menu', 'context-menu-device', 'menubar',\n            'menubar-device', 'context-menu-cover-browser',\n            'context-menu-split']\n    )\n    dont_remove_from = frozenset()\n    action_type = 'current'\n\n    def genesis(self):\n        debug_print = partial(module_debug_print, 'KoreaderAction:genesis:')\n        debug_print('start')\n\n        base = self.interface_action_base_plugin\n        if hasattr(base, 'version_string') and base.version_string:\n            self.version = f'{base.name} (v{base.version_string})'\n        else:\n            self.version = f'{base.name} (v{\".\".join(map(str, base.version))})'\n        self.extension_callback = None\n\n        # Overwrite icon with actual KOReader logo\n        icon = get_icons(\n            'images/icon.png'\n        )\n        self.qaction.setIcon(icon)\n\n        # Left-click action\n        self.qaction.triggered.connect(self.exec_main_action)\n\n        # Right-click menu (already includes left-click action)\n\n        # TODO: Sync calibre to KOReader is disabled see more in #8\n        self.create_menu_action(\n            self.qaction.menu(),\n            'Sync missing to KOReader',\n            'Sync missing to KOReader',\n            icon='edit-undo.png',\n            description='If calibre has an entry in the \"Raw sidecar column\", '\n                        'but KOReader does not have a sidecar file, push the '\n                        'metadata from calibre to a new sidecar file.',\n            triggered=self.sync_missing_sidecars_to_koreader\n        )\n\n        self.create_menu_action(\n            self.qaction.menu(),\n            'Sync from ProgressSync',\n            'Sync from ProgressSync',\n            icon='convert.png',\n            description=\"Use KOReader's built in ProgressSync Plugin \"\n                        \"to update percentRead int or float.\",\n            triggered=self.sync_progress_from_progresssync\n        )\n\n        self.qaction.menu().addSeparator()\n\n        self.create_menu_action(\n            self.qaction.menu(),\n            'Configure KOReader Sync',\n            'Configure',\n            icon='config.png',\n            description='Configure KOReader Sync',\n            triggered=self.show_config\n        )\n\n        self.qaction.menu().addSeparator()\n\n        self.create_menu_action(\n            self.qaction.menu(),\n            'Readme for KOReader Sync',\n            'Readme',\n            icon='dialog_question.png',\n            description='Readme for KOReader Sync',\n            triggered=self.show_readme\n        )\n\n        self.create_menu_action(\n            self.qaction.menu(),\n            'About KOReader Sync',\n            'About',\n            icon='dialog_information.png',\n            description='About KOReader Sync',\n            triggered=self.show_about\n        )\n\n        # Start the scheduled progress sync if enabled\n        if CONFIG[\"checkbox_enable_scheduled_progressync\"]:\n            self.scheduled_progress_sync()\n\n        # Start the device connection watcher if enabled\n        if CONFIG[\"checkbox_enable_automatic_sync\"]:\n            device_signals.device_metadata_available.connect(\n                self._on_device_metadata_available)\n\n        basedir = os.path.dirname(base.plugin_path)\n        for filename in os.listdir(basedir):\n            if filename.startswith(\"KOSync_extension\") and filename.endswith(\".py\"):\n                filepath = os.path.join(basedir, filename)\n                try:\n                    spec = importlib.util.spec_from_file_location(\n                        \"KOSync_extension\", filepath)\n                    extension = importlib.util.module_from_spec(spec)\n                    spec.loader.exec_module(extension)\n                    if hasattr(extension, \"onItemUpdate\"):\n                        self.extension_callback = extension.onItemUpdate\n                        print(f\"Loaded onItemUpdate from {filename}\")\n                        return\n                except Exception as e:\n                    print(f\"Failed to load extension: {e}\")\n\n    def is_usb_device(self, device):\n        \"\"\"Returns True if the device is connected via USB Mass Storage or Folder Device.\"\"\"\n        return isinstance(device, USBMS) or device.__class__.__name__ == 'FOLDER_DEVICE'\n\n    def exec_main_action(self) -> None:\n        # Execute main action defined by user\n        main_button = CONFIG['main_action']\n        if main_button == 'KOReader Sync':\n            self.sync_to_calibre()\n        elif main_button == 'Progress Sync':\n            self.sync_progress_from_progresssync()\n        else:\n            self.sync_to_calibre()\n\n    def show_config(self):\n        self.interface_action_base_plugin.do_user_config(self.gui)\n\n    def show_readme(self):\n        debug_print = partial(module_debug_print,\n                              'KoreaderAction:show_readme:')\n        debug_print('start')\n        readme_url = QUrl(\n            'https://github.com/harmtemolder/koreader-calibre-plugin#readme'\n        )\n        open_url(readme_url)\n\n    def show_about(self):\n        debug_print = partial(module_debug_print, 'KoreaderAction:show_about:')\n        debug_print('start')\n        text = get_resources('about.txt').decode(\n            'utf-8'\n        )\n        if DEBUG:\n            text += '\\n\\nRunning in debug mode'\n        icon = get_icons(\n            'images/icon.png'\n        )\n\n        about_dialog = MessageBox(\n            MessageBox.INFO,\n            f'About {self.version}',\n            text,\n            det_msg='',\n            q_icon=icon,\n            show_copy_button=False,\n            parent=None,\n        )\n\n        return about_dialog.exec_()\n\n    def apply_settings(self):\n        debug_print = partial(\n            module_debug_print,\n            'KoreaderAction:apply_settings:'\n        )\n        debug_print('start')\n\n    def get_connected_device(self):\n        \"\"\"Tries to get the connected device, if any\n\n        :return: the connected device object or None\n        \"\"\"\n        debug_print = partial(\n            module_debug_print,\n            'KoreaderAction:get_connected_device:'\n        )\n\n        try:\n            is_device_present = self.gui.device_manager.is_device_present\n        except:\n            is_device_present = False\n\n        if not is_device_present:\n            debug_print('is_device_present = ', is_device_present)\n            error_dialog(\n                self.gui,\n                'No device found',\n                'No device found',\n                det_msg='',\n                show=True,\n                show_copy_button=False\n            )\n            return None\n\n        try:\n            connected_device = self.gui.device_manager.connected_device\n            connected_device_type = connected_device.__class__.__name__\n        except:\n            debug_print('could not get connected_device')\n            error_dialog(\n                self.gui,\n                'Could not connect to device',\n                'Could not connect to device',\n                det_msg='',\n                show=True,\n                show_copy_button=False\n            )\n            return None\n\n        debug_print('connected_device_type = ', connected_device_type)\n        return connected_device\n\n    def _on_device_metadata_available(self):\n        self.sync_to_calibre(silent=not DEBUG)\n\n    def get_paths(self, device):\n        \"\"\"Retrieves paths to sidecars of all books in calibre's library\n        on the device\n\n        :param device: a device object\n        :return: a list of (uuid, path) tuples to sidecars\n        \"\"\"\n        debug_print = partial(\n            module_debug_print,\n            'KoreaderAction:get_paths:'\n        )\n\n        debug_print(\n            f'found {len(device.books())} paths to books:\\n\\t',\n            '\\n\\t'.join([book.path for book in device.books()])\n        )\n\n        for book in device.books():\n            debug_print(f'uuid to path: {book.uuid} - {book.path}')\n\n        paths = []\n        for book in device.books():\n            # Ignore hidden folders (issue #101)\n            if any(part.startswith('.') for part in book.path.replace('\\\\\\\\', '/').split('/')):\n                debug_print(f'Ignoring book in hidden folder: {book.path}')\n                continue\n\n            sidecar_path = re.sub(\n                r'\\.([^./\\\\]+)$', r'.sdr/metadata.\\1.lua', book.path\n            )\n            paths.append((book.uuid, sidecar_path))\n\n\n        debug_print(\n            f'generated {len(paths)} path(s) to sidecar Lua files:\\n\\t',\n            '\\n\\t'.join([p[1] for p in paths])\n        )\n\n        return paths\n\n    def get_sidecar(self, device, path):\n        \"\"\"Requests the given path from the given device and returns the\n        contents of a sidecar Lua as Python dict\n\n        :param device: a device object\n        :param path: a path to a sidecar Lua on the device\n        :return: dict or None\n        \"\"\"\n        debug_print = partial(\n            module_debug_print,\n            'KoreaderAction:get_sidecar:'\n        )\n\n        with io.BytesIO() as outfile:\n            try:\n                device.get_file(path, outfile)\n            except:\n                debug_print('could not get ', path)\n                return GetSidecarStatus.PATH_NOT_FOUND\n\n            contents = outfile.getvalue()\n\n            try:\n                decoded_contents = contents.decode()\n            except UnicodeDecodeError:\n                debug_print('could not decode ', contents)\n                return GetSidecarStatus.DECODE_FAILED\n\n            debug_print(f'Parsing: {path}')\n            parsed_contents = parse_sidecar_lua(decoded_contents)\n            parsed_contents['calculated'] = {}\n\n            # Ensure 'summary' exists to avoid KeyError later (#117)\n            if 'summary' not in parsed_contents:\n                debug_print(f\"Warning: 'summary' key missing in sidecar for {path}\")\n                parsed_contents['summary'] = {'status': 'unknown', 'modified': datetime.now().strftime(\"%Y-%m-%d\")}\n\n            # Define metadata extraction tasks\n            is_usb = self.is_usb_device(device)\n            metadata_tasks = [\n                ('date_synced', lambda: datetime.now().replace(tzinfo=local_tz)),\n                ('date_status_changed', lambda: datetime.strptime(\n                    parsed_contents['summary'].get('modified', datetime.now().strftime(\"%Y-%m-%d\")), \"%Y-%m-%d\").replace(tzinfo=local_tz)),\n                ('date_sidecar_modified', lambda: datetime.fromtimestamp(\n                    os.path.getmtime(path) if is_usb and os.path.exists(path) else time.time()).replace(tzinfo=local_tz))\n            ]\n\n            for key, task in metadata_tasks:\n                try:\n                    parsed_contents['calculated'][key] = task()\n                except Exception as error:\n                    debug_print(f'Failed to set {key}: {error}')\n\n        return parsed_contents\n\n    def get_calibre_uuid_from_sidecar(self, sidecar_contents):\n        \"\"\"Extracts the calibre UUID from sidecar identifiers if present.\n        (Issue #115)\n        \"\"\"\n        if not isinstance(sidecar_contents, dict):\n            return None\n        stats = sidecar_contents.get('stats', {})\n        identifiers_str = stats.get('identifiers', '')\n        if not identifiers_str:\n            return None\n\n        # KOReader uses both space and \\ as separators in some versions\n        parts = re.split(r'[\\s\\\\]+', identifiers_str)\n        for part in parts:\n            if part.startswith('calibre:'):\n                return part.replace('calibre:', '').strip()\n        return None\n\n    def update_metadata(self, uuid, db, keys_values_to_update):\n        \"\"\"Update multiple metadata columns for the given book.\n\n        :param uuid: identifier for the book\n        :param keys_values_to_update: a dict of keys to update with values\n        :return: a dict of values that can be used to report back to the user\n        \"\"\"\n        debug_print = partial(\n            module_debug_print,\n            'KoreaderAction:update_metadata:'\n        )\n\n        try:\n            debug_print('Looking for uuid in calibre db: ', uuid)\n            book_id = db.lookup_by_uuid(uuid)\n        except:\n            book_id = None\n\n        if not book_id:\n            debug_print(f'could not find {uuid} in calibre\\'s library')\n            return OperationStatus.SKIP, {\n                'result': 'could not find uuid in calibre\\'s library, have you deleted this book from library?'}\n\n        # Get the current metadata for the book from the library\n        metadata = db.get_metadata(book_id)\n\n        # Dict for use in logging\n        updateLog = {}\n\n        read_percent_key = CONFIG['column_percent_read'] or CONFIG['column_percent_read_int']\n\n        # Check config to sync only if data is more recent\n        if CONFIG['checkbox_sync_if_more_recent']:\n            date_modified_key = CONFIG['column_date_sidecar_modified']\n            current_date_modified = metadata.get(date_modified_key)\n            new_date_modified = keys_values_to_update.get(date_modified_key)\n            if current_date_modified is not None and new_date_modified is not None:\n                if current_date_modified.timestamp() >= new_date_modified.timestamp():\n                    debug_print(\n                        f'book {book_id} date_modified {new_date_modified} older than current {current_date_modified}')\n                    return OperationStatus.SKIP, {\n                        'result': 'skipped, data in calibre is newer',\n                    }\n            # Fallback if no 'Date Modified Column' is set or not obtainable (wireless)\n            elif new_date_modified is None:\n                current_read_percent = metadata.get(read_percent_key)\n                new_read_percent = keys_values_to_update.get(read_percent_key)\n                if current_read_percent is not None and new_read_percent is not None:\n                    if current_read_percent >= new_read_percent:\n                        debug_print(\n                            f'book {book_id} read_percent {new_read_percent} lower or equal than current {current_read_percent}')\n                        return OperationStatus.SKIP, {\n                            'result': 'skipped, read Percent is lower or equal to the one stored in calibre',\n                            'book_id': book_id,\n                        }\n                elif current_read_percent is not None and new_read_percent is None:\n                    debug_print(\n                        f'book {book_id} read_percent is None but existing is {current_read_percent}')\n                    return OperationStatus.SKIP, {\n                        'result': 'skipped, no new read percent found',\n                    }\n\n        # Check config to sync only if the book is not yet finished\n        status_key = CONFIG['column_status']\n        if CONFIG['checkbox_no_sync_if_finished']:\n            current_read_percent = metadata.get(read_percent_key)\n            current_status = metadata.get(status_key)\n            if current_read_percent is not None and current_read_percent >= 100 \\\n                    or current_status is not None and current_status == \"complete\":\n                debug_print(f'book {book_id} was already finished')\n                return OperationStatus.SKIP, {\n                    'result': 'skipped, book already finished',\n                }\n\n        # Check and correct reading status if required\n        if status_key:\n            new_status = keys_values_to_update.get(status_key)\n            if not new_status:\n                new_read_percent = keys_values_to_update.get(read_percent_key)\n                current_status = metadata.get(status_key)\n                if new_read_percent and current_status != \"abandoned\":\n                    if new_read_percent > 0 and new_read_percent < 100 and current_status != \"reading\":\n                        debug_print(\n                            f'book {book_id} set column_status to reading')\n                        keys_values_to_update[status_key] = \"reading\"\n                        status_bool_key = CONFIG['column_status_bool']\n                        if status_bool_key:\n                            keys_values_to_update[status_bool_key] = False\n                    elif new_read_percent >= 100 and current_status != \"complete\":\n                        debug_print(\n                            f'book {book_id} set column_status to complete')\n                        keys_values_to_update[status_key] = \"complete\"\n                        status_bool_key = CONFIG['column_status_bool']\n                        if status_bool_key:\n                            keys_values_to_update[status_bool_key] = True\n\n        # Call the extension callback if it exists\n        if self.extension_callback:\n            try:\n                updateLog = self.extension_callback(\n                    self=self,\n                    metadata=metadata,\n                    keys_values_to_update=keys_values_to_update,\n                    updateLog=updateLog,\n                    CONFIG=CONFIG,\n                    book_id=book_id\n                )\n            except Exception as e:\n                debug_print(f'Error in extension onItemUpdate: {e}')\n\n        updates = []\n        # Update that metadata locally\n        for key, new_value in keys_values_to_update.items():\n            old_value = metadata.get(key)\n\n            if new_value != old_value:\n                updates.append(key)\n                metadata.set(key, new_value)\n                updateLog[key] = f'{old_value} >> {new_value}'\n            else:\n                if DEBUG:\n                    updateLog[key] = f'{old_value} -- {new_value}'\n\n        # Write the updated metadata back to the library\n        if len(updates) == 0:\n            updateLog['result'] = 'no updates needed'\n            debug_print(\n                'no changed metadata for uuid = ', uuid,\n                ', id = ', book_id\n            )\n        elif DEBUG and DRY_RUN:\n            debug_print(\n                'would have updated the following fields for uuid = ',\n                uuid, ', id = ', book_id, ': ', updates\n            )\n        else:\n            db.set_metadata(\n                book_id, metadata, set_title=False,\n                set_authors=False\n            )\n            debug_print(\n                'updated the following fields for uuid = ', uuid,\n                ', id = ', book_id, ': ', updates\n            )\n\n        return OperationStatus.PASS, {\n            'result': 'success',\n            **updateLog\n        }\n\n    def check_device(self, device):\n        \"\"\"Return .\n\n        :param device: The connected device.\n        :return: False if device is specifically not supported,\n        otherwise True\n        \"\"\"\n\n        debug_print = partial(\n            module_debug_print,\n            'KoreaderAction:check_device:'\n        )\n\n        if not device:\n            return False\n\n        device_class = device.__class__.__name__\n\n        if device_class in UNSUPPORTED_DEVICES:\n            debug_print('unsupported device, device_class = ', device_class)\n            error_dialog(\n                self.gui,\n                'Device not supported',\n                f'Devices of the type {device_class} are not supported by this plugin. I '\n                f'have tried to get it working, but couldn’t. Sorry.',\n                det_msg='',\n                show=True,\n                show_copy_button=False\n            )\n            return False\n\n        if device_class in SUPPORTED_DEVICES:\n            return True\n\n        debug_print(\n            'not yet supported device, device_class = ',\n            device_class\n        )\n        warning_dialog(\n            self.gui,\n            'Device not yet supported',\n            f'Devices of the type {device_class} are not yet supported by this plugin. '\n            f'Please check if there already is a feature request for this '\n            f'<a href=\"https://github.com/harmtemolder/koreader-calibre-plugin/issues\">'\n            f'here</a>. If not, feel free to create one. I\\'ll try to sync anyway.',\n            det_msg='',\n            show=True,\n            show_copy_button=False\n        )\n        return True\n\n    def device_path_exists(self, device, path):\n        \"\"\"Checks if a path exists on the device, with timing debug logs.\"\"\"\n        debug_print = partial(\n            module_debug_print,\n            'KoreaderAction:device_path_exists:'\n        )\n        start_time = time.time()\n        exists = False\n        method = \"unknown\"\n\n        # 1. Try native driver exists() if available\n        if hasattr(device, 'exists'):\n            try:\n                exists = device.exists(path)\n                method = \"driver.exists\"\n            except:\n                pass\n\n        # 2. Try local filesystem (for USB)\n        if not exists and self.is_usb_device(device):\n            try:\n                if os.path.exists(path):\n                    exists = True\n                    method = \"os.path.exists\"\n            except:\n                pass\n\n        # 3. Try get_file (for Wireless) - this is the \"expensive\" fallback\n        if not exists and method == \"unknown\":\n            try:\n                with io.BytesIO() as dummy:\n                    device.get_file(path, dummy)\n                exists = True\n                method = \"device.get_file\"\n            except:\n                exists = False\n                method = \"device.get_file (failed)\"\n\n        end_time = time.time()\n        debug_print(f\"Path: {path} | Exists: {exists} | Method: {method} | Time: {end_time - start_time:.4f}s\")\n        return exists\n\n    def push_metadata_to_koreader_sidecar(self, device, book_uuid, path):\n        \"\"\"Create a sidecar file for the given book.\n\n        :param device: The connected device object\n        :param book_uuid: Calibre's uuid for the book\n        :param path: path to sidecar file to create\n        :return: tuple of bool and result dict\n        \"\"\"\n\n        debug_print = partial(\n            module_debug_print,\n            'KoreaderAction:push_metadata_to_koreader_sidecar:'\n        )\n\n        try:\n            db = self.gui.current_db.new_api\n            book_id = db.lookup_by_uuid(book_uuid)\n            debug_print(f\"Book id is {book_id}\")\n        except:\n            book_id = None\n\n        if not book_id:\n            debug_print(f'could not find {book_uuid} in calibre’s library')\n            return \"failure\", {\n                'result': f\"Could not find uuid {book_uuid} in Calibre's \"\n                f\"library.\"\n            }\n\n        # Get the current metadata for the book from the library\n        metadata = db.get_metadata(book_id)\n        sidecar_metadata = metadata.get(CONFIG[\"column_sidecar\"])\n        if not sidecar_metadata:\n            return \"no_metadata\", {\n                'result': f'No KOReader metadata for book_id {book_id}, no '\n                f'need to push.'\n            }\n        sidecar_dict = json.loads(sidecar_metadata)\n        sidecar_lua = lua.encode(sidecar_dict)\n        # Lua -> JSON -> Lua conversion is lossy, because JSON does not support integer\n        # keys. This means that a key like [1] will end up as [\"1\"] after the round\n        # trip. The following regex strips the quotes from any Lua object key that consists of\n        # only digits. This is not entirely correct because it now converts keys with\n        # only digits that were originally string keys as well, but it doesn't seem that\n        # KOReader uses those.\n        sidecar_lua = re.sub(r'\\[\"(\\d+)\"\\]', r'[\\1]', sidecar_lua)\n        sidecar_lua_formatted = f\"-- we can read Lua syntax here!\\nreturn {sidecar_lua}\\n\"\n\n        # Create parent directory for USB devices (Issue #68 / #73)\n        is_usb = self.is_usb_device(device)\n        if is_usb:\n            try:\n                parent_dir = os.path.dirname(path)\n                if not os.path.exists(parent_dir):\n                    debug_print(f\"Creating directory: {parent_dir}\")\n                    os.makedirs(parent_dir, exist_ok=True)\n            except OSError as os_e:\n                debug_print(f\"Failed to create directory {parent_dir}: {os_e}\")\n                return \"failure\", {\n                    'result': f'Unable to create directory at: {path} due to {os_e}',\n                }\n\n            # Use direct file writing for USB/Folder devices to avoid driver-specific put_file issues (#143)\n            try:\n                with open(path, \"wb\") as f:\n                    debug_print(f\"Writing directly to {path}\")\n                    f.write(sidecar_lua_formatted.encode('utf-8'))\n                return \"success\", {\n                    'result': 'success',\n                }\n            except Exception as e:\n                debug_print(f\"Failed to write directly to {path}: {e}\")\n                return \"failure\", {\n                    'result': f'Failed to write directly to device: {e}',\n                }\n\n        # Use device.put_file to support wireless devices (#122)\n        # Check if driver supports writing arbitrary files\n        if not hasattr(device, 'put_file'):\n            debug_print(f\"Device driver {device.__class__.__name__} does not support writing sidecar files wirelessly.\")\n            return \"failure\", {\n                'result': 'Wireless write not supported by this device driver. Please use USB or Sync Server.',\n            }\n\n        try:\n            with io.BytesIO(sidecar_lua_formatted.encode('utf-8')) as f:\n                device.put_file(path, f)\n        except Exception as e:\n            debug_print(f\"Failed to push metadata to {path}: {e}\")\n            return \"failure\", {\n                'result': f'Failed to write to device: {e}',\n            }\n\n        return \"success\", {\n            'result': 'success',\n        }\n\n    def sync_missing_sidecars_to_koreader(self, silent=False):\n        \"\"\"Push the content of Calibre's raw metadata column to KOReader\n        for any files which are missing in KOReader. Does not touch existing\n        metadata sidecars on KOReader.\n\n        Intended for e.g. setting up a new device and syncing to it for the first\n        time.\n\n        :return:\n        \"\"\"\n        debug_print = partial(\n            module_debug_print,\n            'KoreaderAction:sync_missing_sidecars_to_koreader:'\n        )\n\n        if CONFIG[\"column_sidecar\"] == '':\n            error_dialog(\n                self.gui,\n                'Failure',\n                'Raw metadata column not mapped, impossible to push metadata to sidecars',\n                show=True,\n                show_copy_button=False\n            )\n            return None\n\n        device = self.get_connected_device()\n\n        if not self.check_device(device):\n            return None\n\n        sidecar_paths = self.get_paths(device)\n        debug_print('sidecar_paths: ', sidecar_paths)\n\n        results = []\n        num_processed = 0\n        num_success = 0\n        num_no_metadata = 0\n        num_fail = 0\n        num_skipped_existing = 0\n\n        for book_uuid, path in sidecar_paths:\n            # Check if exists first (issue #122 revisited)\n            if self.device_path_exists(device, path):\n                debug_print(f\"Skipping existing sidecar: {path}\")\n                num_skipped_existing += 1\n                continue\n\n            num_processed += 1\n            result, details = self.push_metadata_to_koreader_sidecar(device, book_uuid,\n                                                                     path)\n            if result == \"success\":\n                num_success += 1\n                results.append(\n                    {\n                        **details,\n                        'book_uuid': book_uuid,\n                        'sidecar_path': path,\n                    }\n                )\n            elif result == \"failure\":\n                num_fail += 1\n                results.append(\n                    {\n                        **details,\n                        'book_uuid': book_uuid,\n                        'sidecar_path': path,\n                    }\n                )\n            elif result == \"no_metadata\":\n                num_no_metadata += 1\n                results.append(\n                    {\n                        **details,\n                        'book_uuid': book_uuid,\n                        'sidecar_path': path,\n                    }\n                )\n\n        if not silent:\n            results_message = (\n                f'{len(sidecar_paths)} books on device.\\n'\n                f'{num_skipped_existing} books already have sidecars (skipped).\\n'\n                f'Sidecar creation succeeded for {num_success}.\\n'\n                f'Sidecar creation failed for {num_fail}.\\n'\n                f'No attempt made for {num_no_metadata} (no metadata in Calibre to push).\\n'\n                f'See below for details.'\n            )\n\n            if num_success > 0 and num_fail > 0:\n                SyncCompletionDialog(\n                    self.gui,\n                    'Results',\n                    results_message,\n                    results,\n                    'warn'\n                )\n            elif num_success > 0 or num_no_metadata > 0:  # and num_fail == 0\n                SyncCompletionDialog(\n                    self.gui,\n                    'Success',\n                    results_message,\n                    results,\n                    'info'\n                )\n            else:\n                SyncCompletionDialog(\n                    self.gui,\n                    'Failure',\n                    results_message,\n                    results,\n                    'error'\n                )\n\n    def sync_progress_from_progresssync(self, silent=False):\n        \"\"\"Use KOReader's ProgressSync Server to update Calibre metadata rather than a manual sync.\n\n        Intended to easily update Calibre with the latest reading progress from KOReader.\n\n        :return:\n        \"\"\"\n\n        debug_print = partial(\n            module_debug_print,\n            'KoreaderAction:sync_progress_from_progresssync:'\n        )\n\n        md5_column = CONFIG[\"column_md5\"]\n        if md5_column == '':\n            error_dialog(\n                self.gui,\n                'Failure',\n                'MD5 column not mapped, impossible to get metadata from Progress Sync Server',\n                show=True,\n                show_copy_button=False\n            )\n            return None\n\n        if CONFIG[\"progress_sync_password\"] == '':\n            error_dialog(\n                self.gui,\n                'Failure',\n                'Progress Sync Account is not logged in, add credentials in plugin settings',\n                show=True,\n                show_copy_button=False\n            )\n            return None\n\n        status_key = CONFIG['column_status']\n        read_percent_key = CONFIG['column_percent_read_int'] or CONFIG['column_percent_read']\n        if read_percent_key == '' or status_key == '':\n            error_dialog(\n                self.gui,\n                'Failure',\n                'This feature needs a KOReader Progress (int or float) and Status Text column.\\n'\n                'Add those in plugin settings and try again.',\n                show=True,\n                show_copy_button=False\n            )\n            return None\n\n        'Get list of books with MD5 column'\n        db = self.gui.current_db.new_api\n        books_with_md5 = db.search(f'{md5_column}:!''')\n\n        results = []\n        num_success = 0\n        num_skip = 0\n\n        headers = {\n            'x-auth-user': CONFIG[\"progress_sync_username\"],\n            'x-auth-key': CONFIG[\"progress_sync_password\"],\n            'Accept': 'application/vnd.koreader.v1+json',\n            'Connection': 'keep-alive',\n            'Cache-Control': 'no-cache',\n            'User-Agent': f'CalibreKOReaderSync/{self.version}'\n        }\n\n        # Create SSL context based on user preference\n        ssl_context = ssl.create_default_context()\n        if CONFIG['checkbox_skip_ssl_verification']:\n            # Skip SSL verification for custom servers with self-signed certificates\n            ssl_context.check_hostname = False\n            ssl_context.verify_mode = ssl.CERT_NONE\n\n        for book_id in books_with_md5:\n            metadata = db.get_metadata(book_id)\n            md5_value = metadata.get(md5_column)\n            book_uuid = metadata.get('uuid')\n            title = metadata.get('title')\n\n            # Only get sync status if curr progress < 100 and status = reading or if curr_progress/status is not set yet\n            metadata_status = metadata.get(status_key)\n            metadata_read_percent = metadata.get(read_percent_key)\n            if (metadata_status is None or metadata_status == \"reading\") and (metadata_read_percent is None or metadata_read_percent < 100):\n                try:\n                    url = f'{CONFIG[\"progress_sync_url\"]}/syncs/progress/{md5_value}'\n                    request = Request(url, headers=headers)\n                    with urlopen(request, timeout=20, context=ssl_context) as response:\n                        response_data = response.read()\n                        if response_data == b'{}':\n                            results.append({\n                                'md5_value': md5_value,\n                                'error': 'No ProgressSync entry for md5 hash'\n                            })\n                            num_skip += 1\n                            continue\n                        progress_data = json.loads(response_data.decode('utf-8'))\n\n                    # Kinda Janky edge case handling\n                    if len(str(progress_data)) < 8:\n                        continue\n\n                    # List of keys to check\n                    ProgressSync_Columns = [\n                        'column_percent_read', 'column_percent_read_int', 'column_last_read_location', 'column_date_synced', 'column_device_name', 'column_device_id']\n\n                    # Map of progress_data keys to match each config key\n                    progress_mapping = {\n                        'column_percent_read': progress_data['percentage'] if not CONFIG[\"checkbox_percent_read_100\"] else progress_data['percentage']*100,\n                        'column_percent_read_int': round(progress_data['percentage']*100),\n                        'column_last_read_location': progress_data['progress'],\n                        'column_date_synced': datetime.fromtimestamp(progress_data['timestamp']/1000, tz=local_tz),\n                        'column_device_name': progress_data['device'],\n                        'column_device_id': progress_data['device_id']\n                    }\n                    # Change percentage to be human readable on summary screen\n                    if CONFIG[\"checkbox_percent_read_100\"]:\n                        progress_data['percentage']*=100\n\n                    # Dictionary to store values to be updated\n                    keys_values_to_update = {}\n\n                    # Set column_date_book_started if this is the first sync and column not already filled\n                    date_book_started_key = CONFIG.get('column_date_book_started')\n                    if date_book_started_key is not None:\n                        if metadata.get(date_book_started_key) is None:\n                            keys_values_to_update[date_book_started_key] = progress_mapping['column_date_synced']\n\n                    # Set column_date_book_finished if this book is finished and column not already filled\n                    if progress_mapping['column_percent_read_int'] >= 100:\n                        date_book_finished_key = CONFIG.get('column_date_book_finished')\n                        if date_book_finished_key is not None:\n                            if metadata.get(date_book_finished_key) is None:\n                                keys_values_to_update[date_book_finished_key] = progress_mapping['column_date_synced']\n\n                    for key in ProgressSync_Columns:\n                        # Get internal column name from CONFIG\n                        internal_column = CONFIG.get(key, '')\n                        if not internal_column:  # Skip if internal column name is blank\n                            continue\n\n                        # Get current value from metadata\n                        current_value = metadata.get(internal_column)\n                        remote_value = progress_mapping[key]\n\n                        # Compare current and remote values\n                        if current_value != remote_value:\n                            keys_values_to_update[internal_column] = remote_value\n                        # TODO This is redundant isn't it? I can remove a whole chunk of this ngl.\n\n                    # Update only if there are differences\n                    if keys_values_to_update:\n                        operation_status, result = self.update_metadata(\n                            book_uuid, db, keys_values_to_update)\n                    else:\n                        result = {}\n\n                    results.append({\n                        **result,\n                        'title': title,\n                        'book_uuid': book_uuid,\n                        'md5_value': md5_value,\n                        **progress_data\n                    })\n                    num_success += 1\n\n                except (HTTPError, URLError) as e:\n                    msg = f'Failed to make progress sync query: {url}, error: {str(e)}'\n                    debug_print(msg)\n                    results.append({\n                        'title': title,\n                        'book_uuid': book_uuid,\n                        'md5_value': md5_value,\n                        'error': 'No data received'\n                    })\n                    num_skip += 1\n\n            else:\n                results.append({\n                    'title': title,\n                    'book_uuid': book_uuid,\n                    'md5_value': md5_value,\n                    'error': 'Book has already been read'\n                })\n                num_skip += 1\n\n        if not silent:\n            results_message = (\n                f'Total books with MD5 values: {len(books_with_md5)}\\n\\n'\n                f'Successful syncs: {num_success}\\n'\n                f'Failed/Skipped syncs: {num_skip}\\n\\n'\n            )\n\n            if num_success > 0 and num_skip == 0:\n                SyncCompletionDialog(\n                    self.gui,\n                    'Progress sync finished',\n                    results_message + 'All looks good!\\n\\n',\n                    results,\n                    'info'\n                )\n            elif num_skip > 0:\n                SyncCompletionDialog(\n                    self.gui,\n                    'Some syncs failed',\n                    results_message + 'There were some errors during the sync process!\\n'\n                    'Please investigate and report if it looks like a bug\\n\\n',\n                    results,\n                    'warn'\n                )\n            else:\n                SyncCompletionDialog(\n                    self.gui,\n                    'No successful syncs',\n                    results_message + 'No successful syncs\\n'\n                    'Please investigate and report if it looks like a bug\\n\\n',\n                    results,\n                    'error'\n                )\n\n    def scheduled_progress_sync(self):\n        def scheduledTask():\n            # Set another timer for the next day and order sync\n            QTimer.singleShot(24 * 3600 * 1000, scheduledTask)\n            self.sync_progress_from_progresssync(\n                silent=True if not DEBUG else False)\n\n        def main():\n            # Get current local time\n            currentTime = QTime.currentTime()\n\n            # Set target time to user inputted time\n            targetTime = QTime(\n                CONFIG[\"scheduleSyncHour\"], CONFIG[\"scheduleSyncMinute\"])\n\n            # Calculate the time difference\n            timeDiff = currentTime.msecsTo(targetTime)\n\n            # If target time has already passed today, set the target time for tomorrow\n            if timeDiff < 0:\n                timeDiff = timeDiff + 86400000\n\n            # Create a QTimer to trigger the task at the desired time\n            QTimer.singleShot(timeDiff, scheduledTask)\n\n        main()  # Runs scheduled_progress_sync\n\n    def sync_to_calibre(self, silent=False):\n        \"\"\"This plugin’s main purpose. It syncs the contents of\n        KOReader’s metadata sidecar files into calibre’s metadata.\n\n        :return:\n        \"\"\"\n        debug_print = partial(\n            module_debug_print,\n            'KoreaderAction:sync_to_calibre:'\n        )\n\n        device = self.get_connected_device()\n\n        if not self.check_device(device):\n            return None\n\n        sidecar_paths = self.get_paths(device)\n        debug_print('sidecar_paths:', sidecar_paths)\n\n        class KOSyncWorker(QThread):\n            progress_update = pyqtSignal(int, str)\n            finished_signal = pyqtSignal(dict)\n\n            def __init__(self, action, db, sidecar_paths):\n                super().__init__()\n                self.action = action\n                self.db = db\n                self.sidecar_paths = sidecar_paths\n\n            def run(self):\n                results = []\n                num_success = 0\n                num_fail = 0\n                num_skip = 0\n\n                for idx, (book_uuid, sidecar_path) in enumerate(self.sidecar_paths):\n                    debug_print('Trying to get sidecar from ', device,\n                                ', with sidecar_path: ', sidecar_path)\n\n                    # pre-checks before parsing\n                    if book_uuid is None:\n                        status = 'skipped, no UUID'\n                        append_results(results, None, status,\n                                       book_uuid, sidecar_path)\n                        num_skip += 1\n                        continue\n\n                    sidecar_contents = self.action.get_sidecar(\n                        device, sidecar_path)\n                    debug_print(\"sidecar_contents:\", sidecar_contents)\n\n                    try:\n                        book_id = db.lookup_by_uuid(book_uuid)\n                        if not book_id:\n                            # Try to find a better UUID in the sidecar (Issue #115)\n                            better_uuid = self.action.get_calibre_uuid_from_sidecar(sidecar_contents)\n                            if better_uuid:\n                                debug_print(f\"Found alternative UUID in sidecar: {better_uuid}\")\n                                book_id = db.lookup_by_uuid(better_uuid)\n                                if book_id:\n                                    book_uuid = better_uuid # Use the one that worked\n\n                        if not book_id:\n                            raise Exception(\"Book not found\")\n                        metadata = db.get_metadata(book_id)\n                        title = metadata.get('title')\n                    except Exception as e:\n                        debug_print(f\"Failed to lookup book {book_uuid}: {e}\")\n                        status = 'skipped, could not find in library'\n                        append_results(results, \"Unknown\", status,\n                                       book_uuid, sidecar_path)\n                        num_skip += 1\n                        continue\n\n                    self.progress_update.emit(idx + 1, title)\n                    if DEBUG: # Add time delay when debugging\n                        time.sleep(.4)\n\n                    if sidecar_contents is GetSidecarStatus.PATH_NOT_FOUND:\n                        status = ('skipped, sidecar does not exist '\n                                  '(seems like book is never opened)')\n                        append_results(results, title, status,\n                                       book_uuid, sidecar_path)\n                        num_skip += 1\n                        continue\n\n                    if sidecar_contents is GetSidecarStatus.DECODE_FAILED:\n                        status = 'decoding is failed see debug for more details'\n                        append_results(results, title, status,\n                                       book_uuid, sidecar_path)\n                        num_fail += 1\n                        continue\n\n                    debug_print('sidecar_contents is found!')\n\n                    keys_values_to_update = {}\n\n                    for config_name, column in COLUMNS.items():\n                        target = CONFIG[config_name]\n\n                        if target == '':\n                            # No column mapped, so do not sync\n                            continue\n\n                        # Special handling for date started/finished\n                        # Safety check for 'summary' key (#117)\n                        summary = sidecar_contents.get('summary', {})\n                        if config_name == 'column_date_book_started':\n                            if metadata.get(target) is None and summary.get('status') == 'reading':\n                                sidecar_contents['calculated']['date_book_started'] = sidecar_contents['calculated'].get('date_status_changed')\n                        if config_name == 'column_date_book_finished':\n                            if metadata.get(target) is None and summary.get('status') == 'complete':\n                                sidecar_contents['calculated']['date_book_finished'] = sidecar_contents['calculated'].get('date_status_changed')\n\n                        data_location = column['data_location']\n                        value = sidecar_contents\n\n                        for subproperty in data_location:\n                            if value and subproperty in value:\n                                value = value[subproperty]\n                            else:\n                                debug_print(\n                                    f'subproperty \"{subproperty}\" not found in value')\n                                value = None\n                                break\n\n                        # Fallback for MD5 (Issue #98)\n                        if config_name == 'column_md5' and value is None:\n                            value = sidecar_contents.get('stats', {}).get('md5')\n                            if value:\n                                debug_print('Found MD5 in fallback location (stats.md5)')\n\n                        if value is None:\n                            continue\n\n                        # Transform value if required\n                        if 'transform' in column:\n                            debug_print('transforming value for ', target)\n                            value = column['transform'](value)\n\n                        keys_values_to_update[target] = value\n\n                    operation_status, result = self.action.update_metadata(\n                        book_uuid, db, keys_values_to_update)\n\n\n                    results.append(\n                        {\n                            **result,\n                            'title': title,\n                            'book_uuid': book_uuid,\n                            'sidecar_path': sidecar_path,\n                            **({'updated': json.dumps(keys_values_to_update, default=str)} if DEBUG else {})\n                        }\n                    )\n\n                    if operation_status == OperationStatus.PASS:\n                        num_success += 1\n                    elif operation_status == OperationStatus.FAIL:\n                        num_fail += 1\n                    elif operation_status == OperationStatus.SKIP:\n                        num_skip += 1\n                self.finished_signal.emit(\n                    {'results': results, 'num_success': num_success, 'num_fail': num_fail, 'num_skip': num_skip})\n\n        db = self.gui.current_db.new_api\n        startTime = time.perf_counter()\n        self.koSyncWorker = KOSyncWorker(self, db, sidecar_paths)\n        progress_dialog = None\n        if not silent and len(sidecar_paths) > 10:\n            progress_dialog = ProgressDialog(\n                self.gui, \"Syncing Sidecars...\", len(sidecar_paths))\n            progress_dialog.show()\n            self.koSyncWorker.progress_update.connect(progress_dialog.setValue)\n\n        def on_finished(res):\n            if not silent:\n                if progress_dialog:\n                    progress_dialog.close()\n                results_message = (\n                    f\"Total targets found: {len(sidecar_paths)}\\n\\n\"\n                    f\"Metadata sync succeeded for: {res['num_success']}\\n\"\n                    f\"Metadata sync skipped for: {res['num_skip']}\\n\"\n                    f\"Metadata sync failed for: {res['num_fail']}\\n\"\n                    f\"Time taken: {time.perf_counter() - startTime:.4f} seconds.\\n\\n\"\n                )\n                # Sort by if error, then # of changes\n                res['results'].sort(key=lambda row: (\n                    not row.get('error', False), -len(row)))\n                if res['num_success'] > 0 and res['num_fail'] == 0:\n                    SyncCompletionDialog(\n                        self.gui,\n                        'Metadata sync finished',\n                        results_message + 'All looks good!\\n\\n',\n                        res['results'],\n                        'info'\n                    )\n                elif res['num_fail'] > 0:\n                    SyncCompletionDialog(\n                        self.gui,\n                        'Some sync failed',\n                        results_message + 'There was some error during sync process!\\n'\n                        'Please investigate and report if it looks '\n                        'like a bug\\n\\n',\n                        res['results'],\n                        'error'\n                    )\n                elif res['num_success'] == 0 and res['num_fail'] == 0:\n                    SyncCompletionDialog(\n                        self.gui,\n                        'No errors but not successful syncs',\n                        results_message + 'No errors but no successful syncs\\n'\n                        'Do you have book(s) which are ready to be '\n                        'sync?\\n'\n                        'Please investigate and report if it looks '\n                        'like a bug\\n\\n',\n                        res['results'],\n                        'warn'\n                    )\n                else:\n                    error_dialog(\n                        self.gui,\n                        'Edge case',\n                        results_message + 'Seems like and bug, please report ASAP\\n\\n',\n                        det_msg=json.dumps(res['results'], indent=2),\n                        show=True,\n                        show_copy_button=False\n                    )\n        self.koSyncWorker.finished_signal.connect(on_finished)\n        self.koSyncWorker.start()\n\n\nclass ProgressDialog(QDialog):\n    def __init__(self, parent, title: str, count: int):\n        super().__init__(parent)\n        self.setWindowTitle(title)\n        self.setWindowModality(Qt.WindowModal)\n        layout = QVBoxLayout(self)\n        self.progressBar = QProgressBar(self)\n        self.progressBar.setMinimum(0)\n        self.progressBar.setMaximum(count)\n        self.progressBar.setFormat(\"%v of %m\")\n        layout.addWidget(self.progressBar)\n        self.currBook = QLabel('Beginning Sync')\n        layout.addWidget(self.currBook)\n\n    def setValue(self, idx: int, bookTitle: str):\n        self.progressBar.setValue(idx)\n        self.currBook.setText(bookTitle)\n\n\nclass SyncCompletionDialog(QDialog):\n    def __init__(self, parent=None, title=\"\", msg=\"\", results=None, type=None):\n        super().__init__(parent)\n        self.setWindowTitle(title)\n        self.setMinimumWidth(800)\n        self.setMinimumHeight(800)\n\n        layout = QVBoxLayout(self)\n        layout.setSpacing(10)\n\n        # Main Message Area\n        mainMessageLayout = QHBoxLayout()\n        type_icon = {\n            'info': 'dialog_information',\n            'error': 'dialog_error',\n            'warn': 'dialog_warning',\n        }.get(type)\n        if type_icon is not None:\n            icon = QIcon.ic(f'{type_icon}.png')\n            self.setWindowIcon(icon)\n            icon_widget = QLabel(self)\n            icon_widget.setPixmap(icon.pixmap(64, 64))\n            mainMessageLayout.addWidget(icon_widget)\n        message_label = QLabel(msg)\n        mainMessageLayout.addWidget(message_label)\n        mainMessageLayout.addStretch()  # Left align the message/text\n        layout.addLayout(mainMessageLayout)\n\n        # Table in scrollable area if results are provided\n        if results:\n            self.table_area = QScrollArea(self)\n            self.table_area.setWidgetResizable(True)\n            table = self.create_results_table(results)\n            self.table_area.setWidget(table)\n            layout.addWidget(self.table_area)\n\n        # Bottom Buttons\n        bottomButtonLayout = QHBoxLayout()\n        if results:\n            copy_button = QPushButton(\"COPY\", self)\n            copy_button.setFixedWidth(200)\n            copy_button.setIcon(QIcon.ic('edit-copy.png'))\n            copy_button.clicked.connect(lambda: (\n                QApplication.clipboard().setText(str(results)),\n                copy_button.setText('Copied')\n            ))\n            bottomButtonLayout.addWidget(copy_button)\n        bottomButtonLayout.addStretch()  # Right align the rest of this layout\n        ok_button = QPushButton(\"OK\", self)\n        ok_button.setFixedWidth(200)\n        ok_button.setIcon(QIcon.ic('ok.png'))\n        ok_button.clicked.connect(self.accept)\n        ok_button.setDefault(True)\n        bottomButtonLayout.addWidget(ok_button)\n        layout.addLayout(bottomButtonLayout)\n\n        self.show()\n\n    def create_results_table(self, results):\n        # Get all possible headers from results and save as set\n        all_headers = {key for result in results for key in result.keys()}\n\n        headers = []\n        custom_columns = sorted(h for h in all_headers\n                                if h not in ('title', 'book_uuid', 'result', 'error'))\n\n        if 'title' in all_headers:\n            headers.append('title')\n        if 'book_uuid' in all_headers:\n            headers.append('book_uuid')\n        if 'result' in all_headers:\n            headers.append('result')\n        if 'error' in all_headers:\n            headers.append('error')\n        if custom_columns:\n            headers.extend(custom_columns)\n\n        table = QTableWidget()\n        table.setRowCount(len(results))\n        table.setColumnCount(len(headers))\n        table.setHorizontalHeaderLabels(headers)\n\n        for row, result in enumerate(results):\n            for col, header in enumerate(headers):\n                item = QTableWidgetItem(str(result.get(header, \"\")))\n                item.setFlags(item.flags() & ~Qt.ItemIsEditable)\n                # Set the tooltip to the full text\n                item.setToolTip(item.text())\n                table.setItem(row, col, item)\n\n        max_lines = 1\n        for col, header in enumerate(headers):\n            words, line, lines, col_len_limit = header.split(\n            ), \"\", [], max(table.columnWidth(col) // 7, 10)\n            for word in words:\n                line = f\"{line} {word}\".strip()\n                if len(line) > col_len_limit:\n                    lines.append(line.rsplit(' ', 1)[0])\n                    line = word if ' ' in line else ''\n            lines.append(line)\n            max_lines = max(len(lines), max_lines)\n            wrapped = '\\n'.join(lines)\n            table.setHorizontalHeaderItem(col, QTableWidgetItem(wrapped))\n        table.horizontalHeader().setFixedHeight(20 * max_lines)  # Default = 20\n\n        return table\n"
  },
  {
    "path": "config.py",
    "content": "#!/usr/bin/env python3\n\n\"\"\"Config for KOReader Sync plugin for Calibre.\"\"\"\n\nimport os\nimport json\nfrom functools import partial\n\nfrom PyQt5.Qt import (\n    QComboBox,\n    QCheckBox,\n    QGroupBox,\n    QPushButton,\n    QLabel,\n    QLineEdit,\n    QHBoxLayout,\n    QVBoxLayout,\n    QFormLayout,\n    QWidget,\n    QSpinBox,\n    QFrame,\n    QDialog,\n    Qt,\n)\n\nfrom PyQt5.QtGui import QPixmap\nfrom calibre.constants import numeric_version\nfrom calibre.devices.usbms.driver import debug_print as root_debug_print\nfrom calibre.utils.config import JSONConfig\nfrom calibre_plugins.koreader import clean_bookmarks\nfrom calibre.gui2 import show_restart_warning\n\n__license__ = 'GNU GPLv3'\n__copyright__ = '2021, harmtemolder <mail at harmtemolder.com>'\n__modified_by__ = 'kyxap kyxappp@gmail.com'\n__modification_date__ = '2024'\n__docformat__ = 'restructuredtext en'\n\nSUPPORTED_DEVICES = [\n    'FOLDER_DEVICE',\n    'KINDLE2',\n    'KOBO',\n    'KOBOTOUCH',\n    'KOBOTOUCHEXTENDED',\n    'POCKETBOOK622',\n    'POCKETBOOK626',\n    'POCKETBOOK632',\n    'POCKETBOOK_IMPROVED',\n    'SMART_DEVICE_APP',\n    'TOLINO',\n    'USER_DEFINED',\n]\nUNSUPPORTED_DEVICES = [\n    'MTP_DEVICE',\n]\n\ntry:\n    from calibre.gui2.preferences.create_custom_column import CreateNewCustomColumn\n    SUPPORTS_CREATE_CUSTOM_COLUMN = True\nexcept ImportError:\n    SUPPORTS_CREATE_CUSTOM_COLUMN = False\n\n\"\"\"\nEach entry in the below dict has the following keys:\nEach entry is keyed by the name of the config item used to store the selected column's lookup name\n  first_in_group (optional): If present and true, a separator will be added before this item in the Config UI.\n                             If this is a string a QLabel with bolded string value will be added below the separator.\n  column_heading: Default custom column heading\n  datatype: Custom column datatype\n  is_multiple (optional): For text columns, specified as a tuple (default_multiple, only_multiple_in_dropdown)\n  additional_params (optional): Additional parameters for the custom column display parameter as specified in the calibre API as a dictionary.\n    https://github.com/kovidgoyal/calibre/blob/bc29562c0c8534b349c9d330ac9aec72eef2be99/src/calibre/gui2/preferences/create_custom_column.py#L901\n  description: Default custom column description\n  default_lookup_name: The suggested column lookup string in calibre (e.g. \"#ko_progfloat\")\n  config_label: Label for the item in the Config UI\n  config_tool_tip: Tooltip for the item in the Config UI\n  data_source: Source of the data; 'sidecar' is the KOReader sidecar file.\n  data_location: List of keys used to locate the data in the data_source dictionary\n  transform (optional): lambda expression to format the value\n\"\"\"\nCUSTOM_COLUMN_DEFAULTS = {\n    'column_percent_read': {\n        'column_heading': _(\"KOReader Precise Progress\"),\n        'datatype': 'float',\n        'additional_params': {'number_format': \"{:.2%}\"},\n        'description': _(\"Reading progress for the book with decimal precision.\"),\n        'default_lookup_name': '#ko_progfloat',\n        'config_label': _('Percent read column (float):'),\n        'config_tool_tip': _('A \"Floating point numbers\" column to store the current\\n'\n                             'percent read, with \"Format for numbers\" set to 0.00%.'),\n        'data_source': 'sidecar',\n        'data_location': ['percent_finished'],\n        'transform': (lambda value: float(value)),\n    },\n    'column_percent_read_int': {\n        'column_heading': _(\"KOReader Progress\"),\n        'datatype': 'int',\n        'additional_params': {'number_format': \"{}%\"},\n        'description': _(\"Reading progress for the book.\"),\n        'default_lookup_name': '#ko_progint',\n        'config_label': _('Percent read column (int):'),\n        'config_tool_tip': _('An \"Integers\" column to store the current percent read.'),\n        'data_source': 'sidecar',\n        'data_location': ['percent_finished'],\n        'transform': (lambda value: round(float(value) * 100)),\n    },\n    'column_status': {\n        'column_heading': _(\"KOReader Book Status\"),\n        'datatype': 'text',\n        'description': _(\"Reading status of the book, either Finished, Reading, or On hold.\"),\n        'default_lookup_name': '#ko_status',\n        'config_label': _('Reading status column (text):'),\n        'config_tool_tip': _('A regular \"Text\" column to store the reading status of the\\n'\n                             'book, as entered on the book status page (\"Finished\",\\n'\n                             '\"Reading\", \"On hold\").'),\n        'data_source': 'sidecar',\n        'data_location': ['summary', 'status'],\n    },\n    'column_status_bool': {\n        'column_heading': _(\"KOReader Book Status Y/N\"),\n        'datatype': 'bool',\n        'description': _(\"Yes if the book is marked as finished in KOReader, otherwise No.\"),\n        'default_lookup_name': '#ko_statusbool',\n        'config_label': _('Reading status column (yes/no):'),\n        'config_tool_tip': _('A \"Yes/No\" column to store the reading status of the book,\\n'\n                             'as a boolean (\"Yes\" = \"Finished\", \"No\" = everything else).'),\n        'data_source': 'sidecar',\n        'data_location': ['summary', 'status'],\n        'transform': (lambda val: bool(val == 'complete')),\n    },\n    'column_last_read_location': {\n        'column_heading': _(\"KOReader Last Location\"),\n        'datatype': 'text',\n        'description': _(\"Last location you stopped reading at in the book.\"),\n        'default_lookup_name': '#ko_loc',\n        'config_label': _('Last read location column:'),\n        'config_tool_tip': _('A regular \"Text\" column to store the location you last\\n'\n                             'stopped reading at.'),\n        'data_source': 'sidecar',\n        'data_location': ['last_xpointer'],\n    },\n    'column_date_book_started': {\n        'column_heading': _(\"Date KOReader Started\"),\n        'datatype': 'datetime',\n        'description': _(\"Date when the book was started.\"),\n        'default_lookup_name': '#ko_start',\n        'config_label': _('Date Book Started column:'),\n        'config_tool_tip': _('A \"Date\" column to store when the book was started. '\n                             'Will only be set once when synced with reading status.'),\n        'data_source': 'sidecar',\n        'data_location': ['calculated', 'date_book_started'],\n    },\n    'column_date_book_finished': {\n        'column_heading': _(\"Date KOReader Finished\"),\n        'datatype': 'datetime',\n        'description': _(\"Date when the book was finished.\"),\n        'default_lookup_name': '#ko_finish',\n        'config_label': _('Date Book Finished column:'),\n        'config_tool_tip': _('A \"Date\" column to store when the book was finished. '\n                             'Will only be set once when synced with finished status.'),\n        'data_source': 'sidecar',\n        'data_location': ['calculated', 'date_book_finished'],\n    },\n    'column_rating': {\n        'first_in_group': True,\n        'column_heading': _(\"KOReader Rating\"),\n        'datatype': 'rating',\n        'description': _(\"Rating for the book.\"),\n        'default_lookup_name': '#ko_rating',\n        'config_label': _('Rating column:'),\n        'config_tool_tip': _('A \"Rating\" column to store your rating of the book,\\n'\n                             'as entered on the book’s status page.'),\n        'data_source': 'sidecar',\n        'data_location': ['summary', 'rating'],\n        # calibre uses a 10-point scale,\n        'transform': (lambda value: value * 2),\n    },\n    'column_review': {  # Unsure about Interpret this column as\n        'column_heading': _(\"KOReader Review\"),\n        'datatype': 'comments',\n        'description': _(\"Review of book.\"),\n        'default_lookup_name': '#ko_review',\n        'config_label': _('Review column:'),\n        'config_tool_tip': _('A \"Long text\" column to store your review of the book,\\n'\n                             'as entered on the book’s status page.'),\n        'data_source': 'sidecar',\n        'data_location': ['summary', 'note'],\n    },\n    'column_bookmarks': {\n        'column_heading': _(\"KOReader Bookmarks\"),\n        'datatype': 'comments',\n        'description': _(\"All the bookmarks and highlights from KOReader.\"),\n        'default_lookup_name': '#ko_bookmarks',\n        'config_label': _('Bookmarks column:'),\n        'config_tool_tip': _('A \"Long text\" column to store your bookmarks and highlights.'),\n        'data_source': 'sidecar',\n        'data_location': ['annotations'],\n        'transform': clean_bookmarks,\n    },\n    'column_md5': {\n        'first_in_group': True,\n        'column_heading': _(\"KOReader MD5\"),\n        'datatype': 'text',\n        'description': _(\"MD5 hash used by KOReader, allowed for ProgressSync Support.\"),\n        'default_lookup_name': '#ko_md5',\n        'config_label': _('MD5 hash column:'),\n        'config_tool_tip': _('A regular \"Text\" column to store the MD5 hash KOReader uses\\n'\n                             'to sync progress to a KOReader Sync Server. (\"Progress sync\"\\n'\n                             'in the KOReader app.)'),\n        'data_source': 'sidecar',\n        'data_location': ['partial_md5_checksum'],\n    },\n    'column_device_name': {\n        'column_heading': _(\"KOReader Device Name\"),\n        'datatype': 'text',\n        'description': _(\"Last Synced Device Name from ProgressSync.\"),\n        'default_lookup_name': '#ko_device_name',\n        'config_label': _('ProgressSync Device Name:'),\n        'config_tool_tip': _('A regular \"Text\" column to store the last device name used\\n'\n                             'to sync progress via ProgressSync.'),\n        'data_source': 'progresssync',\n        'data_location': ['device'],\n    },\n    'column_device_id': {\n        'column_heading': _(\"KOReader Device ID\"),\n        'datatype': 'text',\n        'description': _(\"Last Synced Device ID from ProgressSync.\"),\n        'default_lookup_name': '#ko_device_id',\n        'config_label': _('ProgressSync Device ID:'),\n        'config_tool_tip': _('A regular \"Text\" column to store the last device id used\\n'\n                             'to sync progress via ProgressSync.'),\n        'data_source': 'progresssync',\n        'data_location': ['device'],\n    },\n    'column_date_synced': {\n        'column_heading': _(\"Date KOReader Synced\"),\n        'datatype': 'datetime',\n        'description': _(\"Date when the book was last synced from KOReader.\"),\n        'default_lookup_name': '#ko_lastsync',\n        'config_label': _('Date Synced column:'),\n        'config_tool_tip': _('A \"Date\" column to store when the last sync was performed.'),\n        'data_source': 'sidecar',\n        'data_location': ['calculated', 'date_synced'],\n    },\n    'column_date_sidecar_modified': {\n        'column_heading': _(\"Date KOReader Modified\"),\n        'datatype': 'datetime',\n        'description': _(\"Date when the book was last modified in KOReader. Wired sync only.\"),\n        'default_lookup_name': '#ko_lastmod',\n        'config_label': _('Date Modified column:'),\n        'config_tool_tip': _('A \"Date\" column to store when the sidecar file was last '\n                             'modified. Works for wired connection only, wireless will be '\n                             'always empty'),\n        'data_source': 'sidecar',\n        'data_location': ['calculated', 'date_sidecar_modified'],\n    },\n    'column_sidecar': {  # Unsure about Interpret this column as\n        'column_heading': _(\"KOReader Raw Sidecar\"),\n        'datatype': 'comments',\n        'description': _(\"Raw sidecar data directly from KOReader. Allows sync to KOReader, also serves as a backup.\"),\n        'default_lookup_name': '#ko_sidecar',\n        'config_label': _('Raw sidecar column:'),\n        'config_tool_tip': _('A \"Long text\" column to store the contents of the\\n'\n                             'metadata sidecar as JSON, with \"Interpret this column as\" set to\\n'\n                             '\"Plain text\". This is required to sync metadata back to KOReader sidecars.'),\n        'data_source': 'sidecar',\n        'data_location': [],  # [] gives the entire sidecar dict\n        'transform': (lambda d: json.dumps(\n            {k: d[k] for k in d if k != 'calculated'},\n            skipkeys=True,\n            indent=2,\n            default=str\n        )),\n    },\n}\n\nCHECKBOXES = {  # Each entry in the below dict is keyed with config_name\n    'checkbox_percent_read_100': {\n        'config_label': 'Percent read column (float) range 0.0-100.0',\n        'config_tool_tip': 'Default the range is 0.0-1.0\\n'\n        'Checking this option the float value is multiplied by 100 to be in range 0.0-100.0',\n    },\n    'checkbox_sync_if_more_recent': {\n        'config_label': 'Sync only if changes are more recent',\n        'config_tool_tip': 'Sync book only if the metadata is more recent. Requires\\n'\n        '\"Date Modified Column\" or \"Percent read column\" to be synced',\n    },\n    'checkbox_no_sync_if_finished': {\n        'config_label': 'No sync if book has already been finished',\n        'config_tool_tip': 'Do not sync book if it has already been finished. Requires\\n'\n        '\"Percent read column\" or \"Reading status column\" to be synced',\n    },\n    'checkbox_enable_automatic_sync': {\n        'config_label': 'Automatic Sync on device connection',\n        'config_tool_tip': 'Sync from KOReader automatically on device connection. \\n'\n        'Restart calibre to apply this setting',\n    },\n    'checkbox_enable_scheduled_progressync': {\n        'config_label': 'Daily ProgressSync',\n        'config_tool_tip': 'Enable daily sync of reading progress and location using \\n'\n        'KOReader\\'s ProgressSync server.',\n    },\n    'checkbox_skip_ssl_verification': {\n        'config_label': 'Skip SSL certificate verification for ProgressSync',\n        'config_tool_tip': 'Disable SSL certificate verification when connecting to ProgressSync server.\\n'\n        'Enable this if you use a custom server with self-signed certificates or IP addresses.\\n'\n        'Warning: This reduces security. Only use with trusted servers.',\n    },\n}\n\nCONFIG = JSONConfig(os.path.join('plugins', 'KOReader Sync.json'))\nfor this_column in CUSTOM_COLUMN_DEFAULTS:\n    CONFIG.defaults[this_column] = ''\nfor this_checkbox in CHECKBOXES:\n    CONFIG.defaults[this_checkbox] = False\n\nCONFIG.defaults['checkbox_skip_ssl_verification'] = False\nCONFIG.defaults['progress_sync_url'] = 'https://sync.koreader.rocks:443'\nCONFIG.defaults['progress_sync_username'] = ''\nCONFIG.defaults['progress_sync_password'] = ''\nCONFIG.defaults['scheduleSyncHour'] = 4\nCONFIG.defaults['scheduleSyncMinute'] = 0\nCONFIG.defaults['main_action'] = 'KOReader Sync'\n\nif numeric_version >= (5, 5, 0):\n    module_debug_print = partial(root_debug_print, ' koreader:config:', sep='')\nelse:\n    module_debug_print = partial(root_debug_print, 'koreader:config:')\n\n\ndef create_separator():\n    separator = QFrame()\n    separator.setFrameShape(QFrame.HLine)\n    separator.setFrameShadow(QFrame.Sunken)\n    return separator\n\n\nclass ConfigWidget(QWidget):  # https://doc.qt.io/qt-5/qwidget.html\n    def __init__(self, plugin_action):\n        QWidget.__init__(self)\n        debug_print = partial(module_debug_print, 'ConfigWidget:__init__:')\n        debug_print('start')\n        self.action = plugin_action\n        self.must_restart = False\n\n        # Set up main layout\n        layout = QVBoxLayout()\n        self.setLayout(layout)\n\n        # Add icon and title\n        title_layout = TitleLayout(\n            self,\n            'images/icon.png',\n            f'Configure {self.action.version}',\n        )\n        layout.addLayout(title_layout)\n\n        # Add custom column dropdowns\n        self._get_create_new_custom_column_instance = None\n        self.sync_custom_columns = {}\n        bottom_options_layout = QHBoxLayout()\n        layout.addLayout(bottom_options_layout)\n        columns_group_box = QGroupBox(\n            _('Synchronisable Custom Columns:'), self)\n        bottom_options_layout.addWidget(columns_group_box)\n        columns_group_box_layout = QHBoxLayout()\n        columns_group_box.setLayout(columns_group_box_layout)\n        columns_group_box_layout2 = QFormLayout()\n        columns_group_box_layout.addLayout(columns_group_box_layout2)\n        columns_group_box_layout.addStretch()\n\n        for config_name, metadata in CUSTOM_COLUMN_DEFAULTS.items():\n            self.sync_custom_columns[config_name] = {'current_columns': self.get_custom_columns(\n                metadata['datatype'], metadata.get('is_multiple', (False, False))[1])}\n            self._column_combo = self.create_custom_column_controls(\n                columns_group_box_layout2, config_name)\n            metadata['comboBox'] = self._column_combo\n            self._column_combo.populate_combo(\n                self.sync_custom_columns[config_name]['current_columns'],\n                CONFIG[config_name]\n            )\n\n        # Main action combobox\n        main_action_layout = QHBoxLayout()\n        main_action_layout.setAlignment(Qt.AlignLeft)\n        self.main_action_box_label = QLabel('Main button:')\n        tooltip = 'Select which action will be used for the main button on calibre GUI'\n        self.main_action_box_label.setToolTip(tooltip)\n        self.main_action_combo = QComboBox()\n        self.main_action_combo.setToolTip(tooltip)\n        self.main_action_combo.setMinimumWidth(200)\n        self.main_action_combo.addItems({'KOReader Sync', 'Progress Sync'})\n        self.main_action_combo.model().sort(0)\n        self.main_action_box_label.setBuddy(self.main_action_combo)\n        main_action_layout.addWidget(self.main_action_box_label)\n        main_action_layout.addWidget(self.main_action_combo)\n        self.main_action_combo.setCurrentText(CONFIG['main_action'])\n        layout.addLayout(main_action_layout)\n\n        # Add custom checkboxes\n        layout.addLayout(self.add_checkbox('checkbox_percent_read_100'))\n        layout.addLayout(self.add_checkbox('checkbox_sync_if_more_recent'))\n        layout.addLayout(self.add_checkbox('checkbox_no_sync_if_finished'))\n\n        layout.addLayout(self.add_checkbox('checkbox_enable_automatic_sync'))\n\n        # Progress Sync Section\n        layout.addWidget(create_separator())\n        ps_header_label = QLabel(\n            \"This plugin supports use of KOReader's built-in ProgressSync server to update reading progress and location without the device connected. \"\n            \"You must have an MD5 column mapped and use Binary matching in KOReader's ProgressSync Settings (default).\\n\"\n            \"You also need a reading progress column and status text column.\\n\"\n            \"This functionality can optionally be scheduled into a daily sync from within calibre. \"\n            \"Enter scheduled time in military time, default is 4 AM local time. You must restart calibre after making changes to scheduled sync settings. \"\n        )\n        ps_header_label.setWordWrap(True)\n        layout.addWidget(ps_header_label)\n\n        # Add SSL verification checkbox\n        layout.addLayout(self.add_checkbox('checkbox_skip_ssl_verification'))\n\n        # Add scheduled sync options\n        scheduled_sync_layout = QHBoxLayout()\n        scheduled_sync_layout.setAlignment(Qt.AlignLeft)\n        scheduled_sync_layout.addLayout(self.add_checkbox(\n            'checkbox_enable_scheduled_progressync'))\n        scheduled_sync_layout.addWidget(QLabel('Scheduled Time:'))\n        self.schedule_hour_input = QSpinBox()\n        self.schedule_hour_input.setRange(0, 23)\n        self.schedule_hour_input.setValue(CONFIG['scheduleSyncHour'])\n        self.schedule_hour_input.setSuffix('h')\n        self.schedule_hour_input.wheelEvent = lambda event: event.ignore()\n        scheduled_sync_layout.addWidget(self.schedule_hour_input)\n        scheduled_sync_layout.addWidget(QLabel(':'))\n        self.schedule_minute_input = QSpinBox()\n        self.schedule_minute_input.setRange(0, 59)\n        self.schedule_minute_input.setValue(CONFIG['scheduleSyncMinute'])\n        self.schedule_minute_input.setSuffix('m')\n        self.schedule_minute_input.wheelEvent = lambda event: event.ignore()\n        scheduled_sync_layout.addWidget(self.schedule_minute_input)\n        layout.addLayout(scheduled_sync_layout)\n\n        # Add ProgressSync Account button\n        progress_sync_button = QPushButton('Add ProgressSync Account', self)\n        progress_sync_button.clicked.connect(self.show_progress_sync_popup)\n        layout.addWidget(progress_sync_button)\n\n    def show_progress_sync_popup(self):\n        self.progress_sync_popup = ProgressSyncPopup(self)\n        self.progress_sync_popup.show()\n\n    def save_settings(self):\n        debug_print = partial(module_debug_print,\n                              'ConfigWidget:save_settings:')\n        debug_print('old CONFIG = ', CONFIG)\n\n        # Check relevant settings for changes in order to show restart warning\n        needRestart = (self.must_restart or  # Custom Column Addition\n                       CONFIG['checkbox_enable_automatic_sync'] != (CHECKBOXES['checkbox_enable_automatic_sync']['checkbox'].checkState() == Qt.Checked) or\n                       CONFIG['checkbox_enable_scheduled_progressync'] != (CHECKBOXES['checkbox_enable_scheduled_progressync']['checkbox'].checkState() == Qt.Checked) or\n                       CONFIG['scheduleSyncHour'] != self.schedule_hour_input.value() or\n                       CONFIG['scheduleSyncMinute'] != self.schedule_minute_input.value()\n                       )\n\n        # Save Column Settings\n        for config_name, metadata in CUSTOM_COLUMN_DEFAULTS.items():\n            CONFIG[config_name] = metadata['comboBox'].get_selected_column()\n\n        # Save Checkbox Settings\n        for config_name in CHECKBOXES:\n            CONFIG[config_name] = CHECKBOXES[config_name]['checkbox'].checkState(\n            ) == Qt.Checked\n\n        # Save main action Settings\n        CONFIG['main_action'] = self.main_action_combo.currentText()\n\n        # Save Scheduled ProgressSync Settings\n        CONFIG['scheduleSyncHour'] = self.schedule_hour_input.value()\n        CONFIG['scheduleSyncMinute'] = self.schedule_minute_input.value()\n        # NOTE: Server/Credentials are saved by the ProgressSyncPopup\n\n        debug_print('new CONFIG = ', CONFIG)\n        if needRestart and show_restart_warning('Changes have been made that require a restart to take effect.\\nRestart now?'):\n            self.action.gui.quit(restart=True)\n\n    def add_checkbox(self, checkboxKey):\n        layout = QHBoxLayout()\n        checkboxMeta = CHECKBOXES[checkboxKey]\n        checkbox = QCheckBox()\n        checkbox.setCheckState(\n            Qt.Checked if CONFIG[checkboxKey] else Qt.Unchecked)\n        label = QLabel(checkboxMeta['config_label'])\n        label.setToolTip(checkboxMeta['config_tool_tip'])\n        label.setBuddy(checkbox)\n        label.mousePressEvent = lambda event, checkbox=checkbox: checkbox.toggle()\n        layout.addWidget(checkbox)\n        layout.addWidget(label)\n        layout.addStretch()\n        CHECKBOXES[checkboxKey]['checkbox'] = checkbox\n        return layout\n\n    def create_custom_column_controls(self, columns_group_box_layout, custom_col_name, min_width=300):\n        if fig := CUSTOM_COLUMN_DEFAULTS[custom_col_name].get('first_in_group', False):\n            columns_group_box_layout.addRow(create_separator())\n            if isinstance(fig, str):\n                columns_group_box_layout.addRow(QLabel(f'<b>{fig}</b>', self))\n        current_Location_label = QLabel(\n            CUSTOM_COLUMN_DEFAULTS[custom_col_name]['config_label'], self)\n        current_Location_label.setToolTip(\n            CUSTOM_COLUMN_DEFAULTS[custom_col_name]['config_tool_tip'])\n        create_column_callback = partial(\n            self.create_custom_column, custom_col_name) if SUPPORTS_CREATE_CUSTOM_COLUMN else None\n        avail_columns = self.sync_custom_columns[custom_col_name]['current_columns']\n        custom_column_combo = CustomColumnComboBox(\n            self, avail_columns, create_column_callback=create_column_callback)\n        custom_column_combo.setMinimumWidth(min_width)\n        current_Location_label.setBuddy(custom_column_combo)\n        columns_group_box_layout.addRow(\n            current_Location_label, custom_column_combo)\n        self.sync_custom_columns[custom_col_name]['combo_box'] = custom_column_combo\n        return custom_column_combo\n\n    def create_custom_column(self, lookup_name=None):\n        if not lookup_name or lookup_name not in CUSTOM_COLUMN_DEFAULTS:\n            return False\n\n        column_meta = CUSTOM_COLUMN_DEFAULTS[lookup_name]\n        display_params = {\n            'description': column_meta['description'],\n            **column_meta.get('additional_params', {})\n        }\n        datatype = column_meta['datatype']\n        column_heading = column_meta['column_heading']\n        is_multiple = column_meta.get('is_multiple', (False, False))\n\n        # Get the create column instance\n        create_new_custom_column_instance = self.get_create_new_custom_column_instance\n        if not create_new_custom_column_instance:\n            return False\n\n        result = create_new_custom_column_instance.create_column(\n            column_meta['default_lookup_name'], column_heading, datatype, is_multiple[0], display=display_params, generate_unused_lookup_name=True, freeze_lookup_name=False)\n        if result and result[0] == CreateNewCustomColumn.Result.COLUMN_ADDED:\n            self.sync_custom_columns[lookup_name]['current_columns'][result[1]] = {\n                'name': column_heading}\n            self.sync_custom_columns[lookup_name]['combo_box'].populate_combo(\n                self.sync_custom_columns[lookup_name]['current_columns'],\n                result[1]\n            )\n            self.must_restart = True\n            return True\n        return False\n\n    @property\n    def get_create_new_custom_column_instance(self):\n        if self._get_create_new_custom_column_instance is None and SUPPORTS_CREATE_CUSTOM_COLUMN:\n            self._get_create_new_custom_column_instance = CreateNewCustomColumn(\n                self.action.gui)\n        return self._get_create_new_custom_column_instance\n\n    def get_custom_columns(self, datatype, only_is_multiple=False):\n        if SUPPORTS_CREATE_CUSTOM_COLUMN:\n            custom_columns = self.get_create_new_custom_column_instance.current_columns()\n        else:\n            custom_columns = self.action.gui.library_view.model().custom_columns\n        available_columns = {}\n        for key, column in custom_columns.items():\n            typ = column['datatype']\n            if typ == datatype:\n                available_columns[key] = column\n        if datatype == 'rating':  # Add rating column if requested\n            ratings_column_name = self.action.gui.library_view.model(\n            ).orig_headers['rating']\n            available_columns['rating'] = {'name': ratings_column_name}\n        if only_is_multiple:  # If user requests only is_multiple columns check and filter\n            available_columns = {\n                key: column for key, column in available_columns.items()\n                if column.get('is_multiple', False) != {}\n            }\n        return available_columns\n\n\nclass ProgressSyncPopup(QDialog):\n    def __init__(self, parent):\n        QDialog.__init__(self, parent)\n        self.setWindowTitle('Add ProgressSync Account')\n        self.setGeometry(100, 100, 400, 200)\n\n        layout = QVBoxLayout()\n        self.setLayout(layout)\n\n        self.url_label = QLabel('ProgressSync Server URL:', self)\n        self.url_input = QLineEdit(self)\n        self.url_input.setText(CONFIG['progress_sync_url'])\n        layout.addWidget(self.url_label)\n        layout.addWidget(self.url_input)\n\n        self.username_label = QLabel('Username:', self)\n        self.username_input = QLineEdit(self)\n        self.username_input.setText(CONFIG['progress_sync_username'])\n        layout.addWidget(self.username_label)\n        layout.addWidget(self.username_input)\n\n        self.password_label = QLabel('Password:', self)\n        self.password_input = QLineEdit(self)\n        self.password_input.setEchoMode(QLineEdit.Password)\n        layout.addWidget(self.password_label)\n        layout.addWidget(self.password_input)\n\n        self.note_label = QLabel(\n            'Enter any custom server or leave the default filled in.\\n'\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'\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'\n            'You must have a percent read (int or float) and status text column.',\n            self\n        )\n        self.note_label.setWordWrap(True)\n        layout.addWidget(self.note_label)\n\n        self.login_button = QPushButton('Log In', self)\n        self.login_button.clicked.connect(self.save_progress_sync_settings)\n        layout.addWidget(self.login_button)\n\n    def save_progress_sync_settings(self):\n        CONFIG['progress_sync_url'] = self.url_input.text()\n        CONFIG['progress_sync_username'] = self.username_input.text()\n        CONFIG['progress_sync_password'] = self.hash_password(\n            self.password_input.text())\n        self.accept()\n\n    def hash_password(self, password):\n        import hashlib\n        return hashlib.md5(password.encode()).hexdigest()\n\n\nclass TitleLayout(QHBoxLayout):\n    \"\"\"A sub-layout to the main layout used in ConfigWidget that contains an\n    icon and title.\n    \"\"\"\n\n    def __init__(self, parent, icon, title):\n        QHBoxLayout.__init__(self)\n\n        # Add icon\n        icon_label = QLabel(parent)\n        pixmap = QPixmap()\n        pixmap.loadFromData(get_resources(icon))\n        icon_label.setPixmap(pixmap)\n        icon_label.setMaximumSize(64, 64)\n        icon_label.setScaledContents(True)\n        self.addWidget(icon_label)\n\n        # Add title\n        title_label = QLabel(f'<h2>{title}</h2>', parent)\n        title_label.setContentsMargins(10, 0, 10, 0)\n        self.addWidget(title_label)\n\n        # Add empty space\n        self.addStretch()\n\n        # Add Readme hyperlink\n        readme_label = QLabel('<a href=\"#\">Readme</a>', parent)\n        readme_label.setTextInteractionFlags(\n            Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard)\n        readme_label.linkActivated.connect(parent.action.show_readme)\n        self.addWidget(readme_label)\n\n        # Add About hyperlink\n        about_label = QLabel('<a href=\"#\">About</a>', parent)\n        about_label.setTextInteractionFlags(\n            Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard)\n        about_label.linkActivated.connect(parent.action.show_about)\n        self.addWidget(about_label)\n\n\nclass CustomColumnComboBox(QComboBox):\n    def __init__(self, parent, custom_columns=None, selected_column='', create_column_callback=None):\n        super().__init__(parent)\n        if custom_columns is None:\n            custom_columns = {}\n        self.create_column_callback = create_column_callback\n        if create_column_callback is not None:\n            self.currentTextChanged.connect(self.current_text_changed)\n        self.populate_combo(custom_columns, selected_column)\n\n    def populate_combo(self, custom_columns, selected_column, show_lookup_name=True):\n        self.blockSignals(True)\n        self.clear()\n        self.column_names = []\n\n        if self.create_column_callback is not None:\n            self.column_names.append('Create new column')\n            self.addItem('Create new column')\n\n        self.column_names.append('do not sync')\n        self.addItem('do not sync')\n        selected_idx = 1\n\n        for key in sorted(custom_columns.keys()):\n            self.column_names.append(key)\n            display_name = '%s (%s)' % (\n                key, custom_columns[key]['name']) if show_lookup_name else custom_columns[key]['name']\n            self.addItem(display_name)\n            if key == selected_column:\n                selected_idx = len(self.column_names) - 1\n\n        self.setCurrentIndex(selected_idx)\n        self.current_index = selected_idx\n        self.blockSignals(False)\n\n    def get_selected_column(self):\n        selected_column = self.column_names[self.currentIndex()]\n        if selected_column == 'Create new column' or selected_column == 'do not sync':\n            selected_column = ''\n        return selected_column\n\n    def current_text_changed(self, new_text):\n        if new_text == 'Create new column':\n            result = self.create_column_callback()\n            if not result:\n                self.setCurrentIndex(self.current_index)\n        else:\n            self.current_index = self.currentIndex()\n\n    def wheelEvent(self, event):  # Prevents the mouse wheel from changing the selected item\n        event.ignore()\n"
  },
  {
    "path": "dummy_device/.driveinfo.calibre",
    "content": "{\"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/\"}"
  },
  {
    "path": "dummy_device/.metadata.calibre",
    "content": "[\n  {\n    \"application_id\": 4,\n    \"rights\": null,\n    \"lpath\": \"Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.epub\",\n    \"rating\": null,\n    \"tags\": [\n      \"Fantasy fiction\",\n      \"Children's stories\",\n      \"Imaginary places -- Juvenile fiction\",\n      \"Alice (Fictitious character from Carroll) -- Juvenile fiction\"\n    ],\n    \"series_index\": null,\n    \"book_producer\": null,\n    \"publication_type\": null,\n    \"timestamp\": \"2020-11-16T21:46:44+00:00\",\n    \"last_modified\": \"2023-05-22T09:25:57.211107+00:00\",\n    \"title_sort\": \"Alice's Adventures in Wonderland\",\n    \"series\": null,\n    \"identifiers\": {\n      \"uri\": \"http://www.gutenberg.org/11\"\n    },\n    \"languages\": [\n      \"eng\"\n    ],\n    \"publisher\": null,\n    \"size\": 253703,\n    \"title\": \"Alice's Adventures in Wonderland\",\n    \"author_sort\": \"Carroll, Lewis\",\n    \"authors\": [\n      \"Lewis Carroll\"\n    ],\n    \"thumbnail\": [\n      49,\n      68,\n      \"/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=\"\n    ],\n    \"link_maps\": {},\n    \"comments\": null,\n    \"author_sort_map\": {\n      \"Lewis Carroll\": \"Carroll, Lewis\"\n    },\n    \"uuid\": \"43bd8264-96fa-461a-a05e-1d1cb245d34f\",\n    \"user_categories\": {},\n    \"db_id\": null,\n    \"user_metadata\": {\n      \"#formats\": {\n        \"table\": \"custom_column_1\",\n        \"column\": \"value\",\n        \"datatype\": \"composite\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Formats\",\n        \"search_terms\": [\n          \"#formats\"\n        ],\n        \"label\": \"formats\",\n        \"colnum\": 1,\n        \"display\": {\n          \"contains_html\": false,\n          \"make_category\": false,\n          \"composite_sort\": \"text\",\n          \"use_decorations\": 0,\n          \"composite_template\": \"{:'re(approximate_formats(), ',', ', ')'}\",\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 22,\n        \"#value#\": \"EPUB\",\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#gr_rating\": {\n        \"table\": \"custom_column_2\",\n        \"column\": \"value\",\n        \"datatype\": \"rating\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Goodreads Rating\",\n        \"search_terms\": [\n          \"#gr_rating\"\n        ],\n        \"label\": \"gr_rating\",\n        \"colnum\": 2,\n        \"display\": {\n          \"description\": \"\",\n          \"allow_half_stars\": false\n        },\n        \"is_custom\": true,\n        \"is_category\": true,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 23,\n        \"#value#\": null,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#gr_review\": {\n        \"table\": \"custom_column_3\",\n        \"column\": \"value\",\n        \"datatype\": \"comments\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Goodreads Review\",\n        \"search_terms\": [\n          \"#gr_review\"\n        ],\n        \"label\": \"gr_review\",\n        \"colnum\": 3,\n        \"display\": {\n          \"heading_position\": \"hide\",\n          \"interpret_as\": \"long-text\",\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 24,\n        \"#value#\": null,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#gr_shelf\": {\n        \"table\": \"custom_column_4\",\n        \"column\": \"value\",\n        \"datatype\": \"text\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Goodreads Shelf\",\n        \"search_terms\": [\n          \"#gr_shelf\"\n        ],\n        \"label\": \"gr_shelf\",\n        \"colnum\": 4,\n        \"display\": {\n          \"use_decorations\": 0,\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": true,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 25,\n        \"#value#\": null,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#ko_annotations\": {\n        \"table\": \"custom_column_13\",\n        \"column\": \"value\",\n        \"datatype\": \"comments\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"KOReader Annotations\",\n        \"search_terms\": [\n          \"#ko_annotations\"\n        ],\n        \"label\": \"ko_annotations\",\n        \"colnum\": 13,\n        \"display\": {\n          \"heading_position\": \"above\",\n          \"interpret_as\": \"markdown\",\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 26,\n        \"#value#\": \"- in CHAPTER VI. Pig and Pepper <!-- datetime: 2020-11-16 18:52:11, page: /body/DocFragment[8]/body/div/h2/text()[1].0, chapter: CHAPTER VI. Pig and Pepper -->\\n- \\u201cYou!\\u201d said the Caterpillar contemptuously. \\u201cWho are you?\\u201d <!-- text: Higlight with note: Page 48 \\u201cYou!\\u201d said the Caterpillar contemptuously. \\u201cWho are you?\\u201d @ 2020-11-16 18:51:04, page: /body/DocFragment[7]/body/div/p[12]/text()[1].0, chapter: CHAPTER V. Advice from a Caterpillar, highlighted: True, datetime: 2020-11-16 18:51:04, pos0: /body/DocFragment[7]/body/div/p[12]/text()[1].0, pos1: /body/DocFragment[7]/body/div/p[12]/text()[2].1 -->\\n- CHAPTER V. Advice from a Caterpillar <!-- page: /body/DocFragment[7]/body/div/h2/text()[1].0, chapter: CHAPTER V. Advice from a Caterpillar, highlighted: True, datetime: 2020-11-16 18:50:53, pos0: /body/DocFragment[7]/body/div/h2/text()[1].0, pos1: /body/DocFragment[7]/body/div/h2/text()[2].26 -->\",\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#ko_md5\": {\n        \"table\": \"custom_column_5\",\n        \"column\": \"value\",\n        \"datatype\": \"text\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"KOReader Sync Server MD5 Hash\",\n        \"search_terms\": [\n          \"#ko_md5\"\n        ],\n        \"label\": \"ko_md5\",\n        \"colnum\": 5,\n        \"display\": {\n          \"use_decorations\": 0,\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": true,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 27,\n        \"#value#\": \"9c928bc227e2011e85f931ca159ff710\",\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#ko_mod\": {\n        \"table\": \"custom_column_20\",\n        \"column\": \"value\",\n        \"datatype\": \"datetime\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"KOReader Last Modified\",\n        \"search_terms\": [\n          \"#ko_mod\"\n        ],\n        \"label\": \"ko_mod\",\n        \"colnum\": 20,\n        \"display\": {\n          \"date_format\": null,\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 28,\n        \"#value#\": \"2021-11-22T06:47:26+00:00\",\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#ko_sidecar\": {\n        \"table\": \"custom_column_6\",\n        \"column\": \"value\",\n        \"datatype\": \"comments\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"KOReader Sidecar\",\n        \"search_terms\": [\n          \"#ko_sidecar\"\n        ],\n        \"label\": \"ko_sidecar\",\n        \"colnum\": 6,\n        \"display\": {\n          \"heading_position\": \"above\",\n          \"interpret_as\": \"long-text\",\n          \"description\": \"The entire dict from KOReader\\u2019s metadata.epub.lua\"\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 29,\n        \"#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}\",\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#ko_sync\": {\n        \"table\": \"custom_column_19\",\n        \"column\": \"value\",\n        \"datatype\": \"datetime\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"KOReader Last Sync\",\n        \"search_terms\": [\n          \"#ko_sync\"\n        ],\n        \"label\": \"ko_sync\",\n        \"colnum\": 19,\n        \"display\": {\n          \"date_format\": null,\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 30,\n        \"#value#\": \"2023-05-22T08:56:46+00:00\",\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#mm_annotations\": {\n        \"table\": \"custom_column_7\",\n        \"column\": \"value\",\n        \"datatype\": \"comments\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Kobo Annotations\",\n        \"search_terms\": [\n          \"#mm_annotations\"\n        ],\n        \"label\": \"mm_annotations\",\n        \"colnum\": 7,\n        \"display\": {\n          \"heading_position\": \"above\",\n          \"interpret_as\": \"html\",\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 31,\n        \"#value#\": null,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#read_first\": {\n        \"table\": \"custom_column_8\",\n        \"column\": \"value\",\n        \"datatype\": \"datetime\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Read First\",\n        \"search_terms\": [\n          \"#read_first\"\n        ],\n        \"label\": \"read_first\",\n        \"colnum\": 8,\n        \"display\": {\n          \"description\": \"The date on which I added this book to my Goodreads Currently Reading shelf\",\n          \"date_format\": null\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 32,\n        \"#value#\": \"2020-11-16T18:50:53+00:00\",\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#read_last\": {\n        \"table\": \"custom_column_9\",\n        \"column\": \"value\",\n        \"datatype\": \"datetime\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Read Last\",\n        \"search_terms\": [\n          \"#read_last\"\n        ],\n        \"label\": \"read_last\",\n        \"colnum\": 9,\n        \"display\": {\n          \"description\": \"The date on which I last opened this book on my e-reader\",\n          \"date_format\": null\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 33,\n        \"#value#\": \"2020-11-16T18:52:11+00:00\",\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#read_location\": {\n        \"table\": \"custom_column_10\",\n        \"column\": \"value\",\n        \"datatype\": \"text\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Read Location\",\n        \"search_terms\": [\n          \"#read_location\"\n        ],\n        \"label\": \"read_location\",\n        \"colnum\": 10,\n        \"display\": {\n          \"description\": \"\",\n          \"use_decorations\": 0\n        },\n        \"is_custom\": true,\n        \"is_category\": true,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 34,\n        \"#value#\": \"/body/DocFragment[9]/body/div/h2/text()[1].0\",\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#read_progress\": {\n        \"table\": \"custom_column_11\",\n        \"column\": \"value\",\n        \"datatype\": \"float\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Read Progress\",\n        \"search_terms\": [\n          \"#read_progress\"\n        ],\n        \"label\": \"read_progress\",\n        \"colnum\": 11,\n        \"display\": {\n          \"description\": \"\",\n          \"number_format\": \"{:.0%}\",\n          \"decimals\": 2\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 35,\n        \"#value#\": 0.45512820512821,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#read_progress_int\": {\n        \"table\": \"custom_column_18\",\n        \"column\": \"value\",\n        \"datatype\": \"int\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Read Progress (int)\",\n        \"search_terms\": [\n          \"#read_progress_int\"\n        ],\n        \"label\": \"read_progress_int\",\n        \"colnum\": 18,\n        \"display\": {\n          \"number_format\": null,\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 36,\n        \"#value#\": 45,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#read_status\": {\n        \"table\": \"custom_column_12\",\n        \"column\": \"value\",\n        \"datatype\": \"composite\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Read Status\",\n        \"search_terms\": [\n          \"#read_status\"\n        ],\n        \"label\": \"read_status\",\n        \"colnum\": 12,\n        \"display\": {\n          \"use_decorations\": 0,\n          \"contains_html\": false,\n          \"description\": \"\",\n          \"composite_template\": \"{#read_progress:get_read_status()}\",\n          \"composite_sort\": \"text\",\n          \"make_category\": true\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 37,\n        \"#value#\": \"\\u2610\",\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#read_status_bool\": {\n        \"table\": \"custom_column_17\",\n        \"column\": \"value\",\n        \"datatype\": \"bool\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Read Status (yes/no)\",\n        \"search_terms\": [\n          \"#read_status_bool\"\n        ],\n        \"label\": \"read_status_bool\",\n        \"colnum\": 17,\n        \"display\": {\n          \"bools_show_text\": false,\n          \"bools_show_icons\": true,\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 38,\n        \"#value#\": false,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#read_status_text\": {\n        \"table\": \"custom_column_16\",\n        \"column\": \"value\",\n        \"datatype\": \"text\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Read Status (text)\",\n        \"search_terms\": [\n          \"#read_status_text\"\n        ],\n        \"label\": \"read_status_text\",\n        \"colnum\": 16,\n        \"display\": {\n          \"use_decorations\": false,\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": true,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 39,\n        \"#value#\": \"reading\",\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      }\n    },\n    \"cover\": null,\n    \"mime\": null,\n    \"pubdate\": \"2008-06-27T04:00:00+00:00\"\n  },\n  {\n    \"application_id\": 3,\n    \"rights\": null,\n    \"lpath\": \"Thoreau, Henry David/Walden, and On The Duty Of Civil Disobedience - Henry David Thoreau.epub\",\n    \"rating\": null,\n    \"tags\": [\n      \"Civil disobedience\",\n      \"Authors\",\n      \"American -- 19th century -- Biography\",\n      \"Thoreau\",\n      \"Henry David\",\n      \"1817-1862 -- Homes and haunts -- Massachusetts -- Walden Woods\",\n      \"Wilderness areas -- Massachusetts -- Walden Woods\",\n      \"Natural history -- Massachusetts -- Walden Woods\",\n      \"Solitude\",\n      \"Government\",\n      \"Resistance to\",\n      \"Walden Woods (Mass.) -- Social life and customs\"\n    ],\n    \"series_index\": null,\n    \"book_producer\": null,\n    \"publication_type\": null,\n    \"timestamp\": \"2020-11-16T21:45:41+00:00\",\n    \"last_modified\": \"2023-05-22T09:25:57.211107+00:00\",\n    \"title_sort\": \"Walden, and On The Duty Of Civil Disobedience\",\n    \"series\": null,\n    \"identifiers\": {\n      \"uri\": \"http://www.gutenberg.org/205\"\n    },\n    \"languages\": [\n      \"eng\"\n    ],\n    \"publisher\": null,\n    \"size\": 612096,\n    \"title\": \"Walden, and On The Duty Of Civil Disobedience\",\n    \"author_sort\": \"Thoreau, Henry David\",\n    \"authors\": [\n      \"Henry David Thoreau\"\n    ],\n    \"thumbnail\": [\n      47,\n      68,\n      \"/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\"\n    ],\n    \"link_maps\": {},\n    \"comments\": null,\n    \"author_sort_map\": {\n      \"Henry David Thoreau\": \"Thoreau, Henry David\"\n    },\n    \"uuid\": \"3393747a-f0d8-44e1-bfaf-5fad857da3eb\",\n    \"user_categories\": {},\n    \"db_id\": null,\n    \"user_metadata\": {\n      \"#formats\": {\n        \"table\": \"custom_column_1\",\n        \"column\": \"value\",\n        \"datatype\": \"composite\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Formats\",\n        \"search_terms\": [\n          \"#formats\"\n        ],\n        \"label\": \"formats\",\n        \"colnum\": 1,\n        \"display\": {\n          \"contains_html\": false,\n          \"make_category\": false,\n          \"composite_sort\": \"text\",\n          \"use_decorations\": 0,\n          \"composite_template\": \"{:'re(approximate_formats(), ',', ', ')'}\",\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 22,\n        \"#value#\": \"EPUB\",\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#gr_rating\": {\n        \"table\": \"custom_column_2\",\n        \"column\": \"value\",\n        \"datatype\": \"rating\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Goodreads Rating\",\n        \"search_terms\": [\n          \"#gr_rating\"\n        ],\n        \"label\": \"gr_rating\",\n        \"colnum\": 2,\n        \"display\": {\n          \"description\": \"\",\n          \"allow_half_stars\": false\n        },\n        \"is_custom\": true,\n        \"is_category\": true,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 23,\n        \"#value#\": null,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#gr_review\": {\n        \"table\": \"custom_column_3\",\n        \"column\": \"value\",\n        \"datatype\": \"comments\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Goodreads Review\",\n        \"search_terms\": [\n          \"#gr_review\"\n        ],\n        \"label\": \"gr_review\",\n        \"colnum\": 3,\n        \"display\": {\n          \"heading_position\": \"hide\",\n          \"interpret_as\": \"long-text\",\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 24,\n        \"#value#\": null,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#gr_shelf\": {\n        \"table\": \"custom_column_4\",\n        \"column\": \"value\",\n        \"datatype\": \"text\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Goodreads Shelf\",\n        \"search_terms\": [\n          \"#gr_shelf\"\n        ],\n        \"label\": \"gr_shelf\",\n        \"colnum\": 4,\n        \"display\": {\n          \"use_decorations\": 0,\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": true,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 25,\n        \"#value#\": null,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#ko_annotations\": {\n        \"table\": \"custom_column_13\",\n        \"column\": \"value\",\n        \"datatype\": \"comments\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"KOReader Annotations\",\n        \"search_terms\": [\n          \"#ko_annotations\"\n        ],\n        \"label\": \"ko_annotations\",\n        \"colnum\": 13,\n        \"display\": {\n          \"heading_position\": \"above\",\n          \"interpret_as\": \"markdown\",\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 26,\n        \"#value#\": null,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#ko_md5\": {\n        \"table\": \"custom_column_5\",\n        \"column\": \"value\",\n        \"datatype\": \"text\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"KOReader Sync Server MD5 Hash\",\n        \"search_terms\": [\n          \"#ko_md5\"\n        ],\n        \"label\": \"ko_md5\",\n        \"colnum\": 5,\n        \"display\": {\n          \"use_decorations\": 0,\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": true,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 27,\n        \"#value#\": null,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#ko_mod\": {\n        \"table\": \"custom_column_20\",\n        \"column\": \"value\",\n        \"datatype\": \"datetime\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"KOReader Last Modified\",\n        \"search_terms\": [\n          \"#ko_mod\"\n        ],\n        \"label\": \"ko_mod\",\n        \"colnum\": 20,\n        \"display\": {\n          \"date_format\": null,\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 28,\n        \"#value#\": \"None\",\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#ko_sidecar\": {\n        \"table\": \"custom_column_6\",\n        \"column\": \"value\",\n        \"datatype\": \"comments\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"KOReader Sidecar\",\n        \"search_terms\": [\n          \"#ko_sidecar\"\n        ],\n        \"label\": \"ko_sidecar\",\n        \"colnum\": 6,\n        \"display\": {\n          \"heading_position\": \"above\",\n          \"interpret_as\": \"long-text\",\n          \"description\": \"The entire dict from KOReader\\u2019s metadata.epub.lua\"\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 29,\n        \"#value#\": null,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#ko_sync\": {\n        \"table\": \"custom_column_19\",\n        \"column\": \"value\",\n        \"datatype\": \"datetime\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"KOReader Last Sync\",\n        \"search_terms\": [\n          \"#ko_sync\"\n        ],\n        \"label\": \"ko_sync\",\n        \"colnum\": 19,\n        \"display\": {\n          \"date_format\": null,\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 30,\n        \"#value#\": \"None\",\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#mm_annotations\": {\n        \"table\": \"custom_column_7\",\n        \"column\": \"value\",\n        \"datatype\": \"comments\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Kobo Annotations\",\n        \"search_terms\": [\n          \"#mm_annotations\"\n        ],\n        \"label\": \"mm_annotations\",\n        \"colnum\": 7,\n        \"display\": {\n          \"heading_position\": \"above\",\n          \"interpret_as\": \"html\",\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 31,\n        \"#value#\": null,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#read_first\": {\n        \"table\": \"custom_column_8\",\n        \"column\": \"value\",\n        \"datatype\": \"datetime\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Read First\",\n        \"search_terms\": [\n          \"#read_first\"\n        ],\n        \"label\": \"read_first\",\n        \"colnum\": 8,\n        \"display\": {\n          \"description\": \"The date on which I added this book to my Goodreads Currently Reading shelf\",\n          \"date_format\": null\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 32,\n        \"#value#\": \"None\",\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#read_last\": {\n        \"table\": \"custom_column_9\",\n        \"column\": \"value\",\n        \"datatype\": \"datetime\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Read Last\",\n        \"search_terms\": [\n          \"#read_last\"\n        ],\n        \"label\": \"read_last\",\n        \"colnum\": 9,\n        \"display\": {\n          \"description\": \"The date on which I last opened this book on my e-reader\",\n          \"date_format\": null\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 33,\n        \"#value#\": \"None\",\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#read_location\": {\n        \"table\": \"custom_column_10\",\n        \"column\": \"value\",\n        \"datatype\": \"text\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Read Location\",\n        \"search_terms\": [\n          \"#read_location\"\n        ],\n        \"label\": \"read_location\",\n        \"colnum\": 10,\n        \"display\": {\n          \"description\": \"\",\n          \"use_decorations\": 0\n        },\n        \"is_custom\": true,\n        \"is_category\": true,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 34,\n        \"#value#\": null,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#read_progress\": {\n        \"table\": \"custom_column_11\",\n        \"column\": \"value\",\n        \"datatype\": \"float\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Read Progress\",\n        \"search_terms\": [\n          \"#read_progress\"\n        ],\n        \"label\": \"read_progress\",\n        \"colnum\": 11,\n        \"display\": {\n          \"description\": \"\",\n          \"number_format\": \"{:.0%}\",\n          \"decimals\": 2\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 35,\n        \"#value#\": null,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#read_progress_int\": {\n        \"table\": \"custom_column_18\",\n        \"column\": \"value\",\n        \"datatype\": \"int\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Read Progress (int)\",\n        \"search_terms\": [\n          \"#read_progress_int\"\n        ],\n        \"label\": \"read_progress_int\",\n        \"colnum\": 18,\n        \"display\": {\n          \"number_format\": null,\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 36,\n        \"#value#\": null,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#read_status\": {\n        \"table\": \"custom_column_12\",\n        \"column\": \"value\",\n        \"datatype\": \"composite\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Read Status\",\n        \"search_terms\": [\n          \"#read_status\"\n        ],\n        \"label\": \"read_status\",\n        \"colnum\": 12,\n        \"display\": {\n          \"use_decorations\": 0,\n          \"contains_html\": false,\n          \"description\": \"\",\n          \"composite_template\": \"{#read_progress:get_read_status()}\",\n          \"composite_sort\": \"text\",\n          \"make_category\": true\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 37,\n        \"#value#\": \"\",\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#read_status_bool\": {\n        \"table\": \"custom_column_17\",\n        \"column\": \"value\",\n        \"datatype\": \"bool\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Read Status (yes/no)\",\n        \"search_terms\": [\n          \"#read_status_bool\"\n        ],\n        \"label\": \"read_status_bool\",\n        \"colnum\": 17,\n        \"display\": {\n          \"bools_show_text\": false,\n          \"bools_show_icons\": true,\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": false,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 38,\n        \"#value#\": null,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      },\n      \"#read_status_text\": {\n        \"table\": \"custom_column_16\",\n        \"column\": \"value\",\n        \"datatype\": \"text\",\n        \"is_multiple\": null,\n        \"kind\": \"field\",\n        \"name\": \"Read Status (text)\",\n        \"search_terms\": [\n          \"#read_status_text\"\n        ],\n        \"label\": \"read_status_text\",\n        \"colnum\": 16,\n        \"display\": {\n          \"use_decorations\": false,\n          \"description\": \"\"\n        },\n        \"is_custom\": true,\n        \"is_category\": true,\n        \"link_column\": \"value\",\n        \"category_sort\": \"value\",\n        \"is_csp\": false,\n        \"is_editable\": true,\n        \"rec_index\": 39,\n        \"#value#\": null,\n        \"#extra#\": null,\n        \"is_multiple2\": {}\n      }\n    },\n    \"cover\": null,\n    \"mime\": null,\n    \"pubdate\": \"1995-01-01T03:00:00+00:00\"\n  }\n]"
  },
  {
    "path": "dummy_device/Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.sdr/metadata.epub.lua",
    "content": "-- we can read Lua syntax here!\nreturn {\n    [\"highlights_imported\"] = true,\n    [\"render_mode\"] = 0,\n    [\"embedded_css\"] = true,\n    [\"disable_fuzzy_search\"] = false,\n    [\"embedded_fonts\"] = true,\n    [\"copt_block_rendering_mode\"] = 3,\n    [\"smooth_scaling\"] = false,\n    [\"page_overlap_style\"] = \"dim\",\n    [\"nightmode_images\"] = true,\n    [\"cre_dom_version\"] = 20200824,\n    [\"last_xpointer\"] = \"/body/DocFragment[9]/body/div/h2/text()[1].0\",\n    [\"highlight_disabled\"] = false,\n    [\"bookmarks\"] = {\n        [1] = {\n            [\"datetime\"] = \"2020-11-16 18:52:11\",\n            [\"page\"] = \"/body/DocFragment[8]/body/div/h2/text()[1].0\",\n            [\"chapter\"] = \"CHAPTER VI. Pig and Pepper\",\n            [\"notes\"] = \"in CHAPTER VI. Pig and Pepper\"\n        },\n        [2] = {\n            [\"text\"] = \"Higlight with note: Page 48 “You!” said the Caterpillar contemptuously. “Who are you?” @ 2020-11-16 18:51:04\",\n            [\"page\"] = \"/body/DocFragment[7]/body/div/p[12]/text()[1].0\",\n            [\"chapter\"] = \"CHAPTER V. Advice from a Caterpillar\",\n            [\"highlighted\"] = true,\n            [\"datetime\"] = \"2020-11-16 18:51:04\",\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            [\"notes\"] = \"“You!” said the Caterpillar contemptuously. “Who are you?”\"\n        },\n        [3] = {\n            [\"page\"] = \"/body/DocFragment[7]/body/div/h2/text()[1].0\",\n            [\"chapter\"] = \"CHAPTER V. Advice from a Caterpillar\",\n            [\"highlighted\"] = true,\n            [\"datetime\"] = \"2020-11-16 18:50:53\",\n            [\"pos0\"] = \"/body/DocFragment[7]/body/div/h2/text()[1].0\",\n            [\"pos1\"] = \"/body/DocFragment[7]/body/div/h2/text()[2].26\",\n            [\"notes\"] = \"CHAPTER V. Advice from a Caterpillar\"\n        }\n    },\n    [\"inverse_reading_order\"] = false,\n    [\"line_space_percent\"] = 100,\n    [\"css\"] = \"./data/epub.css\",\n    [\"percent_finished\"] = 0.45512820512821,\n    [\"rotation_mode\"] = 0,\n    [\"partial_md5_checksum\"] = \"9c928bc227e2011e85f931ca159ff710\",\n    [\"font_size\"] = 22,\n    [\"copt_embedded_fonts\"] = 1,\n    [\"copt_embedded_css\"] = 1,\n    [\"copt_rotation_mode\"] = 0,\n    [\"copt_render_dpi\"] = 96,\n    [\"copt_sync_t_b_page_margins\"] = 0,\n    [\"copt_h_page_margins\"] = {\n        [1] = 10,\n        [2] = 10\n    },\n    [\"copt_t_page_margin\"] = 15,\n    [\"copt_smooth_scaling\"] = 0,\n    [\"copt_word_spacing\"] = {\n        [1] = 95,\n        [2] = 75\n    },\n    [\"copt_nightmode_images\"] = 1,\n    [\"word_spacing\"] = {\n        [1] = 95,\n        [2] = 75\n    },\n    [\"header_font_face\"] = \"Noto Sans\",\n    [\"copt_font_size\"] = 22,\n    [\"font_embolden\"] = 0,\n    [\"text_lang\"] = \"en-US\",\n    [\"copt_font_hinting\"] = 2,\n    [\"copt_font_kerning\"] = 3,\n    [\"copt_word_expansion\"] = 0,\n    [\"copt_line_spacing\"] = 100,\n    [\"copt_font_gamma\"] = 15,\n    [\"gamma\"] = 1,\n    [\"render_dpi\"] = 96,\n    [\"font_face\"] = \"Noto Serif\",\n    [\"visible_pages\"] = 1,\n    [\"show_overlap_enable\"] = false,\n    [\"copt_view_mode\"] = 0,\n    [\"highlight\"] = {\n        [47] = {\n            [1] = {\n                [\"drawer\"] = \"lighten\",\n                [\"chapter\"] = \"CHAPTER V. Advice from a Caterpillar\",\n                [\"datetime\"] = \"2020-11-16 18:50:53\",\n                [\"pos0\"] = \"/body/DocFragment[7]/body/div/h2/text()[1].0\",\n                [\"text\"] = \"CHAPTER V. Advice from a Caterpillar\",\n                [\"pos1\"] = \"/body/DocFragment[7]/body/div/h2/text()[2].26\"\n            }\n        },\n        [48] = {\n            [1] = {\n                [\"drawer\"] = \"lighten\",\n                [\"chapter\"] = \"CHAPTER V. Advice from a Caterpillar\",\n                [\"datetime\"] = \"2020-11-16 18:51:04\",\n                [\"pos0\"] = \"/body/DocFragment[7]/body/div/p[12]/text()[1].0\",\n                [\"text\"] = \"“You!” said the Caterpillar contemptuously. “Who are you?”\",\n                [\"pos1\"] = \"/body/DocFragment[7]/body/div/p[12]/text()[2].1\"\n            }\n        }\n    },\n    [\"copt_status_line\"] = 1,\n    [\"copt_b_page_margin\"] = 15,\n    [\"readermenu_tab_index\"] = 4,\n    [\"copt_font_weight\"] = 0,\n    [\"bookmarks_sorted\"] = true,\n    [\"stats\"] = {\n        [\"authors\"] = \"Lewis Carroll\",\n        [\"series\"] = \"\",\n        [\"title\"] = \"Alice's Adventures in Wonderland\",\n        [\"highlights\"] = 2,\n        [\"notes\"] = 0,\n        [\"md5\"] = \"9c928bc227e2011e85f931ca159ff710\",\n        [\"language\"] = \"en\",\n        [\"pages\"] = 156\n    },\n    [\"hyph_soft_hyphens_only\"] = false,\n    [\"font_hinting\"] = 2,\n    [\"doc_pages\"] = 156,\n    [\"copt_visible_pages\"] = 1,\n    [\"text_lang_embedded_langs\"] = true,\n    [\"config_panel_index\"] = 1,\n    [\"gamma_index\"] = 15,\n    [\"font_kerning\"] = 3,\n    [\"hyphenation\"] = true,\n    [\"hyph_trust_soft_hyphens\"] = false,\n    [\"doc_props\"] = {\n        [\"authors\"] = \"Lewis Carroll\",\n        [\"series\"] = \"\",\n        [\"title\"] = \"Alice's Adventures in Wonderland\",\n        [\"description\"] = \"\",\n        [\"language\"] = \"en\",\n        [\"keywords\"] = \"Fantasy fiction\\\nChildren's stories\\\nImaginary places\n-- Juvenile fiction\\\nAlice (Fictitious character from Carroll) -- Juvenile fiction\"\n    },\n    [\"hyph_force_algorithmic\"] = false,\n    [\"floating_punctuation\"] = 0,\n    [\"word_expansion\"] = 0,\n    [\"highlight_drawer\"] = \"lighten\"\n}\n"
  },
  {
    "path": "dummy_device/Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.sdr/metadata.epub.lua.old",
    "content": "-- we can read Lua syntax here!\nreturn {\n    [\"partial_md5_checksum\"] = \"9c928bc227e2011e85f931ca159ff710\"\n}\n"
  },
  {
    "path": "dummy_library/Henry David Thoreau/Walden, and On The Duty Of Civil Disobedience (3)/metadata.opf",
    "content": "<?xml version='1.0' encoding='utf-8'?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" unique-identifier=\"uuid_id\" version=\"2.0\">\n    <metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:opf=\"http://www.idpf.org/2007/opf\">\n        <dc:identifier opf:scheme=\"calibre\" id=\"calibre_id\">3</dc:identifier>\n        <dc:identifier opf:scheme=\"uuid\" id=\"uuid_id\">3393747a-f0d8-44e1-bfaf-5fad857da3eb</dc:identifier>\n        <dc:title>Walden, and On The Duty Of Civil Disobedience</dc:title>\n        <dc:creator opf:file-as=\"Thoreau, Henry David\" opf:role=\"aut\">Henry David Thoreau</dc:creator>\n        <dc:contributor opf:file-as=\"calibre\" opf:role=\"bkp\">calibre (5.5.0) [https://calibre-ebook.com]</dc:contributor>\n        <dc:date>1995-01-01T03:00:00+00:00</dc:date>\n        <dc:identifier opf:scheme=\"URI\">http://www.gutenberg.org/205</dc:identifier>\n        <dc:language>eng</dc:language>\n        <dc:subject>Civil disobedience</dc:subject>\n        <dc:subject>Authors</dc:subject>\n        <dc:subject>American -- 19th century -- Biography</dc:subject>\n        <dc:subject>Thoreau</dc:subject>\n        <dc:subject>Henry David</dc:subject>\n        <dc:subject>1817-1862 -- Homes and haunts -- Massachusetts -- Walden Woods</dc:subject>\n        <dc:subject>Wilderness areas -- Massachusetts -- Walden Woods</dc:subject>\n        <dc:subject>Natural history -- Massachusetts -- Walden Woods</dc:subject>\n        <dc:subject>Solitude</dc:subject>\n        <dc:subject>Government</dc:subject>\n        <dc:subject>Resistance to</dc:subject>\n        <dc:subject>Walden Woods (Mass.) -- Social life and customs</dc:subject>\n        <meta name=\"calibre:author_link_map\" content=\"{&quot;Henry David Thoreau&quot;: &quot;&quot;}\"/>\n        <meta name=\"calibre:timestamp\" content=\"2020-11-16T21:45:41.447982+00:00\"/>\n        <meta name=\"calibre:title_sort\" content=\"Walden, and On The Duty Of Civil Disobedience\"/>\n        <meta name=\"calibre:user_metadata:#formats\" content=\"{&quot;table&quot;: &quot;custom_column_1&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;composite&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Formats&quot;, &quot;search_terms&quot;: [&quot;#formats&quot;], &quot;label&quot;: &quot;formats&quot;, &quot;colnum&quot;: 1, &quot;display&quot;: {&quot;contains_html&quot;: false, &quot;make_category&quot;: false, &quot;composite_sort&quot;: &quot;text&quot;, &quot;use_decorations&quot;: 0, &quot;composite_template&quot;: &quot;{:'re(approximate_formats(), ',', ', ')'}&quot;, &quot;description&quot;: &quot;&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 22, &quot;#value#&quot;: &quot;EPUB&quot;, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#gr_rating\" content=\"{&quot;table&quot;: &quot;custom_column_2&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;rating&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Goodreads Rating&quot;, &quot;search_terms&quot;: [&quot;#gr_rating&quot;], &quot;label&quot;: &quot;gr_rating&quot;, &quot;colnum&quot;: 2, &quot;display&quot;: {&quot;description&quot;: &quot;&quot;, &quot;allow_half_stars&quot;: false}, &quot;is_custom&quot;: true, &quot;is_category&quot;: true, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 23, &quot;#value#&quot;: null, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#gr_review\" content=\"{&quot;table&quot;: &quot;custom_column_3&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;comments&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Goodreads Review&quot;, &quot;search_terms&quot;: [&quot;#gr_review&quot;], &quot;label&quot;: &quot;gr_review&quot;, &quot;colnum&quot;: 3, &quot;display&quot;: {&quot;heading_position&quot;: &quot;hide&quot;, &quot;interpret_as&quot;: &quot;long-text&quot;, &quot;description&quot;: &quot;&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 24, &quot;#value#&quot;: null, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#gr_shelf\" content=\"{&quot;table&quot;: &quot;custom_column_4&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;text&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Goodreads Shelf&quot;, &quot;search_terms&quot;: [&quot;#gr_shelf&quot;], &quot;label&quot;: &quot;gr_shelf&quot;, &quot;colnum&quot;: 4, &quot;display&quot;: {&quot;use_decorations&quot;: 0, &quot;description&quot;: &quot;&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: true, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 25, &quot;#value#&quot;: null, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#ko_md5\" content=\"{&quot;table&quot;: &quot;custom_column_5&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;text&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;KOReader Sync Server MD5 Hash&quot;, &quot;search_terms&quot;: [&quot;#ko_md5&quot;], &quot;label&quot;: &quot;ko_md5&quot;, &quot;colnum&quot;: 5, &quot;display&quot;: {&quot;use_decorations&quot;: 0, &quot;description&quot;: &quot;&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: true, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 26, &quot;#value#&quot;: null, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#ko_sidecar\" content=\"{&quot;table&quot;: &quot;custom_column_6&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;comments&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;KOReader Sidecar&quot;, &quot;search_terms&quot;: [&quot;#ko_sidecar&quot;], &quot;label&quot;: &quot;ko_sidecar&quot;, &quot;colnum&quot;: 6, &quot;display&quot;: {&quot;heading_position&quot;: &quot;above&quot;, &quot;interpret_as&quot;: &quot;long-text&quot;, &quot;description&quot;: &quot;The entire dict from KOReader’s metadata.epub.lua&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 27, &quot;#value#&quot;: null, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#mm_annotations\" content=\"{&quot;table&quot;: &quot;custom_column_7&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;comments&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Annotations&quot;, &quot;search_terms&quot;: [&quot;#mm_annotations&quot;], &quot;label&quot;: &quot;mm_annotations&quot;, &quot;colnum&quot;: 7, &quot;display&quot;: {}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 28, &quot;#value#&quot;: null, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#read_first\" content=\"{&quot;table&quot;: &quot;custom_column_8&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;datetime&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Read First&quot;, &quot;search_terms&quot;: [&quot;#read_first&quot;], &quot;label&quot;: &quot;read_first&quot;, &quot;colnum&quot;: 8, &quot;display&quot;: {&quot;description&quot;: &quot;The date on which I added this book to my Goodreads Currently Reading shelf&quot;, &quot;date_format&quot;: null}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 29, &quot;#value#&quot;: null, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#read_last\" content=\"{&quot;table&quot;: &quot;custom_column_9&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;datetime&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Read Last&quot;, &quot;search_terms&quot;: [&quot;#read_last&quot;], &quot;label&quot;: &quot;read_last&quot;, &quot;colnum&quot;: 9, &quot;display&quot;: {&quot;description&quot;: &quot;The date on which I last opened this book on my e-reader&quot;, &quot;date_format&quot;: null}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 30, &quot;#value#&quot;: null, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#read_location\" content=\"{&quot;table&quot;: &quot;custom_column_10&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;text&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Read Location&quot;, &quot;search_terms&quot;: [&quot;#read_location&quot;], &quot;label&quot;: &quot;read_location&quot;, &quot;colnum&quot;: 10, &quot;display&quot;: {&quot;description&quot;: &quot;&quot;, &quot;use_decorations&quot;: 0}, &quot;is_custom&quot;: true, &quot;is_category&quot;: true, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 31, &quot;#value#&quot;: null, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#read_progress\" content=\"{&quot;table&quot;: &quot;custom_column_11&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;float&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Read Progress&quot;, &quot;search_terms&quot;: [&quot;#read_progress&quot;], &quot;label&quot;: &quot;read_progress&quot;, &quot;colnum&quot;: 11, &quot;display&quot;: {&quot;description&quot;: &quot;&quot;, &quot;number_format&quot;: &quot;{:.0%}&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 32, &quot;#value#&quot;: null, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#read_status\" content=\"{&quot;table&quot;: &quot;custom_column_12&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;composite&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Read Status&quot;, &quot;search_terms&quot;: [&quot;#read_status&quot;], &quot;label&quot;: &quot;read_status&quot;, &quot;colnum&quot;: 12, &quot;display&quot;: {&quot;use_decorations&quot;: 0, &quot;contains_html&quot;: false, &quot;description&quot;: &quot;&quot;, &quot;composite_template&quot;: &quot;{#read_progress:get_read_status()}&quot;, &quot;composite_sort&quot;: &quot;text&quot;, &quot;make_category&quot;: true}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 33, &quot;#value#&quot;: &quot;&quot;, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n    </metadata>\n    <guide>\n        <reference type=\"cover\" title=\"Cover\" href=\"cover.jpg\"/>\n    </guide>\n</package>\n"
  },
  {
    "path": "dummy_library/Lewis Carroll/Alice's Adventures in Wonderland (4)/metadata.opf",
    "content": "<?xml version='1.0' encoding='utf-8'?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" unique-identifier=\"uuid_id\" version=\"2.0\">\n    <metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:opf=\"http://www.idpf.org/2007/opf\">\n        <dc:identifier opf:scheme=\"calibre\" id=\"calibre_id\">4</dc:identifier>\n        <dc:identifier opf:scheme=\"uuid\" id=\"uuid_id\">43bd8264-96fa-461a-a05e-1d1cb245d34f</dc:identifier>\n        <dc:title>Alice's Adventures in Wonderland</dc:title>\n        <dc:creator opf:file-as=\"Carroll, Lewis\" opf:role=\"aut\">Lewis Carroll</dc:creator>\n        <dc:contributor opf:file-as=\"calibre\" opf:role=\"bkp\">calibre (6.16.0) [https://calibre-ebook.com]</dc:contributor>\n        <dc:date>2008-06-27T04:00:00+00:00</dc:date>\n        <dc:identifier opf:scheme=\"URI\">http://www.gutenberg.org/11</dc:identifier>\n        <dc:language>eng</dc:language>\n        <dc:subject>Fantasy fiction</dc:subject>\n        <dc:subject>Children's stories</dc:subject>\n        <dc:subject>Imaginary places -- Juvenile fiction</dc:subject>\n        <dc:subject>Alice (Fictitious character from Carroll) -- Juvenile fiction</dc:subject>\n        <meta name=\"calibre:timestamp\" content=\"2020-11-16T21:46:44+00:00\"/>\n        <meta name=\"calibre:title_sort\" content=\"Alice's Adventures in Wonderland\"/>\n        <meta name=\"calibre:user_metadata:#formats\" content=\"{&quot;table&quot;: &quot;custom_column_1&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;composite&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Formats&quot;, &quot;search_terms&quot;: [&quot;#formats&quot;], &quot;label&quot;: &quot;formats&quot;, &quot;colnum&quot;: 1, &quot;display&quot;: {&quot;contains_html&quot;: false, &quot;make_category&quot;: false, &quot;composite_sort&quot;: &quot;text&quot;, &quot;use_decorations&quot;: 0, &quot;composite_template&quot;: &quot;{:'re(approximate_formats(), ',', ', ')'}&quot;, &quot;description&quot;: &quot;&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 22, &quot;#value#&quot;: &quot;EPUB&quot;, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#gr_rating\" content=\"{&quot;table&quot;: &quot;custom_column_2&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;rating&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Goodreads Rating&quot;, &quot;search_terms&quot;: [&quot;#gr_rating&quot;], &quot;label&quot;: &quot;gr_rating&quot;, &quot;colnum&quot;: 2, &quot;display&quot;: {&quot;description&quot;: &quot;&quot;, &quot;allow_half_stars&quot;: false}, &quot;is_custom&quot;: true, &quot;is_category&quot;: true, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 23, &quot;#value#&quot;: null, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#gr_read_progress\" content=\"{&quot;table&quot;: &quot;custom_column_15&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;composite&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Goodreads Read Progress&quot;, &quot;search_terms&quot;: [&quot;#gr_read_progress&quot;], &quot;label&quot;: &quot;gr_read_progress&quot;, &quot;colnum&quot;: 15, &quot;display&quot;: {&quot;composite_template&quot;: &quot;{#read_progress:multiply(100):ceil()}&quot;, &quot;composite_sort&quot;: &quot;number&quot;, &quot;make_category&quot;: false, &quot;contains_html&quot;: false, &quot;use_decorations&quot;: 0, &quot;description&quot;: &quot;Convert #read_progress to integers for Goodreads&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 24, &quot;#value#&quot;: &quot;TEMPLATE ERROR could not convert string to float: '46%'&quot;, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#gr_review\" content=\"{&quot;table&quot;: &quot;custom_column_3&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;comments&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Goodreads Review&quot;, &quot;search_terms&quot;: [&quot;#gr_review&quot;], &quot;label&quot;: &quot;gr_review&quot;, &quot;colnum&quot;: 3, &quot;display&quot;: {&quot;heading_position&quot;: &quot;hide&quot;, &quot;interpret_as&quot;: &quot;long-text&quot;, &quot;description&quot;: &quot;&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 25, &quot;#value#&quot;: null, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#gr_shelf\" content=\"{&quot;table&quot;: &quot;custom_column_4&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;text&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Goodreads Shelf&quot;, &quot;search_terms&quot;: [&quot;#gr_shelf&quot;], &quot;label&quot;: &quot;gr_shelf&quot;, &quot;colnum&quot;: 4, &quot;display&quot;: {&quot;use_decorations&quot;: 0, &quot;description&quot;: &quot;&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: true, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 26, &quot;#value#&quot;: null, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#ko_annotations\" content=\"{&quot;table&quot;: &quot;custom_column_13&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;comments&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;KOReader Annotations&quot;, &quot;search_terms&quot;: [&quot;#ko_annotations&quot;], &quot;label&quot;: &quot;ko_annotations&quot;, &quot;colnum&quot;: 13, &quot;display&quot;: {&quot;heading_position&quot;: &quot;above&quot;, &quot;interpret_as&quot;: &quot;markdown&quot;, &quot;description&quot;: &quot;&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 27, &quot;#value#&quot;: &quot;- in CHAPTER VI. Pig and Pepper &lt;!-- datetime: 2020-11-16 18:52:11, page: /body/DocFragment[8]/body/div/h2/text()[1].0, chapter: CHAPTER VI. Pig and Pepper --&gt;\\n- “You!” said the Caterpillar contemptuously. “Who are you?” &lt;!-- 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 --&gt;\\n- CHAPTER V. Advice from a Caterpillar &lt;!-- 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 --&gt;&quot;, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#ko_md5\" content=\"{&quot;table&quot;: &quot;custom_column_5&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;text&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;KOReader Sync Server MD5 Hash&quot;, &quot;search_terms&quot;: [&quot;#ko_md5&quot;], &quot;label&quot;: &quot;ko_md5&quot;, &quot;colnum&quot;: 5, &quot;display&quot;: {&quot;use_decorations&quot;: 0, &quot;description&quot;: &quot;&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: true, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 28, &quot;#value#&quot;: &quot;9c928bc227e2011e85f931ca159ff710&quot;, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#ko_mod\" content=\"{&quot;table&quot;: &quot;custom_column_20&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;datetime&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;KOReader Last Modified&quot;, &quot;search_terms&quot;: [&quot;#ko_mod&quot;], &quot;label&quot;: &quot;ko_mod&quot;, &quot;colnum&quot;: 20, &quot;display&quot;: {&quot;date_format&quot;: null, &quot;description&quot;: &quot;&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 29, &quot;#value#&quot;: {&quot;__class__&quot;: &quot;datetime.datetime&quot;, &quot;__value__&quot;: &quot;2021-11-22T06:47:26.439251+00:00&quot;}, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#ko_sidecar\" content=\"{&quot;table&quot;: &quot;custom_column_6&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;comments&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;KOReader Sidecar&quot;, &quot;search_terms&quot;: [&quot;#ko_sidecar&quot;], &quot;label&quot;: &quot;ko_sidecar&quot;, &quot;colnum&quot;: 6, &quot;display&quot;: {&quot;heading_position&quot;: &quot;above&quot;, &quot;interpret_as&quot;: &quot;long-text&quot;, &quot;description&quot;: &quot;The entire dict from KOReader’s metadata.epub.lua&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 30, &quot;#value#&quot;: &quot;{\\n  \\&quot;bookmarks\\&quot;: {\\n    \\&quot;1\\&quot;: {\\n      \\&quot;chapter\\&quot;: \\&quot;CHAPTER VI. Pig and Pepper\\&quot;,\\n      \\&quot;datetime\\&quot;: \\&quot;2020-11-16 18:52:11\\&quot;,\\n      \\&quot;page\\&quot;: \\&quot;/body/DocFragment[8]/body/div/h2/text()[1].0\\&quot;\\n    },\\n    \\&quot;2\\&quot;: {\\n      \\&quot;chapter\\&quot;: \\&quot;CHAPTER V. Advice from a Caterpillar\\&quot;,\\n      \\&quot;datetime\\&quot;: \\&quot;2020-11-16 18:51:04\\&quot;,\\n      \\&quot;highlighted\\&quot;: true,\\n      \\&quot;page\\&quot;: \\&quot;/body/DocFragment[7]/body/div/p[12]/text()[1].0\\&quot;,\\n      \\&quot;pos0\\&quot;: \\&quot;/body/DocFragment[7]/body/div/p[12]/text()[1].0\\&quot;,\\n      \\&quot;pos1\\&quot;: \\&quot;/body/DocFragment[7]/body/div/p[12]/text()[2].1\\&quot;,\\n      \\&quot;text\\&quot;: \\&quot;Higlight with note: Page 48 \\\\u201cYou!\\\\u201d said the Caterpillar contemptuously. \\\\u201cWho are you?\\\\u201d @ 2020-11-16 18:51:04\\&quot;\\n    },\\n    \\&quot;3\\&quot;: {\\n      \\&quot;chapter\\&quot;: \\&quot;CHAPTER V. Advice from a Caterpillar\\&quot;,\\n      \\&quot;datetime\\&quot;: \\&quot;2020-11-16 18:50:53\\&quot;,\\n      \\&quot;highlighted\\&quot;: true,\\n      \\&quot;page\\&quot;: \\&quot;/body/DocFragment[7]/body/div/h2/text()[1].0\\&quot;,\\n      \\&quot;pos0\\&quot;: \\&quot;/body/DocFragment[7]/body/div/h2/text()[1].0\\&quot;,\\n      \\&quot;pos1\\&quot;: \\&quot;/body/DocFragment[7]/body/div/h2/text()[2].26\\&quot;\\n    }\\n  },\\n  \\&quot;bookmarks_sorted\\&quot;: true,\\n  \\&quot;config_panel_index\\&quot;: 1,\\n  \\&quot;copt_b_page_margin\\&quot;: 15,\\n  \\&quot;copt_block_rendering_mode\\&quot;: 3,\\n  \\&quot;copt_embedded_css\\&quot;: 1,\\n  \\&quot;copt_embedded_fonts\\&quot;: 1,\\n  \\&quot;copt_font_gamma\\&quot;: 15,\\n  \\&quot;copt_font_hinting\\&quot;: 2,\\n  \\&quot;copt_font_kerning\\&quot;: 3,\\n  \\&quot;copt_font_size\\&quot;: 22,\\n  \\&quot;copt_font_weight\\&quot;: 0,\\n  \\&quot;copt_h_page_margins\\&quot;: {\\n    \\&quot;1\\&quot;: 10,\\n    \\&quot;2\\&quot;: 10\\n  },\\n  \\&quot;copt_line_spacing\\&quot;: 100,\\n  \\&quot;copt_nightmode_images\\&quot;: 1,\\n  \\&quot;copt_render_dpi\\&quot;: 96,\\n  \\&quot;copt_rotation_mode\\&quot;: 0,\\n  \\&quot;copt_smooth_scaling\\&quot;: 0,\\n  \\&quot;copt_status_line\\&quot;: 1,\\n  \\&quot;copt_sync_t_b_page_margins\\&quot;: 0,\\n  \\&quot;copt_t_page_margin\\&quot;: 15,\\n  \\&quot;copt_view_mode\\&quot;: 0,\\n  \\&quot;copt_visible_pages\\&quot;: 1,\\n  \\&quot;copt_word_expansion\\&quot;: 0,\\n  \\&quot;copt_word_spacing\\&quot;: {\\n    \\&quot;1\\&quot;: 95,\\n    \\&quot;2\\&quot;: 75\\n  },\\n  \\&quot;cre_dom_version\\&quot;: 20200824,\\n  \\&quot;css\\&quot;: \\&quot;./data/epub.css\\&quot;,\\n  \\&quot;disable_fuzzy_search\\&quot;: false,\\n  \\&quot;doc_pages\\&quot;: 156,\\n  \\&quot;doc_props\\&quot;: {\\n    \\&quot;authors\\&quot;: \\&quot;Lewis Carroll\\&quot;,\\n    \\&quot;description\\&quot;: \\&quot;\\&quot;,\\n    \\&quot;keywords\\&quot;: \\&quot;Fantasy fiction\\\\\\\\\\\\nChildren's stories\\\\\\\\\\\\nImaginary places\\\\n-- Juvenile fiction\\\\\\\\\\\\nAlice (Fictitious character from Carroll) -- Juvenile fiction\\&quot;,\\n    \\&quot;language\\&quot;: \\&quot;en\\&quot;,\\n    \\&quot;series\\&quot;: \\&quot;\\&quot;,\\n    \\&quot;title\\&quot;: \\&quot;Alice's Adventures in Wonderland\\&quot;\\n  },\\n  \\&quot;embedded_css\\&quot;: true,\\n  \\&quot;embedded_fonts\\&quot;: true,\\n  \\&quot;floating_punctuation\\&quot;: 0,\\n  \\&quot;font_embolden\\&quot;: 0,\\n  \\&quot;font_face\\&quot;: \\&quot;Noto Serif\\&quot;,\\n  \\&quot;font_hinting\\&quot;: 2,\\n  \\&quot;font_kerning\\&quot;: 3,\\n  \\&quot;font_size\\&quot;: 22,\\n  \\&quot;gamma\\&quot;: 1,\\n  \\&quot;gamma_index\\&quot;: 15,\\n  \\&quot;header_font_face\\&quot;: \\&quot;Noto Sans\\&quot;,\\n  \\&quot;highlight\\&quot;: {\\n    \\&quot;47\\&quot;: {\\n      \\&quot;1\\&quot;: {\\n        \\&quot;chapter\\&quot;: \\&quot;CHAPTER V. Advice from a Caterpillar\\&quot;,\\n        \\&quot;datetime\\&quot;: \\&quot;2020-11-16 18:50:53\\&quot;,\\n        \\&quot;drawer\\&quot;: \\&quot;lighten\\&quot;,\\n        \\&quot;pos0\\&quot;: \\&quot;/body/DocFragment[7]/body/div/h2/text()[1].0\\&quot;,\\n        \\&quot;pos1\\&quot;: \\&quot;/body/DocFragment[7]/body/div/h2/text()[2].26\\&quot;,\\n        \\&quot;text\\&quot;: \\&quot;CHAPTER V. Advice from a Caterpillar\\&quot;\\n      }\\n    },\\n    \\&quot;48\\&quot;: {\\n      \\&quot;1\\&quot;: {\\n        \\&quot;chapter\\&quot;: \\&quot;CHAPTER V. Advice from a Caterpillar\\&quot;,\\n        \\&quot;datetime\\&quot;: \\&quot;2020-11-16 18:51:04\\&quot;,\\n        \\&quot;drawer\\&quot;: \\&quot;lighten\\&quot;,\\n        \\&quot;pos0\\&quot;: \\&quot;/body/DocFragment[7]/body/div/p[12]/text()[1].0\\&quot;,\\n        \\&quot;pos1\\&quot;: \\&quot;/body/DocFragment[7]/body/div/p[12]/text()[2].1\\&quot;,\\n        \\&quot;text\\&quot;: \\&quot;\\\\u201cYou!\\\\u201d said the Caterpillar contemptuously. \\\\u201cWho are you?\\\\u201d\\&quot;\\n      }\\n    }\\n  },\\n  \\&quot;highlight_disabled\\&quot;: false,\\n  \\&quot;highlight_drawer\\&quot;: \\&quot;lighten\\&quot;,\\n  \\&quot;highlights_imported\\&quot;: true,\\n  \\&quot;hyph_force_algorithmic\\&quot;: false,\\n  \\&quot;hyph_soft_hyphens_only\\&quot;: false,\\n  \\&quot;hyph_trust_soft_hyphens\\&quot;: false,\\n  \\&quot;hyphenation\\&quot;: true,\\n  \\&quot;inverse_reading_order\\&quot;: false,\\n  \\&quot;last_xpointer\\&quot;: \\&quot;/body/DocFragment[9]/body/div/h2/text()[1].0\\&quot;,\\n  \\&quot;line_space_percent\\&quot;: 100,\\n  \\&quot;nightmode_images\\&quot;: true,\\n  \\&quot;page_overlap_style\\&quot;: \\&quot;dim\\&quot;,\\n  \\&quot;partial_md5_checksum\\&quot;: \\&quot;9c928bc227e2011e85f931ca159ff710\\&quot;,\\n  \\&quot;percent_finished\\&quot;: 0.45512820512821,\\n  \\&quot;readermenu_tab_index\\&quot;: 4,\\n  \\&quot;render_dpi\\&quot;: 96,\\n  \\&quot;render_mode\\&quot;: 0,\\n  \\&quot;rotation_mode\\&quot;: 0,\\n  \\&quot;show_overlap_enable\\&quot;: false,\\n  \\&quot;smooth_scaling\\&quot;: false,\\n  \\&quot;stats\\&quot;: {\\n    \\&quot;authors\\&quot;: \\&quot;Lewis Carroll\\&quot;,\\n    \\&quot;highlights\\&quot;: 2,\\n    \\&quot;language\\&quot;: \\&quot;en\\&quot;,\\n    \\&quot;md5\\&quot;: \\&quot;9c928bc227e2011e85f931ca159ff710\\&quot;,\\n    \\&quot;notes\\&quot;: 0,\\n    \\&quot;pages\\&quot;: 156,\\n    \\&quot;series\\&quot;: \\&quot;\\&quot;,\\n    \\&quot;title\\&quot;: \\&quot;Alice's Adventures in Wonderland\\&quot;\\n  },\\n  \\&quot;text_lang\\&quot;: \\&quot;en-US\\&quot;,\\n  \\&quot;text_lang_embedded_langs\\&quot;: true,\\n  \\&quot;visible_pages\\&quot;: 1,\\n  \\&quot;word_expansion\\&quot;: 0,\\n  \\&quot;word_spacing\\&quot;: {\\n    \\&quot;1\\&quot;: 95,\\n    \\&quot;2\\&quot;: 75\\n  }\\n}&quot;, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#ko_sync\" content=\"{&quot;table&quot;: &quot;custom_column_19&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;datetime&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;KOReader Last Sync&quot;, &quot;search_terms&quot;: [&quot;#ko_sync&quot;], &quot;label&quot;: &quot;ko_sync&quot;, &quot;colnum&quot;: 19, &quot;display&quot;: {&quot;date_format&quot;: null, &quot;description&quot;: &quot;&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 31, &quot;#value#&quot;: {&quot;__class__&quot;: &quot;datetime.datetime&quot;, &quot;__value__&quot;: &quot;2023-05-22T08:56:46.421795+00:00&quot;}, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#mm_annotations\" content=\"{&quot;table&quot;: &quot;custom_column_7&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;comments&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Kobo Annotations&quot;, &quot;search_terms&quot;: [&quot;#mm_annotations&quot;], &quot;label&quot;: &quot;mm_annotations&quot;, &quot;colnum&quot;: 7, &quot;display&quot;: {&quot;heading_position&quot;: &quot;above&quot;, &quot;interpret_as&quot;: &quot;html&quot;, &quot;description&quot;: &quot;&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 32, &quot;#value#&quot;: null, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#read_first\" content=\"{&quot;table&quot;: &quot;custom_column_8&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;datetime&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Read First&quot;, &quot;search_terms&quot;: [&quot;#read_first&quot;], &quot;label&quot;: &quot;read_first&quot;, &quot;colnum&quot;: 8, &quot;display&quot;: {&quot;description&quot;: &quot;The date on which I added this book to my Goodreads Currently Reading shelf&quot;, &quot;date_format&quot;: null}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 33, &quot;#value#&quot;: {&quot;__class__&quot;: &quot;datetime.datetime&quot;, &quot;__value__&quot;: &quot;2020-11-16T18:50:53+00:00&quot;}, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#read_last\" content=\"{&quot;table&quot;: &quot;custom_column_9&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;datetime&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Read Last&quot;, &quot;search_terms&quot;: [&quot;#read_last&quot;], &quot;label&quot;: &quot;read_last&quot;, &quot;colnum&quot;: 9, &quot;display&quot;: {&quot;description&quot;: &quot;The date on which I last opened this book on my e-reader&quot;, &quot;date_format&quot;: null}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 34, &quot;#value#&quot;: {&quot;__class__&quot;: &quot;datetime.datetime&quot;, &quot;__value__&quot;: &quot;2020-11-16T18:52:11+00:00&quot;}, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#read_location\" content=\"{&quot;table&quot;: &quot;custom_column_10&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;text&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Read Location&quot;, &quot;search_terms&quot;: [&quot;#read_location&quot;], &quot;label&quot;: &quot;read_location&quot;, &quot;colnum&quot;: 10, &quot;display&quot;: {&quot;description&quot;: &quot;&quot;, &quot;use_decorations&quot;: 0}, &quot;is_custom&quot;: true, &quot;is_category&quot;: true, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 35, &quot;#value#&quot;: &quot;/body/DocFragment[9]/body/div/h2/text()[1].0&quot;, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#read_progress\" content=\"{&quot;table&quot;: &quot;custom_column_11&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;float&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Read Progress&quot;, &quot;search_terms&quot;: [&quot;#read_progress&quot;], &quot;label&quot;: &quot;read_progress&quot;, &quot;colnum&quot;: 11, &quot;display&quot;: {&quot;description&quot;: &quot;&quot;, &quot;number_format&quot;: &quot;{:.0%}&quot;, &quot;decimals&quot;: 2}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 36, &quot;#value#&quot;: 0.45512820512821, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#read_progress_int\" content=\"{&quot;table&quot;: &quot;custom_column_18&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;int&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Read Progress (int)&quot;, &quot;search_terms&quot;: [&quot;#read_progress_int&quot;], &quot;label&quot;: &quot;read_progress_int&quot;, &quot;colnum&quot;: 18, &quot;display&quot;: {&quot;number_format&quot;: null, &quot;description&quot;: &quot;&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 37, &quot;#value#&quot;: 45, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#read_status\" content=\"{&quot;table&quot;: &quot;custom_column_12&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;composite&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Read Status&quot;, &quot;search_terms&quot;: [&quot;#read_status&quot;], &quot;label&quot;: &quot;read_status&quot;, &quot;colnum&quot;: 12, &quot;display&quot;: {&quot;use_decorations&quot;: 0, &quot;contains_html&quot;: false, &quot;description&quot;: &quot;&quot;, &quot;composite_template&quot;: &quot;{#read_progress:get_read_status()}&quot;, &quot;composite_sort&quot;: &quot;text&quot;, &quot;make_category&quot;: true}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 38, &quot;#value#&quot;: &quot;☐&quot;, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#read_status_bool\" content=\"{&quot;table&quot;: &quot;custom_column_17&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;bool&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Read Status (yes/no)&quot;, &quot;search_terms&quot;: [&quot;#read_status_bool&quot;], &quot;label&quot;: &quot;read_status_bool&quot;, &quot;colnum&quot;: 17, &quot;display&quot;: {&quot;bools_show_text&quot;: false, &quot;bools_show_icons&quot;: true, &quot;description&quot;: &quot;&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: false, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 39, &quot;#value#&quot;: false, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:user_metadata:#read_status_text\" content=\"{&quot;table&quot;: &quot;custom_column_16&quot;, &quot;column&quot;: &quot;value&quot;, &quot;datatype&quot;: &quot;text&quot;, &quot;is_multiple&quot;: null, &quot;kind&quot;: &quot;field&quot;, &quot;name&quot;: &quot;Read Status (text)&quot;, &quot;search_terms&quot;: [&quot;#read_status_text&quot;], &quot;label&quot;: &quot;read_status_text&quot;, &quot;colnum&quot;: 16, &quot;display&quot;: {&quot;use_decorations&quot;: false, &quot;description&quot;: &quot;&quot;}, &quot;is_custom&quot;: true, &quot;is_category&quot;: true, &quot;link_column&quot;: &quot;value&quot;, &quot;category_sort&quot;: &quot;value&quot;, &quot;is_csp&quot;: false, &quot;is_editable&quot;: true, &quot;rec_index&quot;: 40, &quot;#value#&quot;: &quot;reading&quot;, &quot;#extra#&quot;: null, &quot;is_multiple2&quot;: {}}\"/>\n        <meta name=\"calibre:annotation\" content=\"{&quot;format&quot;: &quot;EPUB&quot;, &quot;user_type&quot;: &quot;local&quot;, &quot;user&quot;: &quot;viewer&quot;, &quot;annotation&quot;: {&quot;title&quot;: &quot;Chapter X&quot;, &quot;pos_type&quot;: &quot;epubcfi&quot;, &quot;pos&quot;: &quot;epubcfi(/24/2/4/2[pgepubid00012]/10/1:63)&quot;, &quot;timestamp&quot;: &quot;2021-04-24T12:36:54.165216+00:00&quot;, &quot;type&quot;: &quot;bookmark&quot;}}\"/>\n        <meta name=\"calibre:annotation\" content=\"{&quot;format&quot;: &quot;EPUB&quot;, &quot;user_type&quot;: &quot;local&quot;, &quot;user&quot;: &quot;viewer&quot;, &quot;annotation&quot;: {&quot;end_cfi&quot;: &quot;/2/4/2[pgepubid00003]/8/1:159&quot;, &quot;highlighted_text&quot;: &quot;There was nothing so very remarkable in that; nor did Alice think it so very much out of the way to hear the Rabbit say to itself, “Oh dear! Oh dear! I shall be late!” &quot;, &quot;notes&quot;: &quot;Classic&quot;, &quot;spine_index&quot;: 2, &quot;spine_name&quot;: &quot;OEBPS/@public@vhost@g@gutenberg@html@files@11@11-h@11-h-1.htm.html&quot;, &quot;start_cfi&quot;: &quot;/2/4/2[pgepubid00003]/8/1:0&quot;, &quot;style&quot;: {&quot;kind&quot;: &quot;color&quot;, &quot;type&quot;: &quot;builtin&quot;, &quot;which&quot;: &quot;yellow&quot;}, &quot;timestamp&quot;: &quot;2021-04-24T19:10:37.437Z&quot;, &quot;toc_family_titles&quot;: [&quot;CHAPTER I. Down the Rabbit-Hole&quot;], &quot;type&quot;: &quot;highlight&quot;, &quot;uuid&quot;: &quot;DoJPAV9wlStaWi6gIuikkg&quot;}}\"/>\n        <meta name=\"calibre:annotation\" content=\"{&quot;format&quot;: &quot;EPUB&quot;, &quot;user_type&quot;: &quot;local&quot;, &quot;user&quot;: &quot;viewer&quot;, &quot;annotation&quot;: {&quot;end_cfi&quot;: &quot;/2/4/2[pgepubid00006]/2/2[chap04]:45&quot;, &quot;highlighted_text&quot;: &quot;CHAPTER IV.\\nThe Rabbit Sends in a Little Bill&quot;, &quot;spine_index&quot;: 5, &quot;spine_name&quot;: &quot;OEBPS/@public@vhost@g@gutenberg@html@files@11@11-h@11-h-4.htm.html&quot;, &quot;start_cfi&quot;: &quot;/2/4/2[pgepubid00006]/2/2[chap04]:0&quot;, &quot;style&quot;: {&quot;kind&quot;: &quot;color&quot;, &quot;type&quot;: &quot;builtin&quot;, &quot;which&quot;: &quot;yellow&quot;}, &quot;timestamp&quot;: &quot;2021-04-24T12:36:42.340Z&quot;, &quot;toc_family_titles&quot;: [&quot;CHAPTER IV. The Rabbit Sends in a Little Bill&quot;], &quot;type&quot;: &quot;highlight&quot;, &quot;uuid&quot;: &quot;UMWQKVzy3pkfOnyre2mrQg&quot;}}\"/>\n    </metadata>\n    <guide>\n        <reference type=\"cover\" title=\"Cover\" href=\"cover.jpg\"/>\n    </guide>\n</package>\n"
  },
  {
    "path": "dummy_library/metadata_db_prefs_backup.json",
    "content": "{\n  \"bools_are_tristate\": true,\n  \"user_categories\": {},\n  \"saved_searches\": {},\n  \"grouped_search_terms\": {},\n  \"tag_browser_hidden_categories\": [\n    \"#read_location\",\n    \"rating\",\n    \"news\"\n  ],\n  \"library_view books view state\": {\n    \"hidden_columns\": [\n      \"size\",\n      \"rating\",\n      \"tags\",\n      \"publisher\",\n      \"pubdate\",\n      \"last_modified\",\n      \"languages\",\n      \"#read_status\"\n    ],\n    \"last_modified_injected\": true,\n    \"languages_injected\": true,\n    \"sort_history\": [\n      [\n        \"timestamp\",\n        false\n      ],\n      [\n        \"authors\",\n        true\n      ],\n      [\n        \"series\",\n        true\n      ],\n      [\n        \"title\",\n        true\n      ],\n      [\n        \"timestamp\",\n        false\n      ]\n    ],\n    \"column_positions\": {\n      \"ondevice\": 0,\n      \"title\": 2,\n      \"authors\": 1,\n      \"timestamp\": 22,\n      \"size\": 24,\n      \"rating\": 23,\n      \"tags\": 25,\n      \"series\": 3,\n      \"publisher\": 26,\n      \"pubdate\": 27,\n      \"last_modified\": 28,\n      \"languages\": 29,\n      \"#formats\": 15,\n      \"#gr_rating\": 13,\n      \"#gr_review\": 14,\n      \"#gr_shelf\": 12,\n      \"#ko_annotations\": 17,\n      \"#ko_md5\": 18,\n      \"#ko_mod\": 20,\n      \"#ko_sidecar\": 19,\n      \"#ko_sync\": 21,\n      \"#mm_annotations\": 16,\n      \"#read_first\": 9,\n      \"#read_last\": 10,\n      \"#read_location\": 11,\n      \"#read_progress\": 7,\n      \"#read_progress_int\": 8,\n      \"#read_status\": 4,\n      \"#read_status_bool\": 6,\n      \"#read_status_text\": 5\n    },\n    \"column_sizes\": {\n      \"title\": 397,\n      \"authors\": 214,\n      \"timestamp\": 111,\n      \"size\": 0,\n      \"rating\": 0,\n      \"tags\": 0,\n      \"series\": 441,\n      \"publisher\": 0,\n      \"pubdate\": 0,\n      \"last_modified\": 0,\n      \"languages\": 0,\n      \"#formats\": 100,\n      \"#gr_rating\": 100,\n      \"#gr_review\": 100,\n      \"#gr_shelf\": 137,\n      \"#ko_annotations\": 100,\n      \"#ko_md5\": 100,\n      \"#ko_mod\": 100,\n      \"#ko_sidecar\": 100,\n      \"#ko_sync\": 100,\n      \"#mm_annotations\": 100,\n      \"#read_first\": 100,\n      \"#read_last\": 100,\n      \"#read_location\": 100,\n      \"#read_progress\": 100,\n      \"#read_progress_int\": 100,\n      \"#read_status\": 0,\n      \"#read_status_bool\": 100,\n      \"#read_status_text\": 100\n    },\n    \"column_alignment\": {\n      \"#read_progress\": \"right\",\n      \"#read_status\": \"center\",\n      \"pubdate\": \"center\",\n      \"size\": \"center\",\n      \"timestamp\": \"center\"\n    }\n  },\n  \"field_metadata\": {\n    \"authors\": {\n      \"table\": \"authors\",\n      \"column\": \"name\",\n      \"link_column\": \"author\",\n      \"category_sort\": \"sort\",\n      \"datatype\": \"text\",\n      \"is_multiple\": {\n        \"cache_to_list\": \",\",\n        \"ui_to_list\": \"&\",\n        \"list_to_ui\": \" & \"\n      },\n      \"kind\": \"field\",\n      \"name\": \"Authors\",\n      \"search_terms\": [\n        \"authors\",\n        \"author\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": true,\n      \"is_csp\": false,\n      \"label\": \"authors\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 2\n    },\n    \"languages\": {\n      \"table\": \"languages\",\n      \"column\": \"lang_code\",\n      \"link_column\": \"lang_code\",\n      \"category_sort\": \"lang_code\",\n      \"datatype\": \"text\",\n      \"is_multiple\": {\n        \"cache_to_list\": \",\",\n        \"ui_to_list\": \",\",\n        \"list_to_ui\": \", \"\n      },\n      \"kind\": \"field\",\n      \"name\": \"Languages\",\n      \"search_terms\": [\n        \"languages\",\n        \"language\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": true,\n      \"is_csp\": false,\n      \"label\": \"languages\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 21\n    },\n    \"series\": {\n      \"table\": \"series\",\n      \"column\": \"name\",\n      \"link_column\": \"series\",\n      \"category_sort\": \"(title_sort(name))\",\n      \"datatype\": \"series\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Series\",\n      \"search_terms\": [\n        \"series\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": true,\n      \"is_csp\": false,\n      \"label\": \"series\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 8\n    },\n    \"formats\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"text\",\n      \"is_multiple\": {\n        \"cache_to_list\": \",\",\n        \"ui_to_list\": \",\",\n        \"list_to_ui\": \", \"\n      },\n      \"kind\": \"field\",\n      \"name\": \"Formats\",\n      \"search_terms\": [\n        \"formats\",\n        \"format\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": true,\n      \"is_csp\": false,\n      \"label\": \"formats\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 13\n    },\n    \"publisher\": {\n      \"table\": \"publishers\",\n      \"column\": \"name\",\n      \"link_column\": \"publisher\",\n      \"category_sort\": \"name\",\n      \"datatype\": \"text\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Publisher\",\n      \"search_terms\": [\n        \"publisher\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": true,\n      \"is_csp\": false,\n      \"label\": \"publisher\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 9\n    },\n    \"rating\": {\n      \"table\": \"ratings\",\n      \"column\": \"rating\",\n      \"link_column\": \"rating\",\n      \"category_sort\": \"rating\",\n      \"datatype\": \"rating\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Rating\",\n      \"search_terms\": [\n        \"rating\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": true,\n      \"is_csp\": false,\n      \"label\": \"rating\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 5\n    },\n    \"news\": {\n      \"table\": \"news\",\n      \"column\": \"name\",\n      \"category_sort\": \"name\",\n      \"datatype\": null,\n      \"is_multiple\": {},\n      \"kind\": \"category\",\n      \"name\": \"News\",\n      \"search_terms\": [],\n      \"is_custom\": false,\n      \"is_category\": true,\n      \"is_csp\": false,\n      \"label\": \"news\",\n      \"display\": {},\n      \"is_editable\": true\n    },\n    \"tags\": {\n      \"table\": \"tags\",\n      \"column\": \"name\",\n      \"link_column\": \"tag\",\n      \"category_sort\": \"name\",\n      \"datatype\": \"text\",\n      \"is_multiple\": {\n        \"cache_to_list\": \",\",\n        \"ui_to_list\": \",\",\n        \"list_to_ui\": \", \"\n      },\n      \"kind\": \"field\",\n      \"name\": \"Tags\",\n      \"search_terms\": [\n        \"tags\",\n        \"tag\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": true,\n      \"is_csp\": false,\n      \"label\": \"tags\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 6\n    },\n    \"identifiers\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"text\",\n      \"is_multiple\": {\n        \"cache_to_list\": \",\",\n        \"ui_to_list\": \",\",\n        \"list_to_ui\": \", \"\n      },\n      \"kind\": \"field\",\n      \"name\": \"Identifiers\",\n      \"search_terms\": [\n        \"identifiers\",\n        \"identifier\",\n        \"isbn\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": true,\n      \"is_csp\": true,\n      \"label\": \"identifiers\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 20\n    },\n    \"author_sort\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"text\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Author sort\",\n      \"search_terms\": [\n        \"author_sort\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": false,\n      \"is_csp\": false,\n      \"label\": \"author_sort\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 12\n    },\n    \"au_map\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"text\",\n      \"is_multiple\": {\n        \"cache_to_list\": \",\",\n        \"ui_to_list\": null,\n        \"list_to_ui\": null\n      },\n      \"kind\": \"field\",\n      \"name\": null,\n      \"search_terms\": [],\n      \"is_custom\": false,\n      \"is_category\": false,\n      \"is_csp\": false,\n      \"label\": \"au_map\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 18\n    },\n    \"comments\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"text\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Comments\",\n      \"search_terms\": [\n        \"comments\",\n        \"comment\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": false,\n      \"is_csp\": false,\n      \"label\": \"comments\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 7\n    },\n    \"cover\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"int\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Cover\",\n      \"search_terms\": [\n        \"cover\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": false,\n      \"is_csp\": false,\n      \"label\": \"cover\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 17\n    },\n    \"id\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"int\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": null,\n      \"search_terms\": [\n        \"id\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": false,\n      \"is_csp\": false,\n      \"label\": \"id\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 0\n    },\n    \"last_modified\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"datetime\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Modified\",\n      \"search_terms\": [\n        \"last_modified\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": false,\n      \"is_csp\": false,\n      \"label\": \"last_modified\",\n      \"display\": {\n        \"date_format\": \"dd MMM yyyy\"\n      },\n      \"is_editable\": true,\n      \"rec_index\": 19\n    },\n    \"ondevice\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"text\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"On device\",\n      \"search_terms\": [\n        \"ondevice\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": false,\n      \"is_csp\": false,\n      \"label\": \"ondevice\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 40\n    },\n    \"path\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"text\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Path\",\n      \"search_terms\": [],\n      \"is_custom\": false,\n      \"is_category\": false,\n      \"is_csp\": false,\n      \"label\": \"path\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 14\n    },\n    \"pubdate\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"datetime\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Published\",\n      \"search_terms\": [\n        \"pubdate\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": false,\n      \"is_csp\": false,\n      \"label\": \"pubdate\",\n      \"display\": {\n        \"date_format\": \"MMM yyyy\"\n      },\n      \"is_editable\": true,\n      \"rec_index\": 15\n    },\n    \"marked\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"text\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": null,\n      \"search_terms\": [\n        \"marked\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": false,\n      \"is_csp\": false,\n      \"label\": \"marked\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 41\n    },\n    \"in_tag_browser\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"text\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": null,\n      \"search_terms\": [\n        \"in_tag_browser\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": false,\n      \"is_csp\": false,\n      \"label\": \"in_tag_browser\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 43\n    },\n    \"series_index\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"float\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": null,\n      \"search_terms\": [\n        \"series_index\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": false,\n      \"is_csp\": false,\n      \"label\": \"series_index\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 10\n    },\n    \"series_sort\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"text\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Series sort\",\n      \"search_terms\": [\n        \"series_sort\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": false,\n      \"is_csp\": false,\n      \"label\": \"series_sort\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 42\n    },\n    \"sort\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"text\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Title sort\",\n      \"search_terms\": [\n        \"title_sort\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": false,\n      \"is_csp\": false,\n      \"label\": \"sort\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 11\n    },\n    \"size\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"float\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Size\",\n      \"search_terms\": [\n        \"size\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": false,\n      \"is_csp\": false,\n      \"label\": \"size\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 4\n    },\n    \"timestamp\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"datetime\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Date\",\n      \"search_terms\": [\n        \"date\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": false,\n      \"is_csp\": false,\n      \"label\": \"timestamp\",\n      \"display\": {\n        \"date_format\": \"dd MMM yyyy\"\n      },\n      \"is_editable\": true,\n      \"rec_index\": 3\n    },\n    \"title\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"text\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Title\",\n      \"search_terms\": [\n        \"title\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": false,\n      \"is_csp\": false,\n      \"label\": \"title\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 1\n    },\n    \"uuid\": {\n      \"table\": null,\n      \"column\": null,\n      \"datatype\": \"text\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": null,\n      \"search_terms\": [\n        \"uuid\"\n      ],\n      \"is_custom\": false,\n      \"is_category\": false,\n      \"is_csp\": false,\n      \"label\": \"uuid\",\n      \"display\": {},\n      \"is_editable\": true,\n      \"rec_index\": 16\n    },\n    \"#formats\": {\n      \"table\": \"custom_column_1\",\n      \"column\": \"value\",\n      \"datatype\": \"composite\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Formats\",\n      \"search_terms\": [\n        \"#formats\"\n      ],\n      \"label\": \"formats\",\n      \"colnum\": 1,\n      \"display\": {\n        \"contains_html\": false,\n        \"make_category\": false,\n        \"composite_sort\": \"text\",\n        \"use_decorations\": 0,\n        \"composite_template\": \"{:'re(approximate_formats(), ',', ', ')'}\",\n        \"description\": \"\"\n      },\n      \"is_custom\": true,\n      \"is_category\": false,\n      \"link_column\": \"value\",\n      \"category_sort\": \"value\",\n      \"is_csp\": false,\n      \"is_editable\": true,\n      \"rec_index\": 22\n    },\n    \"#gr_rating\": {\n      \"table\": \"custom_column_2\",\n      \"column\": \"value\",\n      \"datatype\": \"rating\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Goodreads Rating\",\n      \"search_terms\": [\n        \"#gr_rating\"\n      ],\n      \"label\": \"gr_rating\",\n      \"colnum\": 2,\n      \"display\": {\n        \"description\": \"\",\n        \"allow_half_stars\": false\n      },\n      \"is_custom\": true,\n      \"is_category\": true,\n      \"link_column\": \"value\",\n      \"category_sort\": \"value\",\n      \"is_csp\": false,\n      \"is_editable\": true,\n      \"rec_index\": 23\n    },\n    \"#gr_review\": {\n      \"table\": \"custom_column_3\",\n      \"column\": \"value\",\n      \"datatype\": \"comments\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Goodreads Review\",\n      \"search_terms\": [\n        \"#gr_review\"\n      ],\n      \"label\": \"gr_review\",\n      \"colnum\": 3,\n      \"display\": {\n        \"heading_position\": \"hide\",\n        \"interpret_as\": \"long-text\",\n        \"description\": \"\"\n      },\n      \"is_custom\": true,\n      \"is_category\": false,\n      \"link_column\": \"value\",\n      \"category_sort\": \"value\",\n      \"is_csp\": false,\n      \"is_editable\": true,\n      \"rec_index\": 24\n    },\n    \"#gr_shelf\": {\n      \"table\": \"custom_column_4\",\n      \"column\": \"value\",\n      \"datatype\": \"text\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Goodreads Shelf\",\n      \"search_terms\": [\n        \"#gr_shelf\"\n      ],\n      \"label\": \"gr_shelf\",\n      \"colnum\": 4,\n      \"display\": {\n        \"use_decorations\": 0,\n        \"description\": \"\"\n      },\n      \"is_custom\": true,\n      \"is_category\": true,\n      \"link_column\": \"value\",\n      \"category_sort\": \"value\",\n      \"is_csp\": false,\n      \"is_editable\": true,\n      \"rec_index\": 25\n    },\n    \"#ko_annotations\": {\n      \"table\": \"custom_column_13\",\n      \"column\": \"value\",\n      \"datatype\": \"comments\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"KOReader Annotations\",\n      \"search_terms\": [\n        \"#ko_annotations\"\n      ],\n      \"label\": \"ko_annotations\",\n      \"colnum\": 13,\n      \"display\": {\n        \"heading_position\": \"above\",\n        \"interpret_as\": \"markdown\",\n        \"description\": \"\"\n      },\n      \"is_custom\": true,\n      \"is_category\": false,\n      \"link_column\": \"value\",\n      \"category_sort\": \"value\",\n      \"is_csp\": false,\n      \"is_editable\": true,\n      \"rec_index\": 26\n    },\n    \"#ko_md5\": {\n      \"table\": \"custom_column_5\",\n      \"column\": \"value\",\n      \"datatype\": \"text\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"KOReader Sync Server MD5 Hash\",\n      \"search_terms\": [\n        \"#ko_md5\"\n      ],\n      \"label\": \"ko_md5\",\n      \"colnum\": 5,\n      \"display\": {\n        \"use_decorations\": 0,\n        \"description\": \"\"\n      },\n      \"is_custom\": true,\n      \"is_category\": true,\n      \"link_column\": \"value\",\n      \"category_sort\": \"value\",\n      \"is_csp\": false,\n      \"is_editable\": true,\n      \"rec_index\": 27\n    },\n    \"#ko_mod\": {\n      \"table\": \"custom_column_20\",\n      \"column\": \"value\",\n      \"datatype\": \"datetime\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"KOReader Last Modified\",\n      \"search_terms\": [\n        \"#ko_mod\"\n      ],\n      \"label\": \"ko_mod\",\n      \"colnum\": 20,\n      \"display\": {\n        \"date_format\": null,\n        \"description\": \"\"\n      },\n      \"is_custom\": true,\n      \"is_category\": false,\n      \"link_column\": \"value\",\n      \"category_sort\": \"value\",\n      \"is_csp\": false,\n      \"is_editable\": true,\n      \"rec_index\": 28\n    },\n    \"#ko_sidecar\": {\n      \"table\": \"custom_column_6\",\n      \"column\": \"value\",\n      \"datatype\": \"comments\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"KOReader Sidecar\",\n      \"search_terms\": [\n        \"#ko_sidecar\"\n      ],\n      \"label\": \"ko_sidecar\",\n      \"colnum\": 6,\n      \"display\": {\n        \"heading_position\": \"above\",\n        \"interpret_as\": \"long-text\",\n        \"description\": \"The entire dict from KOReader\\u2019s metadata.epub.lua\"\n      },\n      \"is_custom\": true,\n      \"is_category\": false,\n      \"link_column\": \"value\",\n      \"category_sort\": \"value\",\n      \"is_csp\": false,\n      \"is_editable\": true,\n      \"rec_index\": 29\n    },\n    \"#ko_sync\": {\n      \"table\": \"custom_column_19\",\n      \"column\": \"value\",\n      \"datatype\": \"datetime\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"KOReader Last Sync\",\n      \"search_terms\": [\n        \"#ko_sync\"\n      ],\n      \"label\": \"ko_sync\",\n      \"colnum\": 19,\n      \"display\": {\n        \"date_format\": null,\n        \"description\": \"\"\n      },\n      \"is_custom\": true,\n      \"is_category\": false,\n      \"link_column\": \"value\",\n      \"category_sort\": \"value\",\n      \"is_csp\": false,\n      \"is_editable\": true,\n      \"rec_index\": 30\n    },\n    \"#mm_annotations\": {\n      \"table\": \"custom_column_7\",\n      \"column\": \"value\",\n      \"datatype\": \"comments\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Kobo Annotations\",\n      \"search_terms\": [\n        \"#mm_annotations\"\n      ],\n      \"label\": \"mm_annotations\",\n      \"colnum\": 7,\n      \"display\": {\n        \"heading_position\": \"above\",\n        \"interpret_as\": \"html\",\n        \"description\": \"\"\n      },\n      \"is_custom\": true,\n      \"is_category\": false,\n      \"link_column\": \"value\",\n      \"category_sort\": \"value\",\n      \"is_csp\": false,\n      \"is_editable\": true,\n      \"rec_index\": 31\n    },\n    \"#read_first\": {\n      \"table\": \"custom_column_8\",\n      \"column\": \"value\",\n      \"datatype\": \"datetime\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Read First\",\n      \"search_terms\": [\n        \"#read_first\"\n      ],\n      \"label\": \"read_first\",\n      \"colnum\": 8,\n      \"display\": {\n        \"description\": \"The date on which I added this book to my Goodreads Currently Reading shelf\",\n        \"date_format\": null\n      },\n      \"is_custom\": true,\n      \"is_category\": false,\n      \"link_column\": \"value\",\n      \"category_sort\": \"value\",\n      \"is_csp\": false,\n      \"is_editable\": true,\n      \"rec_index\": 32\n    },\n    \"#read_last\": {\n      \"table\": \"custom_column_9\",\n      \"column\": \"value\",\n      \"datatype\": \"datetime\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Read Last\",\n      \"search_terms\": [\n        \"#read_last\"\n      ],\n      \"label\": \"read_last\",\n      \"colnum\": 9,\n      \"display\": {\n        \"description\": \"The date on which I last opened this book on my e-reader\",\n        \"date_format\": null\n      },\n      \"is_custom\": true,\n      \"is_category\": false,\n      \"link_column\": \"value\",\n      \"category_sort\": \"value\",\n      \"is_csp\": false,\n      \"is_editable\": true,\n      \"rec_index\": 33\n    },\n    \"#read_location\": {\n      \"table\": \"custom_column_10\",\n      \"column\": \"value\",\n      \"datatype\": \"text\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Read Location\",\n      \"search_terms\": [\n        \"#read_location\"\n      ],\n      \"label\": \"read_location\",\n      \"colnum\": 10,\n      \"display\": {\n        \"description\": \"\",\n        \"use_decorations\": 0\n      },\n      \"is_custom\": true,\n      \"is_category\": true,\n      \"link_column\": \"value\",\n      \"category_sort\": \"value\",\n      \"is_csp\": false,\n      \"is_editable\": true,\n      \"rec_index\": 34\n    },\n    \"#read_progress\": {\n      \"table\": \"custom_column_11\",\n      \"column\": \"value\",\n      \"datatype\": \"float\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Read Progress\",\n      \"search_terms\": [\n        \"#read_progress\"\n      ],\n      \"label\": \"read_progress\",\n      \"colnum\": 11,\n      \"display\": {\n        \"description\": \"\",\n        \"number_format\": \"{:.0%}\",\n        \"decimals\": 2\n      },\n      \"is_custom\": true,\n      \"is_category\": false,\n      \"link_column\": \"value\",\n      \"category_sort\": \"value\",\n      \"is_csp\": false,\n      \"is_editable\": true,\n      \"rec_index\": 35\n    },\n    \"#read_progress_int\": {\n      \"table\": \"custom_column_18\",\n      \"column\": \"value\",\n      \"datatype\": \"int\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Read Progress (int)\",\n      \"search_terms\": [\n        \"#read_progress_int\"\n      ],\n      \"label\": \"read_progress_int\",\n      \"colnum\": 18,\n      \"display\": {\n        \"number_format\": null,\n        \"description\": \"\"\n      },\n      \"is_custom\": true,\n      \"is_category\": false,\n      \"link_column\": \"value\",\n      \"category_sort\": \"value\",\n      \"is_csp\": false,\n      \"is_editable\": true,\n      \"rec_index\": 36\n    },\n    \"#read_status\": {\n      \"table\": \"custom_column_12\",\n      \"column\": \"value\",\n      \"datatype\": \"composite\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Read Status\",\n      \"search_terms\": [\n        \"#read_status\"\n      ],\n      \"label\": \"read_status\",\n      \"colnum\": 12,\n      \"display\": {\n        \"use_decorations\": 0,\n        \"contains_html\": false,\n        \"description\": \"\",\n        \"composite_template\": \"{#read_progress:get_read_status()}\",\n        \"composite_sort\": \"text\",\n        \"make_category\": true\n      },\n      \"is_custom\": true,\n      \"is_category\": false,\n      \"link_column\": \"value\",\n      \"category_sort\": \"value\",\n      \"is_csp\": false,\n      \"is_editable\": true,\n      \"rec_index\": 37\n    },\n    \"#read_status_bool\": {\n      \"table\": \"custom_column_17\",\n      \"column\": \"value\",\n      \"datatype\": \"bool\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Read Status (yes/no)\",\n      \"search_terms\": [\n        \"#read_status_bool\"\n      ],\n      \"label\": \"read_status_bool\",\n      \"colnum\": 17,\n      \"display\": {\n        \"bools_show_text\": false,\n        \"bools_show_icons\": true,\n        \"description\": \"\"\n      },\n      \"is_custom\": true,\n      \"is_category\": false,\n      \"link_column\": \"value\",\n      \"category_sort\": \"value\",\n      \"is_csp\": false,\n      \"is_editable\": true,\n      \"rec_index\": 38\n    },\n    \"#read_status_text\": {\n      \"table\": \"custom_column_16\",\n      \"column\": \"value\",\n      \"datatype\": \"text\",\n      \"is_multiple\": {},\n      \"kind\": \"field\",\n      \"name\": \"Read Status (text)\",\n      \"search_terms\": [\n        \"#read_status_text\"\n      ],\n      \"label\": \"read_status_text\",\n      \"colnum\": 16,\n      \"display\": {\n        \"use_decorations\": false,\n        \"description\": \"\"\n      },\n      \"is_custom\": true,\n      \"is_category\": true,\n      \"link_column\": \"value\",\n      \"category_sort\": \"value\",\n      \"is_csp\": false,\n      \"is_editable\": true,\n      \"rec_index\": 39\n    }\n  },\n  \"books view split pane state\": {\n    \"hidden_columns\": [],\n    \"column_positions\": {\n      \"ondevice\": 0,\n      \"title\": 1,\n      \"authors\": 2,\n      \"timestamp\": 3,\n      \"size\": 4,\n      \"rating\": 5,\n      \"tags\": 6,\n      \"series\": 7,\n      \"publisher\": 8,\n      \"pubdate\": 9,\n      \"last_modified\": 10,\n      \"languages\": 11,\n      \"#formats\": 12,\n      \"#gr_rating\": 20,\n      \"#gr_review\": 14,\n      \"#gr_shelf\": 16,\n      \"#ko_annotations\": 24,\n      \"#ko_md5\": 22,\n      \"#ko_mod\": 29,\n      \"#ko_sidecar\": 23,\n      \"#ko_sync\": 28,\n      \"#mm_annotations\": 13,\n      \"#read_first\": 21,\n      \"#read_last\": 15,\n      \"#read_location\": 19,\n      \"#read_progress\": 17,\n      \"#read_progress_int\": 27,\n      \"#read_status\": 18,\n      \"#read_status_bool\": 25,\n      \"#read_status_text\": 26\n    },\n    \"column_sizes\": {\n      \"title\": 100,\n      \"authors\": 100,\n      \"timestamp\": 100,\n      \"size\": 100,\n      \"rating\": 100,\n      \"tags\": 100,\n      \"series\": 100,\n      \"publisher\": 100,\n      \"pubdate\": 100,\n      \"last_modified\": 100,\n      \"languages\": 100,\n      \"#formats\": 100,\n      \"#gr_rating\": 100,\n      \"#gr_review\": 100,\n      \"#gr_shelf\": 100,\n      \"#ko_annotations\": 100,\n      \"#ko_md5\": 100,\n      \"#ko_mod\": 100,\n      \"#ko_sidecar\": 100,\n      \"#ko_sync\": 100,\n      \"#mm_annotations\": 100,\n      \"#read_first\": 100,\n      \"#read_last\": 100,\n      \"#read_location\": 100,\n      \"#read_progress\": 100,\n      \"#read_progress_int\": 100,\n      \"#read_status\": 100,\n      \"#read_status_bool\": 100,\n      \"#read_status_text\": 100\n    }\n  },\n  \"book_display_fields\": [\n    [\n      \"title\",\n      true\n    ],\n    [\n      \"authors\",\n      true\n    ],\n    [\n      \"series\",\n      true\n    ],\n    [\n      \"identifiers\",\n      true\n    ],\n    [\n      \"tags\",\n      false\n    ],\n    [\n      \"formats\",\n      true\n    ],\n    [\n      \"path\",\n      true\n    ],\n    [\n      \"publisher\",\n      false\n    ],\n    [\n      \"rating\",\n      false\n    ],\n    [\n      \"author_sort\",\n      false\n    ],\n    [\n      \"sort\",\n      false\n    ],\n    [\n      \"timestamp\",\n      false\n    ],\n    [\n      \"uuid\",\n      false\n    ],\n    [\n      \"comments\",\n      true\n    ],\n    [\n      \"id\",\n      false\n    ],\n    [\n      \"pubdate\",\n      false\n    ],\n    [\n      \"last_modified\",\n      false\n    ],\n    [\n      \"size\",\n      false\n    ],\n    [\n      \"languages\",\n      true\n    ],\n    [\n      \"#formats\",\n      true\n    ],\n    [\n      \"#mm_annotations\",\n      true\n    ],\n    [\n      \"#read_status\",\n      true\n    ],\n    [\n      \"#gr_review\",\n      true\n    ],\n    [\n      \"#read_location\",\n      true\n    ],\n    [\n      \"#read_last\",\n      true\n    ],\n    [\n      \"#gr_shelf\",\n      true\n    ],\n    [\n      \"#read_progress\",\n      true\n    ]\n  ],\n  \"qv_display_fields\": [\n    [\n      \"title\",\n      true\n    ],\n    [\n      \"authors\",\n      true\n    ],\n    [\n      \"series\",\n      true\n    ],\n    [\n      \"languages\",\n      false\n    ],\n    [\n      \"formats\",\n      false\n    ],\n    [\n      \"publisher\",\n      false\n    ],\n    [\n      \"rating\",\n      false\n    ],\n    [\n      \"tags\",\n      false\n    ],\n    [\n      \"identifiers\",\n      false\n    ],\n    [\n      \"author_sort\",\n      false\n    ],\n    [\n      \"id\",\n      false\n    ],\n    [\n      \"last_modified\",\n      false\n    ],\n    [\n      \"path\",\n      false\n    ],\n    [\n      \"pubdate\",\n      false\n    ],\n    [\n      \"sort\",\n      false\n    ],\n    [\n      \"size\",\n      false\n    ],\n    [\n      \"timestamp\",\n      false\n    ],\n    [\n      \"uuid\",\n      false\n    ],\n    [\n      \"#formats\",\n      false\n    ],\n    [\n      \"#gr_shelf\",\n      false\n    ],\n    [\n      \"#read_last\",\n      false\n    ],\n    [\n      \"#read_location\",\n      false\n    ],\n    [\n      \"#read_progress\",\n      false\n    ],\n    [\n      \"#read_status\",\n      false\n    ]\n  ],\n  \"column_color_rules\": [],\n  \"column_icon_rules\": [\n    [\n      \"icon_only\",\n      \"#read_status\",\n      \"program:\\n# BasicColorRule():5b226f6b2e706e67222c205b5b2223726561645f737461747573222c20226973222c202272656164225d5d5d\\ntest(strcmp(field('#read_status'), \\\"read\\\", '', '1', ''), 'ok.png', '');\\n\"\n    ],\n    [\n      \"icon_only\",\n      \"#read_status\",\n      \"program:\\n# BasicColorRule():5b22717569636b766965772e706e67222c205b5b2223726561645f737461747573222c20226973222c202263757272656e746c792d72656164696e67225d5d5d\\ntest(strcmp(field('#read_status'), \\\"currently-reading\\\", '', '1', ''), 'quickview.png', '');\\n\"\n    ]\n  ],\n  \"cover_grid_icon_rules\": [],\n  \"gui_view_history\": [\n    [\n      4,\n      \"Alice's Adventures in Wonderland\"\n    ],\n    [\n      7540,\n      \"Out of Africa ; and, Shadows on the grass\"\n    ],\n    [\n      7539,\n      \"Out of Africa & Shadows on the Grass (English, 2.27Mb)\"\n    ],\n    [\n      7531,\n      \"Robinson Crusoe\"\n    ],\n    [\n      7535,\n      \"Out of Africa and Shadows on the Grass\"\n    ],\n    [\n      7524,\n      \"Beyond the Limits: Confronting Global Collapse, Envisioning a Sustainable Future\"\n    ],\n    [\n      7523,\n      \"Schismatrix Plus\"\n    ],\n    [\n      7513,\n      \"Fokke en Sukke [Wed, 05 Aug 2020]\"\n    ],\n    [\n      2614,\n      \"Problemski Hotel\"\n    ],\n    [\n      542,\n      \"Zoenen Met Rommel\"\n    ],\n    [\n      4224,\n      \"Angst\"\n    ],\n    [\n      4877,\n      \"Schindlers List\"\n    ],\n    [\n      5,\n      \"Buonanotte, Signor Lenin\"\n    ],\n    [\n      1426,\n      \"Opuscula Selecta Neerlandicorum De Arte\"\n    ],\n    [\n      4067,\n      \"Encomium Artis Medicae _ De Lof Der Geneeskunde\"\n    ]\n  ],\n  \"update_all_last_mod_dates_on_start\": false,\n  \"plugboards\": {},\n  \"namespaced:KoboUtilitiesPlugin:settings\": {\n    \"SchemaVersion\": 0.1,\n    \"profiles\": {\n      \"Default\": {\n        \"customColumnOptions\": {\n          \"currentReadingLocationColumn\": \"#read_location\",\n          \"lastReadColumn\": \"#read_last\",\n          \"percentReadColumn\": \"#read_progress\",\n          \"ratingColumn\": \"#gr_rating\"\n        },\n        \"forDevice\": \"*Any Device\",\n        \"profileName\": \"Default\",\n        \"storeOptionsStore\": {\n          \"doNotStoreIfReopened\": true,\n          \"promptToStore\": true,\n          \"storeIfMoreRecent\": true,\n          \"storeOnConnect\": true\n        },\n        \"updateOptionsStore\": {\n          \"doEarlyFirmwareUpdate\": false,\n          \"doFirmwareUpdateCheck\": false,\n          \"firmwareUpdateCheckLastTime\": 0\n        }\n      }\n    },\n    \"readingPositionChangesStore\": {\n      \"selectBooksInLibrary\": false,\n      \"updeateGoodreadsProgress\": true\n    }\n  },\n  \"user_template_functions\": [\n    [\n      \"get_read_status\",\n      \"get_read_status(progress_field)\\nreturn '\\u2610' if `progress_field`< 100, '' if empty, else '\\u2611\\ufe0e'\",\n      1,\n      \"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 ''\"\n    ]\n  ],\n  \"virtual_libraries\": {\n    \"On Device\": \"ondevice:\\\"True\\\"\",\n    \"Want to Read\": \"formats:\\\"=EPUB\\\" and #gr_shelf:\\\"=to-read\\\"\"\n  },\n  \"virt_libs_hidden\": [],\n  \"namespaced:FindDuplicatesPlugin:settings\": {\n    \"SchemaVersion\": 1.7,\n    \"authorExemptions\": [],\n    \"bookExemptions\": [],\n    \"lastLibraryCompare\": \"\"\n  },\n  \"namespaced:ImportListPlugin:settings\": {\n    \"clipboardRegexes\": [],\n    \"csvFiles\": [],\n    \"current\": {\n      \"clipboard\": {\n        \"regex\": \"\",\n        \"reverseList\": false,\n        \"text\": \"\"\n      },\n      \"csv\": {\n        \"columnData\": [\n          {\n            \"field\": \"title\",\n            \"index\": 1\n          },\n          {\n            \"field\": \"authors\",\n            \"index\": 2\n          }\n        ],\n        \"delimiter\": \",\",\n        \"file\": \"\",\n        \"match_by_identifier\": null,\n        \"reverseList\": false,\n        \"skipFirst\": true,\n        \"unquote\": true\n      },\n      \"importType\": \"web\",\n      \"readingList\": {\n        \"clearList\": true,\n        \"name\": \"\"\n      },\n      \"web\": {\n        \"categories\": [\n          \"Social Websites\"\n        ],\n        \"encoding\": \"utf-8\",\n        \"javascript\": true,\n        \"reverseList\": true,\n        \"url\": \"https://www.goodreads.com/review/list/38080398-harm?shelf=to-read&per_page=100\",\n        \"xpathData\": [\n          {\n            \"field\": \"rows\",\n            \"xpath\": \"//tbody[@id=\\\"booksBody\\\"]/tr\"\n          },\n          {\n            \"field\": \"title\",\n            \"isRegexStrip\": true,\n            \"regex\": \"\\\\([^\\\\)]+\\\\)\",\n            \"xpath\": \"td[@class=\\\"field title\\\"]//a/text()\"\n          },\n          {\n            \"field\": \"authors\",\n            \"isRegexStrip\": true,\n            \"regex\": \"\",\n            \"xpath\": \"td[@class=\\\"field author\\\"]//a/text()\"\n          },\n          {\n            \"field\": \"series\",\n            \"isRegexStrip\": false,\n            \"regex\": \"\\\\(([^,\\\\.#]+)\",\n            \"xpath\": \"td[@class=\\\"field title\\\"]//a/span/text()\"\n          },\n          {\n            \"field\": \"series_index\",\n            \"isRegexStrip\": false,\n            \"regex\": \"#([\\\\d\\\\.]+)\",\n            \"xpath\": \"td[@class=\\\"field title\\\"]//a/span/text()\"\n          },\n          {\n            \"field\": \"identifier:goodreads\",\n            \"isRegexStrip\": false,\n            \"regex\": \"/review/show/(\\\\d+)\",\n            \"xpath\": \"td[@class=\\\"field actions\\\"]//a/@href\"\n          }\n        ]\n      }\n    },\n    \"javascriptDelay\": 3,\n    \"lastCSVSetting\": \"\",\n    \"lastClipboardSetting\": \"\",\n    \"lastPredefinedSetting\": \"Goodreads: Shelves: To Read\",\n    \"lastTab\": 4,\n    \"lastUserSetting\": \"Goodreads Want to Read\",\n    \"lastViewType\": \"list\",\n    \"lastWebSetting\": \"Goodreads Want to Read\",\n    \"savedSettings\": {\n      \"Goodreads Want to Read\": {\n        \"categories\": [\n          \"Social Websites\"\n        ],\n        \"encoding\": \"utf-8\",\n        \"importType\": \"web\",\n        \"javascript\": true,\n        \"readingList\": {\n          \"clearList\": true,\n          \"name\": \"\"\n        },\n        \"reverseList\": true,\n        \"url\": \"https://www.goodreads.com/review/list/38080398-harm?shelf=to-read&per_page=100\",\n        \"xpathData\": [\n          {\n            \"field\": \"rows\",\n            \"xpath\": \"//tbody[@id=\\\"booksBody\\\"]/tr\"\n          },\n          {\n            \"field\": \"title\",\n            \"isRegexStrip\": true,\n            \"regex\": \"\\\\([^\\\\)]+\\\\)\",\n            \"xpath\": \"td[@class=\\\"field title\\\"]//a/text()\"\n          },\n          {\n            \"field\": \"authors\",\n            \"isRegexStrip\": true,\n            \"regex\": \"\",\n            \"xpath\": \"td[@class=\\\"field author\\\"]//a/text()\"\n          },\n          {\n            \"field\": \"series\",\n            \"isRegexStrip\": false,\n            \"regex\": \"\\\\(([^,\\\\.#]+)\",\n            \"xpath\": \"td[@class=\\\"field title\\\"]//a/span/text()\"\n          },\n          {\n            \"field\": \"series_index\",\n            \"isRegexStrip\": false,\n            \"regex\": \"#([\\\\d\\\\.]+)\",\n            \"xpath\": \"td[@class=\\\"field title\\\"]//a/span/text()\"\n          },\n          {\n            \"field\": \"identifier:goodreads\",\n            \"isRegexStrip\": false,\n            \"regex\": \"/review/show/(\\\\d+)\",\n            \"xpath\": \"td[@class=\\\"field actions\\\"]//a/@href\"\n          }\n        ]\n      }\n    },\n    \"schemaVersion\": 1.2,\n    \"webUrls\": [\n      \"https://www.goodreads.com/review/list/38080398-harm?shelf=to-read&per_page=100\",\n      \"https://www.goodreads.com/review/list/38080398-harm?shelf=to-read&per_page=infinite\",\n      \"https://www.goodreads.com/review/list/38080398-harm?utf8=%E2%9C%93&shelf=to-read&title=harm&per_page=infinite\",\n      \"https://www.goodreads.com/review/list/38080398-harm?shelf=to-read\",\n      \"http://www.goodreads.com/shelf/show/to-read\"\n    ]\n  },\n  \"news_to_be_synced\": [],\n  \"tag_browser_category_order\": [\n    \"authors\",\n    \"languages\",\n    \"series\",\n    \"formats\",\n    \"publisher\",\n    \"rating\",\n    \"news\",\n    \"tags\",\n    \"identifiers\",\n    \"#gr_rating\",\n    \"#gr_shelf\",\n    \"#ko_md5\",\n    \"#read_location\",\n    \"#read_status\"\n  ]\n}"
  },
  {
    "path": "plugin-import-name-koreader.txt",
    "content": ""
  },
  {
    "path": "pluginIndexKOReaderSync.txt",
    "content": "[*][URL=\"https://www.mobileread.com/forums/showthread.php?t=362706\"]KOReader Sync[/URL]\n[I]Synchronize metadata (e.g. read progress and rating) from KOReader to calibre.\nVersion: 0.8.2; Calibre: 5.0.1; Author: harmtemolder & others, currently maintaining by: kyxap; History: Yes;\nPlatforms: Windows, OSX, Linux;[/I]\n"
  },
  {
    "path": "pytest.ini",
    "content": "[pytest]\ntestpaths = tests\npython_files = test_*.py\npython_classes = Test*\npython_functions = test_*\n# Prevent pytest from trying to import root __init__.py\naddopts = -v --ignore=__init__.py\n"
  },
  {
    "path": "slpp.py",
    "content": "\"\"\"Copyright (c) 2010, 2011, 2012 SirAnthony <anthony at adsorbtion.org>\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\"\"\"\n\nimport re\nimport sys\nfrom numbers import Number\n\nimport six\n\nERRORS = {\n    'unexp_end_string': u'Unexpected end of string while parsing Lua string.',\n    'unexp_end_table': u'Unexpected end of table while parsing Lua string.',\n    'mfnumber_minus': u'Malformed number (no digits after initial minus).',\n    'mfnumber_dec_point': u'Malformed number (no digits after decimal point).',\n    'mfnumber_sci': u'Malformed number (bad scientific format).',\n}\n\ndef sequential(lst):\n    length = len(lst)\n    if length == 0 or lst[0] != 0:\n        return False\n    for i in range(length):\n        if i + 1 < length:\n            if lst[i] + 1 != lst[i+1]:\n                return False\n    return True\n\n\nclass ParseError(Exception):\n    pass\n\n\nclass SLPP(object):\n\n    def __init__(self):\n        self.text = ''\n        self.ch = ''\n        self.at = 0\n        self.len = 0\n        self.depth = 0\n        self.space = re.compile(r'\\s', re.M)\n        self.alnum = re.compile(r'\\w', re.M)\n\n        self.newline = '\\n'\n        self.tab = '\\t'\n\n    def decode(self, text):\n        if not text or not isinstance(text, six.string_types):\n            return\n        self.text = text\n        self.at, self.ch, self.depth = 0, '', 0\n        self.len = len(text)\n        self.next_chr()\n        result = self.value()\n        return result\n\n    def encode(self, obj):\n        self.depth = 0\n        return self.__encode(obj)\n\n    def __encode(self, obj):\n        s = ''\n        tab = self.tab\n        newline = self.newline\n\n        if isinstance(obj, str):\n            s += '\"%s\"' % obj.replace(r'\"', r'\\\"')\n        elif six.PY2 and isinstance(obj, unicode):\n            s += '\"%s\"' % obj.encode('utf-8').replace(r'\"', r'\\\"')\n        elif six.PY3 and isinstance(obj, bytes):\n            s += '\"{}\"'.format(''.join(r'\\x{:02x}'.format(c) for c in obj))\n        elif isinstance(obj, bool):\n            s += str(obj).lower()\n        elif obj is None:\n            s += 'nil'\n        elif isinstance(obj, Number):\n            s += str(obj)\n        elif isinstance(obj, (list, tuple, dict)):\n            self.depth += 1\n            if len(obj) == 0 or (not isinstance(obj, dict) and len([\n                    x for x in obj\n                    if isinstance(x, Number) or (isinstance(x, six.string_types) and len(x) < 10)\n               ]) == len(obj)):\n                newline = tab = ''\n            dp = tab * self.depth\n            s += \"%s{%s\" % (tab * (self.depth - 2), newline)\n            if isinstance(obj, dict):\n                key_list = ['[%s]' if isinstance(k, Number) else '[\"%s\"]' for k in obj.keys()]\n                contents = [dp + (key + ' = %s') % (k, self.__encode(v)) for (k, v), key in zip(obj.items(), key_list)]\n                s += (',%s' % newline).join(contents)\n            else:\n                s += (',%s' % newline).join(\n                    [dp + self.__encode(el) for el in obj])\n            self.depth -= 1\n            s += \"%s%s}\" % (newline, tab * self.depth)\n        return s\n\n    def white(self):\n        while self.ch:\n            if self.space.match(self.ch):\n                self.next_chr()\n            else:\n                break\n        self.comment()\n\n    def comment(self):\n        if self.ch == '-' and self.next_is('-'):\n            self.next_chr()\n            # TODO: for fancy comments need to improve\n            multiline = self.next_chr() and self.ch == '[' and self.next_is('[')\n            while self.ch:\n                if multiline:\n                    if self.ch == ']' and self.next_is(']'):\n                        self.next_chr()\n                        self.next_chr()\n                        self.white()\n                        break\n                # `--` is a comment, skip to next new line\n                elif re.match('\\n', self.ch):\n                    self.white()\n                    break\n                self.next_chr()\n\n    def next_is(self, value):\n        if self.at >= self.len:\n            return False\n        return self.text[self.at] == value\n\n    def prev_is(self, value):\n        if self.at < 2:\n            return False\n        return self.text[self.at-2] == value\n\n    def next_chr(self):\n        if self.at >= self.len:\n            self.ch = None\n            return None\n        self.ch = self.text[self.at]\n        self.at += 1\n        return True\n\n    def value(self):\n        self.white()\n        if not self.ch:\n            return\n        if self.ch == '{':\n            return self.object()\n        if self.ch == \"[\":\n            self.next_chr()\n        if self.ch in ['\"',  \"'\",  '[']:\n            return self.string(self.ch)\n        if self.ch.isdigit() or self.ch == '-':\n            return self.number()\n        return self.word()\n\n    def string(self, end=None):\n        s = ''\n        start = self.ch\n        if end == '[':\n            end = ']'\n        if start in ['\"',  \"'\",  '[']:\n            double = start=='[' and self.prev_is(start)\n            while self.next_chr():\n                if self.ch == end and (not double or self.next_is(end)):\n                    self.next_chr()\n                    if start != \"[\" or self.ch == ']':\n                        if double:\n                            self.next_chr()\n                        return s\n                if self.ch == '\\\\' and start == end:\n                    self.next_chr()\n                    if self.ch != end:\n                        s += '\\\\'\n                s += self.ch\n        raise ParseError(ERRORS['unexp_end_string'])\n\n    def object(self):\n        o = {}\n        k = None\n        idx = 0\n        numeric_keys = False\n        self.depth += 1\n        self.next_chr()\n        self.white()\n        if self.ch and self.ch == '}':\n            self.depth -= 1\n            self.next_chr()\n            return o  # Exit here\n        else:\n            while self.ch:\n                self.white()\n                if self.ch == '{':\n                    o[idx] = self.object()\n                    idx += 1\n                    continue\n                elif self.ch == '}':\n                    self.depth -= 1\n                    self.next_chr()\n                    if k is not None:\n                        o[idx] = k\n                    if len([key for key in o if isinstance(key, six.string_types + (float,  bool, tuple))]) == 0:\n                        so = sorted([key for key in o])\n                        if sequential(so):\n                            ar = []\n                            for key in o:\n                                ar.insert(key, o[key])\n                            o = ar\n                    return o  # or here\n                else:\n                    if self.ch == ',':\n                        self.next_chr()\n                        continue\n                    else:\n                        k = self.value()\n                        if self.ch == ']':\n                            self.next_chr()\n                    self.white()\n                    ch = self.ch\n                    if ch in ('=', ','):\n                        self.next_chr()\n                        self.white()\n                        if ch == '=':\n                            o[k] = self.value()\n                        else:\n                            o[idx] = k\n                        idx += 1\n                        k = None\n        raise ParseError(ERRORS['unexp_end_table'])  # Bad exit here\n\n    words = {'true': True, 'false': False, 'nil': None}\n    def word(self):\n        s = ''\n        if self.ch != '\\n':\n            s = self.ch\n        self.next_chr()\n        while self.ch is not None and self.alnum.match(self.ch) and s not in self.words:\n            s += self.ch\n            self.next_chr()\n        return self.words.get(s, s)\n\n    def number(self):\n        def next_digit(err):\n            n = self.ch\n            self.next_chr()\n            if not self.ch or not self.ch.isdigit():\n                raise ParseError(err)\n            return n\n        n = ''\n        try:\n            if self.ch == '-':\n                n += next_digit(ERRORS['mfnumber_minus'])\n            n += self.digit()\n            if n == '0' and self.ch in ['x', 'X']:\n                n += self.ch\n                self.next_chr()\n                n += self.hex()\n            else:\n                if self.ch and self.ch == '.':\n                    n += next_digit(ERRORS['mfnumber_dec_point'])\n                    n += self.digit()\n                if self.ch and self.ch in ['e', 'E']:\n                    n += self.ch\n                    self.next_chr()\n                    if not self.ch or self.ch not in ('+', '-'):\n                        raise ParseError(ERRORS['mfnumber_sci'])\n                    n += next_digit(ERRORS['mfnumber_sci'])\n                    n += self.digit()\n        except ParseError:\n            t, e = sys.exc_info()[:2]\n            print(e)\n            return 0\n        try:\n            return int(n, 0)\n        except:\n            pass\n        return float(n)\n\n    def digit(self):\n        n = ''\n        while self.ch and self.ch.isdigit():\n            n += self.ch\n            self.next_chr()\n        return n\n\n    def hex(self):\n        n = ''\n        while self.ch and (self.ch in 'ABCDEFabcdef' or self.ch.isdigit()):\n            n += self.ch\n            self.next_chr()\n        return n\n\n\nslpp = SLPP()\n\n__all__ = ['slpp']\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/conftest.py",
    "content": "import sys\nimport os\nimport builtins\nimport types\nimport importlib.util\nfrom unittest.mock import MagicMock\n\n# 1. Mock gettext '_' function\nif not hasattr(builtins, '_'):\n    builtins._ = lambda x: x\n\n# Add project root to sys.path\nsys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))\n\n# 2. Mock 'calibre' package and submodules\ncalibre = types.ModuleType(\"calibre\")\ncalibre.constants = MagicMock()\ncalibre.constants.numeric_version = (6, 0, 0)\ncalibre.customize = MagicMock()\n\n# IMPORTANT: Base classes must be classes, not Mocks\nclass MockInterfaceActionBase:\n    pass\ncalibre.customize.InterfaceActionBase = MockInterfaceActionBase\n\ncalibre.devices = MagicMock()\ncalibre.devices.usbms = MagicMock()\n\n# IMPORTANT: USBMS must be a class for isinstance() to work in action.py\nclass MockUSBMS:\n    pass\ncalibre.devices.usbms.driver = MagicMock()\ncalibre.devices.usbms.driver.USBMS = MockUSBMS\n\n# Setup calibre.utils\ncalibre_utils = types.ModuleType(\"calibre.utils\")\ncalibre_utils.__path__ = []\ncalibre.utils = calibre_utils\ncalibre_utils.config = MagicMock()\ncalibre_utils.iso8601 = MagicMock()\nfrom datetime import timezone\ncalibre_utils.iso8601.utc_tz = timezone.utc\ncalibre_utils.iso8601.local_tz = timezone.utc\n\n# Setup calibre.gui2\ncalibre_gui2 = types.ModuleType(\"calibre.gui2\")\ncalibre_gui2.__path__ = []\ncalibre.gui2 = calibre_gui2\ncalibre_gui2.actions = MagicMock()\nclass MockInterfaceAction:\n    def __init__(self, parent, site_customization):\n        self.gui = parent\n        self.interface_action_base_plugin = site_customization\ncalibre_gui2.actions.InterfaceAction = MockInterfaceAction\ncalibre_gui2.device = MagicMock()\ncalibre_gui2.show_restart_warning = MagicMock()\ncalibre_gui2.error_dialog = MagicMock()\ncalibre_gui2.warning_dialog = MagicMock()\ncalibre_gui2.open_url = MagicMock()\n\n# Setup calibre.gui2.dialogs\ncalibre_gui2_dialogs = types.ModuleType(\"calibre.gui2.dialogs\")\ncalibre_gui2_dialogs.__path__ = []\ncalibre_gui2.dialogs = calibre_gui2_dialogs\nsys.modules[\"calibre.gui2.dialogs\"] = calibre_gui2_dialogs\ncalibre_gui2_dialogs.message_box = MagicMock()\ncalibre_gui2_dialogs.message_box.MessageBox = MagicMock()\n\n# Assign to sys.modules\nsys.modules[\"calibre\"] = calibre\nsys.modules[\"calibre.constants\"] = calibre.constants\nsys.modules[\"calibre.customize\"] = calibre.customize\nsys.modules[\"calibre.devices\"] = calibre.devices\nsys.modules[\"calibre.devices.usbms\"] = calibre.devices.usbms\nsys.modules[\"calibre.devices.usbms.driver\"] = calibre.devices.usbms.driver\nsys.modules[\"calibre.utils\"] = calibre.utils\nsys.modules[\"calibre.utils.config\"] = calibre.utils.config\nsys.modules[\"calibre.utils.iso8601\"] = calibre.utils.iso8601\nsys.modules[\"calibre.gui2\"] = calibre.gui2\nsys.modules[\"calibre.gui2.actions\"] = calibre_gui2.actions\nsys.modules[\"calibre.gui2.dialogs\"] = calibre_gui2.dialogs\nsys.modules[\"calibre.gui2.dialogs.message_box\"] = calibre_gui2_dialogs.message_box\nsys.modules[\"calibre.gui2.device\"] = calibre_gui2.device\n\n# 3. Create 'calibre_plugins' package\ncalibre_plugins = types.ModuleType(\"calibre_plugins\")\nsys.modules[\"calibre_plugins\"] = calibre_plugins\n\n# 4. Create 'koreader' package\nkoreader_pkg = types.ModuleType(\"calibre_plugins.koreader\")\nkoreader_pkg.__path__ = []\ncalibre_plugins.koreader = koreader_pkg\nsys.modules[\"calibre_plugins.koreader\"] = koreader_pkg\n\n# 5. Import local modules\ninit_path = os.path.join(os.path.dirname(__file__), '..', '__init__.py')\nspec = importlib.util.spec_from_file_location(\"__init__\", init_path)\n__init__ = importlib.util.module_from_spec(spec)\nsys.modules[\"__init__\"] = __init__\nspec.loader.exec_module(__init__)\n\nimport slpp\n\n# 6. Assign submodules to package\nkoreader_pkg.slpp = slpp\nsys.modules[\"calibre_plugins.koreader.slpp\"] = slpp\nkoreader_pkg.clean_bookmarks = __init__.clean_bookmarks\nkoreader_pkg.DEBUG = __init__.DEBUG\nkoreader_pkg.DRY_RUN = __init__.DRY_RUN\nkoreader_pkg.PYDEVD = __init__.PYDEVD\nkoreader_pkg.KoreaderSync = __init__.KoreaderSync\n\n# 7. Import config\nimport config\nkoreader_pkg.config = config\nsys.modules[\"calibre_plugins.koreader.config\"] = config\n"
  },
  {
    "path": "tests/integration/test_docker_path_resolution.py",
    "content": "\nimport os\nfrom unittest.mock import MagicMock, patch\nfrom action import KoreaderAction\n\ndef test_wireless_device_avoids_local_filesystem():\n    \"\"\"\n    Reproduces Issue #73: Ensure that for non-USB devices (Wireless/Docker),\n    the plugin does NOT use os.path.exists which would look at the local \n    container filesystem.\n    \"\"\"\n    # 1. Setup a mock device that is NOT a USBMS instance\n    # In conftest.py, we mocked MockUSBMS. \n    # Here we create a device that does NOT inherit from it.\n    class WirelessDevice:\n        def exists(self, path):\n            return True\n        def get_file(self, path, outfile):\n            pass\n\n    mock_device = WirelessDevice()\n    \n    # 2. Setup Action\n    mock_parent = MagicMock()\n    mock_site_customization = MagicMock()\n    mock_site_customization.name = 'KOReader Sync'\n    mock_site_customization.version = (0, 8, 0)\n    action = KoreaderAction(mock_parent, mock_site_customization)\n\n    # Path that looks like a Linux/KOReader path but definitely doesn't exist locally\n    device_path = \"/mnt/onboard/Books/Test.sdr/metadata.epub.lua\"\n\n    # 3. Patch os.path.exists to track calls\n    with patch('os.path.exists') as mock_exists:\n        mock_exists.return_value = False # Doesn't exist on host\n        \n        # Trigger the check\n        exists = action.device_path_exists(mock_device, device_path)\n        \n        # ASSERTIONS\n        assert exists is True, \"Should have used driver.exists\"\n        \n        # CRITICAL REPRODUCTION CHECK:\n        # Before the fix, this would have been called with the device_path.\n        # After the fix, it should NOT be called for non-USB devices.\n        mock_exists.assert_not_called()\n\ndef test_usb_device_triggers_makedirs():\n    \"\"\"\n    Verifies that for USB devices, the plugin attempts to create the \n    sidecar directory if it doesn't exist.\n    \"\"\"\n    from calibre.devices.usbms.driver import USBMS\n    class MyUSBDevice(USBMS):\n        def put_file(self, path, stream): pass\n\n    mock_device = MyUSBDevice()\n    mock_parent = MagicMock()\n    mock_site_customization = MagicMock()\n    action = KoreaderAction(mock_parent, mock_site_customization)\n    \n    device_path = \"E:/Books/NewBook.sdr/metadata.epub.lua\"\n    \n    # Mock DB lookup to return metadata\n    action.gui.current_db.new_api.lookup_by_uuid.return_value = 1\n    mock_metadata = MagicMock()\n    mock_metadata.get.return_value = '{\"test\": 1}' # dummy sidecar json\n    action.gui.current_db.new_api.get_metadata.return_value = mock_metadata\n\n    with patch('os.makedirs') as mock_makedirs, \\\n         patch('os.path.exists') as mock_exists:\n        mock_exists.return_value = False\n        \n        action.push_metadata_to_koreader_sidecar(mock_device, \"some-uuid\", device_path)\n        \n        # This will currently FAIL because I removed makedirs\n        mock_makedirs.assert_called_with(os.path.dirname(device_path), exist_ok=True)\n\ndef test_wireless_device_skips_makedirs():\n    \"\"\"\n    Verifies that for wireless devices, the plugin does NOT call os.makedirs.\n    \"\"\"\n    class WirelessDevice:\n        def put_file(self, path, stream): pass\n\n    mock_device = WirelessDevice()\n    mock_parent = MagicMock()\n    mock_site_customization = MagicMock()\n    action = KoreaderAction(mock_parent, mock_site_customization)\n    \n    device_path = \"/mnt/onboard/Books/NewBook.sdr/metadata.epub.lua\"\n    \n    action.gui.current_db.new_api.lookup_by_uuid.return_value = 1\n    mock_metadata = MagicMock()\n    mock_metadata.get.return_value = '{\"test\": 1}'\n    action.gui.current_db.new_api.get_metadata.return_value = mock_metadata\n\n    with patch('os.makedirs') as mock_makedirs:\n        action.push_metadata_to_koreader_sidecar(mock_device, \"some-uuid\", device_path)\n        mock_makedirs.assert_not_called()\n"
  },
  {
    "path": "tests/integration/test_integration.py",
    "content": "\nimport os\nimport sqlite3\nimport pytest\nfrom unittest.mock import MagicMock\nfrom action import KoreaderAction\n\ndef test_dummy_data_consistency():\n    # Verify dummy_library metadata.db\n    conn = sqlite3.connect('dummy_library/metadata.db')\n    cursor = conn.cursor()\n    cursor.execute('SELECT title, uuid FROM books')\n    db_books = {title: uuid for title, uuid in cursor.fetchall()}\n    conn.close()\n\n    assert \"Alice's Adventures in Wonderland\" in db_books\n    assert \"Walden, and On The Duty Of Civil Disobedience\" in db_books\n\n    # Verify dummy_device paths\n    alice_path = \"Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.epub\"\n    thoreau_path = \"Thoreau, Henry David/Walden, and On The Duty Of Civil Disobedience - Henry David Thoreau.epub\"\n    \n    assert os.path.exists(os.path.join('dummy_device', alice_path))\n    assert os.path.exists(os.path.join('dummy_device', thoreau_path))\n\ndef test_get_paths_with_dummy_device():\n    # Mock book objects as they would come from a real device driver\n    class MockBook:\n        def __init__(self, uuid, path):\n            self.uuid = uuid\n            self.path = path\n\n    alice_book = MockBook(\n        uuid='43bd8264-96fa-461a-a05e-1d1cb245d34f',\n        path=\"Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.epub\"\n    )\n    thoreau_book = MockBook(\n        uuid='3393747a-f0d8-44e1-bfaf-5fad857da3eb',\n        path=\"Thoreau, Henry David/Walden, and On The Duty Of Civil Disobedience - Henry David Thoreau.epub\"\n    )\n\n    mock_device = MagicMock()\n    mock_device.books.return_value = [alice_book, thoreau_book]\n\n    # Instantiate action with mocks for parent and site_customization\n    mock_parent = MagicMock()\n    mock_site_customization = MagicMock()\n    mock_site_customization.name = 'KOReader Sync'\n    mock_site_customization.version = (0, 8, 0)\n\n    action = KoreaderAction(mock_parent, mock_site_customization)\n    paths = action.get_paths(mock_device)\n\n    assert len(paths) == 2\n    # Verify Alice's sidecar path generation\n    alice_sidecar = next(p for u, p in paths if u == alice_book.uuid)\n    assert alice_sidecar == \"Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.sdr/metadata.epub.lua\"\n"
  },
  {
    "path": "tests/integration/test_issue_143_fix.py",
    "content": "\nimport os\nimport io\nimport pytest\nfrom unittest.mock import MagicMock, patch\nfrom action import KoreaderAction\n\ndef test_fix_issue_143_usb_direct_write():\n    \"\"\"\n    Verifies fix for Issue #143: USB/Folder devices use direct open()\n    instead of put_file() to avoid driver crashes.\n    \"\"\"\n    from calibre.devices.usbms.driver import USBMS\n    class MyUSBDevice(USBMS):\n        def put_file(self, path, stream):\n            raise Exception(\"Should not be called for USB\")\n\n    mock_device = MyUSBDevice()\n    mock_parent = MagicMock()\n    mock_site_customization = MagicMock()\n    action = KoreaderAction(mock_parent, mock_site_customization)\n    \n    # Mock DB etc\n    action.gui.current_db.new_api.lookup_by_uuid.return_value = 1\n    mock_metadata = MagicMock()\n    mock_metadata.get.return_value = '{\"test\": 1}'\n    action.gui.current_db.new_api.get_metadata.return_value = mock_metadata\n    \n    with patch('builtins.open', MagicMock()) as mock_open, \\\n         patch('os.makedirs') as mock_makedirs, \\\n         patch('os.path.exists') as mock_exists:\n        \n        mock_exists.return_value = True\n        \n        result, details = action.push_metadata_to_koreader_sidecar(mock_device, \"uuid\", \"E:/path.lua\")\n        \n        assert result == \"success\"\n        # Verify open was used\n        mock_open.assert_called()\n        # Verify put_file was NOT used\n        # (Already handled by the exception in MyUSBDevice.put_file)\n\ndef test_wireless_still_uses_put_file():\n    \"\"\"\n    Ensures wireless devices STILL use put_file().\n    \"\"\"\n    class WirelessDevice:\n        def put_file(self, path, stream):\n            self.called = True\n    \n    mock_device = WirelessDevice()\n    mock_device.called = False\n    \n    mock_parent = MagicMock()\n    mock_site_customization = MagicMock()\n    action = KoreaderAction(mock_parent, mock_site_customization)\n    \n    action.gui.current_db.new_api.lookup_by_uuid.return_value = 1\n    mock_metadata = MagicMock()\n    mock_metadata.get.return_value = '{\"test\": 1}'\n    action.gui.current_db.new_api.get_metadata.return_value = mock_metadata\n    \n    result, details = action.push_metadata_to_koreader_sidecar(mock_device, \"uuid\", \"/mnt/path.lua\")\n    \n    assert result == \"success\"\n    assert mock_device.called is True\n"
  },
  {
    "path": "tests/integration/test_issue_143_repro.py",
    "content": "\nimport io\nimport pytest\nfrom unittest.mock import MagicMock\nfrom action import KoreaderAction\n\ndef test_reproduce_issue_143():\n    \"\"\"\n    Reproduces Issue #143: '_io.BytesIO' object has no attribute 'startswith'\n    This happens when a driver's put_file implementation expects a string \n    (likely a file path) but receives a BytesIO object.\n    \"\"\"\n    mock_parent = MagicMock()\n    mock_site_customization = MagicMock()\n    action = KoreaderAction(mock_parent, mock_site_customization)\n    \n    # Simulate a driver that might do 'stream.startswith'\n    class CrashingDriver:\n        def put_file(self, path, stream):\n            # This simulates the internal Calibre driver logic that might be crashing\n            if stream.startswith('some_path'):\n                pass\n\n    mock_device = CrashingDriver()\n    \n    # Mock DB etc to reach the put_file call\n    action.gui.current_db.new_api.lookup_by_uuid.return_value = 1\n    mock_metadata = MagicMock()\n    mock_metadata.get.return_value = '{\"test\": 1}'\n    action.gui.current_db.new_api.get_metadata.return_value = mock_metadata\n    \n    # This should return a failure result containing the error message\n    result, details = action.push_metadata_to_koreader_sidecar(mock_device, \"uuid\", \"some/path.lua\")\n    \n    assert result == \"failure\"\n    assert \"'_io.BytesIO' object has no attribute 'startswith'\" in details['result']\n"
  },
  {
    "path": "tests/integration/test_uuid_mismatch.py",
    "content": "\nimport os\nimport pytest\nfrom unittest.mock import MagicMock\nfrom action import KoreaderAction\n\ndef test_get_calibre_uuid_from_sidecar():\n    action = KoreaderAction(MagicMock(), MagicMock())\n    \n    # Test with valid identifiers\n    sidecar = {\n        'stats': {\n            'identifiers': 'uuid:8d62883d calibre:5ac8d90f-7d24-4b65-9f89-ff77df18bee9 isbn:123'\n        }\n    }\n    assert action.get_calibre_uuid_from_sidecar(sidecar) == '5ac8d90f-7d24-4b65-9f89-ff77df18bee9'\n    \n    # Test with newline/backslash separator\n    sidecar = {\n        'stats': {\n            'identifiers': 'uuid:abc\\\\calibre:xyz\\\\isbn:123'\n        }\n    }\n    assert action.get_calibre_uuid_from_sidecar(sidecar) == 'xyz'\n    \n    # Test with no calibre identifier\n    sidecar = {\n        'stats': {\n            'identifiers': 'uuid:abc isbn:123'\n        }\n    }\n    assert action.get_calibre_uuid_from_sidecar(sidecar) is None\n\ndef test_uuid_mismatch_resolution_integration():\n    # Setup\n    wrong_uuid = \"wrong-uuid-1234-5678\"\n    correct_uuid = \"43bd8264-96fa-461a-a05e-1d1cb245d34f\"\n    sidecar_path = \"Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.sdr/metadata.epub.lua\"\n    \n    class MockBook:\n        def __init__(self, uuid, path):\n            self.uuid = uuid\n            self.path = path\n    mock_book = MockBook(uuid=wrong_uuid, path=sidecar_path.replace(\".sdr/metadata.epub.lua\", \".epub\"))\n    \n    mock_device = MagicMock()\n    mock_device.books.return_value = [mock_book]\n    def get_file_side_effect(path, outfile):\n        real_path = os.path.join(\"dummy_device\", path)\n        with open(real_path, \"rb\") as f:\n            outfile.write(f.read())\n    mock_device.get_file.side_effect = get_file_side_effect\n\n    mock_site_customization = MagicMock()\n    mock_site_customization.name = 'KOReader Sync'\n    mock_site_customization.version = (0, 8, 0)\n    action = KoreaderAction(MagicMock(), mock_site_customization)\n    \n    # Mock DB\n    mock_db = MagicMock()\n    def lookup_by_uuid_side_effect(uuid):\n        if uuid == correct_uuid: return 4\n        return None\n    mock_db.lookup_by_uuid.side_effect = lookup_by_uuid_side_effect\n    \n    # Simulate the worker loop logic\n    # 1. Get sidecar\n    sidecar_contents = action.get_sidecar(mock_device, sidecar_path)\n    \n    # 2. Inject the correct identifier (simulating what KOReader would have)\n    sidecar_contents['stats']['identifiers'] = f'uuid:{wrong_uuid} calibre:{correct_uuid}'\n    \n    # 3. Test the fix: if initial lookup fails, try alternative\n    book_id = mock_db.lookup_by_uuid(wrong_uuid)\n    assert book_id is None # Initial failure\n    \n    better_uuid = action.get_calibre_uuid_from_sidecar(sidecar_contents)\n    assert better_uuid == correct_uuid\n    \n    book_id = mock_db.lookup_by_uuid(better_uuid)\n    assert book_id == 4 # Success!\n"
  },
  {
    "path": "tests/unit/test_bookmarks.py",
    "content": "\nimport pytest\nfrom __init__ import clean_bookmarks\n\ndef test_clean_bookmarks_large_payload():\n    # Simulate a 1.8MB metadata file scenario:\n    # 900 annotations, each with ~2KB of text/notes.\n    num_annotations = 900\n    large_text = \"A\" * 1000  # 1KB of text\n    large_note = \"B\" * 1000  # 1KB of note\n    \n    bookmarks = {}\n    for i in range(1, num_annotations + 1):\n        bookmarks[i] = {\n            'chapter': f'Chapter {i // 10}',\n            'note': f'Note {i}: {large_note}',\n            'text': f'Highlight {i}: {large_text}',\n            'datetime': '2023-01-01 12:00:00'\n        }\n    \n    result = clean_bookmarks(bookmarks)\n    \n    # Check that the result is a string\n    assert isinstance(result, str)\n    \n    # With the fix (O(N) complexity):\n    # Total size should be roughly (2KB per highlight * 900) + HTML overhead\n    # 2000 * 900 = 1.8 MB. \n    # We allow some headroom for HTML and hidden attributes.\n    # Calibre's SQLite limit for a single cell (Long Text) is technically 1GB, \n    # but practical performance issues start much earlier (usually around 10-20MB).\n    # 5MB is a very safe upper bound for 900 highlights of this size.\n    assert len(result) < 5 * 1024 * 1024 \n    \n    # Verify content\n    assert 'Chapter 0' in result\n    assert 'Note 1:' in result\n    assert '900. Highlight' in result\n\ndef test_clean_bookmarks_empty():\n    assert 'Book Highlights and Notes' in clean_bookmarks({})\n"
  },
  {
    "path": "tests/unit/test_md5_logic.py",
    "content": "\nimport pytest\nimport re\nfrom unittest.mock import MagicMock\nfrom action import KoreaderAction\n\ndef test_sidecar_path_regex_robustness():\n    \"\"\"Verifies that the sidecar path regex handles various extensions correctly.\"\"\"\n    def get_sidecar_path(book_path):\n        # Using the NEW regex from action.py\n        return re.sub(r'\\.([^./\\\\]+)$', r'.sdr/metadata.\\1.lua', book_path)\n\n    assert get_sidecar_path(\"Book.epub\") == \"Book.sdr/metadata.epub.lua\"\n    assert get_sidecar_path(\"Folder/Book.mobi\") == \"Folder/Book.sdr/metadata.mobi.lua\"\n    assert get_sidecar_path(\"Book.fb2.zip\") == \"Book.fb2.sdr/metadata.zip.lua\"\n    assert get_sidecar_path(\"My.Book.With.Dots.epub\") == \"My.Book.With.Dots.sdr/metadata.epub.lua\"\n    # Test with hyphen in extension (rare but possible)\n    assert get_sidecar_path(\"file.epub-original\") == \"file.sdr/metadata.epub-original.lua\"\n\ndef test_is_system_path():\n    from action import is_system_path\n    assert is_system_path(\"kfmon.sdr/metadata.lua\") is True\n    assert is_system_path(\"koreader.sdr/metadata.lua\") is True\n    assert is_system_path(\"Books/MyBook.sdr/metadata.lua\") is False\n"
  },
  {
    "path": "tests/unit/test_version.py",
    "content": "import os\nimport re\n\ndef test_version_match():\n    \"\"\"Check if version in .version matches version in __init__.py\"\"\"\n    with open(\".version\", \"r\") as f:\n        version = f.read().strip()\n\n    # Enforce no '-pre' on main branch releases\n    # GITHUB_REF_NAME is provided by GitHub Actions\n    is_main = os.environ.get('GITHUB_REF_NAME') == 'main'\n    \n    if is_main:\n        assert \"-pre\" not in version, \"Release error: .version file on 'main' branch must not contain '-pre'!\"\n\n    with open(\"__init__.py\", \"r\") as f:\n        content = f.read()\n        \n        # On main, we expect an exact match.\n        # On other branches, we allow -pre or -dev suffixes (added by 'make pre' or 'make dev')\n        if is_main:\n            pattern = rf'version_string\\s*=\\s*[\\'\"]{re.escape(version)}[\\'\"]'\n        else:\n            pattern = rf'version_string\\s*=\\s*[\\'\"]{re.escape(version)}(-pre|-dev)?[\\'\"]'\n            \n        assert re.search(pattern, content), f\"Version string matching '{version}' not found in __init__.py\"\n\ndef test_version_tuple_match():\n    \"\"\"Check if version in .version matches version tuple in __init__.py\"\"\"\n    with open(\".version\", \"r\") as f:\n        version = f.read().strip()\n\n    # Strip any suffix like -pre or -dev for the numeric tuple\n    # This matches the logic in our Makefile\n    numeric_version = version.split(\"-\")[0]\n    parts = numeric_version.split(\".\")\n    \n    # format (0, 8, 0)\n    version_tuple = f\"({', '.join(parts)})\"\n\n    with open(\"__init__.py\", \"r\") as f:\n        content = f.read()\n        expected = f\"version = {version_tuple}\"\n        assert expected in content, f\"Expected {expected} not found in __init__.py\"\n"
  }
]