calibre/src/calibre/gui2/device.py
2010-05-13 21:57:54 +01:00

1093 lines
47 KiB
Python

from __future__ import with_statement
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
import os, traceback, Queue, time, socket, cStringIO, re
from threading import Thread, RLock
from itertools import repeat
from functools import partial
from binascii import unhexlify
from PyQt4.Qt import QMenu, QAction, QActionGroup, QIcon, SIGNAL, QPixmap, \
Qt, pyqtSignal
from calibre.customize.ui import available_input_formats, available_output_formats, \
device_plugins
from calibre.devices.interface import DevicePlugin
from calibre.gui2.dialogs.choose_format import ChooseFormatDialog
from calibre.utils.ipc.job import BaseJob
from calibre.devices.scanner import DeviceScanner
from calibre.gui2 import config, error_dialog, Dispatcher, dynamic, \
pixmap_to_data, warning_dialog, \
question_dialog
from calibre.ebooks.metadata import authors_to_string
from calibre import preferred_encoding
from calibre.utils.filenames import ascii_filename
from calibre.devices.errors import FreeSpaceError
from calibre.utils.smtp import compose_mail, sendmail, extract_email_address, \
config as email_config
class DeviceJob(BaseJob):
def __init__(self, func, done, job_manager, args=[], kwargs={},
description=''):
BaseJob.__init__(self, description, done=done)
self.func = func
self.args, self.kwargs = args, kwargs
self.exception = None
self.job_manager = job_manager
self._details = _('No details available.')
def start_work(self):
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
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)
except (Exception, SystemExit), err:
self.failed = True
self._details = unicode(err) + '\n\n' + \
traceback.format_exc()
self.exception = err
finally:
self.job_done()
@property
def log_file(self):
return cStringIO.StringIO(self._details.encode('utf-8'))
class DeviceManager(Thread):
def __init__(self, connected_slot, job_manager, sleep_time=2):
'''
:sleep_time: Time to sleep between device probes in secs
'''
Thread.__init__(self)
self.setDaemon(True)
# [Device driver, Showing in GUI, Ejected]
self.devices = list(device_plugins())
self.sleep_time = sleep_time
self.connected_slot = connected_slot
self.jobs = Queue.Queue(0)
self.keep_going = True
self.job_manager = job_manager
self.current_job = None
self.scanner = DeviceScanner()
self.connected_device = None
self.ejected_devices = set([])
def report_progress(self, *args):
pass
@property
def is_device_connected(self):
return self.connected_device is not None
@property
def device(self):
return self.connected_device
def do_connect(self, connected_devices):
for dev, detected_device in connected_devices:
dev.reset(detected_device=detected_device,
report_progress=self.report_progress)
try:
dev.open()
except:
print 'Unable to open device', dev
traceback.print_exc()
continue
self.connected_device = dev
self.connected_slot(True)
return True
return False
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)
else:
self.connected_slot(False)
self.connected_device = None
def detect_device(self):
self.scanner.scan()
if self.is_device_connected:
connected, detected_device = \
self.scanner.is_device_connected(self.connected_device,
only_presence=True)
if not connected:
self.connected_device_removed()
else:
possibly_connected_devices = []
for device in self.devices:
if device in self.ejected_devices:
continue
possibly_connected, detected_device = \
self.scanner.is_device_connected(device)
if possibly_connected:
possibly_connected_devices.append((device, detected_device))
if possibly_connected_devices:
if not self.do_connect(possibly_connected_devices):
print 'Connect to device failed, retrying in 5 seconds...'
time.sleep(5)
if not self.do_connect(possibly_connected_devices):
print 'Device connect failed again, giving up'
def umount_device(self, *args):
if self.is_device_connected:
self.connected_device.eject()
self.ejected_devices.add(self.connected_device)
self.connected_slot(False)
def next(self):
if not self.jobs.empty():
try:
return self.jobs.get_nowait()
except Queue.Empty:
pass
def run(self):
while self.keep_going:
self.detect_device()
while True:
job = self.next()
if job is not None:
self.current_job = job
self.device.set_progress_reporter(job.report_progress)
self.current_job.run()
self.current_job = None
else:
break
time.sleep(self.sleep_time)
def create_job(self, func, done, description, args=[], kwargs={}):
job = DeviceJob(func, done, self.job_manager,
args=args, kwargs=kwargs, description=description)
self.job_manager.add_job(job)
self.jobs.put(job)
return job
def has_card(self):
try:
return bool(self.device.card_prefix())
except:
return False
def _get_device_information(self):
info = self.device.get_device_information(end_session=False)
info = [i.replace('\x00', '').replace('\x01', '') for i in info]
cp = self.device.card_prefix(end_session=False)
fs = self.device.free_space()
return info, cp, fs
def get_device_information(self, done):
'''Get device information and free space on device'''
return self.create_job(self._get_device_information, done,
description=_('Get device information'))
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):
'''Return callable that returns the list of books on device as two booklists'''
return self.create_job(self._books, done, description=_('Get list of books on device'))
def _annotations(self, path_map):
return self.device.get_annotations(path_map)
def annotations(self, done, path_map):
'''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(self._annotations, done, args=[path_map],
description=_('Get annotations from device'))
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):
return self.create_job(self._sync_booklists, done, args=[booklists],
description=_('Send metadata to device'))
def _upload_books(self, files, names, on_card=None, metadata=None):
'''Upload books to device: '''
return self.device.upload_books(files, names, on_card,
metadata=metadata, end_session=False)
def upload_books(self, done, files, names, on_card=None, titles=None,
metadata=None):
desc = _('Upload %d books to device')%len(names)
if titles:
desc += u':' + u', '.join(titles)
return self.create_job(self._upload_books, done, args=[files, names],
kwargs={'on_card':on_card,'metadata':metadata}, 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):
return self.create_job(self._delete_books, done, args=[paths],
description=_('Delete books from device'))
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 = path.rpartition(getattr(self.device, 'path_sep', '/'))[2]
dest = os.path.join(target, name)
if os.path.abspath(dest) != os.path.abspath(path):
f = open(dest, 'wb')
self.device.get_file(path, f)
f.close()
def save_books(self, done, paths, target):
return self.create_job(self._save_books, done, args=[paths, target],
description=_('Download books from device'))
def _view_book(self, path, target):
f = open(target, 'wb')
self.device.get_file(path, f)
f.close()
return target
def view_book(self, done, path, target):
return self.create_job(self._view_book, done, args=[path, target],
description=_('View book on device'))
class DeviceAction(QAction):
def __init__(self, dest, delete, specific, icon_path, text, parent=None):
if delete:
text += ' ' + _('and delete from library')
QAction.__init__(self, QIcon(icon_path), text, parent)
self.dest = dest
self.delete = delete
self.specific = specific
self.connect(self, SIGNAL('triggered(bool)'),
lambda x : self.emit(SIGNAL('a_s(QAction)'), self))
def __repr__(self):
return self.__class__.__name__ + ':%s:%s:%s'%(self.dest, self.delete,
self.specific)
class DeviceMenu(QMenu):
fetch_annotations = pyqtSignal()
def __init__(self, parent=None):
QMenu.__init__(self, parent)
self.group = QActionGroup(self)
self.actions = []
self._memory = []
self.set_default_menu = self.addMenu(_('Set default send to device'
' action'))
opts = email_config().parse()
default_account = None
if opts.accounts:
self.email_to_menu = self.addMenu(_('Email to')+'...')
keys = sorted(opts.accounts.keys())
for account in keys:
formats, auto, default = opts.accounts[account]
dest = 'mail:'+account+';'+formats
if default:
default_account = (dest, False, False, I('mail.svg'),
_('Email to')+' '+account)
action1 = DeviceAction(dest, False, False, I('mail.svg'),
_('Email to')+' '+account, self)
action2 = DeviceAction(dest, True, False, I('mail.svg'),
_('Email to')+' '+account, self)
map(self.email_to_menu.addAction, (action1, action2))
map(self._memory.append, (action1, action2))
self.email_to_menu.addSeparator()
self.connect(action1, SIGNAL('a_s(QAction)'),
self.action_triggered)
self.connect(action2, SIGNAL('a_s(QAction)'),
self.action_triggered)
_actions = [
('main:', False, False, I('reader.svg'),
_('Send to main memory')),
('carda:0', False, False, I('sd.svg'),
_('Send to storage card A')),
('cardb:0', False, False, I('sd.svg'),
_('Send to storage card B')),
'-----',
('main:', True, False, I('reader.svg'),
_('Send to main memory')),
('carda:0', True, False, I('sd.svg'),
_('Send to storage card A')),
('cardb:0', True, False, I('sd.svg'),
_('Send to storage card B')),
'-----',
('main:', False, True, I('reader.svg'),
_('Send specific format to main memory')),
('carda:0', False, True, I('sd.svg'),
_('Send specific format to storage card A')),
('cardb:0', False, True, I('sd.svg'),
_('Send specific format to storage card B')),
]
if default_account is not None:
_actions.insert(2, default_account)
_actions.insert(6, list(default_account))
_actions[6][1] = True
for round in (0, 1):
for dest, delete, specific, icon, text in _actions:
if dest == '-':
(self.set_default_menu if round else self).addSeparator()
continue
action = DeviceAction(dest, delete, specific, icon, text, self)
self._memory.append(action)
if round == 1:
action.setCheckable(True)
action.setText(action.text())
self.group.addAction(action)
self.set_default_menu.addAction(action)
else:
self.connect(action, SIGNAL('a_s(QAction)'),
self.action_triggered)
self.actions.append(action)
self.addAction(action)
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.connect(self.group, SIGNAL('triggered(QAction*)'),
self.change_default_action)
if opts.accounts:
self.addSeparator()
self.addMenu(self.email_to_menu)
self.addSeparator()
annot = self.addAction(_('Fetch annotations (experimental)'))
annot.setEnabled(False)
annot.triggered.connect(lambda 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.emit(SIGNAL('sync(PyQt_PyObject, PyQt_PyObject, PyQt_PyObject)'),
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] != None:
action.setEnabled(True)
else:
action.setEnabled(False)
elif action.dest == 'cardb:0':
if card_prefix and card_prefix[1] != None:
action.setEnabled(True)
else:
action.setEnabled(False)
annot_enable = enable and getattr(device, 'SUPPORTS_ANNOTATIONS', False)
self.annotation_action.setEnabled(annot_enable)
class Emailer(Thread):
def __init__(self, timeout=60):
Thread.__init__(self)
self.setDaemon(True)
self.job_lock = RLock()
self.jobs = []
self._run = True
self.timeout = timeout
def run(self):
while self._run:
job = None
with self.job_lock:
if self.jobs:
job = self.jobs[0]
self.jobs = self.jobs[1:]
if job is not None:
self._send_mails(*job)
time.sleep(1)
def stop(self):
self._run = False
def send_mails(self, jobnames, callback, attachments, to_s, subjects,
texts, attachment_names):
job = (jobnames, callback, attachments, to_s, subjects, texts,
attachment_names)
with self.job_lock:
self.jobs.append(job)
def _send_mails(self, jobnames, callback, attachments,
to_s, subjects, texts, attachment_names):
opts = email_config().parse()
opts.verbose = 3 if os.environ.get('CALIBRE_DEBUG_EMAIL', False) else 0
from_ = opts.from_
if not from_:
from_ = 'calibre <calibre@'+socket.getfqdn()+'>'
results = []
for i, jobname in enumerate(jobnames):
try:
msg = compose_mail(from_, to_s[i], texts[i], subjects[i],
open(attachments[i], 'rb'),
attachment_name = attachment_names[i])
efrom, eto = map(extract_email_address, (from_, to_s[i]))
eto = [eto]
sendmail(msg, efrom, eto, localhost=None,
verbose=opts.verbose,
timeout=self.timeout, relay=opts.relay_host,
username=opts.relay_username,
password=unhexlify(opts.relay_password), port=opts.relay_port,
encryption=opts.encryption)
results.append([jobname, None, None])
except Exception, e:
results.append([jobname, e, traceback.format_exc()])
callback(results)
class DeviceGUI(object):
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:
d = ChooseFormatDialog(self, _('Choose format to send to device'),
self.device_manager.device.settings().format_map)
d.exec_()
fmt = d.format().lower()
dest, sub_dest = dest.split(':')
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':
to, fmts = sub_dest.split(';')
fmts = [x.strip().lower() for x in fmts.split(',')]
self.send_by_mail(to, fmts, delete)
def send_by_mail(self, to, fmts, delete_from_library, send_ids=None,
do_auto_convert=True, specific_format=None):
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 ids or len(ids) == 0:
return
files, _auto_ids = self.library_view.model().get_preferred_formats_from_ids(ids,
fmts, paths=True, set_metadata=True,
specific_format=specific_format,
exclude_auto=do_auto_convert)
if do_auto_convert:
nids = list(set(ids).difference(_auto_ids))
ids = [i for i in ids if i in nids]
else:
_auto_ids = []
full_metadata = self.library_view.model().metadata_for(ids)
files = [getattr(f, 'name', None) for f in files]
bad, remove_ids, jobnames = [], [], []
texts, subjects, attachments, attachment_names = [], [], [], []
for f, mi, id in zip(files, full_metadata, ids):
t = mi.title
if not t:
t = _('Unknown')
if f is None:
bad.append(t)
else:
remove_ids.append(id)
jobnames.append(u'%s:%s'%(id, t))
attachments.append(f)
subjects.append(_('E-book:')+ ' '+t)
a = authors_to_string(mi.authors if mi.authors else \
[_('Unknown')])
texts.append(_('Attached, you will find the e-book') + \
'\n\n' + t + '\n\t' + _('by') + ' ' + a + '\n\n' + \
_('in the %s format.') %
os.path.splitext(f)[1][1:].upper())
prefix = ascii_filename(t+' - '+a)
if not isinstance(prefix, unicode):
prefix = prefix.decode(preferred_encoding, 'replace')
attachment_names.append(prefix + os.path.splitext(f)[1])
remove = remove_ids if delete_from_library else []
to_s = list(repeat(to, len(attachments)))
if attachments:
self.emailer.send_mails(jobnames,
Dispatcher(partial(self.emails_sent, remove=remove)),
attachments, to_s, subjects, texts, attachment_names)
self.status_bar.showMessage(_('Sending email to')+' '+to, 3000)
auto = []
if _auto_ids != []:
for id in _auto_ids:
if specific_format == None:
formats = [f.lower() for f in self.library_view.model().db.formats(id, index_is_id=True).split(',')]
formats = formats if formats != None else []
if list(set(formats).intersection(available_input_formats())) != [] and list(set(fmts).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(fmts).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(fmts).intersection(set(available_output_formats()))) else None
if not format:
for fmt in fmts:
if fmt in list(set(fmts).intersection(set(available_output_formats()))):
format = fmt
break
if format is None:
bad += auto
else:
autos = [self.library_view.model().db.title(id, index_is_id=True) for id in auto]
autos = '\n'.join('%s'%i for i in autos)
if question_dialog(self, _('No suitable formats'),
_('Auto convert the following books before sending via '
'email?'), det_msg=autos):
self.auto_convert_mail(to, fmts, delete_from_library, auto, format)
if bad:
bad = '\n'.join('%s'%(i,) for i in bad)
d = warning_dialog(self, _('No suitable formats'),
_('Could not email the following books '
'as no suitable formats were found:'), bad)
d.exec_()
def emails_sent(self, results, remove=[]):
errors, good = [], []
for jobname, exception, tb in results:
title = jobname.partition(':')[-1]
if exception is not None:
errors.append([title, exception, tb])
else:
good.append(title)
if errors:
errors = '\n'.join([
'%s\n\n%s\n%s\n' %
(title, e, tb) for \
title, e, tb in errors
])
error_dialog(self, _('Failed to email books'),
_('Failed to email the following books:'),
'%s'%errors, show=True
)
else:
self.status_bar.showMessage(_('Sent by email:') + ', '.join(good),
5000)
def cover_to_thumbnail(self, data):
p = QPixmap()
p.loadFromData(data)
if not p.isNull():
ht = self.device_manager.device.THUMBNAIL_HEIGHT \
if self.device_manager else DevicePlugin.THUMBNAIL_HEIGHT
p = p.scaledToHeight(ht, Qt.SmoothTransformation)
return (p.width(), p.height(), pixmap_to_data(p))
def email_news(self, id):
opts = email_config().parse()
accounts = [(account, [x.strip().lower() for x in x[0].split(',')])
for account, x in opts.accounts.items() if x[1]]
sent_mails = []
for account, fmts in accounts:
files, auto = self.library_view.model().\
get_preferred_formats_from_ids([id], fmts)
files = [f.name for f in files if f is not None]
if not files:
continue
attachment = files[0]
mi = self.library_view.model().db.get_metadata(id,
index_is_id=True)
to_s = [account]
subjects = [_('News:')+' '+mi.title]
texts = [_('Attached is the')+' '+mi.title]
attachment_names = [mi.title+os.path.splitext(attachment)[1]]
attachments = [attachment]
jobnames = ['%s:%s'%(id, mi.title)]
remove = [id] if config['delete_news_from_library_on_upload']\
else []
self.emailer.send_mails(jobnames,
Dispatcher(partial(self.emails_sent, remove=remove)),
attachments, to_s, subjects, texts, attachment_names)
sent_mails.append(to_s[0])
if sent_mails:
self.status_bar.showMessage(_('Sent news to')+' '+\
', '.join(sent_mails), 3000)
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)]
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]
autos = '\n'.join('%s'%i for i in autos)
if question_dialog(self, _('No suitable formats'),
_('Auto convert the following books before uploading to '
'the device?'), det_msg=autos):
self.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 mi in metadata:
prefix = ascii_filename(mi.title)
if not isinstance(prefix, unicode):
prefix = prefix.decode(preferred_encoding, 'replace')
prefix = ascii_filename(prefix)
names.append('%s_%d%s'%(prefix, id,
os.path.splitext(f.name)[1]))
if mi.cover and os.access(mi.cover, os.R_OK):
mi.thumbnail = self.cover_to_thumbnail(open(mi.cover,
'rb').read())
dynamic.set('catalogs_to_be_synced', set([]))
if files:
remove = []
space = { self.location_view.model().free[0] : None,
self.location_view.model().free[1] : 'carda',
self.location_view.model().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=[[f.name for f in files], remove])
self.status_bar.showMessage(_('Sending catalogs to device.'), 5000)
def sync_news(self, send_ids=None, do_auto_convert=True):
if self.device_connected:
settings = self.device_manager.device.settings()
ids = list(dynamic.get('news_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)]
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]
autos = '\n'.join('%s'%i for i in autos)
if question_dialog(self, _('No suitable formats'),
_('Auto convert the following books before uploading to '
'the device?'), det_msg=autos):
self.auto_convert_news(auto, format)
files = [f for f in files if f is not None]
if not files:
dynamic.set('news_to_be_synced', set([]))
return
metadata = self.library_view.model().metadata_for(ids)
names = []
for mi in metadata:
prefix = ascii_filename(mi.title)
if not isinstance(prefix, unicode):
prefix = prefix.decode(preferred_encoding, 'replace')
prefix = ascii_filename(prefix)
names.append('%s_%d%s'%(prefix, id,
os.path.splitext(f.name)[1]))
if mi.cover and os.access(mi.cover, os.R_OK):
mi.thumbnail = self.cover_to_thumbnail(open(mi.cover,
'rb').read())
dynamic.set('news_to_be_synced', set([]))
if config['upload_news_to_device'] and files:
remove = ids if \
config['delete_news_from_library_on_upload'] else []
space = { self.location_view.model().free[0] : None,
self.location_view.model().free[1] : 'carda',
self.location_view.model().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=[[f.name for f in files], remove])
self.status_bar.showMessage(_('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:
return
settings = self.device_manager.device.settings()
_files, _auto_ids = self.library_view.model().get_preferred_formats_from_ids(ids,
settings.format_map,
paths=True, set_metadata=True,
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:
if mi.cover and os.access(mi.cover, os.R_OK):
mi.thumbnail = self.cover_to_thumbnail(open(mi.cover, 'rb').read())
imetadata = iter(metadata)
files = [getattr(f, 'name', None) for f in _files]
bad, good, gf, names, remove_ids = [], [], [], [], []
for f in files:
mi = imetadata.next()
id = ids.next()
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, unicode):
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.showMessage(_('Sending books to device.'), 5000)
auto = []
if _auto_ids != []:
for id in _auto_ids:
if specific_format == 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]
autos = '\n'.join('%s'%i for i in autos)
if question_dialog(self, _('No suitable formats'),
_('Auto convert the following books before uploading to '
'the device?'), det_msg=autos):
self.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_booklists(self):
'''
Upload metadata to device.
'''
self.device_manager.sync_booklists(Dispatcher(self.metadata_synced),
self.booklists())
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_view.model().update_devices(cp, fs)
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]
job = self.device_manager.upload_books(
Dispatcher(self.books_uploaded),
files, names, on_card=on_card,
metadata=metadata, titles=titles
)
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_()
else:
self.device_job_exception(job)
return
self.device_manager.add_books_to_metadata(job.result,
metadata, self.booklists())
self.upload_booklists()
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)
self.set_books_in_library(self.booklists(),
reset=bool(books_to_be_deleted))
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()
for f in files:
getattr(f, 'close', lambda : True)()
self.book_on_device(None, reset=True)
if metadata:
changed = set([])
for mi in metadata:
id_ = getattr(mi, 'application_id', None)
if id_ is not None:
changed.add(id_)
if changed:
self.library_view.model().refresh_ids(list(changed))
def book_on_device(self, index, format=None, reset=False):
loc = [None, None, None]
if reset:
self.book_db_title_cache = None
self.book_db_uuid_cache = None
return
if self.book_db_title_cache is None:
self.book_db_title_cache = []
self.book_db_uuid_cache = []
for i, l in enumerate(self.booklists()):
self.book_db_title_cache.append({})
self.book_db_uuid_cache.append(set())
for book in l:
book_title = book.title.lower() if book.title else ''
book_title = re.sub('(?u)\W|[_]', '', book_title)
if book_title not in self.book_db_title_cache[i]:
self.book_db_title_cache[i][book_title] = \
{'authors':set(), 'db_ids':set(), 'uuids':set()}
book_authors = authors_to_string(book.authors).lower()
book_authors = re.sub('(?u)\W|[_]', '', book_authors)
self.book_db_title_cache[i][book_title]['authors'].add(book_authors)
id = getattr(book, 'application_id', None)
if id is None:
id = book.db_id
if id is not None:
self.book_db_title_cache[i][book_title]['db_ids'].add(id)
uuid = getattr(book, 'uuid', None)
if uuid is not None:
self.book_db_uuid_cache[i].add(uuid)
mi = self.library_view.model().db.get_metadata(index, index_is_id=True)
for i, l in enumerate(self.booklists()):
if mi.uuid in self.book_db_uuid_cache[i]:
loc[i] = True
continue
db_title = re.sub('(?u)\W|[_]', '', mi.title.lower())
cache = self.book_db_title_cache[i].get(db_title, None)
if cache:
if index in cache['db_ids']:
loc[i] = True
break
if mi.authors and \
re.sub('(?u)\W|[_]', '', authors_to_string(mi.authors).lower()) \
in cache['authors']:
loc[i] = True
break
return loc
def set_books_in_library(self, booklists, reset=False):
if reset:
# First build a cache of the library, so the search isn't On**2
self.db_book_title_cache = {}
self.db_book_uuid_cache = set()
for idx in range(self.library_view.model().db.count()):
mi = self.library_view.model().db.get_metadata(idx, index_is_id=False)
title = re.sub('(?u)\W|[_]', '', mi.title.lower())
if title not in self.db_book_title_cache:
self.db_book_title_cache[title] = {'authors':set(), 'db_ids':set()}
authors = authors_to_string(mi.authors).lower() if mi.authors else ''
authors = re.sub('(?u)\W|[_]', '', authors)
self.db_book_title_cache[title]['authors'].add(authors)
self.db_book_title_cache[title]['db_ids'].add(mi.application_id)
self.db_book_uuid_cache.add(mi.uuid)
# Now iterate through all the books on the device, setting the in_library field
# Fastest and most accurate key is the uuid. Second is the application_id, which
# is really the db key, but as this can accidentally match across libraries we
# also verify the title. The db_id exists on Sony devices. Fallback is title
# and author match
for booklist in booklists:
for book in booklist:
if getattr(book, 'uuid', None) in self.db_book_uuid_cache:
book.in_library = True
continue
book_title = book.title.lower() if book.title else ''
book_title = re.sub('(?u)\W|[_]', '', book_title)
book.in_library = False
d = self.db_book_title_cache.get(book_title, None)
if d is not None:
if getattr(book, 'application_id', None) in d['db_ids']:
book.in_library = True
continue
if book.db_id in d['db_ids']:
book.in_library = True
continue
book_authors = authors_to_string(book.authors).lower() if book.authors else ''
book_authors = re.sub('(?u)\W|[_]', '', book_authors)
if book_authors in d['authors']:
book.in_library = True