from __future__ import with_statement __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal ' 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 ' 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(['
  • '+mi.title+'
  • ' \ for mi in metadata]) d = error_dialog(self, _('No space on device'), _('

    Cannot upload books to device there ' 'is no more free space available ')+where+ '

    \n'%(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