'
__modified_by__ = 'kyxap kyxappp@gmail.com'
__modification_date__ = '2024'
__docformat__ = 'restructuredtext en'
DEBUG = _DEBUG
DRY_RUN = False # Used during debugging to skip the actual updating of metadata
PYDEVD = False # Used during debugging to connect to PyCharm's remote debugging
if numeric_version >= (5, 5, 0):
module_debug_print = partial(
root_debug_print,
' koreader:__init__:',
sep=''
)
else:
module_debug_print = partial(root_debug_print, 'koreader:__init__:')
class KoreaderSync(InterfaceActionBase):
name = 'KOReader Sync'
description = 'Get metadata from a connected KOReader device'
author = 'harmtemolder & others, currently maintaining by: kyxap'
version = (0, 8, 2)
version_string = "0.8.2"
minimum_calibre_version = (5, 0, 1) # Because Python 3
config = JSONConfig(os.path.join('plugins', 'KOReader Sync.json'))
actual_plugin = 'calibre_plugins.koreader.action:KoreaderAction'
def is_customizable(self):
return True
def config_widget(self):
if self.actual_plugin_:
from calibre_plugins.koreader.config import \
ConfigWidget # pylint: disable=import-error, disable=import-outside-toplevel
return ConfigWidget(self.actual_plugin_)
return None
def save_settings(self, config_widget):
config_widget.save_settings()
def clean_bookmarks(bookmarks):
"""Transforms KOReader's bookmark metadata into text that can be stored
in calibre. I assume that all bookmarks have a `note` attribute, which I
use as the main text of the bookmark. All other attributes are stored in a
HTML comment.
:param bookmarks: dict with numbered keys and annotations dict values
:return: HTML-formatted str of the all bookmarks and highlights
"""
debug_print = partial(root_debug_print, 'clean_bookmarks:')
# Dictionary to store highlights grouped by chapter
highlights_by_chapter = {}
for annotation in bookmarks.values():
if 'note' not in annotation:
debug_print('annotation does not have `note`', annotation)
else:
debug_print('annotation has `note`', annotation)
# Extracting all attributes to save as hidden text
hidden_attributes = ''
if len(bookmarks) > 0:
hidden_attributes += ' '
hidden_attributes += '\n'
# Extracting attributes that will be used in html
chapter = annotation.get("chapter", "Unknown Chapter")
reader_note = annotation.get("note", "no notes")
highlighted_text = annotation.get("text", "Unknown Highlighted Text")
datetime = annotation.get("datetime", "Unknown Datetime")
# Create highlight dictionary
highlight = {
"chapter": chapter,
"reader_note": reader_note,
"highlighted_text": highlighted_text,
"datetime": datetime,
"hidden_attributes": hidden_attributes
}
# Add highlight to the corresponding chapter
if chapter not in highlights_by_chapter:
highlights_by_chapter[chapter] = []
highlights_by_chapter[chapter].append(highlight)
# Generate HTML content for each chapter
html_content = ('\n\n\n'
'Book Highlights and Notes\n'
'\n\n')
highlight_count = 0
for chapter, chapter_highlights in highlights_by_chapter.items():
if chapter.strip() == '':
chapter = 'Unknown'
html_content += f'\n
Chapter: {chapter}
\n'
html_content += '
'
for highlight in chapter_highlights:
highlight_count += 1
html_content += (f'{highlight_count}. Highlight - {highlight["datetime"]} '
f'
{highlight["highlighted_text"]}\n')
html_content += '
\n'
html_content += ('Note: '
f'{highlight["reader_note"]}
\n')
html_content += f'{highlight["hidden_attributes"]}\n'
html_content += "
\n"
html_content += ''
html_content += "\n"
return html_content.strip()
================================================
FILE: about.txt
================================================
About KOReader Sync
A calibre plugin to synchronize metadata from KOReader to calibre.
The source code of this plugin can be found on GitHub.
If you encounter any issues with the plugin, please submit them here.
================================================
FILE: action.py
================================================
#!/usr/bin/env python3
"""KOReader Sync Plugin for Calibre."""
from datetime import datetime
from functools import partial
import io
import json
import os
import re
import sys
import importlib.util
import time
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
import ssl
from PyQt5.Qt import (
QUrl,
QTimer,
QTime,
QTableWidget,
QTableWidgetItem,
QHBoxLayout,
QVBoxLayout,
QDialog,
QLabel,
QIcon,
QPushButton,
QScrollArea,
QProgressBar,
QApplication,
Qt,
QThread,
pyqtSignal,
)
from calibre_plugins.koreader.slpp import slpp as lua
from calibre_plugins.koreader.config import (
SUPPORTED_DEVICES,
UNSUPPORTED_DEVICES,
CUSTOM_COLUMN_DEFAULTS as COLUMNS,
CONFIG,
)
from calibre_plugins.koreader import (
DEBUG,
DRY_RUN,
PYDEVD,
KoreaderSync,
)
from calibre.utils.iso8601 import utc_tz, local_tz
from calibre.gui2.dialogs.message_box import MessageBox
from calibre.gui2.actions import InterfaceAction
from calibre.gui2.device import device_signals
from calibre.gui2 import (
error_dialog,
warning_dialog,
open_url,
)
from calibre.devices.usbms.driver import debug_print as root_debug_print, USBMS
from calibre.constants import numeric_version
from enum import Enum, auto
__license__ = 'GNU GPLv3'
__copyright__ = '2021, harmtemolder '
__modified_by__ = 'kyxap kyxappp@gmail.com'
__modification_date__ = '2024'
__docformat__ = 'restructuredtext en'
if numeric_version >= (5, 5, 0):
module_debug_print = partial(root_debug_print, ' koreader:action:', sep='')
else:
module_debug_print = partial(root_debug_print, 'koreader:action:')
if DEBUG and PYDEVD:
try:
sys.path.append(
# '/Applications/PyCharm.app/Contents/debug-eggs/pydevd-pycharm.egg' # macOS
'/opt/pycharm-professional/debug-eggs/pydevd-pycharm.egg'
# Manjaro Linux
)
import pydevd_pycharm
pydevd_pycharm.settrace(
'localhost', stdoutToServer=True, stderrToServer=True,
suspend=False
)
except Exception as e:
module_debug_print('could not start pydevd_pycharm, e = ', e)
PYDEVD = False
class GetSidecarStatus(Enum):
PATH_NOT_FOUND = auto()
DECODE_FAILED = auto()
class OperationStatus(Enum):
PASS = auto()
FAIL = auto()
SKIP = auto()
def is_system_path(path):
"""
KOreader user may have some files in the root which we want to skip to
avoid showing warning message
:param path: path to sidecar file (*.lua)
:return: true/false if partial match found
"""
to_ignore = ['kfmon.sdr', 'koreader.sdr']
return any(substring in path for substring in to_ignore)
def append_results(results, title, status_msg, book_uuid, sidecar_path):
debug_print = partial(
module_debug_print,
'KoreaderAction:append_results:'
)
debug_print(f'{sidecar_path} - {status_msg}')
return results.append(
{
'title': title,
'result': status_msg,
'book_uuid': book_uuid,
'sidecar_path': sidecar_path,
}
)
def parse_sidecar_lua(sidecar_lua):
"""Parses a sidecar Lua file into a Python dict
:param sidecar_lua: the contents of a sidecar Lua as a str
:return: a dict of those contents
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:parse_sidecar_lua:'
)
try:
clean_lua = re.sub(r'^[^{]*', '', sidecar_lua).strip()
decoded_lua = lua.decode(clean_lua)
except:
debug_print('could not decode sidecar_lua')
decoded_lua = None
if 'bookmarks' in decoded_lua:
if isinstance(decoded_lua['bookmarks'], list):
decoded_lua['bookmarks'] = {
# Starts from 1
i+1: bookmark for i, bookmark in enumerate(decoded_lua['bookmarks'])}
debug_print('calculating first and last bookmark dates')
bookmark_dates = [
datetime.strptime(
bookmark['datetime'],
'%Y-%m-%d %H:%M:%S'
).replace(tzinfo=utc_tz)
for bookmark in decoded_lua['bookmarks'].values()
]
if len(bookmark_dates) > 0:
decoded_lua['calculated'] = {
'first_bookmark': min(bookmark_dates),
'last_bookmark': max(bookmark_dates),
}
return decoded_lua
class KoreaderAction(InterfaceAction):
name = KoreaderSync.name
action_spec = (name, 'edit-redo.png', KoreaderSync.description, None)
action_add_menu = True
action_menu_clone_qaction = 'Sync from KOReader'
dont_add_to = frozenset(
[
'context-menu', 'context-menu-device', 'menubar',
'menubar-device', 'context-menu-cover-browser',
'context-menu-split']
)
dont_remove_from = frozenset()
action_type = 'current'
def genesis(self):
debug_print = partial(module_debug_print, 'KoreaderAction:genesis:')
debug_print('start')
base = self.interface_action_base_plugin
if hasattr(base, 'version_string') and base.version_string:
self.version = f'{base.name} (v{base.version_string})'
else:
self.version = f'{base.name} (v{".".join(map(str, base.version))})'
self.extension_callback = None
# Overwrite icon with actual KOReader logo
icon = get_icons(
'images/icon.png'
)
self.qaction.setIcon(icon)
# Left-click action
self.qaction.triggered.connect(self.exec_main_action)
# Right-click menu (already includes left-click action)
# TODO: Sync calibre to KOReader is disabled see more in #8
self.create_menu_action(
self.qaction.menu(),
'Sync missing to KOReader',
'Sync missing to KOReader',
icon='edit-undo.png',
description='If calibre has an entry in the "Raw sidecar column", '
'but KOReader does not have a sidecar file, push the '
'metadata from calibre to a new sidecar file.',
triggered=self.sync_missing_sidecars_to_koreader
)
self.create_menu_action(
self.qaction.menu(),
'Sync from ProgressSync',
'Sync from ProgressSync',
icon='convert.png',
description="Use KOReader's built in ProgressSync Plugin "
"to update percentRead int or float.",
triggered=self.sync_progress_from_progresssync
)
self.qaction.menu().addSeparator()
self.create_menu_action(
self.qaction.menu(),
'Configure KOReader Sync',
'Configure',
icon='config.png',
description='Configure KOReader Sync',
triggered=self.show_config
)
self.qaction.menu().addSeparator()
self.create_menu_action(
self.qaction.menu(),
'Readme for KOReader Sync',
'Readme',
icon='dialog_question.png',
description='Readme for KOReader Sync',
triggered=self.show_readme
)
self.create_menu_action(
self.qaction.menu(),
'About KOReader Sync',
'About',
icon='dialog_information.png',
description='About KOReader Sync',
triggered=self.show_about
)
# Start the scheduled progress sync if enabled
if CONFIG["checkbox_enable_scheduled_progressync"]:
self.scheduled_progress_sync()
# Start the device connection watcher if enabled
if CONFIG["checkbox_enable_automatic_sync"]:
device_signals.device_metadata_available.connect(
self._on_device_metadata_available)
basedir = os.path.dirname(base.plugin_path)
for filename in os.listdir(basedir):
if filename.startswith("KOSync_extension") and filename.endswith(".py"):
filepath = os.path.join(basedir, filename)
try:
spec = importlib.util.spec_from_file_location(
"KOSync_extension", filepath)
extension = importlib.util.module_from_spec(spec)
spec.loader.exec_module(extension)
if hasattr(extension, "onItemUpdate"):
self.extension_callback = extension.onItemUpdate
print(f"Loaded onItemUpdate from {filename}")
return
except Exception as e:
print(f"Failed to load extension: {e}")
def is_usb_device(self, device):
"""Returns True if the device is connected via USB Mass Storage or Folder Device."""
return isinstance(device, USBMS) or device.__class__.__name__ == 'FOLDER_DEVICE'
def exec_main_action(self) -> None:
# Execute main action defined by user
main_button = CONFIG['main_action']
if main_button == 'KOReader Sync':
self.sync_to_calibre()
elif main_button == 'Progress Sync':
self.sync_progress_from_progresssync()
else:
self.sync_to_calibre()
def show_config(self):
self.interface_action_base_plugin.do_user_config(self.gui)
def show_readme(self):
debug_print = partial(module_debug_print,
'KoreaderAction:show_readme:')
debug_print('start')
readme_url = QUrl(
'https://github.com/harmtemolder/koreader-calibre-plugin#readme'
)
open_url(readme_url)
def show_about(self):
debug_print = partial(module_debug_print, 'KoreaderAction:show_about:')
debug_print('start')
text = get_resources('about.txt').decode(
'utf-8'
)
if DEBUG:
text += '\n\nRunning in debug mode'
icon = get_icons(
'images/icon.png'
)
about_dialog = MessageBox(
MessageBox.INFO,
f'About {self.version}',
text,
det_msg='',
q_icon=icon,
show_copy_button=False,
parent=None,
)
return about_dialog.exec_()
def apply_settings(self):
debug_print = partial(
module_debug_print,
'KoreaderAction:apply_settings:'
)
debug_print('start')
def get_connected_device(self):
"""Tries to get the connected device, if any
:return: the connected device object or None
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:get_connected_device:'
)
try:
is_device_present = self.gui.device_manager.is_device_present
except:
is_device_present = False
if not is_device_present:
debug_print('is_device_present = ', is_device_present)
error_dialog(
self.gui,
'No device found',
'No device found',
det_msg='',
show=True,
show_copy_button=False
)
return None
try:
connected_device = self.gui.device_manager.connected_device
connected_device_type = connected_device.__class__.__name__
except:
debug_print('could not get connected_device')
error_dialog(
self.gui,
'Could not connect to device',
'Could not connect to device',
det_msg='',
show=True,
show_copy_button=False
)
return None
debug_print('connected_device_type = ', connected_device_type)
return connected_device
def _on_device_metadata_available(self):
self.sync_to_calibre(silent=not DEBUG)
def get_paths(self, device):
"""Retrieves paths to sidecars of all books in calibre's library
on the device
:param device: a device object
:return: a list of (uuid, path) tuples to sidecars
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:get_paths:'
)
debug_print(
f'found {len(device.books())} paths to books:\n\t',
'\n\t'.join([book.path for book in device.books()])
)
for book in device.books():
debug_print(f'uuid to path: {book.uuid} - {book.path}')
paths = []
for book in device.books():
# Ignore hidden folders (issue #101)
if any(part.startswith('.') for part in book.path.replace('\\\\', '/').split('/')):
debug_print(f'Ignoring book in hidden folder: {book.path}')
continue
sidecar_path = re.sub(
r'\.([^./\\]+)$', r'.sdr/metadata.\1.lua', book.path
)
paths.append((book.uuid, sidecar_path))
debug_print(
f'generated {len(paths)} path(s) to sidecar Lua files:\n\t',
'\n\t'.join([p[1] for p in paths])
)
return paths
def get_sidecar(self, device, path):
"""Requests the given path from the given device and returns the
contents of a sidecar Lua as Python dict
:param device: a device object
:param path: a path to a sidecar Lua on the device
:return: dict or None
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:get_sidecar:'
)
with io.BytesIO() as outfile:
try:
device.get_file(path, outfile)
except:
debug_print('could not get ', path)
return GetSidecarStatus.PATH_NOT_FOUND
contents = outfile.getvalue()
try:
decoded_contents = contents.decode()
except UnicodeDecodeError:
debug_print('could not decode ', contents)
return GetSidecarStatus.DECODE_FAILED
debug_print(f'Parsing: {path}')
parsed_contents = parse_sidecar_lua(decoded_contents)
parsed_contents['calculated'] = {}
# Ensure 'summary' exists to avoid KeyError later (#117)
if 'summary' not in parsed_contents:
debug_print(f"Warning: 'summary' key missing in sidecar for {path}")
parsed_contents['summary'] = {'status': 'unknown', 'modified': datetime.now().strftime("%Y-%m-%d")}
# Define metadata extraction tasks
is_usb = self.is_usb_device(device)
metadata_tasks = [
('date_synced', lambda: datetime.now().replace(tzinfo=local_tz)),
('date_status_changed', lambda: datetime.strptime(
parsed_contents['summary'].get('modified', datetime.now().strftime("%Y-%m-%d")), "%Y-%m-%d").replace(tzinfo=local_tz)),
('date_sidecar_modified', lambda: datetime.fromtimestamp(
os.path.getmtime(path) if is_usb and os.path.exists(path) else time.time()).replace(tzinfo=local_tz))
]
for key, task in metadata_tasks:
try:
parsed_contents['calculated'][key] = task()
except Exception as error:
debug_print(f'Failed to set {key}: {error}')
return parsed_contents
def get_calibre_uuid_from_sidecar(self, sidecar_contents):
"""Extracts the calibre UUID from sidecar identifiers if present.
(Issue #115)
"""
if not isinstance(sidecar_contents, dict):
return None
stats = sidecar_contents.get('stats', {})
identifiers_str = stats.get('identifiers', '')
if not identifiers_str:
return None
# KOReader uses both space and \ as separators in some versions
parts = re.split(r'[\s\\]+', identifiers_str)
for part in parts:
if part.startswith('calibre:'):
return part.replace('calibre:', '').strip()
return None
def update_metadata(self, uuid, db, keys_values_to_update):
"""Update multiple metadata columns for the given book.
:param uuid: identifier for the book
:param keys_values_to_update: a dict of keys to update with values
:return: a dict of values that can be used to report back to the user
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:update_metadata:'
)
try:
debug_print('Looking for uuid in calibre db: ', uuid)
book_id = db.lookup_by_uuid(uuid)
except:
book_id = None
if not book_id:
debug_print(f'could not find {uuid} in calibre\'s library')
return OperationStatus.SKIP, {
'result': 'could not find uuid in calibre\'s library, have you deleted this book from library?'}
# Get the current metadata for the book from the library
metadata = db.get_metadata(book_id)
# Dict for use in logging
updateLog = {}
read_percent_key = CONFIG['column_percent_read'] or CONFIG['column_percent_read_int']
# Check config to sync only if data is more recent
if CONFIG['checkbox_sync_if_more_recent']:
date_modified_key = CONFIG['column_date_sidecar_modified']
current_date_modified = metadata.get(date_modified_key)
new_date_modified = keys_values_to_update.get(date_modified_key)
if current_date_modified is not None and new_date_modified is not None:
if current_date_modified.timestamp() >= new_date_modified.timestamp():
debug_print(
f'book {book_id} date_modified {new_date_modified} older than current {current_date_modified}')
return OperationStatus.SKIP, {
'result': 'skipped, data in calibre is newer',
}
# Fallback if no 'Date Modified Column' is set or not obtainable (wireless)
elif new_date_modified is None:
current_read_percent = metadata.get(read_percent_key)
new_read_percent = keys_values_to_update.get(read_percent_key)
if current_read_percent is not None and new_read_percent is not None:
if current_read_percent >= new_read_percent:
debug_print(
f'book {book_id} read_percent {new_read_percent} lower or equal than current {current_read_percent}')
return OperationStatus.SKIP, {
'result': 'skipped, read Percent is lower or equal to the one stored in calibre',
'book_id': book_id,
}
elif current_read_percent is not None and new_read_percent is None:
debug_print(
f'book {book_id} read_percent is None but existing is {current_read_percent}')
return OperationStatus.SKIP, {
'result': 'skipped, no new read percent found',
}
# Check config to sync only if the book is not yet finished
status_key = CONFIG['column_status']
if CONFIG['checkbox_no_sync_if_finished']:
current_read_percent = metadata.get(read_percent_key)
current_status = metadata.get(status_key)
if current_read_percent is not None and current_read_percent >= 100 \
or current_status is not None and current_status == "complete":
debug_print(f'book {book_id} was already finished')
return OperationStatus.SKIP, {
'result': 'skipped, book already finished',
}
# Check and correct reading status if required
if status_key:
new_status = keys_values_to_update.get(status_key)
if not new_status:
new_read_percent = keys_values_to_update.get(read_percent_key)
current_status = metadata.get(status_key)
if new_read_percent and current_status != "abandoned":
if new_read_percent > 0 and new_read_percent < 100 and current_status != "reading":
debug_print(
f'book {book_id} set column_status to reading')
keys_values_to_update[status_key] = "reading"
status_bool_key = CONFIG['column_status_bool']
if status_bool_key:
keys_values_to_update[status_bool_key] = False
elif new_read_percent >= 100 and current_status != "complete":
debug_print(
f'book {book_id} set column_status to complete')
keys_values_to_update[status_key] = "complete"
status_bool_key = CONFIG['column_status_bool']
if status_bool_key:
keys_values_to_update[status_bool_key] = True
# Call the extension callback if it exists
if self.extension_callback:
try:
updateLog = self.extension_callback(
self=self,
metadata=metadata,
keys_values_to_update=keys_values_to_update,
updateLog=updateLog,
CONFIG=CONFIG,
book_id=book_id
)
except Exception as e:
debug_print(f'Error in extension onItemUpdate: {e}')
updates = []
# Update that metadata locally
for key, new_value in keys_values_to_update.items():
old_value = metadata.get(key)
if new_value != old_value:
updates.append(key)
metadata.set(key, new_value)
updateLog[key] = f'{old_value} >> {new_value}'
else:
if DEBUG:
updateLog[key] = f'{old_value} -- {new_value}'
# Write the updated metadata back to the library
if len(updates) == 0:
updateLog['result'] = 'no updates needed'
debug_print(
'no changed metadata for uuid = ', uuid,
', id = ', book_id
)
elif DEBUG and DRY_RUN:
debug_print(
'would have updated the following fields for uuid = ',
uuid, ', id = ', book_id, ': ', updates
)
else:
db.set_metadata(
book_id, metadata, set_title=False,
set_authors=False
)
debug_print(
'updated the following fields for uuid = ', uuid,
', id = ', book_id, ': ', updates
)
return OperationStatus.PASS, {
'result': 'success',
**updateLog
}
def check_device(self, device):
"""Return .
:param device: The connected device.
:return: False if device is specifically not supported,
otherwise True
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:check_device:'
)
if not device:
return False
device_class = device.__class__.__name__
if device_class in UNSUPPORTED_DEVICES:
debug_print('unsupported device, device_class = ', device_class)
error_dialog(
self.gui,
'Device not supported',
f'Devices of the type {device_class} are not supported by this plugin. I '
f'have tried to get it working, but couldn’t. Sorry.',
det_msg='',
show=True,
show_copy_button=False
)
return False
if device_class in SUPPORTED_DEVICES:
return True
debug_print(
'not yet supported device, device_class = ',
device_class
)
warning_dialog(
self.gui,
'Device not yet supported',
f'Devices of the type {device_class} are not yet supported by this plugin. '
f'Please check if there already is a feature request for this '
f''
f'here. If not, feel free to create one. I\'ll try to sync anyway.',
det_msg='',
show=True,
show_copy_button=False
)
return True
def device_path_exists(self, device, path):
"""Checks if a path exists on the device, with timing debug logs."""
debug_print = partial(
module_debug_print,
'KoreaderAction:device_path_exists:'
)
start_time = time.time()
exists = False
method = "unknown"
# 1. Try native driver exists() if available
if hasattr(device, 'exists'):
try:
exists = device.exists(path)
method = "driver.exists"
except:
pass
# 2. Try local filesystem (for USB)
if not exists and self.is_usb_device(device):
try:
if os.path.exists(path):
exists = True
method = "os.path.exists"
except:
pass
# 3. Try get_file (for Wireless) - this is the "expensive" fallback
if not exists and method == "unknown":
try:
with io.BytesIO() as dummy:
device.get_file(path, dummy)
exists = True
method = "device.get_file"
except:
exists = False
method = "device.get_file (failed)"
end_time = time.time()
debug_print(f"Path: {path} | Exists: {exists} | Method: {method} | Time: {end_time - start_time:.4f}s")
return exists
def push_metadata_to_koreader_sidecar(self, device, book_uuid, path):
"""Create a sidecar file for the given book.
:param device: The connected device object
:param book_uuid: Calibre's uuid for the book
:param path: path to sidecar file to create
:return: tuple of bool and result dict
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:push_metadata_to_koreader_sidecar:'
)
try:
db = self.gui.current_db.new_api
book_id = db.lookup_by_uuid(book_uuid)
debug_print(f"Book id is {book_id}")
except:
book_id = None
if not book_id:
debug_print(f'could not find {book_uuid} in calibre’s library')
return "failure", {
'result': f"Could not find uuid {book_uuid} in Calibre's "
f"library."
}
# Get the current metadata for the book from the library
metadata = db.get_metadata(book_id)
sidecar_metadata = metadata.get(CONFIG["column_sidecar"])
if not sidecar_metadata:
return "no_metadata", {
'result': f'No KOReader metadata for book_id {book_id}, no '
f'need to push.'
}
sidecar_dict = json.loads(sidecar_metadata)
sidecar_lua = lua.encode(sidecar_dict)
# Lua -> JSON -> Lua conversion is lossy, because JSON does not support integer
# keys. This means that a key like [1] will end up as ["1"] after the round
# trip. The following regex strips the quotes from any Lua object key that consists of
# only digits. This is not entirely correct because it now converts keys with
# only digits that were originally string keys as well, but it doesn't seem that
# KOReader uses those.
sidecar_lua = re.sub(r'\["(\d+)"\]', r'[\1]', sidecar_lua)
sidecar_lua_formatted = f"-- we can read Lua syntax here!\nreturn {sidecar_lua}\n"
# Create parent directory for USB devices (Issue #68 / #73)
is_usb = self.is_usb_device(device)
if is_usb:
try:
parent_dir = os.path.dirname(path)
if not os.path.exists(parent_dir):
debug_print(f"Creating directory: {parent_dir}")
os.makedirs(parent_dir, exist_ok=True)
except OSError as os_e:
debug_print(f"Failed to create directory {parent_dir}: {os_e}")
return "failure", {
'result': f'Unable to create directory at: {path} due to {os_e}',
}
# Use direct file writing for USB/Folder devices to avoid driver-specific put_file issues (#143)
try:
with open(path, "wb") as f:
debug_print(f"Writing directly to {path}")
f.write(sidecar_lua_formatted.encode('utf-8'))
return "success", {
'result': 'success',
}
except Exception as e:
debug_print(f"Failed to write directly to {path}: {e}")
return "failure", {
'result': f'Failed to write directly to device: {e}',
}
# Use device.put_file to support wireless devices (#122)
# Check if driver supports writing arbitrary files
if not hasattr(device, 'put_file'):
debug_print(f"Device driver {device.__class__.__name__} does not support writing sidecar files wirelessly.")
return "failure", {
'result': 'Wireless write not supported by this device driver. Please use USB or Sync Server.',
}
try:
with io.BytesIO(sidecar_lua_formatted.encode('utf-8')) as f:
device.put_file(path, f)
except Exception as e:
debug_print(f"Failed to push metadata to {path}: {e}")
return "failure", {
'result': f'Failed to write to device: {e}',
}
return "success", {
'result': 'success',
}
def sync_missing_sidecars_to_koreader(self, silent=False):
"""Push the content of Calibre's raw metadata column to KOReader
for any files which are missing in KOReader. Does not touch existing
metadata sidecars on KOReader.
Intended for e.g. setting up a new device and syncing to it for the first
time.
:return:
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:sync_missing_sidecars_to_koreader:'
)
if CONFIG["column_sidecar"] == '':
error_dialog(
self.gui,
'Failure',
'Raw metadata column not mapped, impossible to push metadata to sidecars',
show=True,
show_copy_button=False
)
return None
device = self.get_connected_device()
if not self.check_device(device):
return None
sidecar_paths = self.get_paths(device)
debug_print('sidecar_paths: ', sidecar_paths)
results = []
num_processed = 0
num_success = 0
num_no_metadata = 0
num_fail = 0
num_skipped_existing = 0
for book_uuid, path in sidecar_paths:
# Check if exists first (issue #122 revisited)
if self.device_path_exists(device, path):
debug_print(f"Skipping existing sidecar: {path}")
num_skipped_existing += 1
continue
num_processed += 1
result, details = self.push_metadata_to_koreader_sidecar(device, book_uuid,
path)
if result == "success":
num_success += 1
results.append(
{
**details,
'book_uuid': book_uuid,
'sidecar_path': path,
}
)
elif result == "failure":
num_fail += 1
results.append(
{
**details,
'book_uuid': book_uuid,
'sidecar_path': path,
}
)
elif result == "no_metadata":
num_no_metadata += 1
results.append(
{
**details,
'book_uuid': book_uuid,
'sidecar_path': path,
}
)
if not silent:
results_message = (
f'{len(sidecar_paths)} books on device.\n'
f'{num_skipped_existing} books already have sidecars (skipped).\n'
f'Sidecar creation succeeded for {num_success}.\n'
f'Sidecar creation failed for {num_fail}.\n'
f'No attempt made for {num_no_metadata} (no metadata in Calibre to push).\n'
f'See below for details.'
)
if num_success > 0 and num_fail > 0:
SyncCompletionDialog(
self.gui,
'Results',
results_message,
results,
'warn'
)
elif num_success > 0 or num_no_metadata > 0: # and num_fail == 0
SyncCompletionDialog(
self.gui,
'Success',
results_message,
results,
'info'
)
else:
SyncCompletionDialog(
self.gui,
'Failure',
results_message,
results,
'error'
)
def sync_progress_from_progresssync(self, silent=False):
"""Use KOReader's ProgressSync Server to update Calibre metadata rather than a manual sync.
Intended to easily update Calibre with the latest reading progress from KOReader.
:return:
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:sync_progress_from_progresssync:'
)
md5_column = CONFIG["column_md5"]
if md5_column == '':
error_dialog(
self.gui,
'Failure',
'MD5 column not mapped, impossible to get metadata from Progress Sync Server',
show=True,
show_copy_button=False
)
return None
if CONFIG["progress_sync_password"] == '':
error_dialog(
self.gui,
'Failure',
'Progress Sync Account is not logged in, add credentials in plugin settings',
show=True,
show_copy_button=False
)
return None
status_key = CONFIG['column_status']
read_percent_key = CONFIG['column_percent_read_int'] or CONFIG['column_percent_read']
if read_percent_key == '' or status_key == '':
error_dialog(
self.gui,
'Failure',
'This feature needs a KOReader Progress (int or float) and Status Text column.\n'
'Add those in plugin settings and try again.',
show=True,
show_copy_button=False
)
return None
'Get list of books with MD5 column'
db = self.gui.current_db.new_api
books_with_md5 = db.search(f'{md5_column}:!''')
results = []
num_success = 0
num_skip = 0
headers = {
'x-auth-user': CONFIG["progress_sync_username"],
'x-auth-key': CONFIG["progress_sync_password"],
'Accept': 'application/vnd.koreader.v1+json',
'Connection': 'keep-alive',
'Cache-Control': 'no-cache',
'User-Agent': f'CalibreKOReaderSync/{self.version}'
}
# Create SSL context based on user preference
ssl_context = ssl.create_default_context()
if CONFIG['checkbox_skip_ssl_verification']:
# Skip SSL verification for custom servers with self-signed certificates
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
for book_id in books_with_md5:
metadata = db.get_metadata(book_id)
md5_value = metadata.get(md5_column)
book_uuid = metadata.get('uuid')
title = metadata.get('title')
# Only get sync status if curr progress < 100 and status = reading or if curr_progress/status is not set yet
metadata_status = metadata.get(status_key)
metadata_read_percent = metadata.get(read_percent_key)
if (metadata_status is None or metadata_status == "reading") and (metadata_read_percent is None or metadata_read_percent < 100):
try:
url = f'{CONFIG["progress_sync_url"]}/syncs/progress/{md5_value}'
request = Request(url, headers=headers)
with urlopen(request, timeout=20, context=ssl_context) as response:
response_data = response.read()
if response_data == b'{}':
results.append({
'md5_value': md5_value,
'error': 'No ProgressSync entry for md5 hash'
})
num_skip += 1
continue
progress_data = json.loads(response_data.decode('utf-8'))
# Kinda Janky edge case handling
if len(str(progress_data)) < 8:
continue
# List of keys to check
ProgressSync_Columns = [
'column_percent_read', 'column_percent_read_int', 'column_last_read_location', 'column_date_synced', 'column_device_name', 'column_device_id']
# Map of progress_data keys to match each config key
progress_mapping = {
'column_percent_read': progress_data['percentage'] if not CONFIG["checkbox_percent_read_100"] else progress_data['percentage']*100,
'column_percent_read_int': round(progress_data['percentage']*100),
'column_last_read_location': progress_data['progress'],
'column_date_synced': datetime.fromtimestamp(progress_data['timestamp']/1000, tz=local_tz),
'column_device_name': progress_data['device'],
'column_device_id': progress_data['device_id']
}
# Change percentage to be human readable on summary screen
if CONFIG["checkbox_percent_read_100"]:
progress_data['percentage']*=100
# Dictionary to store values to be updated
keys_values_to_update = {}
# Set column_date_book_started if this is the first sync and column not already filled
date_book_started_key = CONFIG.get('column_date_book_started')
if date_book_started_key is not None:
if metadata.get(date_book_started_key) is None:
keys_values_to_update[date_book_started_key] = progress_mapping['column_date_synced']
# Set column_date_book_finished if this book is finished and column not already filled
if progress_mapping['column_percent_read_int'] >= 100:
date_book_finished_key = CONFIG.get('column_date_book_finished')
if date_book_finished_key is not None:
if metadata.get(date_book_finished_key) is None:
keys_values_to_update[date_book_finished_key] = progress_mapping['column_date_synced']
for key in ProgressSync_Columns:
# Get internal column name from CONFIG
internal_column = CONFIG.get(key, '')
if not internal_column: # Skip if internal column name is blank
continue
# Get current value from metadata
current_value = metadata.get(internal_column)
remote_value = progress_mapping[key]
# Compare current and remote values
if current_value != remote_value:
keys_values_to_update[internal_column] = remote_value
# TODO This is redundant isn't it? I can remove a whole chunk of this ngl.
# Update only if there are differences
if keys_values_to_update:
operation_status, result = self.update_metadata(
book_uuid, db, keys_values_to_update)
else:
result = {}
results.append({
**result,
'title': title,
'book_uuid': book_uuid,
'md5_value': md5_value,
**progress_data
})
num_success += 1
except (HTTPError, URLError) as e:
msg = f'Failed to make progress sync query: {url}, error: {str(e)}'
debug_print(msg)
results.append({
'title': title,
'book_uuid': book_uuid,
'md5_value': md5_value,
'error': 'No data received'
})
num_skip += 1
else:
results.append({
'title': title,
'book_uuid': book_uuid,
'md5_value': md5_value,
'error': 'Book has already been read'
})
num_skip += 1
if not silent:
results_message = (
f'Total books with MD5 values: {len(books_with_md5)}\n\n'
f'Successful syncs: {num_success}\n'
f'Failed/Skipped syncs: {num_skip}\n\n'
)
if num_success > 0 and num_skip == 0:
SyncCompletionDialog(
self.gui,
'Progress sync finished',
results_message + 'All looks good!\n\n',
results,
'info'
)
elif num_skip > 0:
SyncCompletionDialog(
self.gui,
'Some syncs failed',
results_message + 'There were some errors during the sync process!\n'
'Please investigate and report if it looks like a bug\n\n',
results,
'warn'
)
else:
SyncCompletionDialog(
self.gui,
'No successful syncs',
results_message + 'No successful syncs\n'
'Please investigate and report if it looks like a bug\n\n',
results,
'error'
)
def scheduled_progress_sync(self):
def scheduledTask():
# Set another timer for the next day and order sync
QTimer.singleShot(24 * 3600 * 1000, scheduledTask)
self.sync_progress_from_progresssync(
silent=True if not DEBUG else False)
def main():
# Get current local time
currentTime = QTime.currentTime()
# Set target time to user inputted time
targetTime = QTime(
CONFIG["scheduleSyncHour"], CONFIG["scheduleSyncMinute"])
# Calculate the time difference
timeDiff = currentTime.msecsTo(targetTime)
# If target time has already passed today, set the target time for tomorrow
if timeDiff < 0:
timeDiff = timeDiff + 86400000
# Create a QTimer to trigger the task at the desired time
QTimer.singleShot(timeDiff, scheduledTask)
main() # Runs scheduled_progress_sync
def sync_to_calibre(self, silent=False):
"""This plugin’s main purpose. It syncs the contents of
KOReader’s metadata sidecar files into calibre’s metadata.
:return:
"""
debug_print = partial(
module_debug_print,
'KoreaderAction:sync_to_calibre:'
)
device = self.get_connected_device()
if not self.check_device(device):
return None
sidecar_paths = self.get_paths(device)
debug_print('sidecar_paths:', sidecar_paths)
class KOSyncWorker(QThread):
progress_update = pyqtSignal(int, str)
finished_signal = pyqtSignal(dict)
def __init__(self, action, db, sidecar_paths):
super().__init__()
self.action = action
self.db = db
self.sidecar_paths = sidecar_paths
def run(self):
results = []
num_success = 0
num_fail = 0
num_skip = 0
for idx, (book_uuid, sidecar_path) in enumerate(self.sidecar_paths):
debug_print('Trying to get sidecar from ', device,
', with sidecar_path: ', sidecar_path)
# pre-checks before parsing
if book_uuid is None:
status = 'skipped, no UUID'
append_results(results, None, status,
book_uuid, sidecar_path)
num_skip += 1
continue
sidecar_contents = self.action.get_sidecar(
device, sidecar_path)
debug_print("sidecar_contents:", sidecar_contents)
try:
book_id = db.lookup_by_uuid(book_uuid)
if not book_id:
# Try to find a better UUID in the sidecar (Issue #115)
better_uuid = self.action.get_calibre_uuid_from_sidecar(sidecar_contents)
if better_uuid:
debug_print(f"Found alternative UUID in sidecar: {better_uuid}")
book_id = db.lookup_by_uuid(better_uuid)
if book_id:
book_uuid = better_uuid # Use the one that worked
if not book_id:
raise Exception("Book not found")
metadata = db.get_metadata(book_id)
title = metadata.get('title')
except Exception as e:
debug_print(f"Failed to lookup book {book_uuid}: {e}")
status = 'skipped, could not find in library'
append_results(results, "Unknown", status,
book_uuid, sidecar_path)
num_skip += 1
continue
self.progress_update.emit(idx + 1, title)
if DEBUG: # Add time delay when debugging
time.sleep(.4)
if sidecar_contents is GetSidecarStatus.PATH_NOT_FOUND:
status = ('skipped, sidecar does not exist '
'(seems like book is never opened)')
append_results(results, title, status,
book_uuid, sidecar_path)
num_skip += 1
continue
if sidecar_contents is GetSidecarStatus.DECODE_FAILED:
status = 'decoding is failed see debug for more details'
append_results(results, title, status,
book_uuid, sidecar_path)
num_fail += 1
continue
debug_print('sidecar_contents is found!')
keys_values_to_update = {}
for config_name, column in COLUMNS.items():
target = CONFIG[config_name]
if target == '':
# No column mapped, so do not sync
continue
# Special handling for date started/finished
# Safety check for 'summary' key (#117)
summary = sidecar_contents.get('summary', {})
if config_name == 'column_date_book_started':
if metadata.get(target) is None and summary.get('status') == 'reading':
sidecar_contents['calculated']['date_book_started'] = sidecar_contents['calculated'].get('date_status_changed')
if config_name == 'column_date_book_finished':
if metadata.get(target) is None and summary.get('status') == 'complete':
sidecar_contents['calculated']['date_book_finished'] = sidecar_contents['calculated'].get('date_status_changed')
data_location = column['data_location']
value = sidecar_contents
for subproperty in data_location:
if value and subproperty in value:
value = value[subproperty]
else:
debug_print(
f'subproperty "{subproperty}" not found in value')
value = None
break
# Fallback for MD5 (Issue #98)
if config_name == 'column_md5' and value is None:
value = sidecar_contents.get('stats', {}).get('md5')
if value:
debug_print('Found MD5 in fallback location (stats.md5)')
if value is None:
continue
# Transform value if required
if 'transform' in column:
debug_print('transforming value for ', target)
value = column['transform'](value)
keys_values_to_update[target] = value
operation_status, result = self.action.update_metadata(
book_uuid, db, keys_values_to_update)
results.append(
{
**result,
'title': title,
'book_uuid': book_uuid,
'sidecar_path': sidecar_path,
**({'updated': json.dumps(keys_values_to_update, default=str)} if DEBUG else {})
}
)
if operation_status == OperationStatus.PASS:
num_success += 1
elif operation_status == OperationStatus.FAIL:
num_fail += 1
elif operation_status == OperationStatus.SKIP:
num_skip += 1
self.finished_signal.emit(
{'results': results, 'num_success': num_success, 'num_fail': num_fail, 'num_skip': num_skip})
db = self.gui.current_db.new_api
startTime = time.perf_counter()
self.koSyncWorker = KOSyncWorker(self, db, sidecar_paths)
progress_dialog = None
if not silent and len(sidecar_paths) > 10:
progress_dialog = ProgressDialog(
self.gui, "Syncing Sidecars...", len(sidecar_paths))
progress_dialog.show()
self.koSyncWorker.progress_update.connect(progress_dialog.setValue)
def on_finished(res):
if not silent:
if progress_dialog:
progress_dialog.close()
results_message = (
f"Total targets found: {len(sidecar_paths)}\n\n"
f"Metadata sync succeeded for: {res['num_success']}\n"
f"Metadata sync skipped for: {res['num_skip']}\n"
f"Metadata sync failed for: {res['num_fail']}\n"
f"Time taken: {time.perf_counter() - startTime:.4f} seconds.\n\n"
)
# Sort by if error, then # of changes
res['results'].sort(key=lambda row: (
not row.get('error', False), -len(row)))
if res['num_success'] > 0 and res['num_fail'] == 0:
SyncCompletionDialog(
self.gui,
'Metadata sync finished',
results_message + 'All looks good!\n\n',
res['results'],
'info'
)
elif res['num_fail'] > 0:
SyncCompletionDialog(
self.gui,
'Some sync failed',
results_message + 'There was some error during sync process!\n'
'Please investigate and report if it looks '
'like a bug\n\n',
res['results'],
'error'
)
elif res['num_success'] == 0 and res['num_fail'] == 0:
SyncCompletionDialog(
self.gui,
'No errors but not successful syncs',
results_message + 'No errors but no successful syncs\n'
'Do you have book(s) which are ready to be '
'sync?\n'
'Please investigate and report if it looks '
'like a bug\n\n',
res['results'],
'warn'
)
else:
error_dialog(
self.gui,
'Edge case',
results_message + 'Seems like and bug, please report ASAP\n\n',
det_msg=json.dumps(res['results'], indent=2),
show=True,
show_copy_button=False
)
self.koSyncWorker.finished_signal.connect(on_finished)
self.koSyncWorker.start()
class ProgressDialog(QDialog):
def __init__(self, parent, title: str, count: int):
super().__init__(parent)
self.setWindowTitle(title)
self.setWindowModality(Qt.WindowModal)
layout = QVBoxLayout(self)
self.progressBar = QProgressBar(self)
self.progressBar.setMinimum(0)
self.progressBar.setMaximum(count)
self.progressBar.setFormat("%v of %m")
layout.addWidget(self.progressBar)
self.currBook = QLabel('Beginning Sync')
layout.addWidget(self.currBook)
def setValue(self, idx: int, bookTitle: str):
self.progressBar.setValue(idx)
self.currBook.setText(bookTitle)
class SyncCompletionDialog(QDialog):
def __init__(self, parent=None, title="", msg="", results=None, type=None):
super().__init__(parent)
self.setWindowTitle(title)
self.setMinimumWidth(800)
self.setMinimumHeight(800)
layout = QVBoxLayout(self)
layout.setSpacing(10)
# Main Message Area
mainMessageLayout = QHBoxLayout()
type_icon = {
'info': 'dialog_information',
'error': 'dialog_error',
'warn': 'dialog_warning',
}.get(type)
if type_icon is not None:
icon = QIcon.ic(f'{type_icon}.png')
self.setWindowIcon(icon)
icon_widget = QLabel(self)
icon_widget.setPixmap(icon.pixmap(64, 64))
mainMessageLayout.addWidget(icon_widget)
message_label = QLabel(msg)
mainMessageLayout.addWidget(message_label)
mainMessageLayout.addStretch() # Left align the message/text
layout.addLayout(mainMessageLayout)
# Table in scrollable area if results are provided
if results:
self.table_area = QScrollArea(self)
self.table_area.setWidgetResizable(True)
table = self.create_results_table(results)
self.table_area.setWidget(table)
layout.addWidget(self.table_area)
# Bottom Buttons
bottomButtonLayout = QHBoxLayout()
if results:
copy_button = QPushButton("COPY", self)
copy_button.setFixedWidth(200)
copy_button.setIcon(QIcon.ic('edit-copy.png'))
copy_button.clicked.connect(lambda: (
QApplication.clipboard().setText(str(results)),
copy_button.setText('Copied')
))
bottomButtonLayout.addWidget(copy_button)
bottomButtonLayout.addStretch() # Right align the rest of this layout
ok_button = QPushButton("OK", self)
ok_button.setFixedWidth(200)
ok_button.setIcon(QIcon.ic('ok.png'))
ok_button.clicked.connect(self.accept)
ok_button.setDefault(True)
bottomButtonLayout.addWidget(ok_button)
layout.addLayout(bottomButtonLayout)
self.show()
def create_results_table(self, results):
# Get all possible headers from results and save as set
all_headers = {key for result in results for key in result.keys()}
headers = []
custom_columns = sorted(h for h in all_headers
if h not in ('title', 'book_uuid', 'result', 'error'))
if 'title' in all_headers:
headers.append('title')
if 'book_uuid' in all_headers:
headers.append('book_uuid')
if 'result' in all_headers:
headers.append('result')
if 'error' in all_headers:
headers.append('error')
if custom_columns:
headers.extend(custom_columns)
table = QTableWidget()
table.setRowCount(len(results))
table.setColumnCount(len(headers))
table.setHorizontalHeaderLabels(headers)
for row, result in enumerate(results):
for col, header in enumerate(headers):
item = QTableWidgetItem(str(result.get(header, "")))
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
# Set the tooltip to the full text
item.setToolTip(item.text())
table.setItem(row, col, item)
max_lines = 1
for col, header in enumerate(headers):
words, line, lines, col_len_limit = header.split(
), "", [], max(table.columnWidth(col) // 7, 10)
for word in words:
line = f"{line} {word}".strip()
if len(line) > col_len_limit:
lines.append(line.rsplit(' ', 1)[0])
line = word if ' ' in line else ''
lines.append(line)
max_lines = max(len(lines), max_lines)
wrapped = '\n'.join(lines)
table.setHorizontalHeaderItem(col, QTableWidgetItem(wrapped))
table.horizontalHeader().setFixedHeight(20 * max_lines) # Default = 20
return table
================================================
FILE: config.py
================================================
#!/usr/bin/env python3
"""Config for KOReader Sync plugin for Calibre."""
import os
import json
from functools import partial
from PyQt5.Qt import (
QComboBox,
QCheckBox,
QGroupBox,
QPushButton,
QLabel,
QLineEdit,
QHBoxLayout,
QVBoxLayout,
QFormLayout,
QWidget,
QSpinBox,
QFrame,
QDialog,
Qt,
)
from PyQt5.QtGui import QPixmap
from calibre.constants import numeric_version
from calibre.devices.usbms.driver import debug_print as root_debug_print
from calibre.utils.config import JSONConfig
from calibre_plugins.koreader import clean_bookmarks
from calibre.gui2 import show_restart_warning
__license__ = 'GNU GPLv3'
__copyright__ = '2021, harmtemolder '
__modified_by__ = 'kyxap kyxappp@gmail.com'
__modification_date__ = '2024'
__docformat__ = 'restructuredtext en'
SUPPORTED_DEVICES = [
'FOLDER_DEVICE',
'KINDLE2',
'KOBO',
'KOBOTOUCH',
'KOBOTOUCHEXTENDED',
'POCKETBOOK622',
'POCKETBOOK626',
'POCKETBOOK632',
'POCKETBOOK_IMPROVED',
'SMART_DEVICE_APP',
'TOLINO',
'USER_DEFINED',
]
UNSUPPORTED_DEVICES = [
'MTP_DEVICE',
]
try:
from calibre.gui2.preferences.create_custom_column import CreateNewCustomColumn
SUPPORTS_CREATE_CUSTOM_COLUMN = True
except ImportError:
SUPPORTS_CREATE_CUSTOM_COLUMN = False
"""
Each entry in the below dict has the following keys:
Each entry is keyed by the name of the config item used to store the selected column's lookup name
first_in_group (optional): If present and true, a separator will be added before this item in the Config UI.
If this is a string a QLabel with bolded string value will be added below the separator.
column_heading: Default custom column heading
datatype: Custom column datatype
is_multiple (optional): For text columns, specified as a tuple (default_multiple, only_multiple_in_dropdown)
additional_params (optional): Additional parameters for the custom column display parameter as specified in the calibre API as a dictionary.
https://github.com/kovidgoyal/calibre/blob/bc29562c0c8534b349c9d330ac9aec72eef2be99/src/calibre/gui2/preferences/create_custom_column.py#L901
description: Default custom column description
default_lookup_name: The suggested column lookup string in calibre (e.g. "#ko_progfloat")
config_label: Label for the item in the Config UI
config_tool_tip: Tooltip for the item in the Config UI
data_source: Source of the data; 'sidecar' is the KOReader sidecar file.
data_location: List of keys used to locate the data in the data_source dictionary
transform (optional): lambda expression to format the value
"""
CUSTOM_COLUMN_DEFAULTS = {
'column_percent_read': {
'column_heading': _("KOReader Precise Progress"),
'datatype': 'float',
'additional_params': {'number_format': "{:.2%}"},
'description': _("Reading progress for the book with decimal precision."),
'default_lookup_name': '#ko_progfloat',
'config_label': _('Percent read column (float):'),
'config_tool_tip': _('A "Floating point numbers" column to store the current\n'
'percent read, with "Format for numbers" set to 0.00%.'),
'data_source': 'sidecar',
'data_location': ['percent_finished'],
'transform': (lambda value: float(value)),
},
'column_percent_read_int': {
'column_heading': _("KOReader Progress"),
'datatype': 'int',
'additional_params': {'number_format': "{}%"},
'description': _("Reading progress for the book."),
'default_lookup_name': '#ko_progint',
'config_label': _('Percent read column (int):'),
'config_tool_tip': _('An "Integers" column to store the current percent read.'),
'data_source': 'sidecar',
'data_location': ['percent_finished'],
'transform': (lambda value: round(float(value) * 100)),
},
'column_status': {
'column_heading': _("KOReader Book Status"),
'datatype': 'text',
'description': _("Reading status of the book, either Finished, Reading, or On hold."),
'default_lookup_name': '#ko_status',
'config_label': _('Reading status column (text):'),
'config_tool_tip': _('A regular "Text" column to store the reading status of the\n'
'book, as entered on the book status page ("Finished",\n'
'"Reading", "On hold").'),
'data_source': 'sidecar',
'data_location': ['summary', 'status'],
},
'column_status_bool': {
'column_heading': _("KOReader Book Status Y/N"),
'datatype': 'bool',
'description': _("Yes if the book is marked as finished in KOReader, otherwise No."),
'default_lookup_name': '#ko_statusbool',
'config_label': _('Reading status column (yes/no):'),
'config_tool_tip': _('A "Yes/No" column to store the reading status of the book,\n'
'as a boolean ("Yes" = "Finished", "No" = everything else).'),
'data_source': 'sidecar',
'data_location': ['summary', 'status'],
'transform': (lambda val: bool(val == 'complete')),
},
'column_last_read_location': {
'column_heading': _("KOReader Last Location"),
'datatype': 'text',
'description': _("Last location you stopped reading at in the book."),
'default_lookup_name': '#ko_loc',
'config_label': _('Last read location column:'),
'config_tool_tip': _('A regular "Text" column to store the location you last\n'
'stopped reading at.'),
'data_source': 'sidecar',
'data_location': ['last_xpointer'],
},
'column_date_book_started': {
'column_heading': _("Date KOReader Started"),
'datatype': 'datetime',
'description': _("Date when the book was started."),
'default_lookup_name': '#ko_start',
'config_label': _('Date Book Started column:'),
'config_tool_tip': _('A "Date" column to store when the book was started. '
'Will only be set once when synced with reading status.'),
'data_source': 'sidecar',
'data_location': ['calculated', 'date_book_started'],
},
'column_date_book_finished': {
'column_heading': _("Date KOReader Finished"),
'datatype': 'datetime',
'description': _("Date when the book was finished."),
'default_lookup_name': '#ko_finish',
'config_label': _('Date Book Finished column:'),
'config_tool_tip': _('A "Date" column to store when the book was finished. '
'Will only be set once when synced with finished status.'),
'data_source': 'sidecar',
'data_location': ['calculated', 'date_book_finished'],
},
'column_rating': {
'first_in_group': True,
'column_heading': _("KOReader Rating"),
'datatype': 'rating',
'description': _("Rating for the book."),
'default_lookup_name': '#ko_rating',
'config_label': _('Rating column:'),
'config_tool_tip': _('A "Rating" column to store your rating of the book,\n'
'as entered on the book’s status page.'),
'data_source': 'sidecar',
'data_location': ['summary', 'rating'],
# calibre uses a 10-point scale,
'transform': (lambda value: value * 2),
},
'column_review': { # Unsure about Interpret this column as
'column_heading': _("KOReader Review"),
'datatype': 'comments',
'description': _("Review of book."),
'default_lookup_name': '#ko_review',
'config_label': _('Review column:'),
'config_tool_tip': _('A "Long text" column to store your review of the book,\n'
'as entered on the book’s status page.'),
'data_source': 'sidecar',
'data_location': ['summary', 'note'],
},
'column_bookmarks': {
'column_heading': _("KOReader Bookmarks"),
'datatype': 'comments',
'description': _("All the bookmarks and highlights from KOReader."),
'default_lookup_name': '#ko_bookmarks',
'config_label': _('Bookmarks column:'),
'config_tool_tip': _('A "Long text" column to store your bookmarks and highlights.'),
'data_source': 'sidecar',
'data_location': ['annotations'],
'transform': clean_bookmarks,
},
'column_md5': {
'first_in_group': True,
'column_heading': _("KOReader MD5"),
'datatype': 'text',
'description': _("MD5 hash used by KOReader, allowed for ProgressSync Support."),
'default_lookup_name': '#ko_md5',
'config_label': _('MD5 hash column:'),
'config_tool_tip': _('A regular "Text" column to store the MD5 hash KOReader uses\n'
'to sync progress to a KOReader Sync Server. ("Progress sync"\n'
'in the KOReader app.)'),
'data_source': 'sidecar',
'data_location': ['partial_md5_checksum'],
},
'column_device_name': {
'column_heading': _("KOReader Device Name"),
'datatype': 'text',
'description': _("Last Synced Device Name from ProgressSync."),
'default_lookup_name': '#ko_device_name',
'config_label': _('ProgressSync Device Name:'),
'config_tool_tip': _('A regular "Text" column to store the last device name used\n'
'to sync progress via ProgressSync.'),
'data_source': 'progresssync',
'data_location': ['device'],
},
'column_device_id': {
'column_heading': _("KOReader Device ID"),
'datatype': 'text',
'description': _("Last Synced Device ID from ProgressSync."),
'default_lookup_name': '#ko_device_id',
'config_label': _('ProgressSync Device ID:'),
'config_tool_tip': _('A regular "Text" column to store the last device id used\n'
'to sync progress via ProgressSync.'),
'data_source': 'progresssync',
'data_location': ['device'],
},
'column_date_synced': {
'column_heading': _("Date KOReader Synced"),
'datatype': 'datetime',
'description': _("Date when the book was last synced from KOReader."),
'default_lookup_name': '#ko_lastsync',
'config_label': _('Date Synced column:'),
'config_tool_tip': _('A "Date" column to store when the last sync was performed.'),
'data_source': 'sidecar',
'data_location': ['calculated', 'date_synced'],
},
'column_date_sidecar_modified': {
'column_heading': _("Date KOReader Modified"),
'datatype': 'datetime',
'description': _("Date when the book was last modified in KOReader. Wired sync only."),
'default_lookup_name': '#ko_lastmod',
'config_label': _('Date Modified column:'),
'config_tool_tip': _('A "Date" column to store when the sidecar file was last '
'modified. Works for wired connection only, wireless will be '
'always empty'),
'data_source': 'sidecar',
'data_location': ['calculated', 'date_sidecar_modified'],
},
'column_sidecar': { # Unsure about Interpret this column as
'column_heading': _("KOReader Raw Sidecar"),
'datatype': 'comments',
'description': _("Raw sidecar data directly from KOReader. Allows sync to KOReader, also serves as a backup."),
'default_lookup_name': '#ko_sidecar',
'config_label': _('Raw sidecar column:'),
'config_tool_tip': _('A "Long text" column to store the contents of the\n'
'metadata sidecar as JSON, with "Interpret this column as" set to\n'
'"Plain text". This is required to sync metadata back to KOReader sidecars.'),
'data_source': 'sidecar',
'data_location': [], # [] gives the entire sidecar dict
'transform': (lambda d: json.dumps(
{k: d[k] for k in d if k != 'calculated'},
skipkeys=True,
indent=2,
default=str
)),
},
}
CHECKBOXES = { # Each entry in the below dict is keyed with config_name
'checkbox_percent_read_100': {
'config_label': 'Percent read column (float) range 0.0-100.0',
'config_tool_tip': 'Default the range is 0.0-1.0\n'
'Checking this option the float value is multiplied by 100 to be in range 0.0-100.0',
},
'checkbox_sync_if_more_recent': {
'config_label': 'Sync only if changes are more recent',
'config_tool_tip': 'Sync book only if the metadata is more recent. Requires\n'
'"Date Modified Column" or "Percent read column" to be synced',
},
'checkbox_no_sync_if_finished': {
'config_label': 'No sync if book has already been finished',
'config_tool_tip': 'Do not sync book if it has already been finished. Requires\n'
'"Percent read column" or "Reading status column" to be synced',
},
'checkbox_enable_automatic_sync': {
'config_label': 'Automatic Sync on device connection',
'config_tool_tip': 'Sync from KOReader automatically on device connection. \n'
'Restart calibre to apply this setting',
},
'checkbox_enable_scheduled_progressync': {
'config_label': 'Daily ProgressSync',
'config_tool_tip': 'Enable daily sync of reading progress and location using \n'
'KOReader\'s ProgressSync server.',
},
'checkbox_skip_ssl_verification': {
'config_label': 'Skip SSL certificate verification for ProgressSync',
'config_tool_tip': 'Disable SSL certificate verification when connecting to ProgressSync server.\n'
'Enable this if you use a custom server with self-signed certificates or IP addresses.\n'
'Warning: This reduces security. Only use with trusted servers.',
},
}
CONFIG = JSONConfig(os.path.join('plugins', 'KOReader Sync.json'))
for this_column in CUSTOM_COLUMN_DEFAULTS:
CONFIG.defaults[this_column] = ''
for this_checkbox in CHECKBOXES:
CONFIG.defaults[this_checkbox] = False
CONFIG.defaults['checkbox_skip_ssl_verification'] = False
CONFIG.defaults['progress_sync_url'] = 'https://sync.koreader.rocks:443'
CONFIG.defaults['progress_sync_username'] = ''
CONFIG.defaults['progress_sync_password'] = ''
CONFIG.defaults['scheduleSyncHour'] = 4
CONFIG.defaults['scheduleSyncMinute'] = 0
CONFIG.defaults['main_action'] = 'KOReader Sync'
if numeric_version >= (5, 5, 0):
module_debug_print = partial(root_debug_print, ' koreader:config:', sep='')
else:
module_debug_print = partial(root_debug_print, 'koreader:config:')
def create_separator():
separator = QFrame()
separator.setFrameShape(QFrame.HLine)
separator.setFrameShadow(QFrame.Sunken)
return separator
class ConfigWidget(QWidget): # https://doc.qt.io/qt-5/qwidget.html
def __init__(self, plugin_action):
QWidget.__init__(self)
debug_print = partial(module_debug_print, 'ConfigWidget:__init__:')
debug_print('start')
self.action = plugin_action
self.must_restart = False
# Set up main layout
layout = QVBoxLayout()
self.setLayout(layout)
# Add icon and title
title_layout = TitleLayout(
self,
'images/icon.png',
f'Configure {self.action.version}',
)
layout.addLayout(title_layout)
# Add custom column dropdowns
self._get_create_new_custom_column_instance = None
self.sync_custom_columns = {}
bottom_options_layout = QHBoxLayout()
layout.addLayout(bottom_options_layout)
columns_group_box = QGroupBox(
_('Synchronisable Custom Columns:'), self)
bottom_options_layout.addWidget(columns_group_box)
columns_group_box_layout = QHBoxLayout()
columns_group_box.setLayout(columns_group_box_layout)
columns_group_box_layout2 = QFormLayout()
columns_group_box_layout.addLayout(columns_group_box_layout2)
columns_group_box_layout.addStretch()
for config_name, metadata in CUSTOM_COLUMN_DEFAULTS.items():
self.sync_custom_columns[config_name] = {'current_columns': self.get_custom_columns(
metadata['datatype'], metadata.get('is_multiple', (False, False))[1])}
self._column_combo = self.create_custom_column_controls(
columns_group_box_layout2, config_name)
metadata['comboBox'] = self._column_combo
self._column_combo.populate_combo(
self.sync_custom_columns[config_name]['current_columns'],
CONFIG[config_name]
)
# Main action combobox
main_action_layout = QHBoxLayout()
main_action_layout.setAlignment(Qt.AlignLeft)
self.main_action_box_label = QLabel('Main button:')
tooltip = 'Select which action will be used for the main button on calibre GUI'
self.main_action_box_label.setToolTip(tooltip)
self.main_action_combo = QComboBox()
self.main_action_combo.setToolTip(tooltip)
self.main_action_combo.setMinimumWidth(200)
self.main_action_combo.addItems({'KOReader Sync', 'Progress Sync'})
self.main_action_combo.model().sort(0)
self.main_action_box_label.setBuddy(self.main_action_combo)
main_action_layout.addWidget(self.main_action_box_label)
main_action_layout.addWidget(self.main_action_combo)
self.main_action_combo.setCurrentText(CONFIG['main_action'])
layout.addLayout(main_action_layout)
# Add custom checkboxes
layout.addLayout(self.add_checkbox('checkbox_percent_read_100'))
layout.addLayout(self.add_checkbox('checkbox_sync_if_more_recent'))
layout.addLayout(self.add_checkbox('checkbox_no_sync_if_finished'))
layout.addLayout(self.add_checkbox('checkbox_enable_automatic_sync'))
# Progress Sync Section
layout.addWidget(create_separator())
ps_header_label = QLabel(
"This plugin supports use of KOReader's built-in ProgressSync server to update reading progress and location without the device connected. "
"You must have an MD5 column mapped and use Binary matching in KOReader's ProgressSync Settings (default).\n"
"You also need a reading progress column and status text column.\n"
"This functionality can optionally be scheduled into a daily sync from within calibre. "
"Enter scheduled time in military time, default is 4 AM local time. You must restart calibre after making changes to scheduled sync settings. "
)
ps_header_label.setWordWrap(True)
layout.addWidget(ps_header_label)
# Add SSL verification checkbox
layout.addLayout(self.add_checkbox('checkbox_skip_ssl_verification'))
# Add scheduled sync options
scheduled_sync_layout = QHBoxLayout()
scheduled_sync_layout.setAlignment(Qt.AlignLeft)
scheduled_sync_layout.addLayout(self.add_checkbox(
'checkbox_enable_scheduled_progressync'))
scheduled_sync_layout.addWidget(QLabel('Scheduled Time:'))
self.schedule_hour_input = QSpinBox()
self.schedule_hour_input.setRange(0, 23)
self.schedule_hour_input.setValue(CONFIG['scheduleSyncHour'])
self.schedule_hour_input.setSuffix('h')
self.schedule_hour_input.wheelEvent = lambda event: event.ignore()
scheduled_sync_layout.addWidget(self.schedule_hour_input)
scheduled_sync_layout.addWidget(QLabel(':'))
self.schedule_minute_input = QSpinBox()
self.schedule_minute_input.setRange(0, 59)
self.schedule_minute_input.setValue(CONFIG['scheduleSyncMinute'])
self.schedule_minute_input.setSuffix('m')
self.schedule_minute_input.wheelEvent = lambda event: event.ignore()
scheduled_sync_layout.addWidget(self.schedule_minute_input)
layout.addLayout(scheduled_sync_layout)
# Add ProgressSync Account button
progress_sync_button = QPushButton('Add ProgressSync Account', self)
progress_sync_button.clicked.connect(self.show_progress_sync_popup)
layout.addWidget(progress_sync_button)
def show_progress_sync_popup(self):
self.progress_sync_popup = ProgressSyncPopup(self)
self.progress_sync_popup.show()
def save_settings(self):
debug_print = partial(module_debug_print,
'ConfigWidget:save_settings:')
debug_print('old CONFIG = ', CONFIG)
# Check relevant settings for changes in order to show restart warning
needRestart = (self.must_restart or # Custom Column Addition
CONFIG['checkbox_enable_automatic_sync'] != (CHECKBOXES['checkbox_enable_automatic_sync']['checkbox'].checkState() == Qt.Checked) or
CONFIG['checkbox_enable_scheduled_progressync'] != (CHECKBOXES['checkbox_enable_scheduled_progressync']['checkbox'].checkState() == Qt.Checked) or
CONFIG['scheduleSyncHour'] != self.schedule_hour_input.value() or
CONFIG['scheduleSyncMinute'] != self.schedule_minute_input.value()
)
# Save Column Settings
for config_name, metadata in CUSTOM_COLUMN_DEFAULTS.items():
CONFIG[config_name] = metadata['comboBox'].get_selected_column()
# Save Checkbox Settings
for config_name in CHECKBOXES:
CONFIG[config_name] = CHECKBOXES[config_name]['checkbox'].checkState(
) == Qt.Checked
# Save main action Settings
CONFIG['main_action'] = self.main_action_combo.currentText()
# Save Scheduled ProgressSync Settings
CONFIG['scheduleSyncHour'] = self.schedule_hour_input.value()
CONFIG['scheduleSyncMinute'] = self.schedule_minute_input.value()
# NOTE: Server/Credentials are saved by the ProgressSyncPopup
debug_print('new CONFIG = ', CONFIG)
if needRestart and show_restart_warning('Changes have been made that require a restart to take effect.\nRestart now?'):
self.action.gui.quit(restart=True)
def add_checkbox(self, checkboxKey):
layout = QHBoxLayout()
checkboxMeta = CHECKBOXES[checkboxKey]
checkbox = QCheckBox()
checkbox.setCheckState(
Qt.Checked if CONFIG[checkboxKey] else Qt.Unchecked)
label = QLabel(checkboxMeta['config_label'])
label.setToolTip(checkboxMeta['config_tool_tip'])
label.setBuddy(checkbox)
label.mousePressEvent = lambda event, checkbox=checkbox: checkbox.toggle()
layout.addWidget(checkbox)
layout.addWidget(label)
layout.addStretch()
CHECKBOXES[checkboxKey]['checkbox'] = checkbox
return layout
def create_custom_column_controls(self, columns_group_box_layout, custom_col_name, min_width=300):
if fig := CUSTOM_COLUMN_DEFAULTS[custom_col_name].get('first_in_group', False):
columns_group_box_layout.addRow(create_separator())
if isinstance(fig, str):
columns_group_box_layout.addRow(QLabel(f'{fig}', self))
current_Location_label = QLabel(
CUSTOM_COLUMN_DEFAULTS[custom_col_name]['config_label'], self)
current_Location_label.setToolTip(
CUSTOM_COLUMN_DEFAULTS[custom_col_name]['config_tool_tip'])
create_column_callback = partial(
self.create_custom_column, custom_col_name) if SUPPORTS_CREATE_CUSTOM_COLUMN else None
avail_columns = self.sync_custom_columns[custom_col_name]['current_columns']
custom_column_combo = CustomColumnComboBox(
self, avail_columns, create_column_callback=create_column_callback)
custom_column_combo.setMinimumWidth(min_width)
current_Location_label.setBuddy(custom_column_combo)
columns_group_box_layout.addRow(
current_Location_label, custom_column_combo)
self.sync_custom_columns[custom_col_name]['combo_box'] = custom_column_combo
return custom_column_combo
def create_custom_column(self, lookup_name=None):
if not lookup_name or lookup_name not in CUSTOM_COLUMN_DEFAULTS:
return False
column_meta = CUSTOM_COLUMN_DEFAULTS[lookup_name]
display_params = {
'description': column_meta['description'],
**column_meta.get('additional_params', {})
}
datatype = column_meta['datatype']
column_heading = column_meta['column_heading']
is_multiple = column_meta.get('is_multiple', (False, False))
# Get the create column instance
create_new_custom_column_instance = self.get_create_new_custom_column_instance
if not create_new_custom_column_instance:
return False
result = create_new_custom_column_instance.create_column(
column_meta['default_lookup_name'], column_heading, datatype, is_multiple[0], display=display_params, generate_unused_lookup_name=True, freeze_lookup_name=False)
if result and result[0] == CreateNewCustomColumn.Result.COLUMN_ADDED:
self.sync_custom_columns[lookup_name]['current_columns'][result[1]] = {
'name': column_heading}
self.sync_custom_columns[lookup_name]['combo_box'].populate_combo(
self.sync_custom_columns[lookup_name]['current_columns'],
result[1]
)
self.must_restart = True
return True
return False
@property
def get_create_new_custom_column_instance(self):
if self._get_create_new_custom_column_instance is None and SUPPORTS_CREATE_CUSTOM_COLUMN:
self._get_create_new_custom_column_instance = CreateNewCustomColumn(
self.action.gui)
return self._get_create_new_custom_column_instance
def get_custom_columns(self, datatype, only_is_multiple=False):
if SUPPORTS_CREATE_CUSTOM_COLUMN:
custom_columns = self.get_create_new_custom_column_instance.current_columns()
else:
custom_columns = self.action.gui.library_view.model().custom_columns
available_columns = {}
for key, column in custom_columns.items():
typ = column['datatype']
if typ == datatype:
available_columns[key] = column
if datatype == 'rating': # Add rating column if requested
ratings_column_name = self.action.gui.library_view.model(
).orig_headers['rating']
available_columns['rating'] = {'name': ratings_column_name}
if only_is_multiple: # If user requests only is_multiple columns check and filter
available_columns = {
key: column for key, column in available_columns.items()
if column.get('is_multiple', False) != {}
}
return available_columns
class ProgressSyncPopup(QDialog):
def __init__(self, parent):
QDialog.__init__(self, parent)
self.setWindowTitle('Add ProgressSync Account')
self.setGeometry(100, 100, 400, 200)
layout = QVBoxLayout()
self.setLayout(layout)
self.url_label = QLabel('ProgressSync Server URL:', self)
self.url_input = QLineEdit(self)
self.url_input.setText(CONFIG['progress_sync_url'])
layout.addWidget(self.url_label)
layout.addWidget(self.url_input)
self.username_label = QLabel('Username:', self)
self.username_input = QLineEdit(self)
self.username_input.setText(CONFIG['progress_sync_username'])
layout.addWidget(self.username_label)
layout.addWidget(self.username_input)
self.password_label = QLabel('Password:', self)
self.password_input = QLineEdit(self)
self.password_input.setEchoMode(QLineEdit.Password)
layout.addWidget(self.password_label)
layout.addWidget(self.password_input)
self.note_label = QLabel(
'Enter any custom server or leave the default filled in.\n'
'Enter your username and password. Then click log in, this does not validate your account so make sure you enter the correct info.\n'
'Make sure you have one or more of the following columns set up: column_percent_read, column_percent_read_int, column_last_read_location\n'
'You must have a percent read (int or float) and status text column.',
self
)
self.note_label.setWordWrap(True)
layout.addWidget(self.note_label)
self.login_button = QPushButton('Log In', self)
self.login_button.clicked.connect(self.save_progress_sync_settings)
layout.addWidget(self.login_button)
def save_progress_sync_settings(self):
CONFIG['progress_sync_url'] = self.url_input.text()
CONFIG['progress_sync_username'] = self.username_input.text()
CONFIG['progress_sync_password'] = self.hash_password(
self.password_input.text())
self.accept()
def hash_password(self, password):
import hashlib
return hashlib.md5(password.encode()).hexdigest()
class TitleLayout(QHBoxLayout):
"""A sub-layout to the main layout used in ConfigWidget that contains an
icon and title.
"""
def __init__(self, parent, icon, title):
QHBoxLayout.__init__(self)
# Add icon
icon_label = QLabel(parent)
pixmap = QPixmap()
pixmap.loadFromData(get_resources(icon))
icon_label.setPixmap(pixmap)
icon_label.setMaximumSize(64, 64)
icon_label.setScaledContents(True)
self.addWidget(icon_label)
# Add title
title_label = QLabel(f'{title}
', parent)
title_label.setContentsMargins(10, 0, 10, 0)
self.addWidget(title_label)
# Add empty space
self.addStretch()
# Add Readme hyperlink
readme_label = QLabel('Readme', parent)
readme_label.setTextInteractionFlags(
Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard)
readme_label.linkActivated.connect(parent.action.show_readme)
self.addWidget(readme_label)
# Add About hyperlink
about_label = QLabel('About', parent)
about_label.setTextInteractionFlags(
Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard)
about_label.linkActivated.connect(parent.action.show_about)
self.addWidget(about_label)
class CustomColumnComboBox(QComboBox):
def __init__(self, parent, custom_columns=None, selected_column='', create_column_callback=None):
super().__init__(parent)
if custom_columns is None:
custom_columns = {}
self.create_column_callback = create_column_callback
if create_column_callback is not None:
self.currentTextChanged.connect(self.current_text_changed)
self.populate_combo(custom_columns, selected_column)
def populate_combo(self, custom_columns, selected_column, show_lookup_name=True):
self.blockSignals(True)
self.clear()
self.column_names = []
if self.create_column_callback is not None:
self.column_names.append('Create new column')
self.addItem('Create new column')
self.column_names.append('do not sync')
self.addItem('do not sync')
selected_idx = 1
for key in sorted(custom_columns.keys()):
self.column_names.append(key)
display_name = '%s (%s)' % (
key, custom_columns[key]['name']) if show_lookup_name else custom_columns[key]['name']
self.addItem(display_name)
if key == selected_column:
selected_idx = len(self.column_names) - 1
self.setCurrentIndex(selected_idx)
self.current_index = selected_idx
self.blockSignals(False)
def get_selected_column(self):
selected_column = self.column_names[self.currentIndex()]
if selected_column == 'Create new column' or selected_column == 'do not sync':
selected_column = ''
return selected_column
def current_text_changed(self, new_text):
if new_text == 'Create new column':
result = self.create_column_callback()
if not result:
self.setCurrentIndex(self.current_index)
else:
self.current_index = self.currentIndex()
def wheelEvent(self, event): # Prevents the mouse wheel from changing the selected item
event.ignore()
================================================
FILE: dummy_device/.driveinfo.calibre
================================================
{"device_store_uuid": "94894e7d-f6d4-4aa7-88a2-5654287cdc86", "device_name": "Folder Device", "location_code": "main", "last_library_uuid": "f6727444-b3ca-4bb0-bcaa-c75333f6b171", "calibre_version": "6.16.0", "date_last_connected": "2023-05-22T09:26:00.387289+00:00", "prefix": "/home/harm/git/koreader-calibre-plugin/dummy_device/"}
================================================
FILE: dummy_device/.metadata.calibre
================================================
[
{
"application_id": 4,
"rights": null,
"lpath": "Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.epub",
"rating": null,
"tags": [
"Fantasy fiction",
"Children's stories",
"Imaginary places -- Juvenile fiction",
"Alice (Fictitious character from Carroll) -- Juvenile fiction"
],
"series_index": null,
"book_producer": null,
"publication_type": null,
"timestamp": "2020-11-16T21:46:44+00:00",
"last_modified": "2023-05-22T09:25:57.211107+00:00",
"title_sort": "Alice's Adventures in Wonderland",
"series": null,
"identifiers": {
"uri": "http://www.gutenberg.org/11"
},
"languages": [
"eng"
],
"publisher": null,
"size": 253703,
"title": "Alice's Adventures in Wonderland",
"author_sort": "Carroll, Lewis",
"authors": [
"Lewis Carroll"
],
"thumbnail": [
49,
68,
"/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCABEADEDASIAAhEBAxEB/8QAHAAAAQQDAQAAAAAAAAAAAAAAAAIFBgcBAwQI/8QANBAAAgEDAwIEBAQFBQAAAAAAAQIDAAQRBRIhMUEGE1FhFCIycSNCgaEkM0OCwZGSo7Hw/8QAGQEAAwEBAQAAAAAAAAAAAAAAAAIDBAEF/8QAJREAAwACAAUDBQAAAAAAAAAAAAECAxESEyExcQSh0QVBUWGB/9oADAMBAAIRAxEAPwB61AXvxEPw8ZeEY8wKQD9SnufQMP7hWm1XUBPGbhZNoLbvmBGOMd/vTxUF07xVpl1dQ2Vzq+pRXL4Vn2oI/MJA2j5SRz3Ix71mlbL09D4kWsfAD8QC4VgQGYcjacg/r/iiUauDJ5Z7jbyPUc/b6uvtTkdHYSBDql7uIyBvTJH+2oH4a8RXuq609pfaiYIQjEMWCnIIwMkGmaSWznFvoSyFtRS5t2kEhiCDzVyOWwff7URHVjPH5n0iRQ3I5XkN+wBHua49buotPhg+G1x5ZZXA2qyuQvdsAdqddHmafSopHlMrF3XzCMFgHIBx9hSKlS2hvvocttFY4opRhVefZ7FnnkfeACxOK9BVVssmljS7c/CyLKoXzJFjPfqc459aeb4en5EuHSbTS17+BjstHvY3jnS+kiuE5RkJ+XHvmlaPol3LqT/xDQ+V/MkU4PPYfeprpOg2lzpiytcSyrM7BZFcLtAJx+wrntdJuJrSzntFmlkWV0lztVSoBLEcZPGD+lQ5t7aNObFh4E8a0+/k0pFZ29wzPdo0wwSZpMt/7Hapd4fAOiQFSCN8mCOh/EaoJDbwzareRhVeYsAQ6Mw2gDGe3ap34eUpoUCMApDSAgDAHzt27U2Ps/4ZI77HTmij9aKcqLqrIdSihs7a2iiVpptqs7LnaDxVp1U4hs57e2ikhR5mRRveTpxngLz/AKkfaluU56hO+ZOh7W5SOyhWGOMlIwp/DwQQeFyRkkev71YmmW0drbW7XqJGx3Z3Nnlt2QTk89utVxD5VvJC0iM0Ue1nCDHAPOPc5qxX1vRZBFMbtlEZDjajAnA6Hj3rHhaabPU+o4eBzMr2+BgsfCcPlrcrcyIZCGkV4+d3AP8A1it+jR+XpcaD8sko/wCRqjOt6hrV1fTWsaO9pMwltzjbhc8EYxzz39vapJoCvHokCSZ3qzhsnJzvar4Za22zzKmVrS0OWTRRRVzguop4e8LQv4divbd8alLArRTTE7YzweAPbjPWpXTOlhJa2sdmms3CRRoI1XZHwABxnb6YqeSaqdSxoamtsi81lrw1K4jk03zJXILGBcRucAZDEAe/Na9N0+d/G+nPd2U8EYL43rhWdQTx2I6HNS+KCZQQmuT9Mk7Iicev01l0mVlaTXJdyggFoocjpn8n2qc4KXbXb9/Bov1bqFDfReDq1UtvAnbFu/yhRIBu9iDyM9Mg1zaIu3SYl44eQcdPrbpWiTSzdAxvrF0x6HGwH17L7U42dotlZx26OzKmfmbqcnP+abFieNdWQu1T2jdRWcUVUQVSDDEWLGNCT1JHWiiugY8iIf0k6Y+kfag28J6xIf7RRRRsAEEQOREmfXaKWaKKADFFFFcA/9k="
],
"link_maps": {},
"comments": null,
"author_sort_map": {
"Lewis Carroll": "Carroll, Lewis"
},
"uuid": "43bd8264-96fa-461a-a05e-1d1cb245d34f",
"user_categories": {},
"db_id": null,
"user_metadata": {
"#formats": {
"table": "custom_column_1",
"column": "value",
"datatype": "composite",
"is_multiple": null,
"kind": "field",
"name": "Formats",
"search_terms": [
"#formats"
],
"label": "formats",
"colnum": 1,
"display": {
"contains_html": false,
"make_category": false,
"composite_sort": "text",
"use_decorations": 0,
"composite_template": "{:'re(approximate_formats(), ',', ', ')'}",
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 22,
"#value#": "EPUB",
"#extra#": null,
"is_multiple2": {}
},
"#gr_rating": {
"table": "custom_column_2",
"column": "value",
"datatype": "rating",
"is_multiple": null,
"kind": "field",
"name": "Goodreads Rating",
"search_terms": [
"#gr_rating"
],
"label": "gr_rating",
"colnum": 2,
"display": {
"description": "",
"allow_half_stars": false
},
"is_custom": true,
"is_category": true,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 23,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
},
"#gr_review": {
"table": "custom_column_3",
"column": "value",
"datatype": "comments",
"is_multiple": null,
"kind": "field",
"name": "Goodreads Review",
"search_terms": [
"#gr_review"
],
"label": "gr_review",
"colnum": 3,
"display": {
"heading_position": "hide",
"interpret_as": "long-text",
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 24,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
},
"#gr_shelf": {
"table": "custom_column_4",
"column": "value",
"datatype": "text",
"is_multiple": null,
"kind": "field",
"name": "Goodreads Shelf",
"search_terms": [
"#gr_shelf"
],
"label": "gr_shelf",
"colnum": 4,
"display": {
"use_decorations": 0,
"description": ""
},
"is_custom": true,
"is_category": true,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 25,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
},
"#ko_annotations": {
"table": "custom_column_13",
"column": "value",
"datatype": "comments",
"is_multiple": null,
"kind": "field",
"name": "KOReader Annotations",
"search_terms": [
"#ko_annotations"
],
"label": "ko_annotations",
"colnum": 13,
"display": {
"heading_position": "above",
"interpret_as": "markdown",
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 26,
"#value#": "- in CHAPTER VI. Pig and Pepper \n- \u201cYou!\u201d said the Caterpillar contemptuously. \u201cWho are you?\u201d \n- CHAPTER V. Advice from a Caterpillar ",
"#extra#": null,
"is_multiple2": {}
},
"#ko_md5": {
"table": "custom_column_5",
"column": "value",
"datatype": "text",
"is_multiple": null,
"kind": "field",
"name": "KOReader Sync Server MD5 Hash",
"search_terms": [
"#ko_md5"
],
"label": "ko_md5",
"colnum": 5,
"display": {
"use_decorations": 0,
"description": ""
},
"is_custom": true,
"is_category": true,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 27,
"#value#": "9c928bc227e2011e85f931ca159ff710",
"#extra#": null,
"is_multiple2": {}
},
"#ko_mod": {
"table": "custom_column_20",
"column": "value",
"datatype": "datetime",
"is_multiple": null,
"kind": "field",
"name": "KOReader Last Modified",
"search_terms": [
"#ko_mod"
],
"label": "ko_mod",
"colnum": 20,
"display": {
"date_format": null,
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 28,
"#value#": "2021-11-22T06:47:26+00:00",
"#extra#": null,
"is_multiple2": {}
},
"#ko_sidecar": {
"table": "custom_column_6",
"column": "value",
"datatype": "comments",
"is_multiple": null,
"kind": "field",
"name": "KOReader Sidecar",
"search_terms": [
"#ko_sidecar"
],
"label": "ko_sidecar",
"colnum": 6,
"display": {
"heading_position": "above",
"interpret_as": "long-text",
"description": "The entire dict from KOReader\u2019s metadata.epub.lua"
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 29,
"#value#": "{\n \"bookmarks\": {\n \"1\": {\n \"chapter\": \"CHAPTER VI. Pig and Pepper\",\n \"datetime\": \"2020-11-16 18:52:11\",\n \"page\": \"/body/DocFragment[8]/body/div/h2/text()[1].0\"\n },\n \"2\": {\n \"chapter\": \"CHAPTER V. Advice from a Caterpillar\",\n \"datetime\": \"2020-11-16 18:51:04\",\n \"highlighted\": true,\n \"page\": \"/body/DocFragment[7]/body/div/p[12]/text()[1].0\",\n \"pos0\": \"/body/DocFragment[7]/body/div/p[12]/text()[1].0\",\n \"pos1\": \"/body/DocFragment[7]/body/div/p[12]/text()[2].1\",\n \"text\": \"Higlight with note: Page 48 \\u201cYou!\\u201d said the Caterpillar contemptuously. \\u201cWho are you?\\u201d @ 2020-11-16 18:51:04\"\n },\n \"3\": {\n \"chapter\": \"CHAPTER V. Advice from a Caterpillar\",\n \"datetime\": \"2020-11-16 18:50:53\",\n \"highlighted\": true,\n \"page\": \"/body/DocFragment[7]/body/div/h2/text()[1].0\",\n \"pos0\": \"/body/DocFragment[7]/body/div/h2/text()[1].0\",\n \"pos1\": \"/body/DocFragment[7]/body/div/h2/text()[2].26\"\n }\n },\n \"bookmarks_sorted\": true,\n \"config_panel_index\": 1,\n \"copt_b_page_margin\": 15,\n \"copt_block_rendering_mode\": 3,\n \"copt_embedded_css\": 1,\n \"copt_embedded_fonts\": 1,\n \"copt_font_gamma\": 15,\n \"copt_font_hinting\": 2,\n \"copt_font_kerning\": 3,\n \"copt_font_size\": 22,\n \"copt_font_weight\": 0,\n \"copt_h_page_margins\": {\n \"1\": 10,\n \"2\": 10\n },\n \"copt_line_spacing\": 100,\n \"copt_nightmode_images\": 1,\n \"copt_render_dpi\": 96,\n \"copt_rotation_mode\": 0,\n \"copt_smooth_scaling\": 0,\n \"copt_status_line\": 1,\n \"copt_sync_t_b_page_margins\": 0,\n \"copt_t_page_margin\": 15,\n \"copt_view_mode\": 0,\n \"copt_visible_pages\": 1,\n \"copt_word_expansion\": 0,\n \"copt_word_spacing\": {\n \"1\": 95,\n \"2\": 75\n },\n \"cre_dom_version\": 20200824,\n \"css\": \"./data/epub.css\",\n \"disable_fuzzy_search\": false,\n \"doc_pages\": 156,\n \"doc_props\": {\n \"authors\": \"Lewis Carroll\",\n \"description\": \"\",\n \"keywords\": \"Fantasy fiction\\\\\\nChildren's stories\\\\\\nImaginary places\\n-- Juvenile fiction\\\\\\nAlice (Fictitious character from Carroll) -- Juvenile fiction\",\n \"language\": \"en\",\n \"series\": \"\",\n \"title\": \"Alice's Adventures in Wonderland\"\n },\n \"embedded_css\": true,\n \"embedded_fonts\": true,\n \"floating_punctuation\": 0,\n \"font_embolden\": 0,\n \"font_face\": \"Noto Serif\",\n \"font_hinting\": 2,\n \"font_kerning\": 3,\n \"font_size\": 22,\n \"gamma\": 1,\n \"gamma_index\": 15,\n \"header_font_face\": \"Noto Sans\",\n \"highlight\": {\n \"47\": {\n \"1\": {\n \"chapter\": \"CHAPTER V. Advice from a Caterpillar\",\n \"datetime\": \"2020-11-16 18:50:53\",\n \"drawer\": \"lighten\",\n \"pos0\": \"/body/DocFragment[7]/body/div/h2/text()[1].0\",\n \"pos1\": \"/body/DocFragment[7]/body/div/h2/text()[2].26\",\n \"text\": \"CHAPTER V. Advice from a Caterpillar\"\n }\n },\n \"48\": {\n \"1\": {\n \"chapter\": \"CHAPTER V. Advice from a Caterpillar\",\n \"datetime\": \"2020-11-16 18:51:04\",\n \"drawer\": \"lighten\",\n \"pos0\": \"/body/DocFragment[7]/body/div/p[12]/text()[1].0\",\n \"pos1\": \"/body/DocFragment[7]/body/div/p[12]/text()[2].1\",\n \"text\": \"\\u201cYou!\\u201d said the Caterpillar contemptuously. \\u201cWho are you?\\u201d\"\n }\n }\n },\n \"highlight_disabled\": false,\n \"highlight_drawer\": \"lighten\",\n \"highlights_imported\": true,\n \"hyph_force_algorithmic\": false,\n \"hyph_soft_hyphens_only\": false,\n \"hyph_trust_soft_hyphens\": false,\n \"hyphenation\": true,\n \"inverse_reading_order\": false,\n \"last_xpointer\": \"/body/DocFragment[9]/body/div/h2/text()[1].0\",\n \"line_space_percent\": 100,\n \"nightmode_images\": true,\n \"page_overlap_style\": \"dim\",\n \"partial_md5_checksum\": \"9c928bc227e2011e85f931ca159ff710\",\n \"percent_finished\": 0.45512820512821,\n \"readermenu_tab_index\": 4,\n \"render_dpi\": 96,\n \"render_mode\": 0,\n \"rotation_mode\": 0,\n \"show_overlap_enable\": false,\n \"smooth_scaling\": false,\n \"stats\": {\n \"authors\": \"Lewis Carroll\",\n \"highlights\": 2,\n \"language\": \"en\",\n \"md5\": \"9c928bc227e2011e85f931ca159ff710\",\n \"notes\": 0,\n \"pages\": 156,\n \"series\": \"\",\n \"title\": \"Alice's Adventures in Wonderland\"\n },\n \"text_lang\": \"en-US\",\n \"text_lang_embedded_langs\": true,\n \"visible_pages\": 1,\n \"word_expansion\": 0,\n \"word_spacing\": {\n \"1\": 95,\n \"2\": 75\n }\n}",
"#extra#": null,
"is_multiple2": {}
},
"#ko_sync": {
"table": "custom_column_19",
"column": "value",
"datatype": "datetime",
"is_multiple": null,
"kind": "field",
"name": "KOReader Last Sync",
"search_terms": [
"#ko_sync"
],
"label": "ko_sync",
"colnum": 19,
"display": {
"date_format": null,
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 30,
"#value#": "2023-05-22T08:56:46+00:00",
"#extra#": null,
"is_multiple2": {}
},
"#mm_annotations": {
"table": "custom_column_7",
"column": "value",
"datatype": "comments",
"is_multiple": null,
"kind": "field",
"name": "Kobo Annotations",
"search_terms": [
"#mm_annotations"
],
"label": "mm_annotations",
"colnum": 7,
"display": {
"heading_position": "above",
"interpret_as": "html",
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 31,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
},
"#read_first": {
"table": "custom_column_8",
"column": "value",
"datatype": "datetime",
"is_multiple": null,
"kind": "field",
"name": "Read First",
"search_terms": [
"#read_first"
],
"label": "read_first",
"colnum": 8,
"display": {
"description": "The date on which I added this book to my Goodreads Currently Reading shelf",
"date_format": null
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 32,
"#value#": "2020-11-16T18:50:53+00:00",
"#extra#": null,
"is_multiple2": {}
},
"#read_last": {
"table": "custom_column_9",
"column": "value",
"datatype": "datetime",
"is_multiple": null,
"kind": "field",
"name": "Read Last",
"search_terms": [
"#read_last"
],
"label": "read_last",
"colnum": 9,
"display": {
"description": "The date on which I last opened this book on my e-reader",
"date_format": null
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 33,
"#value#": "2020-11-16T18:52:11+00:00",
"#extra#": null,
"is_multiple2": {}
},
"#read_location": {
"table": "custom_column_10",
"column": "value",
"datatype": "text",
"is_multiple": null,
"kind": "field",
"name": "Read Location",
"search_terms": [
"#read_location"
],
"label": "read_location",
"colnum": 10,
"display": {
"description": "",
"use_decorations": 0
},
"is_custom": true,
"is_category": true,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 34,
"#value#": "/body/DocFragment[9]/body/div/h2/text()[1].0",
"#extra#": null,
"is_multiple2": {}
},
"#read_progress": {
"table": "custom_column_11",
"column": "value",
"datatype": "float",
"is_multiple": null,
"kind": "field",
"name": "Read Progress",
"search_terms": [
"#read_progress"
],
"label": "read_progress",
"colnum": 11,
"display": {
"description": "",
"number_format": "{:.0%}",
"decimals": 2
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 35,
"#value#": 0.45512820512821,
"#extra#": null,
"is_multiple2": {}
},
"#read_progress_int": {
"table": "custom_column_18",
"column": "value",
"datatype": "int",
"is_multiple": null,
"kind": "field",
"name": "Read Progress (int)",
"search_terms": [
"#read_progress_int"
],
"label": "read_progress_int",
"colnum": 18,
"display": {
"number_format": null,
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 36,
"#value#": 45,
"#extra#": null,
"is_multiple2": {}
},
"#read_status": {
"table": "custom_column_12",
"column": "value",
"datatype": "composite",
"is_multiple": null,
"kind": "field",
"name": "Read Status",
"search_terms": [
"#read_status"
],
"label": "read_status",
"colnum": 12,
"display": {
"use_decorations": 0,
"contains_html": false,
"description": "",
"composite_template": "{#read_progress:get_read_status()}",
"composite_sort": "text",
"make_category": true
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 37,
"#value#": "\u2610",
"#extra#": null,
"is_multiple2": {}
},
"#read_status_bool": {
"table": "custom_column_17",
"column": "value",
"datatype": "bool",
"is_multiple": null,
"kind": "field",
"name": "Read Status (yes/no)",
"search_terms": [
"#read_status_bool"
],
"label": "read_status_bool",
"colnum": 17,
"display": {
"bools_show_text": false,
"bools_show_icons": true,
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 38,
"#value#": false,
"#extra#": null,
"is_multiple2": {}
},
"#read_status_text": {
"table": "custom_column_16",
"column": "value",
"datatype": "text",
"is_multiple": null,
"kind": "field",
"name": "Read Status (text)",
"search_terms": [
"#read_status_text"
],
"label": "read_status_text",
"colnum": 16,
"display": {
"use_decorations": false,
"description": ""
},
"is_custom": true,
"is_category": true,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 39,
"#value#": "reading",
"#extra#": null,
"is_multiple2": {}
}
},
"cover": null,
"mime": null,
"pubdate": "2008-06-27T04:00:00+00:00"
},
{
"application_id": 3,
"rights": null,
"lpath": "Thoreau, Henry David/Walden, and On The Duty Of Civil Disobedience - Henry David Thoreau.epub",
"rating": null,
"tags": [
"Civil disobedience",
"Authors",
"American -- 19th century -- Biography",
"Thoreau",
"Henry David",
"1817-1862 -- Homes and haunts -- Massachusetts -- Walden Woods",
"Wilderness areas -- Massachusetts -- Walden Woods",
"Natural history -- Massachusetts -- Walden Woods",
"Solitude",
"Government",
"Resistance to",
"Walden Woods (Mass.) -- Social life and customs"
],
"series_index": null,
"book_producer": null,
"publication_type": null,
"timestamp": "2020-11-16T21:45:41+00:00",
"last_modified": "2023-05-22T09:25:57.211107+00:00",
"title_sort": "Walden, and On The Duty Of Civil Disobedience",
"series": null,
"identifiers": {
"uri": "http://www.gutenberg.org/205"
},
"languages": [
"eng"
],
"publisher": null,
"size": 612096,
"title": "Walden, and On The Duty Of Civil Disobedience",
"author_sort": "Thoreau, Henry David",
"authors": [
"Henry David Thoreau"
],
"thumbnail": [
47,
68,
"/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCABEAC8DASIAAhEBAxEB/8QAGwAAAQUBAQAAAAAAAAAAAAAAAwACBQYHAQT/xAAuEAACAgECBAQEBwEAAAAAAAABAgMRAAQhBRIxURNBYZEUcaHwBiJCgbHB0XL/xAAXAQEBAQEAAAAAAAAAAAAAAAABAgAE/8QAGBEBAQEBAQAAAAAAAAAAAAAAAAERAjH/2gAMAwEAAhEDEQA/ANKAwWpillhZIZfCc9Hq6/bHtI6vyiIsKBBv13x0bylSWhKkcu1316+2cEjpBEOpDgmZeQEWCNyKIP1o4MabWhQDqxfKATyDrdk9O22eslnjUPACGamUm6G+/wBB742OWVo2vTlCEBC83U77fQe+ODTgrco5qvzrGtQFnONNMFv4Zr22DDtZ9umKN2kJDxFNgRfnsMLG0awouifkLxjvLuUFCtgUN3hUwb6uGOXw3aj3/rGNTA2oF83KRW1Rnr75xpNRdry12MTf7hJdXHEaNk1djp75GxauRdSAHL836S336e2ISCSyAEyKT/yhGFG+9V88GmqjkmaEEiRVDMpHQG/8wlgnrg0NfnERKEK3c5DT6kfFhC9uf4yW1biPRyP2H95RdPxQNxGV32NstnyAxkapPWcUli49BpnIMbQkL6Hy/jPNqNb8Hp9ZOjgEGlArY+W330yI1Ug12v8AjYpAoRrQmqYV5j53vni1OpV5ZIG5fFeQnnvZTew3+98rErVwri4k/FciczlJI1WqoDbsfU5cLrMs0OrRfxFHOWjjKvXMR+UHuarb1zUuoB2yeoqPDxuRo+C6l0blZVFG63sZTuG+BB8NPUsYOoBmldQFIIYkc3pS9ay/sGaJ1Q0xGx9cqsum1qSOItayrfRlLWfnjz41FWTTScYfVLq4Zonh8IIkoY3zWDXy88oDDl1bOoNCU3265Z9fwB3kJ8aX8+zKN2PoOg+owScClghaSXTtBCkbEFyOZiR1q9sqCo7hkKPxWKQOAHfmWrHnew37dM1M70czvhq6vUSaFoVKsjhnKp5i9+nrX75orHJ6MJTnWjRmsqL7+eLFkw1w6eIm2QE+uNbTws1tGrHuReLFiCGnhGwiSu3L0xzdcWLCmP/Z"
],
"link_maps": {},
"comments": null,
"author_sort_map": {
"Henry David Thoreau": "Thoreau, Henry David"
},
"uuid": "3393747a-f0d8-44e1-bfaf-5fad857da3eb",
"user_categories": {},
"db_id": null,
"user_metadata": {
"#formats": {
"table": "custom_column_1",
"column": "value",
"datatype": "composite",
"is_multiple": null,
"kind": "field",
"name": "Formats",
"search_terms": [
"#formats"
],
"label": "formats",
"colnum": 1,
"display": {
"contains_html": false,
"make_category": false,
"composite_sort": "text",
"use_decorations": 0,
"composite_template": "{:'re(approximate_formats(), ',', ', ')'}",
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 22,
"#value#": "EPUB",
"#extra#": null,
"is_multiple2": {}
},
"#gr_rating": {
"table": "custom_column_2",
"column": "value",
"datatype": "rating",
"is_multiple": null,
"kind": "field",
"name": "Goodreads Rating",
"search_terms": [
"#gr_rating"
],
"label": "gr_rating",
"colnum": 2,
"display": {
"description": "",
"allow_half_stars": false
},
"is_custom": true,
"is_category": true,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 23,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
},
"#gr_review": {
"table": "custom_column_3",
"column": "value",
"datatype": "comments",
"is_multiple": null,
"kind": "field",
"name": "Goodreads Review",
"search_terms": [
"#gr_review"
],
"label": "gr_review",
"colnum": 3,
"display": {
"heading_position": "hide",
"interpret_as": "long-text",
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 24,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
},
"#gr_shelf": {
"table": "custom_column_4",
"column": "value",
"datatype": "text",
"is_multiple": null,
"kind": "field",
"name": "Goodreads Shelf",
"search_terms": [
"#gr_shelf"
],
"label": "gr_shelf",
"colnum": 4,
"display": {
"use_decorations": 0,
"description": ""
},
"is_custom": true,
"is_category": true,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 25,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
},
"#ko_annotations": {
"table": "custom_column_13",
"column": "value",
"datatype": "comments",
"is_multiple": null,
"kind": "field",
"name": "KOReader Annotations",
"search_terms": [
"#ko_annotations"
],
"label": "ko_annotations",
"colnum": 13,
"display": {
"heading_position": "above",
"interpret_as": "markdown",
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 26,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
},
"#ko_md5": {
"table": "custom_column_5",
"column": "value",
"datatype": "text",
"is_multiple": null,
"kind": "field",
"name": "KOReader Sync Server MD5 Hash",
"search_terms": [
"#ko_md5"
],
"label": "ko_md5",
"colnum": 5,
"display": {
"use_decorations": 0,
"description": ""
},
"is_custom": true,
"is_category": true,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 27,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
},
"#ko_mod": {
"table": "custom_column_20",
"column": "value",
"datatype": "datetime",
"is_multiple": null,
"kind": "field",
"name": "KOReader Last Modified",
"search_terms": [
"#ko_mod"
],
"label": "ko_mod",
"colnum": 20,
"display": {
"date_format": null,
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 28,
"#value#": "None",
"#extra#": null,
"is_multiple2": {}
},
"#ko_sidecar": {
"table": "custom_column_6",
"column": "value",
"datatype": "comments",
"is_multiple": null,
"kind": "field",
"name": "KOReader Sidecar",
"search_terms": [
"#ko_sidecar"
],
"label": "ko_sidecar",
"colnum": 6,
"display": {
"heading_position": "above",
"interpret_as": "long-text",
"description": "The entire dict from KOReader\u2019s metadata.epub.lua"
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 29,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
},
"#ko_sync": {
"table": "custom_column_19",
"column": "value",
"datatype": "datetime",
"is_multiple": null,
"kind": "field",
"name": "KOReader Last Sync",
"search_terms": [
"#ko_sync"
],
"label": "ko_sync",
"colnum": 19,
"display": {
"date_format": null,
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 30,
"#value#": "None",
"#extra#": null,
"is_multiple2": {}
},
"#mm_annotations": {
"table": "custom_column_7",
"column": "value",
"datatype": "comments",
"is_multiple": null,
"kind": "field",
"name": "Kobo Annotations",
"search_terms": [
"#mm_annotations"
],
"label": "mm_annotations",
"colnum": 7,
"display": {
"heading_position": "above",
"interpret_as": "html",
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 31,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
},
"#read_first": {
"table": "custom_column_8",
"column": "value",
"datatype": "datetime",
"is_multiple": null,
"kind": "field",
"name": "Read First",
"search_terms": [
"#read_first"
],
"label": "read_first",
"colnum": 8,
"display": {
"description": "The date on which I added this book to my Goodreads Currently Reading shelf",
"date_format": null
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 32,
"#value#": "None",
"#extra#": null,
"is_multiple2": {}
},
"#read_last": {
"table": "custom_column_9",
"column": "value",
"datatype": "datetime",
"is_multiple": null,
"kind": "field",
"name": "Read Last",
"search_terms": [
"#read_last"
],
"label": "read_last",
"colnum": 9,
"display": {
"description": "The date on which I last opened this book on my e-reader",
"date_format": null
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 33,
"#value#": "None",
"#extra#": null,
"is_multiple2": {}
},
"#read_location": {
"table": "custom_column_10",
"column": "value",
"datatype": "text",
"is_multiple": null,
"kind": "field",
"name": "Read Location",
"search_terms": [
"#read_location"
],
"label": "read_location",
"colnum": 10,
"display": {
"description": "",
"use_decorations": 0
},
"is_custom": true,
"is_category": true,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 34,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
},
"#read_progress": {
"table": "custom_column_11",
"column": "value",
"datatype": "float",
"is_multiple": null,
"kind": "field",
"name": "Read Progress",
"search_terms": [
"#read_progress"
],
"label": "read_progress",
"colnum": 11,
"display": {
"description": "",
"number_format": "{:.0%}",
"decimals": 2
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 35,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
},
"#read_progress_int": {
"table": "custom_column_18",
"column": "value",
"datatype": "int",
"is_multiple": null,
"kind": "field",
"name": "Read Progress (int)",
"search_terms": [
"#read_progress_int"
],
"label": "read_progress_int",
"colnum": 18,
"display": {
"number_format": null,
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 36,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
},
"#read_status": {
"table": "custom_column_12",
"column": "value",
"datatype": "composite",
"is_multiple": null,
"kind": "field",
"name": "Read Status",
"search_terms": [
"#read_status"
],
"label": "read_status",
"colnum": 12,
"display": {
"use_decorations": 0,
"contains_html": false,
"description": "",
"composite_template": "{#read_progress:get_read_status()}",
"composite_sort": "text",
"make_category": true
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 37,
"#value#": "",
"#extra#": null,
"is_multiple2": {}
},
"#read_status_bool": {
"table": "custom_column_17",
"column": "value",
"datatype": "bool",
"is_multiple": null,
"kind": "field",
"name": "Read Status (yes/no)",
"search_terms": [
"#read_status_bool"
],
"label": "read_status_bool",
"colnum": 17,
"display": {
"bools_show_text": false,
"bools_show_icons": true,
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 38,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
},
"#read_status_text": {
"table": "custom_column_16",
"column": "value",
"datatype": "text",
"is_multiple": null,
"kind": "field",
"name": "Read Status (text)",
"search_terms": [
"#read_status_text"
],
"label": "read_status_text",
"colnum": 16,
"display": {
"use_decorations": false,
"description": ""
},
"is_custom": true,
"is_category": true,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 39,
"#value#": null,
"#extra#": null,
"is_multiple2": {}
}
},
"cover": null,
"mime": null,
"pubdate": "1995-01-01T03:00:00+00:00"
}
]
================================================
FILE: dummy_device/Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.sdr/metadata.epub.lua
================================================
-- we can read Lua syntax here!
return {
["highlights_imported"] = true,
["render_mode"] = 0,
["embedded_css"] = true,
["disable_fuzzy_search"] = false,
["embedded_fonts"] = true,
["copt_block_rendering_mode"] = 3,
["smooth_scaling"] = false,
["page_overlap_style"] = "dim",
["nightmode_images"] = true,
["cre_dom_version"] = 20200824,
["last_xpointer"] = "/body/DocFragment[9]/body/div/h2/text()[1].0",
["highlight_disabled"] = false,
["bookmarks"] = {
[1] = {
["datetime"] = "2020-11-16 18:52:11",
["page"] = "/body/DocFragment[8]/body/div/h2/text()[1].0",
["chapter"] = "CHAPTER VI. Pig and Pepper",
["notes"] = "in CHAPTER VI. Pig and Pepper"
},
[2] = {
["text"] = "Higlight with note: Page 48 “You!” said the Caterpillar contemptuously. “Who are you?” @ 2020-11-16 18:51:04",
["page"] = "/body/DocFragment[7]/body/div/p[12]/text()[1].0",
["chapter"] = "CHAPTER V. Advice from a Caterpillar",
["highlighted"] = true,
["datetime"] = "2020-11-16 18:51:04",
["pos0"] = "/body/DocFragment[7]/body/div/p[12]/text()[1].0",
["pos1"] = "/body/DocFragment[7]/body/div/p[12]/text()[2].1",
["notes"] = "“You!” said the Caterpillar contemptuously. “Who are you?”"
},
[3] = {
["page"] = "/body/DocFragment[7]/body/div/h2/text()[1].0",
["chapter"] = "CHAPTER V. Advice from a Caterpillar",
["highlighted"] = true,
["datetime"] = "2020-11-16 18:50:53",
["pos0"] = "/body/DocFragment[7]/body/div/h2/text()[1].0",
["pos1"] = "/body/DocFragment[7]/body/div/h2/text()[2].26",
["notes"] = "CHAPTER V. Advice from a Caterpillar"
}
},
["inverse_reading_order"] = false,
["line_space_percent"] = 100,
["css"] = "./data/epub.css",
["percent_finished"] = 0.45512820512821,
["rotation_mode"] = 0,
["partial_md5_checksum"] = "9c928bc227e2011e85f931ca159ff710",
["font_size"] = 22,
["copt_embedded_fonts"] = 1,
["copt_embedded_css"] = 1,
["copt_rotation_mode"] = 0,
["copt_render_dpi"] = 96,
["copt_sync_t_b_page_margins"] = 0,
["copt_h_page_margins"] = {
[1] = 10,
[2] = 10
},
["copt_t_page_margin"] = 15,
["copt_smooth_scaling"] = 0,
["copt_word_spacing"] = {
[1] = 95,
[2] = 75
},
["copt_nightmode_images"] = 1,
["word_spacing"] = {
[1] = 95,
[2] = 75
},
["header_font_face"] = "Noto Sans",
["copt_font_size"] = 22,
["font_embolden"] = 0,
["text_lang"] = "en-US",
["copt_font_hinting"] = 2,
["copt_font_kerning"] = 3,
["copt_word_expansion"] = 0,
["copt_line_spacing"] = 100,
["copt_font_gamma"] = 15,
["gamma"] = 1,
["render_dpi"] = 96,
["font_face"] = "Noto Serif",
["visible_pages"] = 1,
["show_overlap_enable"] = false,
["copt_view_mode"] = 0,
["highlight"] = {
[47] = {
[1] = {
["drawer"] = "lighten",
["chapter"] = "CHAPTER V. Advice from a Caterpillar",
["datetime"] = "2020-11-16 18:50:53",
["pos0"] = "/body/DocFragment[7]/body/div/h2/text()[1].0",
["text"] = "CHAPTER V. Advice from a Caterpillar",
["pos1"] = "/body/DocFragment[7]/body/div/h2/text()[2].26"
}
},
[48] = {
[1] = {
["drawer"] = "lighten",
["chapter"] = "CHAPTER V. Advice from a Caterpillar",
["datetime"] = "2020-11-16 18:51:04",
["pos0"] = "/body/DocFragment[7]/body/div/p[12]/text()[1].0",
["text"] = "“You!” said the Caterpillar contemptuously. “Who are you?”",
["pos1"] = "/body/DocFragment[7]/body/div/p[12]/text()[2].1"
}
}
},
["copt_status_line"] = 1,
["copt_b_page_margin"] = 15,
["readermenu_tab_index"] = 4,
["copt_font_weight"] = 0,
["bookmarks_sorted"] = true,
["stats"] = {
["authors"] = "Lewis Carroll",
["series"] = "",
["title"] = "Alice's Adventures in Wonderland",
["highlights"] = 2,
["notes"] = 0,
["md5"] = "9c928bc227e2011e85f931ca159ff710",
["language"] = "en",
["pages"] = 156
},
["hyph_soft_hyphens_only"] = false,
["font_hinting"] = 2,
["doc_pages"] = 156,
["copt_visible_pages"] = 1,
["text_lang_embedded_langs"] = true,
["config_panel_index"] = 1,
["gamma_index"] = 15,
["font_kerning"] = 3,
["hyphenation"] = true,
["hyph_trust_soft_hyphens"] = false,
["doc_props"] = {
["authors"] = "Lewis Carroll",
["series"] = "",
["title"] = "Alice's Adventures in Wonderland",
["description"] = "",
["language"] = "en",
["keywords"] = "Fantasy fiction\
Children's stories\
Imaginary places
-- Juvenile fiction\
Alice (Fictitious character from Carroll) -- Juvenile fiction"
},
["hyph_force_algorithmic"] = false,
["floating_punctuation"] = 0,
["word_expansion"] = 0,
["highlight_drawer"] = "lighten"
}
================================================
FILE: dummy_device/Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.sdr/metadata.epub.lua.old
================================================
-- we can read Lua syntax here!
return {
["partial_md5_checksum"] = "9c928bc227e2011e85f931ca159ff710"
}
================================================
FILE: dummy_library/Henry David Thoreau/Walden, and On The Duty Of Civil Disobedience (3)/metadata.opf
================================================
3
3393747a-f0d8-44e1-bfaf-5fad857da3eb
Walden, and On The Duty Of Civil Disobedience
Henry David Thoreau
calibre (5.5.0) [https://calibre-ebook.com]
1995-01-01T03:00:00+00:00
http://www.gutenberg.org/205
eng
Civil disobedience
Authors
American -- 19th century -- Biography
Thoreau
Henry David
1817-1862 -- Homes and haunts -- Massachusetts -- Walden Woods
Wilderness areas -- Massachusetts -- Walden Woods
Natural history -- Massachusetts -- Walden Woods
Solitude
Government
Resistance to
Walden Woods (Mass.) -- Social life and customs
================================================
FILE: dummy_library/Lewis Carroll/Alice's Adventures in Wonderland (4)/metadata.opf
================================================
4
43bd8264-96fa-461a-a05e-1d1cb245d34f
Alice's Adventures in Wonderland
Lewis Carroll
calibre (6.16.0) [https://calibre-ebook.com]
2008-06-27T04:00:00+00:00
http://www.gutenberg.org/11
eng
Fantasy fiction
Children's stories
Imaginary places -- Juvenile fiction
Alice (Fictitious character from Carroll) -- Juvenile fiction
================================================
FILE: dummy_library/metadata_db_prefs_backup.json
================================================
{
"bools_are_tristate": true,
"user_categories": {},
"saved_searches": {},
"grouped_search_terms": {},
"tag_browser_hidden_categories": [
"#read_location",
"rating",
"news"
],
"library_view books view state": {
"hidden_columns": [
"size",
"rating",
"tags",
"publisher",
"pubdate",
"last_modified",
"languages",
"#read_status"
],
"last_modified_injected": true,
"languages_injected": true,
"sort_history": [
[
"timestamp",
false
],
[
"authors",
true
],
[
"series",
true
],
[
"title",
true
],
[
"timestamp",
false
]
],
"column_positions": {
"ondevice": 0,
"title": 2,
"authors": 1,
"timestamp": 22,
"size": 24,
"rating": 23,
"tags": 25,
"series": 3,
"publisher": 26,
"pubdate": 27,
"last_modified": 28,
"languages": 29,
"#formats": 15,
"#gr_rating": 13,
"#gr_review": 14,
"#gr_shelf": 12,
"#ko_annotations": 17,
"#ko_md5": 18,
"#ko_mod": 20,
"#ko_sidecar": 19,
"#ko_sync": 21,
"#mm_annotations": 16,
"#read_first": 9,
"#read_last": 10,
"#read_location": 11,
"#read_progress": 7,
"#read_progress_int": 8,
"#read_status": 4,
"#read_status_bool": 6,
"#read_status_text": 5
},
"column_sizes": {
"title": 397,
"authors": 214,
"timestamp": 111,
"size": 0,
"rating": 0,
"tags": 0,
"series": 441,
"publisher": 0,
"pubdate": 0,
"last_modified": 0,
"languages": 0,
"#formats": 100,
"#gr_rating": 100,
"#gr_review": 100,
"#gr_shelf": 137,
"#ko_annotations": 100,
"#ko_md5": 100,
"#ko_mod": 100,
"#ko_sidecar": 100,
"#ko_sync": 100,
"#mm_annotations": 100,
"#read_first": 100,
"#read_last": 100,
"#read_location": 100,
"#read_progress": 100,
"#read_progress_int": 100,
"#read_status": 0,
"#read_status_bool": 100,
"#read_status_text": 100
},
"column_alignment": {
"#read_progress": "right",
"#read_status": "center",
"pubdate": "center",
"size": "center",
"timestamp": "center"
}
},
"field_metadata": {
"authors": {
"table": "authors",
"column": "name",
"link_column": "author",
"category_sort": "sort",
"datatype": "text",
"is_multiple": {
"cache_to_list": ",",
"ui_to_list": "&",
"list_to_ui": " & "
},
"kind": "field",
"name": "Authors",
"search_terms": [
"authors",
"author"
],
"is_custom": false,
"is_category": true,
"is_csp": false,
"label": "authors",
"display": {},
"is_editable": true,
"rec_index": 2
},
"languages": {
"table": "languages",
"column": "lang_code",
"link_column": "lang_code",
"category_sort": "lang_code",
"datatype": "text",
"is_multiple": {
"cache_to_list": ",",
"ui_to_list": ",",
"list_to_ui": ", "
},
"kind": "field",
"name": "Languages",
"search_terms": [
"languages",
"language"
],
"is_custom": false,
"is_category": true,
"is_csp": false,
"label": "languages",
"display": {},
"is_editable": true,
"rec_index": 21
},
"series": {
"table": "series",
"column": "name",
"link_column": "series",
"category_sort": "(title_sort(name))",
"datatype": "series",
"is_multiple": {},
"kind": "field",
"name": "Series",
"search_terms": [
"series"
],
"is_custom": false,
"is_category": true,
"is_csp": false,
"label": "series",
"display": {},
"is_editable": true,
"rec_index": 8
},
"formats": {
"table": null,
"column": null,
"datatype": "text",
"is_multiple": {
"cache_to_list": ",",
"ui_to_list": ",",
"list_to_ui": ", "
},
"kind": "field",
"name": "Formats",
"search_terms": [
"formats",
"format"
],
"is_custom": false,
"is_category": true,
"is_csp": false,
"label": "formats",
"display": {},
"is_editable": true,
"rec_index": 13
},
"publisher": {
"table": "publishers",
"column": "name",
"link_column": "publisher",
"category_sort": "name",
"datatype": "text",
"is_multiple": {},
"kind": "field",
"name": "Publisher",
"search_terms": [
"publisher"
],
"is_custom": false,
"is_category": true,
"is_csp": false,
"label": "publisher",
"display": {},
"is_editable": true,
"rec_index": 9
},
"rating": {
"table": "ratings",
"column": "rating",
"link_column": "rating",
"category_sort": "rating",
"datatype": "rating",
"is_multiple": {},
"kind": "field",
"name": "Rating",
"search_terms": [
"rating"
],
"is_custom": false,
"is_category": true,
"is_csp": false,
"label": "rating",
"display": {},
"is_editable": true,
"rec_index": 5
},
"news": {
"table": "news",
"column": "name",
"category_sort": "name",
"datatype": null,
"is_multiple": {},
"kind": "category",
"name": "News",
"search_terms": [],
"is_custom": false,
"is_category": true,
"is_csp": false,
"label": "news",
"display": {},
"is_editable": true
},
"tags": {
"table": "tags",
"column": "name",
"link_column": "tag",
"category_sort": "name",
"datatype": "text",
"is_multiple": {
"cache_to_list": ",",
"ui_to_list": ",",
"list_to_ui": ", "
},
"kind": "field",
"name": "Tags",
"search_terms": [
"tags",
"tag"
],
"is_custom": false,
"is_category": true,
"is_csp": false,
"label": "tags",
"display": {},
"is_editable": true,
"rec_index": 6
},
"identifiers": {
"table": null,
"column": null,
"datatype": "text",
"is_multiple": {
"cache_to_list": ",",
"ui_to_list": ",",
"list_to_ui": ", "
},
"kind": "field",
"name": "Identifiers",
"search_terms": [
"identifiers",
"identifier",
"isbn"
],
"is_custom": false,
"is_category": true,
"is_csp": true,
"label": "identifiers",
"display": {},
"is_editable": true,
"rec_index": 20
},
"author_sort": {
"table": null,
"column": null,
"datatype": "text",
"is_multiple": {},
"kind": "field",
"name": "Author sort",
"search_terms": [
"author_sort"
],
"is_custom": false,
"is_category": false,
"is_csp": false,
"label": "author_sort",
"display": {},
"is_editable": true,
"rec_index": 12
},
"au_map": {
"table": null,
"column": null,
"datatype": "text",
"is_multiple": {
"cache_to_list": ",",
"ui_to_list": null,
"list_to_ui": null
},
"kind": "field",
"name": null,
"search_terms": [],
"is_custom": false,
"is_category": false,
"is_csp": false,
"label": "au_map",
"display": {},
"is_editable": true,
"rec_index": 18
},
"comments": {
"table": null,
"column": null,
"datatype": "text",
"is_multiple": {},
"kind": "field",
"name": "Comments",
"search_terms": [
"comments",
"comment"
],
"is_custom": false,
"is_category": false,
"is_csp": false,
"label": "comments",
"display": {},
"is_editable": true,
"rec_index": 7
},
"cover": {
"table": null,
"column": null,
"datatype": "int",
"is_multiple": {},
"kind": "field",
"name": "Cover",
"search_terms": [
"cover"
],
"is_custom": false,
"is_category": false,
"is_csp": false,
"label": "cover",
"display": {},
"is_editable": true,
"rec_index": 17
},
"id": {
"table": null,
"column": null,
"datatype": "int",
"is_multiple": {},
"kind": "field",
"name": null,
"search_terms": [
"id"
],
"is_custom": false,
"is_category": false,
"is_csp": false,
"label": "id",
"display": {},
"is_editable": true,
"rec_index": 0
},
"last_modified": {
"table": null,
"column": null,
"datatype": "datetime",
"is_multiple": {},
"kind": "field",
"name": "Modified",
"search_terms": [
"last_modified"
],
"is_custom": false,
"is_category": false,
"is_csp": false,
"label": "last_modified",
"display": {
"date_format": "dd MMM yyyy"
},
"is_editable": true,
"rec_index": 19
},
"ondevice": {
"table": null,
"column": null,
"datatype": "text",
"is_multiple": {},
"kind": "field",
"name": "On device",
"search_terms": [
"ondevice"
],
"is_custom": false,
"is_category": false,
"is_csp": false,
"label": "ondevice",
"display": {},
"is_editable": true,
"rec_index": 40
},
"path": {
"table": null,
"column": null,
"datatype": "text",
"is_multiple": {},
"kind": "field",
"name": "Path",
"search_terms": [],
"is_custom": false,
"is_category": false,
"is_csp": false,
"label": "path",
"display": {},
"is_editable": true,
"rec_index": 14
},
"pubdate": {
"table": null,
"column": null,
"datatype": "datetime",
"is_multiple": {},
"kind": "field",
"name": "Published",
"search_terms": [
"pubdate"
],
"is_custom": false,
"is_category": false,
"is_csp": false,
"label": "pubdate",
"display": {
"date_format": "MMM yyyy"
},
"is_editable": true,
"rec_index": 15
},
"marked": {
"table": null,
"column": null,
"datatype": "text",
"is_multiple": {},
"kind": "field",
"name": null,
"search_terms": [
"marked"
],
"is_custom": false,
"is_category": false,
"is_csp": false,
"label": "marked",
"display": {},
"is_editable": true,
"rec_index": 41
},
"in_tag_browser": {
"table": null,
"column": null,
"datatype": "text",
"is_multiple": {},
"kind": "field",
"name": null,
"search_terms": [
"in_tag_browser"
],
"is_custom": false,
"is_category": false,
"is_csp": false,
"label": "in_tag_browser",
"display": {},
"is_editable": true,
"rec_index": 43
},
"series_index": {
"table": null,
"column": null,
"datatype": "float",
"is_multiple": {},
"kind": "field",
"name": null,
"search_terms": [
"series_index"
],
"is_custom": false,
"is_category": false,
"is_csp": false,
"label": "series_index",
"display": {},
"is_editable": true,
"rec_index": 10
},
"series_sort": {
"table": null,
"column": null,
"datatype": "text",
"is_multiple": {},
"kind": "field",
"name": "Series sort",
"search_terms": [
"series_sort"
],
"is_custom": false,
"is_category": false,
"is_csp": false,
"label": "series_sort",
"display": {},
"is_editable": true,
"rec_index": 42
},
"sort": {
"table": null,
"column": null,
"datatype": "text",
"is_multiple": {},
"kind": "field",
"name": "Title sort",
"search_terms": [
"title_sort"
],
"is_custom": false,
"is_category": false,
"is_csp": false,
"label": "sort",
"display": {},
"is_editable": true,
"rec_index": 11
},
"size": {
"table": null,
"column": null,
"datatype": "float",
"is_multiple": {},
"kind": "field",
"name": "Size",
"search_terms": [
"size"
],
"is_custom": false,
"is_category": false,
"is_csp": false,
"label": "size",
"display": {},
"is_editable": true,
"rec_index": 4
},
"timestamp": {
"table": null,
"column": null,
"datatype": "datetime",
"is_multiple": {},
"kind": "field",
"name": "Date",
"search_terms": [
"date"
],
"is_custom": false,
"is_category": false,
"is_csp": false,
"label": "timestamp",
"display": {
"date_format": "dd MMM yyyy"
},
"is_editable": true,
"rec_index": 3
},
"title": {
"table": null,
"column": null,
"datatype": "text",
"is_multiple": {},
"kind": "field",
"name": "Title",
"search_terms": [
"title"
],
"is_custom": false,
"is_category": false,
"is_csp": false,
"label": "title",
"display": {},
"is_editable": true,
"rec_index": 1
},
"uuid": {
"table": null,
"column": null,
"datatype": "text",
"is_multiple": {},
"kind": "field",
"name": null,
"search_terms": [
"uuid"
],
"is_custom": false,
"is_category": false,
"is_csp": false,
"label": "uuid",
"display": {},
"is_editable": true,
"rec_index": 16
},
"#formats": {
"table": "custom_column_1",
"column": "value",
"datatype": "composite",
"is_multiple": {},
"kind": "field",
"name": "Formats",
"search_terms": [
"#formats"
],
"label": "formats",
"colnum": 1,
"display": {
"contains_html": false,
"make_category": false,
"composite_sort": "text",
"use_decorations": 0,
"composite_template": "{:'re(approximate_formats(), ',', ', ')'}",
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 22
},
"#gr_rating": {
"table": "custom_column_2",
"column": "value",
"datatype": "rating",
"is_multiple": {},
"kind": "field",
"name": "Goodreads Rating",
"search_terms": [
"#gr_rating"
],
"label": "gr_rating",
"colnum": 2,
"display": {
"description": "",
"allow_half_stars": false
},
"is_custom": true,
"is_category": true,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 23
},
"#gr_review": {
"table": "custom_column_3",
"column": "value",
"datatype": "comments",
"is_multiple": {},
"kind": "field",
"name": "Goodreads Review",
"search_terms": [
"#gr_review"
],
"label": "gr_review",
"colnum": 3,
"display": {
"heading_position": "hide",
"interpret_as": "long-text",
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 24
},
"#gr_shelf": {
"table": "custom_column_4",
"column": "value",
"datatype": "text",
"is_multiple": {},
"kind": "field",
"name": "Goodreads Shelf",
"search_terms": [
"#gr_shelf"
],
"label": "gr_shelf",
"colnum": 4,
"display": {
"use_decorations": 0,
"description": ""
},
"is_custom": true,
"is_category": true,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 25
},
"#ko_annotations": {
"table": "custom_column_13",
"column": "value",
"datatype": "comments",
"is_multiple": {},
"kind": "field",
"name": "KOReader Annotations",
"search_terms": [
"#ko_annotations"
],
"label": "ko_annotations",
"colnum": 13,
"display": {
"heading_position": "above",
"interpret_as": "markdown",
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 26
},
"#ko_md5": {
"table": "custom_column_5",
"column": "value",
"datatype": "text",
"is_multiple": {},
"kind": "field",
"name": "KOReader Sync Server MD5 Hash",
"search_terms": [
"#ko_md5"
],
"label": "ko_md5",
"colnum": 5,
"display": {
"use_decorations": 0,
"description": ""
},
"is_custom": true,
"is_category": true,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 27
},
"#ko_mod": {
"table": "custom_column_20",
"column": "value",
"datatype": "datetime",
"is_multiple": {},
"kind": "field",
"name": "KOReader Last Modified",
"search_terms": [
"#ko_mod"
],
"label": "ko_mod",
"colnum": 20,
"display": {
"date_format": null,
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 28
},
"#ko_sidecar": {
"table": "custom_column_6",
"column": "value",
"datatype": "comments",
"is_multiple": {},
"kind": "field",
"name": "KOReader Sidecar",
"search_terms": [
"#ko_sidecar"
],
"label": "ko_sidecar",
"colnum": 6,
"display": {
"heading_position": "above",
"interpret_as": "long-text",
"description": "The entire dict from KOReader\u2019s metadata.epub.lua"
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 29
},
"#ko_sync": {
"table": "custom_column_19",
"column": "value",
"datatype": "datetime",
"is_multiple": {},
"kind": "field",
"name": "KOReader Last Sync",
"search_terms": [
"#ko_sync"
],
"label": "ko_sync",
"colnum": 19,
"display": {
"date_format": null,
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 30
},
"#mm_annotations": {
"table": "custom_column_7",
"column": "value",
"datatype": "comments",
"is_multiple": {},
"kind": "field",
"name": "Kobo Annotations",
"search_terms": [
"#mm_annotations"
],
"label": "mm_annotations",
"colnum": 7,
"display": {
"heading_position": "above",
"interpret_as": "html",
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 31
},
"#read_first": {
"table": "custom_column_8",
"column": "value",
"datatype": "datetime",
"is_multiple": {},
"kind": "field",
"name": "Read First",
"search_terms": [
"#read_first"
],
"label": "read_first",
"colnum": 8,
"display": {
"description": "The date on which I added this book to my Goodreads Currently Reading shelf",
"date_format": null
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 32
},
"#read_last": {
"table": "custom_column_9",
"column": "value",
"datatype": "datetime",
"is_multiple": {},
"kind": "field",
"name": "Read Last",
"search_terms": [
"#read_last"
],
"label": "read_last",
"colnum": 9,
"display": {
"description": "The date on which I last opened this book on my e-reader",
"date_format": null
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 33
},
"#read_location": {
"table": "custom_column_10",
"column": "value",
"datatype": "text",
"is_multiple": {},
"kind": "field",
"name": "Read Location",
"search_terms": [
"#read_location"
],
"label": "read_location",
"colnum": 10,
"display": {
"description": "",
"use_decorations": 0
},
"is_custom": true,
"is_category": true,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 34
},
"#read_progress": {
"table": "custom_column_11",
"column": "value",
"datatype": "float",
"is_multiple": {},
"kind": "field",
"name": "Read Progress",
"search_terms": [
"#read_progress"
],
"label": "read_progress",
"colnum": 11,
"display": {
"description": "",
"number_format": "{:.0%}",
"decimals": 2
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 35
},
"#read_progress_int": {
"table": "custom_column_18",
"column": "value",
"datatype": "int",
"is_multiple": {},
"kind": "field",
"name": "Read Progress (int)",
"search_terms": [
"#read_progress_int"
],
"label": "read_progress_int",
"colnum": 18,
"display": {
"number_format": null,
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 36
},
"#read_status": {
"table": "custom_column_12",
"column": "value",
"datatype": "composite",
"is_multiple": {},
"kind": "field",
"name": "Read Status",
"search_terms": [
"#read_status"
],
"label": "read_status",
"colnum": 12,
"display": {
"use_decorations": 0,
"contains_html": false,
"description": "",
"composite_template": "{#read_progress:get_read_status()}",
"composite_sort": "text",
"make_category": true
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 37
},
"#read_status_bool": {
"table": "custom_column_17",
"column": "value",
"datatype": "bool",
"is_multiple": {},
"kind": "field",
"name": "Read Status (yes/no)",
"search_terms": [
"#read_status_bool"
],
"label": "read_status_bool",
"colnum": 17,
"display": {
"bools_show_text": false,
"bools_show_icons": true,
"description": ""
},
"is_custom": true,
"is_category": false,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 38
},
"#read_status_text": {
"table": "custom_column_16",
"column": "value",
"datatype": "text",
"is_multiple": {},
"kind": "field",
"name": "Read Status (text)",
"search_terms": [
"#read_status_text"
],
"label": "read_status_text",
"colnum": 16,
"display": {
"use_decorations": false,
"description": ""
},
"is_custom": true,
"is_category": true,
"link_column": "value",
"category_sort": "value",
"is_csp": false,
"is_editable": true,
"rec_index": 39
}
},
"books view split pane state": {
"hidden_columns": [],
"column_positions": {
"ondevice": 0,
"title": 1,
"authors": 2,
"timestamp": 3,
"size": 4,
"rating": 5,
"tags": 6,
"series": 7,
"publisher": 8,
"pubdate": 9,
"last_modified": 10,
"languages": 11,
"#formats": 12,
"#gr_rating": 20,
"#gr_review": 14,
"#gr_shelf": 16,
"#ko_annotations": 24,
"#ko_md5": 22,
"#ko_mod": 29,
"#ko_sidecar": 23,
"#ko_sync": 28,
"#mm_annotations": 13,
"#read_first": 21,
"#read_last": 15,
"#read_location": 19,
"#read_progress": 17,
"#read_progress_int": 27,
"#read_status": 18,
"#read_status_bool": 25,
"#read_status_text": 26
},
"column_sizes": {
"title": 100,
"authors": 100,
"timestamp": 100,
"size": 100,
"rating": 100,
"tags": 100,
"series": 100,
"publisher": 100,
"pubdate": 100,
"last_modified": 100,
"languages": 100,
"#formats": 100,
"#gr_rating": 100,
"#gr_review": 100,
"#gr_shelf": 100,
"#ko_annotations": 100,
"#ko_md5": 100,
"#ko_mod": 100,
"#ko_sidecar": 100,
"#ko_sync": 100,
"#mm_annotations": 100,
"#read_first": 100,
"#read_last": 100,
"#read_location": 100,
"#read_progress": 100,
"#read_progress_int": 100,
"#read_status": 100,
"#read_status_bool": 100,
"#read_status_text": 100
}
},
"book_display_fields": [
[
"title",
true
],
[
"authors",
true
],
[
"series",
true
],
[
"identifiers",
true
],
[
"tags",
false
],
[
"formats",
true
],
[
"path",
true
],
[
"publisher",
false
],
[
"rating",
false
],
[
"author_sort",
false
],
[
"sort",
false
],
[
"timestamp",
false
],
[
"uuid",
false
],
[
"comments",
true
],
[
"id",
false
],
[
"pubdate",
false
],
[
"last_modified",
false
],
[
"size",
false
],
[
"languages",
true
],
[
"#formats",
true
],
[
"#mm_annotations",
true
],
[
"#read_status",
true
],
[
"#gr_review",
true
],
[
"#read_location",
true
],
[
"#read_last",
true
],
[
"#gr_shelf",
true
],
[
"#read_progress",
true
]
],
"qv_display_fields": [
[
"title",
true
],
[
"authors",
true
],
[
"series",
true
],
[
"languages",
false
],
[
"formats",
false
],
[
"publisher",
false
],
[
"rating",
false
],
[
"tags",
false
],
[
"identifiers",
false
],
[
"author_sort",
false
],
[
"id",
false
],
[
"last_modified",
false
],
[
"path",
false
],
[
"pubdate",
false
],
[
"sort",
false
],
[
"size",
false
],
[
"timestamp",
false
],
[
"uuid",
false
],
[
"#formats",
false
],
[
"#gr_shelf",
false
],
[
"#read_last",
false
],
[
"#read_location",
false
],
[
"#read_progress",
false
],
[
"#read_status",
false
]
],
"column_color_rules": [],
"column_icon_rules": [
[
"icon_only",
"#read_status",
"program:\n# BasicColorRule():5b226f6b2e706e67222c205b5b2223726561645f737461747573222c20226973222c202272656164225d5d5d\ntest(strcmp(field('#read_status'), \"read\", '', '1', ''), 'ok.png', '');\n"
],
[
"icon_only",
"#read_status",
"program:\n# BasicColorRule():5b22717569636b766965772e706e67222c205b5b2223726561645f737461747573222c20226973222c202263757272656e746c792d72656164696e67225d5d5d\ntest(strcmp(field('#read_status'), \"currently-reading\", '', '1', ''), 'quickview.png', '');\n"
]
],
"cover_grid_icon_rules": [],
"gui_view_history": [
[
4,
"Alice's Adventures in Wonderland"
],
[
7540,
"Out of Africa ; and, Shadows on the grass"
],
[
7539,
"Out of Africa & Shadows on the Grass (English, 2.27Mb)"
],
[
7531,
"Robinson Crusoe"
],
[
7535,
"Out of Africa and Shadows on the Grass"
],
[
7524,
"Beyond the Limits: Confronting Global Collapse, Envisioning a Sustainable Future"
],
[
7523,
"Schismatrix Plus"
],
[
7513,
"Fokke en Sukke [Wed, 05 Aug 2020]"
],
[
2614,
"Problemski Hotel"
],
[
542,
"Zoenen Met Rommel"
],
[
4224,
"Angst"
],
[
4877,
"Schindlers List"
],
[
5,
"Buonanotte, Signor Lenin"
],
[
1426,
"Opuscula Selecta Neerlandicorum De Arte"
],
[
4067,
"Encomium Artis Medicae _ De Lof Der Geneeskunde"
]
],
"update_all_last_mod_dates_on_start": false,
"plugboards": {},
"namespaced:KoboUtilitiesPlugin:settings": {
"SchemaVersion": 0.1,
"profiles": {
"Default": {
"customColumnOptions": {
"currentReadingLocationColumn": "#read_location",
"lastReadColumn": "#read_last",
"percentReadColumn": "#read_progress",
"ratingColumn": "#gr_rating"
},
"forDevice": "*Any Device",
"profileName": "Default",
"storeOptionsStore": {
"doNotStoreIfReopened": true,
"promptToStore": true,
"storeIfMoreRecent": true,
"storeOnConnect": true
},
"updateOptionsStore": {
"doEarlyFirmwareUpdate": false,
"doFirmwareUpdateCheck": false,
"firmwareUpdateCheckLastTime": 0
}
}
},
"readingPositionChangesStore": {
"selectBooksInLibrary": false,
"updeateGoodreadsProgress": true
}
},
"user_template_functions": [
[
"get_read_status",
"get_read_status(progress_field)\nreturn '\u2610' if `progress_field`< 100, '' if empty, else '\u2611\ufe0e'",
1,
"def evaluate(self, formatter, kwargs, mi, locals, progress_field):\n\tif progress_field:\n\t\tif float(progress_field.strip('%').strip()) < 100:\n\t\t\treturn '\u2610'\n\t\treturn '\u2611\ufe0e'\n\treturn ''"
]
],
"virtual_libraries": {
"On Device": "ondevice:\"True\"",
"Want to Read": "formats:\"=EPUB\" and #gr_shelf:\"=to-read\""
},
"virt_libs_hidden": [],
"namespaced:FindDuplicatesPlugin:settings": {
"SchemaVersion": 1.7,
"authorExemptions": [],
"bookExemptions": [],
"lastLibraryCompare": ""
},
"namespaced:ImportListPlugin:settings": {
"clipboardRegexes": [],
"csvFiles": [],
"current": {
"clipboard": {
"regex": "",
"reverseList": false,
"text": ""
},
"csv": {
"columnData": [
{
"field": "title",
"index": 1
},
{
"field": "authors",
"index": 2
}
],
"delimiter": ",",
"file": "",
"match_by_identifier": null,
"reverseList": false,
"skipFirst": true,
"unquote": true
},
"importType": "web",
"readingList": {
"clearList": true,
"name": ""
},
"web": {
"categories": [
"Social Websites"
],
"encoding": "utf-8",
"javascript": true,
"reverseList": true,
"url": "https://www.goodreads.com/review/list/38080398-harm?shelf=to-read&per_page=100",
"xpathData": [
{
"field": "rows",
"xpath": "//tbody[@id=\"booksBody\"]/tr"
},
{
"field": "title",
"isRegexStrip": true,
"regex": "\\([^\\)]+\\)",
"xpath": "td[@class=\"field title\"]//a/text()"
},
{
"field": "authors",
"isRegexStrip": true,
"regex": "",
"xpath": "td[@class=\"field author\"]//a/text()"
},
{
"field": "series",
"isRegexStrip": false,
"regex": "\\(([^,\\.#]+)",
"xpath": "td[@class=\"field title\"]//a/span/text()"
},
{
"field": "series_index",
"isRegexStrip": false,
"regex": "#([\\d\\.]+)",
"xpath": "td[@class=\"field title\"]//a/span/text()"
},
{
"field": "identifier:goodreads",
"isRegexStrip": false,
"regex": "/review/show/(\\d+)",
"xpath": "td[@class=\"field actions\"]//a/@href"
}
]
}
},
"javascriptDelay": 3,
"lastCSVSetting": "",
"lastClipboardSetting": "",
"lastPredefinedSetting": "Goodreads: Shelves: To Read",
"lastTab": 4,
"lastUserSetting": "Goodreads Want to Read",
"lastViewType": "list",
"lastWebSetting": "Goodreads Want to Read",
"savedSettings": {
"Goodreads Want to Read": {
"categories": [
"Social Websites"
],
"encoding": "utf-8",
"importType": "web",
"javascript": true,
"readingList": {
"clearList": true,
"name": ""
},
"reverseList": true,
"url": "https://www.goodreads.com/review/list/38080398-harm?shelf=to-read&per_page=100",
"xpathData": [
{
"field": "rows",
"xpath": "//tbody[@id=\"booksBody\"]/tr"
},
{
"field": "title",
"isRegexStrip": true,
"regex": "\\([^\\)]+\\)",
"xpath": "td[@class=\"field title\"]//a/text()"
},
{
"field": "authors",
"isRegexStrip": true,
"regex": "",
"xpath": "td[@class=\"field author\"]//a/text()"
},
{
"field": "series",
"isRegexStrip": false,
"regex": "\\(([^,\\.#]+)",
"xpath": "td[@class=\"field title\"]//a/span/text()"
},
{
"field": "series_index",
"isRegexStrip": false,
"regex": "#([\\d\\.]+)",
"xpath": "td[@class=\"field title\"]//a/span/text()"
},
{
"field": "identifier:goodreads",
"isRegexStrip": false,
"regex": "/review/show/(\\d+)",
"xpath": "td[@class=\"field actions\"]//a/@href"
}
]
}
},
"schemaVersion": 1.2,
"webUrls": [
"https://www.goodreads.com/review/list/38080398-harm?shelf=to-read&per_page=100",
"https://www.goodreads.com/review/list/38080398-harm?shelf=to-read&per_page=infinite",
"https://www.goodreads.com/review/list/38080398-harm?utf8=%E2%9C%93&shelf=to-read&title=harm&per_page=infinite",
"https://www.goodreads.com/review/list/38080398-harm?shelf=to-read",
"http://www.goodreads.com/shelf/show/to-read"
]
},
"news_to_be_synced": [],
"tag_browser_category_order": [
"authors",
"languages",
"series",
"formats",
"publisher",
"rating",
"news",
"tags",
"identifiers",
"#gr_rating",
"#gr_shelf",
"#ko_md5",
"#read_location",
"#read_status"
]
}
================================================
FILE: plugin-import-name-koreader.txt
================================================
================================================
FILE: pluginIndexKOReaderSync.txt
================================================
[*][URL="https://www.mobileread.com/forums/showthread.php?t=362706"]KOReader Sync[/URL]
[I]Synchronize metadata (e.g. read progress and rating) from KOReader to calibre.
Version: 0.8.2; Calibre: 5.0.1; Author: harmtemolder & others, currently maintaining by: kyxap; History: Yes;
Platforms: Windows, OSX, Linux;[/I]
================================================
FILE: pytest.ini
================================================
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
# Prevent pytest from trying to import root __init__.py
addopts = -v --ignore=__init__.py
================================================
FILE: slpp.py
================================================
"""Copyright (c) 2010, 2011, 2012 SirAnthony
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE."""
import re
import sys
from numbers import Number
import six
ERRORS = {
'unexp_end_string': u'Unexpected end of string while parsing Lua string.',
'unexp_end_table': u'Unexpected end of table while parsing Lua string.',
'mfnumber_minus': u'Malformed number (no digits after initial minus).',
'mfnumber_dec_point': u'Malformed number (no digits after decimal point).',
'mfnumber_sci': u'Malformed number (bad scientific format).',
}
def sequential(lst):
length = len(lst)
if length == 0 or lst[0] != 0:
return False
for i in range(length):
if i + 1 < length:
if lst[i] + 1 != lst[i+1]:
return False
return True
class ParseError(Exception):
pass
class SLPP(object):
def __init__(self):
self.text = ''
self.ch = ''
self.at = 0
self.len = 0
self.depth = 0
self.space = re.compile(r'\s', re.M)
self.alnum = re.compile(r'\w', re.M)
self.newline = '\n'
self.tab = '\t'
def decode(self, text):
if not text or not isinstance(text, six.string_types):
return
self.text = text
self.at, self.ch, self.depth = 0, '', 0
self.len = len(text)
self.next_chr()
result = self.value()
return result
def encode(self, obj):
self.depth = 0
return self.__encode(obj)
def __encode(self, obj):
s = ''
tab = self.tab
newline = self.newline
if isinstance(obj, str):
s += '"%s"' % obj.replace(r'"', r'\"')
elif six.PY2 and isinstance(obj, unicode):
s += '"%s"' % obj.encode('utf-8').replace(r'"', r'\"')
elif six.PY3 and isinstance(obj, bytes):
s += '"{}"'.format(''.join(r'\x{:02x}'.format(c) for c in obj))
elif isinstance(obj, bool):
s += str(obj).lower()
elif obj is None:
s += 'nil'
elif isinstance(obj, Number):
s += str(obj)
elif isinstance(obj, (list, tuple, dict)):
self.depth += 1
if len(obj) == 0 or (not isinstance(obj, dict) and len([
x for x in obj
if isinstance(x, Number) or (isinstance(x, six.string_types) and len(x) < 10)
]) == len(obj)):
newline = tab = ''
dp = tab * self.depth
s += "%s{%s" % (tab * (self.depth - 2), newline)
if isinstance(obj, dict):
key_list = ['[%s]' if isinstance(k, Number) else '["%s"]' for k in obj.keys()]
contents = [dp + (key + ' = %s') % (k, self.__encode(v)) for (k, v), key in zip(obj.items(), key_list)]
s += (',%s' % newline).join(contents)
else:
s += (',%s' % newline).join(
[dp + self.__encode(el) for el in obj])
self.depth -= 1
s += "%s%s}" % (newline, tab * self.depth)
return s
def white(self):
while self.ch:
if self.space.match(self.ch):
self.next_chr()
else:
break
self.comment()
def comment(self):
if self.ch == '-' and self.next_is('-'):
self.next_chr()
# TODO: for fancy comments need to improve
multiline = self.next_chr() and self.ch == '[' and self.next_is('[')
while self.ch:
if multiline:
if self.ch == ']' and self.next_is(']'):
self.next_chr()
self.next_chr()
self.white()
break
# `--` is a comment, skip to next new line
elif re.match('\n', self.ch):
self.white()
break
self.next_chr()
def next_is(self, value):
if self.at >= self.len:
return False
return self.text[self.at] == value
def prev_is(self, value):
if self.at < 2:
return False
return self.text[self.at-2] == value
def next_chr(self):
if self.at >= self.len:
self.ch = None
return None
self.ch = self.text[self.at]
self.at += 1
return True
def value(self):
self.white()
if not self.ch:
return
if self.ch == '{':
return self.object()
if self.ch == "[":
self.next_chr()
if self.ch in ['"', "'", '[']:
return self.string(self.ch)
if self.ch.isdigit() or self.ch == '-':
return self.number()
return self.word()
def string(self, end=None):
s = ''
start = self.ch
if end == '[':
end = ']'
if start in ['"', "'", '[']:
double = start=='[' and self.prev_is(start)
while self.next_chr():
if self.ch == end and (not double or self.next_is(end)):
self.next_chr()
if start != "[" or self.ch == ']':
if double:
self.next_chr()
return s
if self.ch == '\\' and start == end:
self.next_chr()
if self.ch != end:
s += '\\'
s += self.ch
raise ParseError(ERRORS['unexp_end_string'])
def object(self):
o = {}
k = None
idx = 0
numeric_keys = False
self.depth += 1
self.next_chr()
self.white()
if self.ch and self.ch == '}':
self.depth -= 1
self.next_chr()
return o # Exit here
else:
while self.ch:
self.white()
if self.ch == '{':
o[idx] = self.object()
idx += 1
continue
elif self.ch == '}':
self.depth -= 1
self.next_chr()
if k is not None:
o[idx] = k
if len([key for key in o if isinstance(key, six.string_types + (float, bool, tuple))]) == 0:
so = sorted([key for key in o])
if sequential(so):
ar = []
for key in o:
ar.insert(key, o[key])
o = ar
return o # or here
else:
if self.ch == ',':
self.next_chr()
continue
else:
k = self.value()
if self.ch == ']':
self.next_chr()
self.white()
ch = self.ch
if ch in ('=', ','):
self.next_chr()
self.white()
if ch == '=':
o[k] = self.value()
else:
o[idx] = k
idx += 1
k = None
raise ParseError(ERRORS['unexp_end_table']) # Bad exit here
words = {'true': True, 'false': False, 'nil': None}
def word(self):
s = ''
if self.ch != '\n':
s = self.ch
self.next_chr()
while self.ch is not None and self.alnum.match(self.ch) and s not in self.words:
s += self.ch
self.next_chr()
return self.words.get(s, s)
def number(self):
def next_digit(err):
n = self.ch
self.next_chr()
if not self.ch or not self.ch.isdigit():
raise ParseError(err)
return n
n = ''
try:
if self.ch == '-':
n += next_digit(ERRORS['mfnumber_minus'])
n += self.digit()
if n == '0' and self.ch in ['x', 'X']:
n += self.ch
self.next_chr()
n += self.hex()
else:
if self.ch and self.ch == '.':
n += next_digit(ERRORS['mfnumber_dec_point'])
n += self.digit()
if self.ch and self.ch in ['e', 'E']:
n += self.ch
self.next_chr()
if not self.ch or self.ch not in ('+', '-'):
raise ParseError(ERRORS['mfnumber_sci'])
n += next_digit(ERRORS['mfnumber_sci'])
n += self.digit()
except ParseError:
t, e = sys.exc_info()[:2]
print(e)
return 0
try:
return int(n, 0)
except:
pass
return float(n)
def digit(self):
n = ''
while self.ch and self.ch.isdigit():
n += self.ch
self.next_chr()
return n
def hex(self):
n = ''
while self.ch and (self.ch in 'ABCDEFabcdef' or self.ch.isdigit()):
n += self.ch
self.next_chr()
return n
slpp = SLPP()
__all__ = ['slpp']
================================================
FILE: tests/__init__.py
================================================
================================================
FILE: tests/conftest.py
================================================
import sys
import os
import builtins
import types
import importlib.util
from unittest.mock import MagicMock
# 1. Mock gettext '_' function
if not hasattr(builtins, '_'):
builtins._ = lambda x: x
# Add project root to sys.path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# 2. Mock 'calibre' package and submodules
calibre = types.ModuleType("calibre")
calibre.constants = MagicMock()
calibre.constants.numeric_version = (6, 0, 0)
calibre.customize = MagicMock()
# IMPORTANT: Base classes must be classes, not Mocks
class MockInterfaceActionBase:
pass
calibre.customize.InterfaceActionBase = MockInterfaceActionBase
calibre.devices = MagicMock()
calibre.devices.usbms = MagicMock()
# IMPORTANT: USBMS must be a class for isinstance() to work in action.py
class MockUSBMS:
pass
calibre.devices.usbms.driver = MagicMock()
calibre.devices.usbms.driver.USBMS = MockUSBMS
# Setup calibre.utils
calibre_utils = types.ModuleType("calibre.utils")
calibre_utils.__path__ = []
calibre.utils = calibre_utils
calibre_utils.config = MagicMock()
calibre_utils.iso8601 = MagicMock()
from datetime import timezone
calibre_utils.iso8601.utc_tz = timezone.utc
calibre_utils.iso8601.local_tz = timezone.utc
# Setup calibre.gui2
calibre_gui2 = types.ModuleType("calibre.gui2")
calibre_gui2.__path__ = []
calibre.gui2 = calibre_gui2
calibre_gui2.actions = MagicMock()
class MockInterfaceAction:
def __init__(self, parent, site_customization):
self.gui = parent
self.interface_action_base_plugin = site_customization
calibre_gui2.actions.InterfaceAction = MockInterfaceAction
calibre_gui2.device = MagicMock()
calibre_gui2.show_restart_warning = MagicMock()
calibre_gui2.error_dialog = MagicMock()
calibre_gui2.warning_dialog = MagicMock()
calibre_gui2.open_url = MagicMock()
# Setup calibre.gui2.dialogs
calibre_gui2_dialogs = types.ModuleType("calibre.gui2.dialogs")
calibre_gui2_dialogs.__path__ = []
calibre_gui2.dialogs = calibre_gui2_dialogs
sys.modules["calibre.gui2.dialogs"] = calibre_gui2_dialogs
calibre_gui2_dialogs.message_box = MagicMock()
calibre_gui2_dialogs.message_box.MessageBox = MagicMock()
# Assign to sys.modules
sys.modules["calibre"] = calibre
sys.modules["calibre.constants"] = calibre.constants
sys.modules["calibre.customize"] = calibre.customize
sys.modules["calibre.devices"] = calibre.devices
sys.modules["calibre.devices.usbms"] = calibre.devices.usbms
sys.modules["calibre.devices.usbms.driver"] = calibre.devices.usbms.driver
sys.modules["calibre.utils"] = calibre.utils
sys.modules["calibre.utils.config"] = calibre.utils.config
sys.modules["calibre.utils.iso8601"] = calibre.utils.iso8601
sys.modules["calibre.gui2"] = calibre.gui2
sys.modules["calibre.gui2.actions"] = calibre_gui2.actions
sys.modules["calibre.gui2.dialogs"] = calibre_gui2.dialogs
sys.modules["calibre.gui2.dialogs.message_box"] = calibre_gui2_dialogs.message_box
sys.modules["calibre.gui2.device"] = calibre_gui2.device
# 3. Create 'calibre_plugins' package
calibre_plugins = types.ModuleType("calibre_plugins")
sys.modules["calibre_plugins"] = calibre_plugins
# 4. Create 'koreader' package
koreader_pkg = types.ModuleType("calibre_plugins.koreader")
koreader_pkg.__path__ = []
calibre_plugins.koreader = koreader_pkg
sys.modules["calibre_plugins.koreader"] = koreader_pkg
# 5. Import local modules
init_path = os.path.join(os.path.dirname(__file__), '..', '__init__.py')
spec = importlib.util.spec_from_file_location("__init__", init_path)
__init__ = importlib.util.module_from_spec(spec)
sys.modules["__init__"] = __init__
spec.loader.exec_module(__init__)
import slpp
# 6. Assign submodules to package
koreader_pkg.slpp = slpp
sys.modules["calibre_plugins.koreader.slpp"] = slpp
koreader_pkg.clean_bookmarks = __init__.clean_bookmarks
koreader_pkg.DEBUG = __init__.DEBUG
koreader_pkg.DRY_RUN = __init__.DRY_RUN
koreader_pkg.PYDEVD = __init__.PYDEVD
koreader_pkg.KoreaderSync = __init__.KoreaderSync
# 7. Import config
import config
koreader_pkg.config = config
sys.modules["calibre_plugins.koreader.config"] = config
================================================
FILE: tests/integration/test_docker_path_resolution.py
================================================
import os
from unittest.mock import MagicMock, patch
from action import KoreaderAction
def test_wireless_device_avoids_local_filesystem():
"""
Reproduces Issue #73: Ensure that for non-USB devices (Wireless/Docker),
the plugin does NOT use os.path.exists which would look at the local
container filesystem.
"""
# 1. Setup a mock device that is NOT a USBMS instance
# In conftest.py, we mocked MockUSBMS.
# Here we create a device that does NOT inherit from it.
class WirelessDevice:
def exists(self, path):
return True
def get_file(self, path, outfile):
pass
mock_device = WirelessDevice()
# 2. Setup Action
mock_parent = MagicMock()
mock_site_customization = MagicMock()
mock_site_customization.name = 'KOReader Sync'
mock_site_customization.version = (0, 8, 0)
action = KoreaderAction(mock_parent, mock_site_customization)
# Path that looks like a Linux/KOReader path but definitely doesn't exist locally
device_path = "/mnt/onboard/Books/Test.sdr/metadata.epub.lua"
# 3. Patch os.path.exists to track calls
with patch('os.path.exists') as mock_exists:
mock_exists.return_value = False # Doesn't exist on host
# Trigger the check
exists = action.device_path_exists(mock_device, device_path)
# ASSERTIONS
assert exists is True, "Should have used driver.exists"
# CRITICAL REPRODUCTION CHECK:
# Before the fix, this would have been called with the device_path.
# After the fix, it should NOT be called for non-USB devices.
mock_exists.assert_not_called()
def test_usb_device_triggers_makedirs():
"""
Verifies that for USB devices, the plugin attempts to create the
sidecar directory if it doesn't exist.
"""
from calibre.devices.usbms.driver import USBMS
class MyUSBDevice(USBMS):
def put_file(self, path, stream): pass
mock_device = MyUSBDevice()
mock_parent = MagicMock()
mock_site_customization = MagicMock()
action = KoreaderAction(mock_parent, mock_site_customization)
device_path = "E:/Books/NewBook.sdr/metadata.epub.lua"
# Mock DB lookup to return metadata
action.gui.current_db.new_api.lookup_by_uuid.return_value = 1
mock_metadata = MagicMock()
mock_metadata.get.return_value = '{"test": 1}' # dummy sidecar json
action.gui.current_db.new_api.get_metadata.return_value = mock_metadata
with patch('os.makedirs') as mock_makedirs, \
patch('os.path.exists') as mock_exists:
mock_exists.return_value = False
action.push_metadata_to_koreader_sidecar(mock_device, "some-uuid", device_path)
# This will currently FAIL because I removed makedirs
mock_makedirs.assert_called_with(os.path.dirname(device_path), exist_ok=True)
def test_wireless_device_skips_makedirs():
"""
Verifies that for wireless devices, the plugin does NOT call os.makedirs.
"""
class WirelessDevice:
def put_file(self, path, stream): pass
mock_device = WirelessDevice()
mock_parent = MagicMock()
mock_site_customization = MagicMock()
action = KoreaderAction(mock_parent, mock_site_customization)
device_path = "/mnt/onboard/Books/NewBook.sdr/metadata.epub.lua"
action.gui.current_db.new_api.lookup_by_uuid.return_value = 1
mock_metadata = MagicMock()
mock_metadata.get.return_value = '{"test": 1}'
action.gui.current_db.new_api.get_metadata.return_value = mock_metadata
with patch('os.makedirs') as mock_makedirs:
action.push_metadata_to_koreader_sidecar(mock_device, "some-uuid", device_path)
mock_makedirs.assert_not_called()
================================================
FILE: tests/integration/test_integration.py
================================================
import os
import sqlite3
import pytest
from unittest.mock import MagicMock
from action import KoreaderAction
def test_dummy_data_consistency():
# Verify dummy_library metadata.db
conn = sqlite3.connect('dummy_library/metadata.db')
cursor = conn.cursor()
cursor.execute('SELECT title, uuid FROM books')
db_books = {title: uuid for title, uuid in cursor.fetchall()}
conn.close()
assert "Alice's Adventures in Wonderland" in db_books
assert "Walden, and On The Duty Of Civil Disobedience" in db_books
# Verify dummy_device paths
alice_path = "Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.epub"
thoreau_path = "Thoreau, Henry David/Walden, and On The Duty Of Civil Disobedience - Henry David Thoreau.epub"
assert os.path.exists(os.path.join('dummy_device', alice_path))
assert os.path.exists(os.path.join('dummy_device', thoreau_path))
def test_get_paths_with_dummy_device():
# Mock book objects as they would come from a real device driver
class MockBook:
def __init__(self, uuid, path):
self.uuid = uuid
self.path = path
alice_book = MockBook(
uuid='43bd8264-96fa-461a-a05e-1d1cb245d34f',
path="Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.epub"
)
thoreau_book = MockBook(
uuid='3393747a-f0d8-44e1-bfaf-5fad857da3eb',
path="Thoreau, Henry David/Walden, and On The Duty Of Civil Disobedience - Henry David Thoreau.epub"
)
mock_device = MagicMock()
mock_device.books.return_value = [alice_book, thoreau_book]
# Instantiate action with mocks for parent and site_customization
mock_parent = MagicMock()
mock_site_customization = MagicMock()
mock_site_customization.name = 'KOReader Sync'
mock_site_customization.version = (0, 8, 0)
action = KoreaderAction(mock_parent, mock_site_customization)
paths = action.get_paths(mock_device)
assert len(paths) == 2
# Verify Alice's sidecar path generation
alice_sidecar = next(p for u, p in paths if u == alice_book.uuid)
assert alice_sidecar == "Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.sdr/metadata.epub.lua"
================================================
FILE: tests/integration/test_issue_143_fix.py
================================================
import os
import io
import pytest
from unittest.mock import MagicMock, patch
from action import KoreaderAction
def test_fix_issue_143_usb_direct_write():
"""
Verifies fix for Issue #143: USB/Folder devices use direct open()
instead of put_file() to avoid driver crashes.
"""
from calibre.devices.usbms.driver import USBMS
class MyUSBDevice(USBMS):
def put_file(self, path, stream):
raise Exception("Should not be called for USB")
mock_device = MyUSBDevice()
mock_parent = MagicMock()
mock_site_customization = MagicMock()
action = KoreaderAction(mock_parent, mock_site_customization)
# Mock DB etc
action.gui.current_db.new_api.lookup_by_uuid.return_value = 1
mock_metadata = MagicMock()
mock_metadata.get.return_value = '{"test": 1}'
action.gui.current_db.new_api.get_metadata.return_value = mock_metadata
with patch('builtins.open', MagicMock()) as mock_open, \
patch('os.makedirs') as mock_makedirs, \
patch('os.path.exists') as mock_exists:
mock_exists.return_value = True
result, details = action.push_metadata_to_koreader_sidecar(mock_device, "uuid", "E:/path.lua")
assert result == "success"
# Verify open was used
mock_open.assert_called()
# Verify put_file was NOT used
# (Already handled by the exception in MyUSBDevice.put_file)
def test_wireless_still_uses_put_file():
"""
Ensures wireless devices STILL use put_file().
"""
class WirelessDevice:
def put_file(self, path, stream):
self.called = True
mock_device = WirelessDevice()
mock_device.called = False
mock_parent = MagicMock()
mock_site_customization = MagicMock()
action = KoreaderAction(mock_parent, mock_site_customization)
action.gui.current_db.new_api.lookup_by_uuid.return_value = 1
mock_metadata = MagicMock()
mock_metadata.get.return_value = '{"test": 1}'
action.gui.current_db.new_api.get_metadata.return_value = mock_metadata
result, details = action.push_metadata_to_koreader_sidecar(mock_device, "uuid", "/mnt/path.lua")
assert result == "success"
assert mock_device.called is True
================================================
FILE: tests/integration/test_issue_143_repro.py
================================================
import io
import pytest
from unittest.mock import MagicMock
from action import KoreaderAction
def test_reproduce_issue_143():
"""
Reproduces Issue #143: '_io.BytesIO' object has no attribute 'startswith'
This happens when a driver's put_file implementation expects a string
(likely a file path) but receives a BytesIO object.
"""
mock_parent = MagicMock()
mock_site_customization = MagicMock()
action = KoreaderAction(mock_parent, mock_site_customization)
# Simulate a driver that might do 'stream.startswith'
class CrashingDriver:
def put_file(self, path, stream):
# This simulates the internal Calibre driver logic that might be crashing
if stream.startswith('some_path'):
pass
mock_device = CrashingDriver()
# Mock DB etc to reach the put_file call
action.gui.current_db.new_api.lookup_by_uuid.return_value = 1
mock_metadata = MagicMock()
mock_metadata.get.return_value = '{"test": 1}'
action.gui.current_db.new_api.get_metadata.return_value = mock_metadata
# This should return a failure result containing the error message
result, details = action.push_metadata_to_koreader_sidecar(mock_device, "uuid", "some/path.lua")
assert result == "failure"
assert "'_io.BytesIO' object has no attribute 'startswith'" in details['result']
================================================
FILE: tests/integration/test_uuid_mismatch.py
================================================
import os
import pytest
from unittest.mock import MagicMock
from action import KoreaderAction
def test_get_calibre_uuid_from_sidecar():
action = KoreaderAction(MagicMock(), MagicMock())
# Test with valid identifiers
sidecar = {
'stats': {
'identifiers': 'uuid:8d62883d calibre:5ac8d90f-7d24-4b65-9f89-ff77df18bee9 isbn:123'
}
}
assert action.get_calibre_uuid_from_sidecar(sidecar) == '5ac8d90f-7d24-4b65-9f89-ff77df18bee9'
# Test with newline/backslash separator
sidecar = {
'stats': {
'identifiers': 'uuid:abc\\calibre:xyz\\isbn:123'
}
}
assert action.get_calibre_uuid_from_sidecar(sidecar) == 'xyz'
# Test with no calibre identifier
sidecar = {
'stats': {
'identifiers': 'uuid:abc isbn:123'
}
}
assert action.get_calibre_uuid_from_sidecar(sidecar) is None
def test_uuid_mismatch_resolution_integration():
# Setup
wrong_uuid = "wrong-uuid-1234-5678"
correct_uuid = "43bd8264-96fa-461a-a05e-1d1cb245d34f"
sidecar_path = "Carroll, Lewis/Alice's Adventures in Wonderland - Lewis Carroll.sdr/metadata.epub.lua"
class MockBook:
def __init__(self, uuid, path):
self.uuid = uuid
self.path = path
mock_book = MockBook(uuid=wrong_uuid, path=sidecar_path.replace(".sdr/metadata.epub.lua", ".epub"))
mock_device = MagicMock()
mock_device.books.return_value = [mock_book]
def get_file_side_effect(path, outfile):
real_path = os.path.join("dummy_device", path)
with open(real_path, "rb") as f:
outfile.write(f.read())
mock_device.get_file.side_effect = get_file_side_effect
mock_site_customization = MagicMock()
mock_site_customization.name = 'KOReader Sync'
mock_site_customization.version = (0, 8, 0)
action = KoreaderAction(MagicMock(), mock_site_customization)
# Mock DB
mock_db = MagicMock()
def lookup_by_uuid_side_effect(uuid):
if uuid == correct_uuid: return 4
return None
mock_db.lookup_by_uuid.side_effect = lookup_by_uuid_side_effect
# Simulate the worker loop logic
# 1. Get sidecar
sidecar_contents = action.get_sidecar(mock_device, sidecar_path)
# 2. Inject the correct identifier (simulating what KOReader would have)
sidecar_contents['stats']['identifiers'] = f'uuid:{wrong_uuid} calibre:{correct_uuid}'
# 3. Test the fix: if initial lookup fails, try alternative
book_id = mock_db.lookup_by_uuid(wrong_uuid)
assert book_id is None # Initial failure
better_uuid = action.get_calibre_uuid_from_sidecar(sidecar_contents)
assert better_uuid == correct_uuid
book_id = mock_db.lookup_by_uuid(better_uuid)
assert book_id == 4 # Success!
================================================
FILE: tests/unit/test_bookmarks.py
================================================
import pytest
from __init__ import clean_bookmarks
def test_clean_bookmarks_large_payload():
# Simulate a 1.8MB metadata file scenario:
# 900 annotations, each with ~2KB of text/notes.
num_annotations = 900
large_text = "A" * 1000 # 1KB of text
large_note = "B" * 1000 # 1KB of note
bookmarks = {}
for i in range(1, num_annotations + 1):
bookmarks[i] = {
'chapter': f'Chapter {i // 10}',
'note': f'Note {i}: {large_note}',
'text': f'Highlight {i}: {large_text}',
'datetime': '2023-01-01 12:00:00'
}
result = clean_bookmarks(bookmarks)
# Check that the result is a string
assert isinstance(result, str)
# With the fix (O(N) complexity):
# Total size should be roughly (2KB per highlight * 900) + HTML overhead
# 2000 * 900 = 1.8 MB.
# We allow some headroom for HTML and hidden attributes.
# Calibre's SQLite limit for a single cell (Long Text) is technically 1GB,
# but practical performance issues start much earlier (usually around 10-20MB).
# 5MB is a very safe upper bound for 900 highlights of this size.
assert len(result) < 5 * 1024 * 1024
# Verify content
assert 'Chapter 0' in result
assert 'Note 1:' in result
assert '900. Highlight' in result
def test_clean_bookmarks_empty():
assert 'Book Highlights and Notes' in clean_bookmarks({})
================================================
FILE: tests/unit/test_md5_logic.py
================================================
import pytest
import re
from unittest.mock import MagicMock
from action import KoreaderAction
def test_sidecar_path_regex_robustness():
"""Verifies that the sidecar path regex handles various extensions correctly."""
def get_sidecar_path(book_path):
# Using the NEW regex from action.py
return re.sub(r'\.([^./\\]+)$', r'.sdr/metadata.\1.lua', book_path)
assert get_sidecar_path("Book.epub") == "Book.sdr/metadata.epub.lua"
assert get_sidecar_path("Folder/Book.mobi") == "Folder/Book.sdr/metadata.mobi.lua"
assert get_sidecar_path("Book.fb2.zip") == "Book.fb2.sdr/metadata.zip.lua"
assert get_sidecar_path("My.Book.With.Dots.epub") == "My.Book.With.Dots.sdr/metadata.epub.lua"
# Test with hyphen in extension (rare but possible)
assert get_sidecar_path("file.epub-original") == "file.sdr/metadata.epub-original.lua"
def test_is_system_path():
from action import is_system_path
assert is_system_path("kfmon.sdr/metadata.lua") is True
assert is_system_path("koreader.sdr/metadata.lua") is True
assert is_system_path("Books/MyBook.sdr/metadata.lua") is False
================================================
FILE: tests/unit/test_version.py
================================================
import os
import re
def test_version_match():
"""Check if version in .version matches version in __init__.py"""
with open(".version", "r") as f:
version = f.read().strip()
# Enforce no '-pre' on main branch releases
# GITHUB_REF_NAME is provided by GitHub Actions
is_main = os.environ.get('GITHUB_REF_NAME') == 'main'
if is_main:
assert "-pre" not in version, "Release error: .version file on 'main' branch must not contain '-pre'!"
with open("__init__.py", "r") as f:
content = f.read()
# On main, we expect an exact match.
# On other branches, we allow -pre or -dev suffixes (added by 'make pre' or 'make dev')
if is_main:
pattern = rf'version_string\s*=\s*[\'"]{re.escape(version)}[\'"]'
else:
pattern = rf'version_string\s*=\s*[\'"]{re.escape(version)}(-pre|-dev)?[\'"]'
assert re.search(pattern, content), f"Version string matching '{version}' not found in __init__.py"
def test_version_tuple_match():
"""Check if version in .version matches version tuple in __init__.py"""
with open(".version", "r") as f:
version = f.read().strip()
# Strip any suffix like -pre or -dev for the numeric tuple
# This matches the logic in our Makefile
numeric_version = version.split("-")[0]
parts = numeric_version.split(".")
# format (0, 8, 0)
version_tuple = f"({', '.join(parts)})"
with open("__init__.py", "r") as f:
content = f.read()
expected = f"version = {version_tuple}"
assert expected in content, f"Expected {expected} not found in __init__.py"