%PDF- %PDF-
| Direktori : /lib/calibre/calibre/gui2/ |
| Current File : //lib/calibre/calibre/gui2/device.py |
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
# Imports {{{
import os, traceback, time, io, re, sys, weakref
from threading import Thread, Event
from qt.core import (
QMenu, QAction, QActionGroup, QIcon, Qt, pyqtSignal, QDialog,
QObject, QVBoxLayout, QDialogButtonBox, QCursor, QCoreApplication,
QApplication, QEventLoop, QTimer)
from calibre.customize.ui import (available_input_formats, available_output_formats,
device_plugins, disabled_device_plugins)
from calibre.devices.interface import DevicePlugin, currently_connected_device
from calibre.devices.errors import (UserFeedback, OpenFeedback, OpenFailed, OpenActionNeeded,
InitialConnectionError)
from calibre.ebooks.covers import cprefs, override_prefs, scale_cover, generate_cover
from calibre.gui2.dialogs.choose_format_device import ChooseFormatDeviceDialog
from calibre.utils.ipc.job import BaseJob
from calibre.devices.scanner import DeviceScanner
from calibre.gui2 import (config, error_dialog, Dispatcher, dynamic,
warning_dialog, info_dialog, choose_dir, FunctionDispatcher,
show_restart_warning, gprefs, question_dialog)
from calibre.ebooks.metadata import authors_to_string
from calibre import preferred_encoding, prints, force_unicode, as_unicode, sanitize_file_name
from calibre.utils.filenames import ascii_filename
from calibre.devices.errors import (FreeSpaceError, WrongDestinationError,
BlacklistedDevice)
from calibre.devices.folder_device.driver import FOLDER_DEVICE
from calibre.constants import DEBUG
from calibre.utils.config import tweaks, device_prefs
from calibre.utils.img import scale_image
from calibre.library.save_to_disk import find_plugboard
from calibre.ptempfile import PersistentTemporaryFile, force_unicode as filename_to_unicode
from polyglot.builtins import string_or_unicode
from polyglot import queue
# }}}
class DeviceJob(BaseJob): # {{{
def __init__(self, func, done, job_manager, args=[], kwargs={},
description=''):
BaseJob.__init__(self, description)
self.func = func
self.callback_on_done = done
if not isinstance(self.callback_on_done, (Dispatcher,
FunctionDispatcher)):
self.callback_on_done = FunctionDispatcher(self.callback_on_done)
self.args, self.kwargs = args, kwargs
self.exception = None
self.job_manager = job_manager
self._details = _('No details available.')
self._aborted = False
def start_work(self):
if DEBUG:
prints('Job:', self.id, self.description, 'started')
self.start_time = time.time()
self.job_manager.changed_queue.put(self)
def job_done(self):
self.duration = time.time() - self.start_time
self.percent = 1
if DEBUG:
prints('DeviceJob:', self.id, self.description,
'done, calling callback')
try:
self.callback_on_done(self)
except:
pass
if DEBUG:
prints('DeviceJob:', self.id, self.description,
'callback returned')
self.job_manager.changed_queue.put(self)
def report_progress(self, percent, msg=''):
self.notifications.put((percent, msg))
self.job_manager.changed_queue.put(self)
def run(self):
self.start_work()
try:
self.result = self.func(*self.args, **self.kwargs)
if self._aborted:
return
except (Exception, SystemExit) as err:
if self._aborted:
return
self.failed = True
ex = as_unicode(err)
self._details = ex + '\n\n' + \
force_unicode(traceback.format_exc())
self.exception = err
finally:
self.job_done()
def abort(self, err):
call_job_done = False
if self.run_state == self.WAITING:
self.start_work()
call_job_done = True
self._aborted = True
self.failed = True
self._details = str(err)
self.exception = err
if call_job_done:
self.job_done()
@property
def log_file(self):
return io.BytesIO(self._details.encode('utf-8'))
# }}}
def device_name_for_plugboards(device_class):
if hasattr(device_class, 'DEVICE_PLUGBOARD_NAME'):
return device_class.DEVICE_PLUGBOARD_NAME
return device_class.__class__.__name__
class BusyCursor:
def __enter__(self):
QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
def __exit__(self, *args):
QApplication.restoreOverrideCursor()
class DeviceManager(Thread): # {{{
def __init__(self, connected_slot, job_manager, open_feedback_slot,
open_feedback_msg, allow_connect_slot,
after_callback_feedback_slot, sleep_time=2):
'''
:sleep_time: Time to sleep between device probes in secs
'''
Thread.__init__(self, daemon=True)
# [Device driver, Showing in GUI, Ejected]
self.devices = list(device_plugins())
self.disabled_device_plugins = list(disabled_device_plugins())
self.managed_devices = [x for x in self.devices if
not x.MANAGES_DEVICE_PRESENCE]
self.unmanaged_devices = [x for x in self.devices if
x.MANAGES_DEVICE_PRESENCE]
self.sleep_time = sleep_time
self.connected_slot = connected_slot # see DeviceMixin.device_connected()
self.allow_connect_slot = allow_connect_slot
self.jobs = queue.Queue(0)
self.job_steps = queue.Queue(0)
self.keep_going = True
self.job_manager = job_manager
self.reported_errors = set()
self.current_job = None
self.scanner = DeviceScanner()
self.connected_device = None
self.connected_device_kind = None
self.ejected_devices = set()
self.mount_connection_requests = queue.Queue(0)
self.open_feedback_slot = open_feedback_slot
self.open_feedback_only_once_seen = set()
self.after_callback_feedback_slot = after_callback_feedback_slot
self.open_feedback_msg = open_feedback_msg
self._device_information = None
self.current_library_uuid = None
self.call_shutdown_on_disconnect = False
self.devices_initialized = Event()
self.dynamic_plugins = {}
def report_progress(self, *args):
pass
@property
def is_device_connected(self):
return self.connected_device is not None
@property
def is_device_present(self):
return self.connected_device is not None and self.connected_device not in self.ejected_devices
@property
def device(self):
return self.connected_device
def do_connect(self, connected_devices, device_kind):
for dev, detected_device in connected_devices:
if dev.OPEN_FEEDBACK_MESSAGE is not None:
self.open_feedback_slot(dev.OPEN_FEEDBACK_MESSAGE)
try:
dev.reset(detected_device=detected_device,
report_progress=self.report_progress)
dev.open(detected_device, self.current_library_uuid)
except OpenFeedback as e:
if dev not in self.ejected_devices:
self.open_feedback_msg(dev.get_gui_name(), e)
self.ejected_devices.add(dev)
continue
except OpenFailed:
raise
except:
tb = traceback.format_exc()
if DEBUG or tb not in self.reported_errors:
self.reported_errors.add(tb)
prints('Unable to open device', str(dev))
prints(tb)
continue
self.after_device_connect(dev, device_kind)
return True
return False
def after_device_connect(self, dev, device_kind):
allow_connect = True
try:
uid = dev.get_device_uid()
except NotImplementedError:
uid = None
asked = gprefs.get('ask_to_manage_device', [])
if (dev.ASK_TO_ALLOW_CONNECT and uid and uid not in asked):
if not self.allow_connect_slot(dev.get_gui_name(), dev.icon):
allow_connect = False
asked.append(uid)
gprefs.set('ask_to_manage_device', asked)
if not allow_connect:
dev.ignore_connected_device(uid)
return
self.connected_device = currently_connected_device._device = dev
self.connected_device.specialize_global_preferences(device_prefs)
self.connected_device_kind = device_kind
self.connected_slot(True, device_kind)
def connected_device_removed(self):
while True:
try:
job = self.jobs.get_nowait()
job.abort(Exception(_('Device no longer connected.')))
except queue.Empty:
break
try:
self.connected_device.post_yank_cleanup()
except:
pass
if self.connected_device in self.ejected_devices:
self.ejected_devices.remove(self.connected_device)
call_connected_slot = False
else:
call_connected_slot = True
if self.call_shutdown_on_disconnect:
# The current device is an instance of a plugin class instantiated
# to handle this connection, probably as a mounted device. We are
# now abandoning the instance that we created, so we tell it that it
# is being shut down.
self.connected_device.shutdown()
self.call_shutdown_on_disconnect = False
device_prefs.set_overrides()
self.connected_device = currently_connected_device._device = None
self._device_information = None
if call_connected_slot:
self.connected_slot(False, None)
def detect_device(self):
self.scanner.scan()
if self.is_device_connected:
if self.connected_device.MANAGES_DEVICE_PRESENCE:
cd = self.connected_device.detect_managed_devices(self.scanner.devices)
if cd is None:
self.connected_device_removed()
else:
connected, detected_device = \
self.scanner.is_device_connected(self.connected_device,
only_presence=True)
if not connected:
if DEBUG:
# Allow the device subsystem to output debugging info about
# why it thinks the device is not connected. Used, e.g.
# in the can_handle() method of the T1 driver
self.scanner.is_device_connected(self.connected_device,
only_presence=True, debug=True)
self.connected_device_removed()
else:
for dev in self.unmanaged_devices:
try:
cd = dev.detect_managed_devices(self.scanner.devices)
except:
prints('Error during device detection for %s:'%dev)
traceback.print_exc()
else:
if cd is not None:
try:
dev.open(cd, self.current_library_uuid)
except BlacklistedDevice as e:
prints('Ignoring blacklisted device: %s'%
as_unicode(e))
except OpenActionNeeded as e:
if e.only_once_id not in self.open_feedback_only_once_seen:
self.open_feedback_only_once_seen.add(e.only_once_id)
self.open_feedback_msg(e.device_name, e)
except:
prints('Error while trying to open %s (Driver: %s)'%
(cd, dev))
traceback.print_exc()
else:
self.after_device_connect(dev, 'unmanaged-device')
return
try:
possibly_connected_devices = []
for device in self.managed_devices:
if device in self.ejected_devices:
continue
try:
possibly_connected, detected_device = \
self.scanner.is_device_connected(device)
except InitialConnectionError as e:
self.open_feedback_msg(device.get_gui_name(), e)
continue
if possibly_connected:
possibly_connected_devices.append((device, detected_device))
if possibly_connected_devices:
if not self.do_connect(possibly_connected_devices,
device_kind='device'):
if DEBUG:
prints('Connect to device failed, retrying in 5 seconds...')
time.sleep(5)
if not self.do_connect(possibly_connected_devices,
device_kind='device'):
if DEBUG:
prints('Device connect failed again, giving up')
except OpenFailed as e:
if e.show_me:
traceback.print_exc()
# Mount devices that don't use USB, such as the folder device
# This will be called on the GUI thread. Because of this, we must store
# information that the scanner thread will use to do the real work.
def mount_device(self, kls, kind, path):
self.mount_connection_requests.put((kls, kind, path))
# disconnect a device
def umount_device(self, *args):
if self.is_device_connected and not self.job_manager.has_device_jobs():
if self.connected_device_kind in {'unmanaged-device', 'device'}:
self.connected_device.eject()
if self.connected_device_kind != 'unmanaged-device':
self.ejected_devices.add(self.connected_device)
self.connected_slot(False, None)
elif hasattr(self.connected_device, 'unmount_device'):
# As we are on the wrong thread, this call must *not* do
# anything besides set a flag that the right thread will see.
self.connected_device.unmount_device()
def next_job(self):
if not self.job_steps.empty():
try:
return self.job_steps.get_nowait()
except queue.Empty:
pass
if not self.jobs.empty():
try:
return self.jobs.get_nowait()
except queue.Empty:
pass
def run_startup(self, dev):
name = 'unknown'
try:
name = dev.__class__.__name__
dev.startup()
except:
prints('Startup method for device %s threw exception'%name)
traceback.print_exc()
def run(self):
# Do any device-specific startup processing.
for d in self.devices:
self.run_startup(d)
n = d.is_dynamically_controllable()
if n:
self.dynamic_plugins[n] = d
self.devices_initialized.set()
while self.keep_going:
kls = None
while True:
try:
(kls,device_kind, folder_path) = \
self.mount_connection_requests.get_nowait()
except queue.Empty:
break
if kls is not None:
try:
dev = kls(folder_path)
# We just created a new device instance. Call its startup
# method and set the flag to call the shutdown method when
# it disconnects.
self.run_startup(dev)
self.call_shutdown_on_disconnect = True
self.do_connect([[dev, None],], device_kind=device_kind)
except:
prints('Unable to open %s as device (%s)'%(device_kind, folder_path))
traceback.print_exc()
else:
self.detect_device()
do_sleep = True
while True:
job = self.next_job()
if job is not None:
do_sleep = False
self.current_job = job
if self.device is not None:
self.device.set_progress_reporter(job.report_progress)
self.current_job.run()
self.current_job = None
feedback = getattr(self.device, 'user_feedback_after_callback', None)
if feedback is not None:
self.device.user_feedback_after_callback = None
self.after_callback_feedback_slot(feedback)
else:
break
if do_sleep:
time.sleep(self.sleep_time)
# We are exiting. Call the shutdown method for each plugin
for p in self.devices:
try:
p.shutdown()
except:
pass
def create_job_step(self, func, done, description, to_job, args=[], kwargs={}):
job = DeviceJob(func, done, self.job_manager,
args=args, kwargs=kwargs, description=description)
self.job_manager.add_job(job)
if (done is None or isinstance(done, FunctionDispatcher)) and \
(to_job is not None and to_job == self.current_job):
self.job_steps.put(job)
else:
self.jobs.put(job)
return job
def create_job(self, func, done, description, args=[], kwargs={}):
return self.create_job_step(func, done, description, None, args, kwargs)
def has_card(self):
try:
return bool(self.device.card_prefix())
except:
return False
def _debug_detection(self):
from calibre.devices import debug
raw = debug(plugins=self.devices,
disabled_plugins=self.disabled_device_plugins)
return raw
def debug_detection(self, done):
if self.is_device_connected:
raise ValueError('Device is currently detected in calibre, cannot'
' debug device detection')
self.create_job(self._debug_detection, done,
_('Debug device detection'))
def _get_device_information(self):
info = self.device.get_device_information(end_session=False)
if len(info) < 5:
info = tuple(list(info) + [{}])
info = [i.replace('\x00', '').replace('\x01', '') if isinstance(i, string_or_unicode) else i
for i in info]
cp = self.device.card_prefix(end_session=False)
fs = self.device.free_space()
self._device_information = {'info': info, 'prefixes': cp, 'freespace': fs}
return info, cp, fs
def get_device_information(self, done, add_as_step_to_job=None):
'''Get device information and free space on device'''
return self.create_job_step(self._get_device_information, done,
description=_('Get device information'), to_job=add_as_step_to_job)
def _set_library_information(self, library_name, library_uuid, field_metadata):
'''Give the device the current library information'''
self.device.set_library_info(library_name, library_uuid, field_metadata)
def set_library_information(self, done, library_name, library_uuid,
field_metadata, add_as_step_to_job=None):
'''Give the device the current library information'''
return self.create_job_step(self._set_library_information, done,
args=[library_name, library_uuid, field_metadata],
description=_('Set library information'), to_job=add_as_step_to_job)
def slow_driveinfo(self):
''' Update the stored device information with the driveinfo if the
device indicates that getting driveinfo is slow '''
info = self._device_information['info']
if (not info[4] and self.device.SLOW_DRIVEINFO):
info = list(info)
info[4] = self.device.get_driveinfo()
self._device_information['info'] = tuple(info)
def get_current_device_information(self):
return self._device_information if self.is_device_present else None
def _books(self):
'''Get metadata from device'''
mainlist = self.device.books(oncard=None, end_session=False)
cardalist = self.device.books(oncard='carda')
cardblist = self.device.books(oncard='cardb')
return (mainlist, cardalist, cardblist)
def books(self, done, add_as_step_to_job=None):
'''Return callable that returns the list of books on device as two booklists'''
return self.create_job_step(self._books, done,
description=_('Get list of books on device'), to_job=add_as_step_to_job)
def _prepare_addable_books(self, paths):
return self.device.prepare_addable_books(paths)
def prepare_addable_books(self, done, paths, add_as_step_to_job=None):
return self.create_job_step(self._prepare_addable_books, done, args=[paths],
description=_('Prepare files for transfer from device'),
to_job=add_as_step_to_job)
def _annotations(self, path_map):
return self.device.get_annotations(path_map)
def annotations(self, done, path_map, add_as_step_to_job=None):
'''Return mapping of ids to annotations. Each annotation is of the
form (type, location_info, content). path_map is a mapping of
ids to paths on the device.'''
return self.create_job_step(self._annotations, done, args=[path_map],
description=_('Get annotations from device'), to_job=add_as_step_to_job)
def _sync_booklists(self, booklists):
'''Sync metadata to device'''
self.device.sync_booklists(booklists, end_session=False)
return self.device.card_prefix(end_session=False), self.device.free_space()
def sync_booklists(self, done, booklists, plugboards, add_as_step_to_job=None):
if hasattr(self.connected_device, 'set_plugboards') and \
callable(self.connected_device.set_plugboards):
self.connected_device.set_plugboards(plugboards, find_plugboard)
return self.create_job_step(self._sync_booklists, done, args=[booklists],
description=_('Send metadata to device'), to_job=add_as_step_to_job)
def upload_collections(self, done, booklist, on_card, add_as_step_to_job=None):
return self.create_job_step(booklist.rebuild_collections, done,
args=[booklist, on_card],
description=_('Send collections to device'),
to_job=add_as_step_to_job)
def _upload_books(self, files, names, on_card=None, metadata=None, plugboards=None):
'''Upload books to device: '''
from calibre.ebooks.metadata.meta import set_metadata
if hasattr(self.connected_device, 'set_plugboards') and \
callable(self.connected_device.set_plugboards):
self.connected_device.set_plugboards(plugboards, find_plugboard)
if metadata and files and len(metadata) == len(files):
for f, mi in zip(files, metadata):
if isinstance(f, str):
ext = f.rpartition('.')[-1].lower()
cpb = find_plugboard(
device_name_for_plugboards(self.connected_device),
ext, plugboards)
if ext:
try:
if DEBUG:
prints('Setting metadata in:', mi.title, 'at:',
f, file=sys.__stdout__)
with lopen(f, 'r+b') as stream:
if cpb:
newmi = mi.deepcopy_metadata()
newmi.template_to_attribute(mi, cpb)
else:
newmi = mi
nuke_comments = getattr(self.connected_device,
'NUKE_COMMENTS', None)
if nuke_comments is not None:
mi.comments = nuke_comments
set_metadata(stream, newmi, stream_type=ext)
except:
if DEBUG:
prints(traceback.format_exc(), file=sys.__stdout__)
try:
return self.device.upload_books(files, names, on_card,
metadata=metadata, end_session=False)
finally:
if metadata:
for mi in metadata:
try:
if mi.cover:
os.remove(mi.cover)
except:
pass
def upload_books(self, done, files, names, on_card=None, titles=None,
metadata=None, plugboards=None, add_as_step_to_job=None):
desc = ngettext('Upload one book to the device', 'Upload {} books to the device', len(names)).format(len(names))
if titles:
desc += ': ' + ', '.join(titles)
return self.create_job_step(self._upload_books, done, to_job=add_as_step_to_job,
args=[files, names],
kwargs={'on_card':on_card,'metadata':metadata,'plugboards':plugboards}, description=desc)
def add_books_to_metadata(self, locations, metadata, booklists):
self.device.add_books_to_metadata(locations, metadata, booklists)
def _delete_books(self, paths):
'''Remove books from device'''
self.device.delete_books(paths, end_session=True)
def delete_books(self, done, paths, add_as_step_to_job=None):
return self.create_job_step(self._delete_books, done, args=[paths],
description=_('Delete books from device'),
to_job=add_as_step_to_job)
def remove_books_from_metadata(self, paths, booklists):
self.device.remove_books_from_metadata(paths, booklists)
def _save_books(self, paths, target):
'''Copy books from device to disk'''
for path in paths:
name = sanitize_file_name(os.path.basename(path))
dest = os.path.join(target, name)
if os.path.abspath(dest) != os.path.abspath(path):
with lopen(dest, 'wb') as f:
self.device.get_file(path, f)
def save_books(self, done, paths, target, add_as_step_to_job=None):
return self.create_job_step(self._save_books, done, args=[paths, target],
description=_('Download books from device'),
to_job=add_as_step_to_job)
def _view_book(self, path, target):
with lopen(target, 'wb') as f:
self.device.get_file(path, f)
return target
def view_book(self, done, path, target, add_as_step_to_job=None):
return self.create_job_step(self._view_book, done, args=[path, target],
description=_('View book on device'), to_job=add_as_step_to_job)
def set_current_library_uuid(self, uuid):
self.current_library_uuid = uuid
def set_driveinfo_name(self, location_code, name):
if self.connected_device:
self.connected_device.set_driveinfo_name(location_code, name)
# dynamic plugin interface
# This is a helper function that handles queueing with the device manager
def _call_request(self, name, method, *args, **kwargs):
d = self.dynamic_plugins.get(name, None)
if d:
return getattr(d, method)(*args, **kwargs)
return kwargs.get('default', None)
# The dynamic plugin methods below must be called on the GUI thread. They
# will switch to the device thread before calling the plugin.
def start_plugin(self, name):
return self._call_request(name, 'start_plugin')
def stop_plugin(self, name):
self._call_request(name, 'stop_plugin')
def get_option(self, name, opt_string, default=None):
return self._call_request(name, 'get_option', opt_string, default=default)
def set_option(self, name, opt_string, opt_value):
self._call_request(name, 'set_option', opt_string, opt_value)
def is_running(self, name):
if self._call_request(name, 'is_running'):
return True
return False
def is_enabled(self, name):
try:
d = self.dynamic_plugins.get(name, None)
if d:
return True
except:
pass
return False
# }}}
class DeviceAction(QAction): # {{{
a_s = pyqtSignal(object)
def __init__(self, dest, delete, specific, icon_path, text, parent=None):
QAction.__init__(self, QIcon(icon_path), text, parent)
self.dest = dest
self.delete = delete
self.specific = specific
self.triggered.connect(self.emit_triggered)
def emit_triggered(self, *args):
self.a_s.emit(self)
def __repr__(self):
return self.__class__.__name__ + ':%s:%s:%s'%(self.dest, self.delete,
self.specific)
# }}}
class DeviceMenu(QMenu): # {{{
fetch_annotations = pyqtSignal()
disconnect_mounted_device = pyqtSignal()
sync = pyqtSignal(object, object, object)
def __init__(self, parent=None):
QMenu.__init__(self, parent)
self.group = QActionGroup(self)
self._actions = []
self._memory = []
self.set_default_menu = QMenu(_('Set default send to device action'))
self.set_default_menu.setIcon(QIcon(I('config.png')))
basic_actions = [
('main:', False, False, I('reader.png'),
_('Send to main memory')),
('carda:0', False, False, I('sd.png'),
_('Send to storage card A')),
('cardb:0', False, False, I('sd.png'),
_('Send to storage card B')),
]
delete_actions = [
('main:', True, False, I('reader.png'),
_('Main memory')),
('carda:0', True, False, I('sd.png'),
_('Storage card A')),
('cardb:0', True, False, I('sd.png'),
_('Storage card B')),
]
specific_actions = [
('main:', False, True, I('reader.png'),
_('Main memory')),
('carda:0', False, True, I('sd.png'),
_('Storage card A')),
('cardb:0', False, True, I('sd.png'),
_('Storage card B')),
]
later_menus = []
for menu in (self, self.set_default_menu):
for actions, desc in (
(basic_actions, ''),
(specific_actions, _('Send specific format to')),
(delete_actions, _('Send and delete from library')),
):
mdest = menu
if actions is not basic_actions:
mdest = QMenu(desc)
self._memory.append(mdest)
later_menus.append(mdest)
if menu is self.set_default_menu:
menu.addMenu(mdest)
menu.addSeparator()
for dest, delete, specific, icon, text in actions:
action = DeviceAction(dest, delete, specific, icon, text, self)
self._memory.append(action)
if menu is self.set_default_menu:
action.setCheckable(True)
action.setText(action.text())
self.group.addAction(action)
else:
action.a_s.connect(self.action_triggered)
self._actions.append(action)
mdest.addAction(action)
if actions is basic_actions:
menu.addSeparator()
da = config['default_send_to_device_action']
done = False
for action in self.group.actions():
if repr(action) == da:
action.setChecked(True)
done = True
break
if not done:
action = list(self.group.actions())[0]
action.setChecked(True)
config['default_send_to_device_action'] = repr(action)
self.group.triggered.connect(self.change_default_action)
self.addSeparator()
self.addMenu(later_menus[0])
self.addSeparator()
mitem = self.addAction(QIcon(I('eject.png')), _('Eject device'))
mitem.setEnabled(False)
connect_lambda(mitem.triggered, self, lambda self, x: self.disconnect_mounted_device.emit())
self.disconnect_mounted_device_action = mitem
self.addSeparator()
self.addMenu(self.set_default_menu)
self.addSeparator()
self.addMenu(later_menus[1])
self.addSeparator()
annot = self.addAction(_('Fetch annotations (experimental)'))
annot.setEnabled(False)
connect_lambda(annot.triggered, self, lambda self, x: self.fetch_annotations.emit())
self.annotation_action = annot
self.enable_device_actions(False)
def change_default_action(self, action):
config['default_send_to_device_action'] = repr(action)
action.setChecked(True)
def action_triggered(self, action):
self.sync.emit(action.dest, action.delete, action.specific)
def trigger_default(self, *args):
r = config['default_send_to_device_action']
for action in self._actions:
if repr(action) == r:
self.action_triggered(action)
break
def enable_device_actions(self, enable, card_prefix=(None, None),
device=None):
for action in self._actions:
if action.dest in ('main:', 'carda:0', 'cardb:0'):
if not enable:
action.setEnabled(False)
else:
if action.dest == 'main:':
action.setEnabled(True)
elif action.dest == 'carda:0':
if card_prefix and card_prefix[0] is not None:
action.setEnabled(True)
else:
action.setEnabled(False)
elif action.dest == 'cardb:0':
if card_prefix and card_prefix[1] is not None:
action.setEnabled(True)
else:
action.setEnabled(False)
annot_enable = enable and getattr(device, 'SUPPORTS_ANNOTATIONS', False)
self.annotation_action.setEnabled(annot_enable)
# }}}
class DeviceSignals(QObject): # {{{
#: This signal is emitted once, after metadata is downloaded from the
#: connected device.
#: The sequence: gui.device_manager.is_device_connected will become True,
#: and the device_connection_changed signal will be emitted,
#: then sometime later gui.device_metadata_available will be signaled.
#: This does not mean that there are no more jobs running. Automatic metadata
#: management might have kicked off a sync_booklists to write new metadata onto
#: the device, and that job might still be running when the signal is emitted.
device_metadata_available = pyqtSignal()
#: This signal is emitted once when the device is detected and once when
#: it is disconnected. If the parameter is True, then it is a connection,
#: otherwise a disconnection. Device information is not available in either
# case. If you need device information when connecting then use the
# device_metadata_available signal.
device_connection_changed = pyqtSignal(object)
device_signals = DeviceSignals()
# }}}
class DeviceMixin: # {{{
def __init__(self, *args, **kwargs):
pass
def init_device_mixin(self):
self.device_error_dialog = error_dialog(self, _('Error'),
_('Error communicating with device'), ' ')
self.device_error_dialog.setModal(Qt.WindowModality.NonModal)
self.device_manager = DeviceManager(FunctionDispatcher(self.device_detected),
self.job_manager, Dispatcher(self.status_bar.show_message),
Dispatcher(self.show_open_feedback),
FunctionDispatcher(self.allow_connect), Dispatcher(self.after_callback_feedback))
self.device_manager.start()
self.device_manager.devices_initialized.wait()
if tweaks['auto_connect_to_folder']:
self.connect_to_folder_named(tweaks['auto_connect_to_folder'])
def allow_connect(self, name, icon):
return question_dialog(self, _('Manage the %s?')%name,
_('Detected the <b>%s</b>. Do you want calibre to manage it?')%
name, show_copy_button=False,
override_icon=QIcon(icon))
def after_callback_feedback(self, feedback):
title, msg, det_msg = feedback
info_dialog(self, feedback['title'], feedback['msg'], det_msg=feedback['det_msg']).show()
def debug_detection(self, done):
self.debug_detection_callback = weakref.ref(done)
self.device_manager.debug_detection(FunctionDispatcher(self.debug_detection_done))
def debug_detection_done(self, job):
d = self.debug_detection_callback()
if d is not None:
d(job)
def show_open_feedback(self, devname, e):
try:
self.__of_dev_mem__ = d = e.custom_dialog(self)
except NotImplementedError:
self.__of_dev_mem__ = d = info_dialog(self, devname, e.feedback_msg)
d.show()
def auto_convert_question(self, msg, autos):
autos = '\n'.join(map(str, map(force_unicode, autos)))
return self.ask_a_yes_no_question(
_('No suitable formats'), msg,
ans_when_user_unavailable=True,
det_msg=autos, skip_dialog_name='auto_convert_before_send'
)
def set_default_thumbnail(self, height):
ratio = height / float(cprefs['cover_height'])
self.default_thumbnail_prefs = prefs = override_prefs(cprefs)
scale_cover(prefs, ratio)
def connect_to_folder_named(self, folder):
if os.path.exists(folder) and os.path.isdir(folder):
self.device_manager.mount_device(kls=FOLDER_DEVICE, kind='folder',
path=folder)
def connect_to_folder(self):
dir = choose_dir(self, 'Select Device Folder',
_('Select folder to open as device'))
if dir is not None:
self.device_manager.mount_device(kls=FOLDER_DEVICE, kind='folder', path=dir)
# disconnect from folder devices
def disconnect_mounted_device(self):
self.device_manager.umount_device()
def configure_connected_device(self):
if not self.device_manager.is_device_connected:
return
if self.job_manager.has_device_jobs(queued_also=True):
return error_dialog(self, _('Running jobs'),
_('Cannot configure the device while there are running'
' device jobs.'), show=True)
dev = self.device_manager.connected_device
cw = dev.config_widget()
config_dialog = QDialog(self)
config_dialog.setWindowTitle(_('Configure %s')%dev.get_gui_name())
config_dialog.setWindowIcon(QIcon(I('config.png')))
l = QVBoxLayout(config_dialog)
config_dialog.setLayout(l)
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok|QDialogButtonBox.StandardButton.Cancel)
bb.accepted.connect(config_dialog.accept)
bb.rejected.connect(config_dialog.reject)
l.addWidget(cw)
l.addWidget(bb)
# We do not save/restore the size of this dialog as different devices
# have very different size requirements
config_dialog.resize(config_dialog.sizeHint())
def validate():
if cw.validate():
QDialog.accept(config_dialog)
config_dialog.accept = validate
if config_dialog.exec() == QDialog.DialogCode.Accepted:
dev.save_settings(cw)
do_restart = show_restart_warning(_('Restart calibre for the changes to %s'
' to be applied.')%dev.get_gui_name(), parent=self)
if do_restart:
self.quit(restart=True)
def _sync_action_triggered(self, *args):
m = getattr(self, '_sync_menu', None)
if m is not None:
m.trigger_default()
def create_device_menu(self):
self._sync_menu = DeviceMenu(self)
self.iactions['Send To Device'].qaction.setMenu(self._sync_menu)
self.iactions['Connect Share'].build_email_entries()
self._sync_menu.sync.connect(self.dispatch_sync_event)
self._sync_menu.fetch_annotations.connect(
self.iactions['Fetch Annotations'].fetch_annotations)
self._sync_menu.disconnect_mounted_device.connect(self.disconnect_mounted_device)
self.iactions['Connect Share'].set_state(self.device_connected,
None)
if self.device_connected:
self._sync_menu.disconnect_mounted_device_action.setEnabled(True)
else:
self._sync_menu.disconnect_mounted_device_action.setEnabled(False)
def device_job_exception(self, job):
'''
Handle exceptions in threaded device jobs.
'''
if isinstance(getattr(job, 'exception', None), UserFeedback):
ex = job.exception
func = {UserFeedback.ERROR:error_dialog,
UserFeedback.WARNING:warning_dialog,
UserFeedback.INFO:info_dialog}[ex.level]
return func(self, _('Failed'), ex.msg, det_msg=ex.details if
ex.details else '', show=True)
try:
if 'Could not read 32 bytes on the control bus.' in \
str(job.details):
error_dialog(self, _('Error talking to device'),
_('There was a temporary error talking to the '
'device. Please unplug and reconnect the device '
'or reboot.')).show()
return
except:
pass
if getattr(job, 'exception', None).__class__.__name__ == 'MTPInvalidSendPathError':
try:
from calibre.gui2.device_drivers.mtp_config import SendError
return SendError(self, job.exception).exec()
except:
traceback.print_exc()
try:
prints(job.details, file=sys.stderr)
except:
pass
if not self.device_error_dialog.isVisible():
self.device_error_dialog.set_details(job.details)
self.device_error_dialog.show()
# Device connected {{{
def set_device_menu_items_state(self, connected):
self.iactions['Connect Share'].set_state(connected,
self.device_manager.device)
if connected:
self._sync_menu.disconnect_mounted_device_action.setEnabled(True)
self._sync_menu.enable_device_actions(True,
self.device_manager.device.card_prefix(),
self.device_manager.device)
self.eject_action.setEnabled(True)
else:
self._sync_menu.disconnect_mounted_device_action.setEnabled(False)
self._sync_menu.enable_device_actions(False)
self.eject_action.setEnabled(False)
def device_detected(self, connected, device_kind):
'''
Called when a device is connected to the computer.
If connected is False then device_kind is None.
'''
# This can happen as this function is called in a queued connection and
# the user could have yanked the device in the meantime
if connected and not self.device_manager.is_device_connected:
connected = False
self.set_device_menu_items_state(connected)
if connected:
self.device_connected = device_kind
self.device_manager.get_device_information(
FunctionDispatcher(self.info_read))
self.set_default_thumbnail(
self.device_manager.device.THUMBNAIL_HEIGHT)
self.status_bar.show_message(_('Device: ')+
self.device_manager.device.get_gui_name()+
_(' detected.'), 3000)
self.library_view.set_device_connected(self.device_connected)
self.refresh_ondevice(reset_only=True)
else:
self.device_connected = None
self.status_bar.device_disconnected()
dviews = (self.memory_view, self.card_a_view, self.card_b_view)
for v in dviews:
v.save_state()
if self.current_view() != self.library_view:
self.book_details.reset_info()
self.location_manager.update_devices()
self.bars_manager.update_bars(reveal_bar=True)
self.library_view.set_device_connected(self.device_connected)
# Empty any device view information
for v in dviews:
v.set_database([])
# Use a singleShot timer to ensure that the job event queue has
# emptied before the ondevice column is removed from the booklist.
# This deals with race conditions when repainting the booklist
# causing incorrect evaluation of the connected_device_name
# formatter function
QTimer.singleShot(0, self.refresh_ondevice)
device_signals.device_connection_changed.emit(connected)
def info_read(self, job):
'''
Called once device information has been read.
'''
if job.failed:
return self.device_job_exception(job)
info, cp, fs = job.result
self.location_manager.update_devices(cp, fs,
self.device_manager.device.icon)
self.bars_manager.update_bars(reveal_bar=True)
self.status_bar.device_connected(info[0])
db = self.current_db
self.device_manager.set_library_information(None, os.path.basename(db.library_path),
db.library_id, db.field_metadata,
add_as_step_to_job=job)
self.device_manager.books(FunctionDispatcher(self.metadata_downloaded),
add_as_step_to_job=job)
def metadata_downloaded(self, job):
'''
Called once metadata has been read for all books on the device.
'''
if job.failed:
self.device_job_exception(job)
return
self.device_manager.slow_driveinfo()
# set_books_in_library might schedule a sync_booklists job
if DEBUG:
prints('DeviceJob: metadata_downloaded: Starting set_books_in_library')
self.set_books_in_library(job.result, reset=True, add_as_step_to_job=job)
if DEBUG:
prints('DeviceJob: metadata_downloaded: updating views')
mainlist, cardalist, cardblist = job.result
self.memory_view.set_database(mainlist)
self.memory_view.set_editable(self.device_manager.device.CAN_SET_METADATA,
self.device_manager.device.BACKLOADING_ERROR_MESSAGE
is None)
self.card_a_view.set_database(cardalist)
self.card_a_view.set_editable(self.device_manager.device.CAN_SET_METADATA,
self.device_manager.device.BACKLOADING_ERROR_MESSAGE
is None)
self.card_b_view.set_database(cardblist)
self.card_b_view.set_editable(self.device_manager.device.CAN_SET_METADATA,
self.device_manager.device.BACKLOADING_ERROR_MESSAGE
is None)
if DEBUG:
prints('DeviceJob: metadata_downloaded: syncing')
self.sync_news()
self.sync_catalogs()
if DEBUG:
prints('DeviceJob: metadata_downloaded: refreshing ondevice')
self.refresh_ondevice()
if DEBUG:
prints('DeviceJob: metadata_downloaded: sending metadata_available signal')
device_signals.device_metadata_available.emit()
def refresh_ondevice(self, reset_only=False):
'''
Force the library view to refresh, taking into consideration new
device books information
'''
with self.library_view.preserve_state():
self.book_on_device(None, reset=True)
if reset_only:
return
self.library_view.model().refresh_ondevice()
# }}}
def remove_paths(self, paths):
return self.device_manager.delete_books(
FunctionDispatcher(self.books_deleted), paths)
def books_deleted(self, job):
'''
Called once deletion is done on the device
'''
cv, row = self.current_view(), -1
if cv is not self.library_view:
row = cv.currentIndex().row()
for view in (self.memory_view, self.card_a_view, self.card_b_view):
view.model().deletion_done(job, job.failed)
if job.failed:
self.device_job_exception(job)
return
dm = self.iactions['Remove Books'].delete_memory
if job in dm:
paths, model = dm.pop(job)
self.device_manager.remove_books_from_metadata(paths,
self.booklists())
model.paths_deleted(paths)
# Force recomputation the library's ondevice info. We need to call
# set_books_in_library even though books were not added because
# the deleted book might have been an exact match. Upload the booklists
# if set_books_in_library did not.
if not self.set_books_in_library(self.booklists(), reset=True,
add_as_step_to_job=job, do_device_sync=False):
self.upload_booklists(job)
# We need to reset the ondevice flags in the library. Use a big hammer,
# so we don't need to worry about whether some succeeded or not.
self.refresh_ondevice()
if row > -1:
cv.set_current_row(row)
try:
if not self.current_view().currentIndex().isValid():
self.current_view().set_current_row()
self.current_view().refresh_book_details()
except:
traceback.print_exc()
def dispatch_sync_event(self, dest, delete, specific):
rows = self.library_view.selectionModel().selectedRows()
if not rows or len(rows) == 0:
error_dialog(self, _('No books'), _('No books')+' '+
_('selected to send')).exec()
return
fmt = None
if specific:
if (not self.device_connected or not self.device_manager or
self.device_manager.device is None):
error_dialog(self, _('No device'),
_('No device connected'), show=True)
return
formats = []
aval_out_formats = available_output_formats()
format_count = {}
for row in rows:
fmts = self.library_view.model().db.formats(row.row())
if fmts:
for f in fmts.split(','):
f = f.lower()
if f in format_count:
format_count[f] += 1
else:
format_count[f] = 1
for f in self.device_manager.device.settings().format_map:
if f in format_count.keys():
formats.append((f, _('%(num)i of %(total)i books') % dict(
num=format_count[f], total=len(rows)),
True if f in aval_out_formats else False))
elif f in aval_out_formats:
formats.append((f, _('0 of %i books') % len(rows), True))
d = ChooseFormatDeviceDialog(self, _('Choose format to send to device'), formats)
if d.exec() != QDialog.DialogCode.Accepted:
return
if d.format():
fmt = d.format().lower()
dest, sub_dest = dest.partition(':')[0::2]
if dest in ('main', 'carda', 'cardb'):
if not self.device_connected or not self.device_manager:
error_dialog(self, _('No device'),
_('Cannot send: No device is connected')).exec()
return
if dest == 'carda' and not self.device_manager.has_card():
error_dialog(self, _('No card'),
_('Cannot send: Device has no storage card')).exec()
return
if dest == 'cardb' and not self.device_manager.has_card():
error_dialog(self, _('No card'),
_('Cannot send: Device has no storage card')).exec()
return
if dest == 'main':
on_card = None
else:
on_card = dest
self.sync_to_device(on_card, delete, fmt)
elif dest == 'mail':
sub_dest_parts = sub_dest.split(';')
while len(sub_dest_parts) < 3:
sub_dest_parts.append('')
to = sub_dest_parts[0]
fmts = sub_dest_parts[1]
subject = ';'.join(sub_dest_parts[2:])
fmts = [x.strip().lower() for x in fmts.split(',')]
self.send_by_mail(to, fmts, delete, subject=subject)
elif dest == 'choosemail':
from calibre.gui2.email import select_recipients
data = select_recipients(self)
if data:
self.send_multiple_by_mail(data, delete)
def cover_to_thumbnail(self, data):
if self.device_manager.device and \
hasattr(self.device_manager.device, 'THUMBNAIL_WIDTH'):
try:
return scale_image(data,
self.device_manager.device.THUMBNAIL_WIDTH,
self.device_manager.device.THUMBNAIL_HEIGHT,
preserve_aspect_ratio=False)
except:
pass
return
ht = self.device_manager.device.THUMBNAIL_HEIGHT \
if self.device_manager else DevicePlugin.THUMBNAIL_HEIGHT
try:
return scale_image(data, ht, ht,
compression_quality=self.device_manager.device.THUMBNAIL_COMPRESSION_QUALITY)
except:
pass
def sync_catalogs(self, send_ids=None, do_auto_convert=True):
if self.device_connected:
settings = self.device_manager.device.settings()
ids = list(dynamic.get('catalogs_to_be_synced', set())) if send_ids is None else send_ids
ids = [id for id in ids if self.library_view.model().db.has_id(id)]
with BusyCursor():
files, _auto_ids = self.library_view.model().get_preferred_formats_from_ids(
ids, settings.format_map,
exclude_auto=do_auto_convert)
auto = []
if do_auto_convert and _auto_ids:
for id in _auto_ids:
dbfmts = self.library_view.model().db.formats(id, index_is_id=True)
formats = [] if dbfmts is None else \
[f.lower() for f in dbfmts.split(',')]
if set(formats).intersection(available_input_formats()) \
and set(settings.format_map).intersection(available_output_formats()):
auto.append(id)
if auto:
format = None
for fmt in settings.format_map:
if fmt in list(set(settings.format_map).intersection(set(available_output_formats()))):
format = fmt
break
if format is not None:
autos = [self.library_view.model().db.title(id, index_is_id=True) for id in auto]
if self.auto_convert_question(
_('Auto convert the following books before uploading to '
'the device?'), autos):
self.iactions['Convert Books'].auto_convert_catalogs(auto, format)
files = [f for f in files if f is not None]
if not files:
dynamic.set('catalogs_to_be_synced', set())
return
metadata = self.library_view.model().metadata_for(ids)
names = []
for book_id, mi in zip(ids, metadata):
prefix = ascii_filename(mi.title)
if not isinstance(prefix, str):
prefix = prefix.decode(preferred_encoding, 'replace')
prefix = ascii_filename(prefix)
names.append('%s_%d%s'%(prefix, book_id,
os.path.splitext(files[-1])[1]))
self.update_thumbnail(mi)
dynamic.set('catalogs_to_be_synced', set())
if files:
remove = []
space = {self.location_manager.free[0] : None,
self.location_manager.free[1] : 'carda',
self.location_manager.free[2] : 'cardb'}
on_card = space.get(sorted(space.keys(), reverse=True)[0], None)
self.upload_books(files, names, metadata,
on_card=on_card,
memory=[files, remove])
self.status_bar.show_message(_('Sending catalogs to device.'), 5000)
@property
def news_to_be_synced(self):
'Set of ids to be sent to device'
ans = []
try:
ans = self.library_view.model().db.new_api.pref('news_to_be_synced',
[])
except:
import traceback
traceback.print_exc()
return set(ans)
@news_to_be_synced.setter
def news_to_be_synced(self, ids):
try:
self.library_view.model().db.new_api.set_pref('news_to_be_synced',
list(ids))
except:
import traceback
traceback.print_exc()
def sync_news(self, send_ids=None, do_auto_convert=True):
if self.device_connected:
del_on_upload = config['delete_news_from_library_on_upload']
settings = self.device_manager.device.settings()
ids = list(self.news_to_be_synced) if send_ids is None else send_ids
ids = [book_id for book_id in ids if self.library_view.model().db.has_id(book_id)]
with BusyCursor():
files, _auto_ids = self.library_view.model().get_preferred_formats_from_ids(
ids, settings.format_map,
exclude_auto=do_auto_convert)
auto = []
if do_auto_convert and _auto_ids:
for book_id in _auto_ids:
dbfmts = self.library_view.model().db.formats(book_id, index_is_id=True)
formats = [] if dbfmts is None else \
[f.lower() for f in dbfmts.split(',')]
if set(formats).intersection(available_input_formats()) \
and set(settings.format_map).intersection(available_output_formats()):
auto.append(book_id)
if auto:
format = None
for fmt in settings.format_map:
if fmt in list(set(settings.format_map).intersection(set(available_output_formats()))):
format = fmt
break
if format is not None:
autos = [self.library_view.model().db.title(book_id, index_is_id=True) for book_id in auto]
if self.auto_convert_question(
_('Auto convert the following books before uploading to '
'the device?'), autos):
self.iactions['Convert Books'].auto_convert_news(auto, format)
files = [f for f in files if f is not None]
if not files:
self.news_to_be_synced = set()
return
metadata = self.library_view.model().metadata_for(ids)
names = []
for book_id, mi in zip(ids, metadata):
prefix = ascii_filename(mi.title)
if not isinstance(prefix, str):
prefix = prefix.decode(preferred_encoding, 'replace')
prefix = ascii_filename(prefix)
names.append('%s_%d%s'%(prefix, book_id,
os.path.splitext(files[-1])[1]))
self.update_thumbnail(mi)
self.news_to_be_synced = set()
if config['upload_news_to_device'] and files:
remove = ids if del_on_upload else []
space = {self.location_manager.free[0] : None,
self.location_manager.free[1] : 'carda',
self.location_manager.free[2] : 'cardb'}
on_card = space.get(sorted(space.keys(), reverse=True)[0], None)
try:
total_size = sum(os.stat(f).st_size for f in files)
except:
try:
import traceback
traceback.print_exc()
except:
pass
total_size = self.location_manager.free[0]
loc = tweaks['send_news_to_device_location']
loc_index = {"carda": 1, "cardb": 2}.get(loc, 0)
if self.location_manager.free[loc_index] > total_size + (1024**2):
# Send news to main memory if enough space available
# as some devices like the Nook Color cannot handle
# periodicals on SD cards properly
on_card = loc if loc in ('carda', 'cardb') else None
self.upload_books(files, names, metadata,
on_card=on_card,
memory=[files, remove])
self.status_bar.show_message(_('Sending news to device.'), 5000)
def sync_to_device(self, on_card, delete_from_library,
specific_format=None, send_ids=None, do_auto_convert=True):
ids = [self.library_view.model().id(r)
for r in self.library_view.selectionModel().selectedRows()] \
if send_ids is None else send_ids
if not self.device_manager or not ids or len(ids) == 0 or \
not self.device_manager.is_device_connected:
return
settings = self.device_manager.device.settings()
with BusyCursor():
_files, _auto_ids = self.library_view.model().get_preferred_formats_from_ids(ids,
settings.format_map,
specific_format=specific_format,
exclude_auto=do_auto_convert)
if do_auto_convert:
ok_ids = list(set(ids).difference(_auto_ids))
ids = [i for i in ids if i in ok_ids]
else:
_auto_ids = []
metadata = self.library_view.model().metadata_for(ids)
ids = iter(ids)
for mi in metadata:
self.update_thumbnail(mi)
imetadata = iter(metadata)
bad, good, gf, names, remove_ids = [], [], [], [], []
for f in _files:
mi = next(imetadata)
id = next(ids)
if f is None:
bad.append(mi.title)
else:
remove_ids.append(id)
good.append(mi)
gf.append(f)
t = mi.title
if not t:
t = _('Unknown')
a = mi.format_authors()
if not a:
a = _('Unknown')
prefix = ascii_filename(t+' - '+a)
if not isinstance(prefix, str):
prefix = prefix.decode(preferred_encoding, 'replace')
prefix = ascii_filename(prefix)
names.append('%s_%d%s'%(prefix, id, os.path.splitext(f)[1]))
remove = remove_ids if delete_from_library else []
self.upload_books(gf, names, good, on_card, memory=(_files, remove))
self.status_bar.show_message(_('Sending books to device.'), 5000)
auto = []
if _auto_ids != []:
for id in _auto_ids:
if specific_format is None:
formats = self.library_view.model().db.formats(id, index_is_id=True)
formats = formats.split(',') if formats is not None else []
formats = [f.lower().strip() for f in formats]
if (list(set(formats).intersection(available_input_formats())) != [] and
list(set(settings.format_map).intersection(available_output_formats())) != []):
auto.append(id)
else:
bad.append(self.library_view.model().db.title(id, index_is_id=True))
else:
if specific_format in list(set(settings.format_map).intersection(set(available_output_formats()))):
auto.append(id)
else:
bad.append(self.library_view.model().db.title(id, index_is_id=True))
if auto != []:
format = specific_format if specific_format in \
list(set(settings.format_map).intersection(set(available_output_formats()))) \
else None
if not format:
for fmt in settings.format_map:
if fmt in list(set(settings.format_map).intersection(set(available_output_formats()))):
format = fmt
break
if not format:
bad += auto
else:
autos = [self.library_view.model().db.title(id, index_is_id=True) for id in auto]
if self.auto_convert_question(
_('Auto convert the following books before uploading to '
'the device?'), autos):
self.iactions['Convert Books'].auto_convert(auto, on_card, format)
if bad:
bad = '\n'.join('%s'%(i,) for i in bad)
d = warning_dialog(self, _('No suitable formats'),
_('Could not upload the following books to the device, '
'as no suitable formats were found. Convert the book(s) to a '
'format supported by your device first.'
), bad)
d.exec()
def upload_dirtied_booklists(self):
'''
Upload metadata to device.
'''
plugboards = self.library_view.model().db.new_api.pref('plugboards', {})
self.device_manager.sync_booklists(Dispatcher(lambda x: x),
self.booklists(), plugboards)
def upload_booklists(self, add_as_step_to_job=None):
'''
Upload metadata to device.
'''
plugboards = self.library_view.model().db.new_api.pref('plugboards', {})
self.device_manager.sync_booklists(FunctionDispatcher(self.metadata_synced),
self.booklists(), plugboards,
add_as_step_to_job=add_as_step_to_job)
def metadata_synced(self, job):
'''
Called once metadata has been uploaded.
'''
if job.failed:
self.device_job_exception(job)
return
cp, fs = job.result
self.location_manager.update_devices(cp, fs,
self.device_manager.device.icon)
# reset the views so that up-to-date info is shown. These need to be
# here because some drivers update collections in sync_booklists
cv, row = self.current_view(), -1
if cv is not self.library_view:
row = cv.currentIndex().row()
self.memory_view.reset()
self.card_a_view.reset()
self.card_b_view.reset()
if row > -1:
cv.set_current_row(row)
def _upload_collections(self, job):
if job.failed:
self.device_job_exception(job)
def upload_collections(self, booklist, view=None, oncard=None):
return self.device_manager.upload_collections(self._upload_collections,
booklist, oncard)
def upload_books(self, files, names, metadata, on_card=None, memory=None):
'''
Upload books to device.
:param files: List of either paths to files or file like objects
'''
titles = [i.title for i in metadata]
plugboards = self.library_view.model().db.new_api.pref('plugboards', {})
job = self.device_manager.upload_books(
FunctionDispatcher(self.books_uploaded),
files, names, on_card=on_card,
metadata=metadata, titles=titles, plugboards=plugboards
)
self.upload_memory[job] = (metadata, on_card, memory, files)
def books_uploaded(self, job):
'''
Called once books have been uploaded.
'''
metadata, on_card, memory, files = self.upload_memory.pop(job)
if job.exception is not None:
if isinstance(job.exception, FreeSpaceError):
where = 'in main memory.' if 'memory' in str(job.exception) \
else 'on the storage card.'
titles = '\n'.join(['<li>'+mi.title+'</li>'
for mi in metadata])
d = error_dialog(self, _('No space on device'),
_('<p>Cannot upload books to device there '
'is no more free space available ')+where+
'</p>\n<ul>%s</ul>'%(titles,))
d.exec()
elif isinstance(job.exception, WrongDestinationError):
error_dialog(self, _('Incorrect destination'),
str(job.exception), show=True)
else:
self.device_job_exception(job)
return
try:
self.device_manager.add_books_to_metadata(job.result,
metadata, self.booklists())
except:
traceback.print_exc()
raise
books_to_be_deleted = []
if memory and memory[1]:
books_to_be_deleted = memory[1]
self.library_view.model().delete_books_by_id(books_to_be_deleted)
# There are some cases where sending a book to the device overwrites a
# book already there with a different book. This happens frequently in
# news. When this happens, the book match indication will be wrong
# because the UUID changed. Force both the device and the library view
# to refresh the flags. Set_books_in_library could upload the booklists.
# If it does not, then do it here.
if not self.set_books_in_library(self.booklists(), reset=True,
add_as_step_to_job=job, do_device_sync=False):
self.upload_booklists(job)
self.refresh_ondevice()
view = self.card_a_view if on_card == 'carda' else \
self.card_b_view if on_card == 'cardb' else self.memory_view
view.model().resort(reset=False)
view.model().research()
if files:
for f in files:
# Remove temporary files
try:
rem = not getattr(
self.device_manager.device,
'KEEP_TEMP_FILES_AFTER_UPLOAD', False)
if rem and 'caltmpfmt.' in f:
os.remove(f)
except:
pass
def update_metadata_on_device(self):
self.set_books_in_library(self.booklists(), reset=True, force_send=True)
self.refresh_ondevice()
def set_current_library_information(self, library_name, library_uuid, field_metadata):
self.device_manager.set_current_library_uuid(library_uuid)
if self.device_manager.is_device_connected:
self.device_manager.set_library_information(None, library_name,
library_uuid, field_metadata)
def book_on_device(self, id, reset=False):
'''
Return an indication of whether the given book represented by its db id
is on the currently connected device. It returns a 5 element list. The
first three elements represent memory locations main, carda, and cardb,
and are true if the book is identifiably in that memory. The fourth
is a count of how many instances of the book were found across all
the memory locations. The fifth is a set of paths to the
matching books on the device.
'''
loc = [None, None, None, 0, set()]
if reset:
self.book_db_id_cache = None
self.book_db_id_counts = None
self.book_db_uuid_path_map = None
return
if not self.device_manager.is_device_connected or \
not hasattr(self, 'db_book_uuid_cache'):
return loc
if self.book_db_id_cache is None:
self.book_db_id_cache = []
self.book_db_id_counts = {}
self.book_db_uuid_path_map = {}
for i, l in enumerate(self.booklists()):
self.book_db_id_cache.append(set())
for book in l:
db_id = getattr(book, 'application_id', None)
if db_id is not None:
# increment the count of books on the device with this
# db_id.
self.book_db_id_cache[i].add(db_id)
if db_id not in self.book_db_uuid_path_map:
self.book_db_uuid_path_map[db_id] = set()
if getattr(book, 'lpath', False):
self.book_db_uuid_path_map[db_id].add(book.lpath)
c = self.book_db_id_counts.get(db_id, 0)
self.book_db_id_counts[db_id] = c + 1
for i, l in enumerate(self.booklists()):
if id in self.book_db_id_cache[i]:
loc[i] = True
loc[3] = self.book_db_id_counts.get(id, 0)
loc[4] |= self.book_db_uuid_path_map[id]
return loc
def update_thumbnail(self, book):
if book.cover and os.access(book.cover, os.R_OK):
with lopen(book.cover, 'rb') as f:
book.thumbnail = self.cover_to_thumbnail(f.read())
else:
cprefs = self.default_thumbnail_prefs
book.thumbnail = (cprefs['cover_width'], cprefs['cover_height'], generate_cover(book, prefs=cprefs))
def set_books_in_library(self, booklists, reset=False, add_as_step_to_job=None,
force_send=False, do_device_sync=True):
'''
Set the ondevice indications in the device database.
This method should be called before book_on_device is called, because
it sets the application_id for matched books. Book_on_device uses that
to both speed up matching and to count matches.
'''
if not self.device_manager.is_device_connected:
return False
# It might be possible to get here without having initialized the
# library view. In this case, simply give up
try:
db = self.library_view.model().db
except:
return False
string_pat = re.compile(r'(?u)\W|[_]')
def clean_string(x):
try:
x = x.lower() if x else ''
except Exception:
x = ''
return string_pat.sub('', x)
update_metadata = (
device_prefs['manage_device_metadata'] == 'on_connect' or force_send)
get_covers = False
desired_thumbnail_height = 0
if update_metadata and self.device_manager.is_device_connected:
if self.device_manager.device.WANTS_UPDATED_THUMBNAILS:
get_covers = True
desired_thumbnail_height = self.device_manager.device.THUMBNAIL_HEIGHT
# Force a reset if the caches are not initialized
if reset or not hasattr(self, 'db_book_title_cache'):
# Build a cache (map) of the library, so the search isn't On**2
db_book_title_cache = {}
db_book_uuid_cache = {}
for id_ in db.data.iterallids():
title = clean_string(db.title(id_, index_is_id=True))
if title not in db_book_title_cache:
db_book_title_cache[title] = \
{'authors':{}, 'author_sort':{}, 'db_ids':{}}
# If there are multiple books in the library with the same title
# and author, then remember the last one. That is OK, because as
# we can't tell the difference between the books, one is as good
# as another.
authors = clean_string(db.authors(id_, index_is_id=True))
if authors:
db_book_title_cache[title]['authors'][authors] = id_
if db.author_sort(id_, index_is_id=True):
aus = clean_string(db.author_sort(id_, index_is_id=True))
db_book_title_cache[title]['author_sort'][aus] = id_
db_book_title_cache[title]['db_ids'][id_] = id_
db_book_uuid_cache[db.uuid(id_, index_is_id=True)] = id_
self.db_book_title_cache = db_book_title_cache
self.db_book_uuid_cache = db_book_uuid_cache
book_ids_to_refresh = set()
book_formats_to_send = []
books_with_future_dates = []
first_call_to_synchronize_with_db = [True]
def update_book(id_, book) :
if not update_metadata:
return
mi = db.get_metadata(id_, index_is_id=True, get_cover=get_covers)
book.smart_update(mi, replace_metadata=True)
if get_covers and desired_thumbnail_height != 0:
self.update_thumbnail(book)
def updateq(id_, book):
try:
if not update_metadata:
return False
if do_device_sync and self.device_manager.device is not None:
set_of_ids, (fmt_name, date_bad) = \
self.device_manager.device.synchronize_with_db(db, id_, book,
first_call_to_synchronize_with_db[0])
first_call_to_synchronize_with_db[0] = False
if date_bad:
books_with_future_dates.append(book.title)
elif fmt_name is not None:
book_formats_to_send.append((id_, fmt_name))
if set_of_ids is not None:
book_ids_to_refresh.update(set_of_ids)
return True
return (db.metadata_last_modified(id_, index_is_id=True) !=
getattr(book, 'last_modified', None) or
(isinstance(getattr(book, 'thumbnail', None), (list, tuple)) and
max(book.thumbnail[0], book.thumbnail[1]) != desired_thumbnail_height
)
)
except:
return True
# Now iterate through all the books on the device, setting the
# in_library field. If the UUID matches a book in the library, then
# do not consider that book for other matching. In all cases set
# the application_id to the db_id of the matching book. This value
# will be used by books_on_device to indicate matches. While we are
# going by, update the metadata for a book if automatic management is on
total_book_count = 0
for booklist in booklists:
for book in booklist:
if book:
total_book_count += 1
if DEBUG:
prints('DeviceJob: set_books_in_library: books to process=', total_book_count)
start_time = time.time()
with BusyCursor():
current_book_count = 0
for booklist in booklists:
for book in booklist:
if current_book_count % 100 == 0:
self.status_bar.show_message(
_('Analyzing books on the device: %d%% finished')%(
int((float(current_book_count)/total_book_count)*100.0)), show_notification=False)
# I am assuming that this sort-of multi-threading won't break
# anything. Reasons: excluding UI events prevents the user
# from explicitly changing anything, and (in theory) no
# changes are happening because of timers and the like.
# Why every tenth book? WAG balancing performance in the
# loop with preventing App Not Responding errors
if current_book_count % 10 == 0:
QCoreApplication.processEvents(
flags=QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents|QEventLoop.ProcessEventsFlag.ExcludeSocketNotifiers)
current_book_count += 1
book.in_library = None
if getattr(book, 'uuid', None) in self.db_book_uuid_cache:
id_ = db_book_uuid_cache[book.uuid]
if updateq(id_, book):
update_book(id_, book)
book.in_library = 'UUID'
# ensure that the correct application_id is set
book.application_id = id_
continue
# No UUID exact match. Try metadata matching.
book_title = clean_string(book.title)
d = self.db_book_title_cache.get(book_title, None)
if d is not None:
# At this point we know that the title matches. The book
# will match if any of the db_id, author, or author_sort
# also match.
if getattr(book, 'application_id', None) in d['db_ids']:
id_ = getattr(book, 'application_id', None)
update_book(id_, book)
book.in_library = 'APP_ID'
# app_id already matches a db_id. No need to set it.
continue
# Sonys know their db_id independent of the application_id
# in the metadata cache. Check that as well.
if getattr(book, 'db_id', None) in d['db_ids']:
update_book(book.db_id, book)
book.in_library = 'DB_ID'
book.application_id = book.db_id
continue
# We now know that the application_id is not right. Set it
# to None to prevent book_on_device from accidentally
# matching on it. It will be set to a correct value below if
# the book is matched with one in the library
book.application_id = None
if book.authors:
# Compare against both author and author sort, because
# either can appear as the author
book_authors = clean_string(authors_to_string(book.authors))
if book_authors in d['authors']:
id_ = d['authors'][book_authors]
update_book(id_, book)
book.in_library = 'AUTHOR'
book.application_id = id_
elif book_authors in d['author_sort']:
id_ = d['author_sort'][book_authors]
update_book(id_, book)
book.in_library = 'AUTH_SORT'
book.application_id = id_
else:
# Book definitely not matched. Clear its application ID
book.application_id = None
# Set author_sort if it isn't already
asort = getattr(book, 'author_sort', None)
if not asort and book.authors:
book.author_sort = self.library_view.model().db.\
author_sort_from_authors(book.authors)
if update_metadata:
if self.device_manager.is_device_connected:
plugboards = self.library_view.model().db.new_api.pref('plugboards', {})
self.device_manager.sync_booklists(
FunctionDispatcher(self.metadata_synced), booklists,
plugboards, add_as_step_to_job)
if book_ids_to_refresh:
try:
prints('DeviceJob: set_books_in_library refreshing GUI for ',
len(book_ids_to_refresh), 'books')
self.library_view.model().refresh_ids(book_ids_to_refresh,
current_row=self.library_view.currentIndex().row())
except:
# This shouldn't ever happen, but just in case ...
traceback.print_exc()
# Sync books if necessary
try:
files, names, metadata = [], [], []
for id_, fmt_name in book_formats_to_send:
if DEBUG:
prints('DeviceJob: Syncing book. id:', id_, 'name from device', fmt_name)
ext = os.path.splitext(fmt_name)[1][1:]
fmt_info = db.new_api.format_metadata(id_, ext)
if fmt_info:
try:
pt = PersistentTemporaryFile(suffix='caltmpfmt.'+ext)
db.new_api.copy_format_to(id_, ext, pt)
pt.close()
files.append(filename_to_unicode(os.path.abspath(pt.name)))
names.append(fmt_name)
mi = db.new_api.get_metadata(id_, get_cover=True)
self.update_thumbnail(mi)
metadata.append(mi)
except:
prints('Problem creating temporary file for', fmt_name)
traceback.print_exc()
else:
if DEBUG:
prints("DeviceJob: book doesn't have that format")
if files:
self.upload_books(files, names, metadata)
except:
# Shouldn't ever happen, but just in case
traceback.print_exc()
# Inform user about future-dated books
try:
if books_with_future_dates:
d = error_dialog(self, _('Book format sync problem'),
_('Some book formats in your library cannot be '
'synced because they have dates in the future'),
det_msg='\n'.join(books_with_future_dates),
show=False,
show_copy_button=True)
d.show()
except:
traceback.print_exc()
if DEBUG:
prints('DeviceJob: set_books_in_library finished: time=',
time.time() - start_time)
# The status line is reset when the job finishes
return update_metadata
# }}}