## Copyright (C) 2007 Kovid Goyal kovid@kovidgoyal.net ## This program is free software; you can redistribute it and/or modify ## it under the terms of the GNU General Public License as published by ## the Free Software Foundation; either version 2 of the License, or ## (at your option) any later version. ## ## This program is distributed in the hope that it will be useful, ## but WITHOUT ANY WARRANTY; without even the implied warranty of ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ## GNU General Public License for more details. ## ## You should have received a copy of the GNU General Public License along ## with this program; if not, write to the Free Software Foundation, Inc., ## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.Warning import os, sys, traceback, StringIO, textwrap from PyQt4.QtCore import Qt, SIGNAL, QObject, QCoreApplication, \ QSettings, QVariant, QSize, QThread from PyQt4.QtGui import QPixmap, QColor, QPainter, QMenu, QIcon, QMessageBox from PyQt4.QtSvg import QSvgRenderer from libprs500 import __version__, __appname__, iswindows, isosx from libprs500.ebooks.metadata.meta import get_metadata from libprs500.devices.errors import FreeSpaceError from libprs500.devices.interface import Device from libprs500.gui2 import APP_TITLE, warning_dialog, choose_files, error_dialog, \ initialize_file_icon_provider, BOOK_EXTENSIONS, \ pixmap_to_data from libprs500.gui2.main_ui import Ui_MainWindow from libprs500.gui2.device import DeviceDetector, DeviceManager from libprs500.gui2.status import StatusBar from libprs500.gui2.jobs import JobManager from libprs500.gui2.dialogs.metadata_single import MetadataSingleDialog from libprs500.gui2.dialogs.metadata_bulk import MetadataBulkDialog from libprs500.gui2.dialogs.jobs import JobsDialog class Main(QObject, Ui_MainWindow): def set_default_thumbnail(self, height): r = QSvgRenderer(':/images/book.svg') pixmap = QPixmap(height, height) pixmap.fill(QColor(255,255,255)) p = QPainter(pixmap) r.render(p) p.end() self.default_thumbnail = (pixmap.width(), pixmap.height(), pixmap_to_data(pixmap)) def __init__(self, window): QObject.__init__(self) Ui_MainWindow.__init__(self) self.window = window self.setupUi(window) self.read_settings() self.job_manager = JobManager() self.jobs_dialog = JobsDialog(self.window, self.job_manager) self.device_manager = None self.upload_memory = {} self.delete_memory = {} self.default_thumbnail = None self.device_error_dialog = error_dialog(self.window, 'Error communicating with device', ' ') self.device_error_dialog.setModal(Qt.NonModal) self.tb_wrapper = textwrap.TextWrapper(width=40) ####################### Location View ######################## QObject.connect(self.location_view, SIGNAL('location_selected(PyQt_PyObject)'), self.location_selected) QObject.connect(self.stack, SIGNAL('currentChanged(int)'), self.location_view.location_changed) ####################### Vanity ######################## self.vanity_template = self.vanity.text().arg(__version__) self.vanity.setText(self.vanity_template.arg(' ')) ####################### Status Bar ##################### self.status_bar = StatusBar(self.jobs_dialog) self.window.setStatusBar(self.status_bar) QObject.connect(self.job_manager, SIGNAL('job_added(int)'), self.status_bar.job_added) QObject.connect(self.job_manager, SIGNAL('job_done(int)'), self.status_bar.job_done) ####################### Setup Toolbar ##################### sm = QMenu() sm.addAction(QIcon(':/images/reader.svg'), 'Send to main memory') sm.addAction(QIcon(':/images/sd.svg'), 'Send to storage card') self.sync_menu = sm # Needed md = QMenu() md.addAction('Edit metadata individually') md.addAction('Edit metadata in bulk') self.metadata_menu = md QObject.connect(self.action_add, SIGNAL("triggered(bool)"), self.add_books) QObject.connect(self.action_del, SIGNAL("triggered(bool)"), self.delete_books) QObject.connect(self.action_edit, SIGNAL("triggered(bool)"), self.edit_metadata) QObject.connect(md.actions()[0], SIGNAL('triggered(bool)'), self.edit_metadata) QObject.connect(md.actions()[1], SIGNAL('triggered(bool)'), self.edit_bulk_metadata) QObject.connect(self.action_sync, SIGNAL("triggered(bool)"), self.sync_to_main_memory) QObject.connect(sm.actions()[0], SIGNAL('triggered(bool)'), self.sync_to_main_memory) QObject.connect(sm.actions()[1], SIGNAL('triggered(bool)'), self.sync_to_card) self.action_sync.setMenu(sm) self.action_edit.setMenu(md) self.tool_bar.addAction(self.action_sync) self.tool_bar.addAction(self.action_edit) self.tool_bar.setContextMenuPolicy(Qt.PreventContextMenu) ####################### Library view ######################## self.library_view.set_database(self.database_path) for func, target in [ ('connect_to_search_box', self.search), ('connect_to_book_display', self.status_bar.book_info.show_data), ]: for view in (self.library_view, self.memory_view, self.card_view): getattr(view, func)(target) self.memory_view.connect_dirtied_signal(self.upload_booklists) self.card_view.connect_dirtied_signal(self.upload_booklists) window.closeEvent = self.close_event window.show() self.stack.setCurrentIndex(0) self.library_view.migrate_database() self.library_view.sortByColumn(3, Qt.DescendingOrder) self.library_view.resizeColumnsToContents() self.library_view.resizeRowsToContents() self.search.setFocus(Qt.OtherFocusReason) ####################### Setup device detection ######################## self.detector = DeviceDetector(sleep_time=2000) QObject.connect(self.detector, SIGNAL('connected(PyQt_PyObject, PyQt_PyObject)'), self.device_detected, Qt.QueuedConnection) self.detector.start(QThread.InheritPriority) def current_view(self): '''Convenience method that returns the currently visible view ''' idx = self.stack.currentIndex() if idx == 0: return self.library_view if idx == 1: return self.memory_view if idx == 2: return self.card_view def booklists(self): return self.memory_view.model().db, self.card_view.model().db ########################## Connect to device ############################## def device_detected(self, cls, connected): ''' Called when a device is connected to the computer. ''' if connected: self.device_manager = DeviceManager(cls) func = self.device_manager.info_func() self.job_manager.run_device_job(self.info_read, func) self.set_default_thumbnail(cls.THUMBNAIL_HEIGHT) self.status_bar.showMessage('Device: '+cls.__name__+' detected.', 3000) self.action_sync.setEnabled(True) else: self.job_manager.terminate_device_jobs() self.device_manager.device_removed() self.location_view.model().update_devices() self.action_sync.setEnabled(False) if self.current_view() != self.library_view: self.status_bar.reset_info() self.location_selected('library') def info_read(self, id, description, result, exception, formatted_traceback): ''' Called once device information has been read. ''' if exception: self.device_job_exception(id, description, exception, formatted_traceback) return info, cp, fs = result self.location_view.model().update_devices(cp, fs) self.vanity.setText(self.vanity_template.arg('Connected '+' '.join(info[:-1]))) func = self.device_manager.books_func() self.job_manager.run_device_job(self.metadata_downloaded, func) def metadata_downloaded(self, id, description, result, exception, formatted_traceback): ''' Called once metadata has been read for all books on the device. ''' if exception: self.device_job_exception(id, description, exception, formatted_traceback) return mainlist, cardlist = result self.memory_view.set_database(mainlist) self.card_view.set_database(cardlist) for view in (self.memory_view, self.card_view): view.sortByColumn(3, Qt.DescendingOrder) view.resizeColumnsToContents() view.resizeRowsToContents() view.resize_on_select = not view.isVisible() #self.location_selected('main') ############################################################################ ############################# Upload booklists ############################# def upload_booklists(self): ''' Upload metadata to device. ''' self.job_manager.run_device_job(self.metadata_synced, self.device_manager.sync_booklists_func(), self.booklists()) def metadata_synced(self, id, description, result, exception, formatted_traceback): ''' Called once metadata has been uploaded. ''' if exception: self.device_job_exception(id, description, exception, formatted_traceback) return cp, fs = result self.location_view.model().update_devices(cp, fs) ############################################################################ ################################# Add books ################################ def add_books(self, checked): ''' Add books from the local filesystem to either the library or the device. ''' books = choose_files(self.window, 'add books dialog dir', 'Select books', filters=[('Books', BOOK_EXTENSIONS)]) if not books: return on_card = False if self.stack.currentIndex() != 2 else True # Get format and metadata information formats, metadata, names, infos = [], [], [], [] for book in books: format = os.path.splitext(book)[1] format = format[1:] if format else None stream = open(book, 'rb') mi = get_metadata(stream, stream_type=format) if not mi.title: mi.title = os.path.splitext(os.path.basename(book))[0] formats.append(format) metadata.append(mi) names.append(os.path.basename(book)) infos.append({'title':mi.title, 'authors':mi.author, 'cover':self.default_thumbnail}) if self.stack.currentIndex() == 0: model = self.current_view().model() model.add_books(books, formats, metadata) model.resort() model.research() else: self.upload_books(books, names, infos, on_card=on_card) self.status_bar.showMessage('Adding books to device.', 2000) def upload_books(self, files, names, metadata, on_card=False): ''' Upload books to device. @param files: List of either paths to files or file like objects ''' titles = ', '.join([i['title'] for i in metadata]) id = self.job_manager.run_device_job(self.books_uploaded, self.device_manager.upload_books_func(), files, names, on_card=on_card, job_extra_description=titles ) self.upload_memory[id] = metadata def books_uploaded(self, id, description, result, exception, formatted_traceback): ''' Called once books have been uploaded. ''' metadata = self.upload_memory.pop(id) if exception: if isinstance(exception, FreeSpaceError): where = 'in main memory.' if 'memory' in str(exception) else 'on the storage card.' titles = '\n'.join(['
  • '+mi['title']+'
  • ' for mi in metadata]) d = error_dialog(self.window, '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(id, description, exception, formatted_traceback) return self.device_manager.add_books_to_metadata(result, metadata, self.booklists()) self.upload_booklists() for view in (self.memory_view, self.card_view): view.model().resort() view.model().research() ############################################################################ ############################### Delete books ############################### def delete_books(self, checked): ''' Delete selected books from device or library. ''' view = self.current_view() rows = view.selectionModel().selectedRows() if not rows or len(rows) == 0: return if self.stack.currentIndex() == 0: view.model().delete_books(rows) else: view = self.memory_view if self.stack.currentIndex() == 1 else self.card_view paths = view.model().paths(rows) id = self.remove_paths(paths) self.delete_memory[id] = paths view.model().mark_for_deletion(id, rows) self.status_bar.showMessage('Deleting books from device.', 1000) def remove_paths(self, paths): return self.job_manager.run_device_job(self.books_deleted, self.device_manager.delete_books_func(), paths) def books_deleted(self, id, description, result, exception, formatted_traceback): ''' Called once deletion is done on the device ''' for view in (self.memory_view, self.card_view): view.model().deletion_done(id, bool(exception)) if exception: self.device_job_exception(id, description, exception, formatted_traceback) return self.upload_booklists() if self.delete_memory.has_key(id): paths = self.delete_memory.pop(id) self.device_manager.remove_books_from_metadata(paths, self.booklists()) for view in (self.memory_view, self.card_view): view.model().remap() ############################################################################ ############################### Edit metadata ############################## def edit_metadata(self, checked): ''' Edit metadata of selected books in library individually. ''' rows = self.library_view.selectionModel().selectedRows() if not rows or len(rows) == 0: d = error_dialog(self.window, 'Cannot edit metadata', 'No books selected') d.exec_() return changed = False for row in rows: if MetadataSingleDialog(self.window, row.row(), self.library_view.model().db).changed: changed = True if changed: self.library_view.model().resort() self.library_view.model().research() def edit_bulk_metadata(self, checked): ''' Edit metadata of selected books in library in bulk. ''' rows = [r.row() for r in self.library_view.selectionModel().selectedRows()] if not rows or len(rows) == 0: d = error_dialog(self.window, 'Cannot edit metadata', 'No books selected') d.exec_() return if MetadataBulkDialog(self.window, rows, self.library_view.model().db).changed: self.library_view.model().resort() self.library_view.model().research() ############################################################################ ############################# Syncing to device############################# def sync_to_main_memory(self, checked): self.sync_to_device(False) def sync_to_card(self, checked): self.sync_to_device(True) def cover_to_thumbnail(self, data): p = QPixmap() p.loadFromData(data) if not p.isNull(): ht = self.device_manager.device_class.THUMBNAIL_HEIGHT if self.device_manager else \ Device.THUMBNAIL_HEIGHT p = p.scaledToHeight(ht, Qt.SmoothTransformation) return (p.width(), p.height(), pixmap_to_data(p)) def sync_to_device(self, on_card): rows = self.library_view.selectionModel().selectedRows() if not self.device_manager or not rows or len(rows) == 0: return ids = iter(self.library_view.model().id(r) for r in rows) metadata = self.library_view.model().get_metadata(rows) for mi in metadata: cdata = mi['cover'] if cdata: mi['cover'] = self.cover_to_thumbnail(cdata) metadata = iter(metadata) files = self.library_view.model().get_preferred_formats(rows, self.device_manager.device_class.FORMATS) bad, good, gf, names = [], [], [], [] for f in files: mi = metadata.next() id = ids.next() if f is None: bad.append(mi['title']) else: good.append(mi) gf.append(f) names.append('%s_%d%s'%(__appname__, id, os.path.splitext(f.name)[1])) self.upload_books(gf, names, good, on_card) self.status_bar.showMessage('Sending books to device.', 5000) if bad: bad = '\n'.join('
  • %s
  • '%(i,) for i in bad) d = warning_dialog(self.window, 'No suitable formats', 'Could not upload the following books to the device, as no suitable formats were found:
    '%(bad,)) d.exec_() ############################################################################ def location_selected(self, location): ''' Called when a location icon is clicked (e.g. Library) ''' page = 0 if location == 'library' else 1 if location == 'main' else 2 self.stack.setCurrentIndex(page) view = self.memory_view if page == 1 else self.card_view if page == 2 else None if view: if view.resize_on_select: view.resizeRowsToContents() view.resizeColumnsToContents() view.resize_on_select = False self.status_bar.reset_info() self.current_view().clearSelection() self.current_view().setCurrentIndex(self.current_view().model().index(0, 0)) def wrap_traceback(self, tb): tb = unicode(tb, 'utf8', 'replace') tb = '\n'.join(self.tb_wrapper.wrap(tb)) return tb def device_job_exception(self, id, description, exception, formatted_traceback): ''' Handle exceptions in threaded jobs. ''' print >>sys.stderr, 'Error in job:', description print >>sys.stderr, exception print >>sys.stderr, formatted_traceback if not self.device_error_dialog.isVisible(): msg = u'

    %s: '%(exception.__class__.__name__,) + unicode(str(exception), 'utf8', 'replace') + u'

    ' msg += u'

    Failed to perform job: '+description msg += u'

    Further device related error messages will not be shown while this message is visible.' msg += u'

    Detailed traceback:

    '
                msg += self.wrap_traceback(formatted_traceback)
                self.device_error_dialog.setText(msg)
                self.device_error_dialog.show()
            
            
        
        def read_settings(self):
            settings = QSettings()
            settings.beginGroup("MainWindow")
            self.window.resize(settings.value("size", QVariant(QSize(800, 600))).toSize())
            settings.endGroup()
            self.database_path = settings.value("database path", 
                    QVariant(os.path.join(os.path.expanduser('~'),'library1.db'))).toString()
        
        def write_settings(self):
            settings = QSettings()
            settings.beginGroup("MainWindow")
            settings.setValue("size", QVariant(self.window.size()))
            settings.endGroup()
        
        def close_event(self, e):
            msg = 'There are active jobs. Are you sure you want to quit?'
            if self.job_manager.has_device_jobs():
                msg = '

    '+__appname__ + ' is communicating with the device!
    '+\ 'Quitting may cause corruption on the device.
    '+\ 'Are you sure you want to quit?' if self.job_manager.has_jobs(): d = QMessageBox(QMessageBox.Warning, 'WARNING: Active jobs', msg, QMessageBox.Yes|QMessageBox.No, self.window) d.setIconPixmap(QPixmap(':/images/dialog_warning.svg')) d.setDefaultButton(QMessageBox.No) if d.exec_() == QMessageBox.Yes: self.write_settings() e.accept() else: e.ignore() def unhandled_exception(self, type, value, tb): sio = StringIO.StringIO() traceback.print_exception(type, value, tb, file=sio) fe = sio.getvalue() print >>sys.stderr, fe if type == KeyboardInterrupt: self.window.close() self.window.thread().exit(0) msg = '

    ' + unicode(str(value), 'utf8', 'replace') + '

    ' msg += '

    Detailed traceback:

    '+self.wrap_traceback(fe)+'
    ' d = error_dialog(self.window, 'ERROR: Unhandled exception', msg) d.exec_() def main(): from PyQt4.Qt import QApplication, QMainWindow app = QApplication(sys.argv) window = QMainWindow() window.setWindowTitle(APP_TITLE) QCoreApplication.setOrganizationName("KovidsBrain") QCoreApplication.setApplicationName(APP_TITLE) initialize_file_icon_provider() main = Main(window) sys.excepthook = main.unhandled_exception return app.exec_() if __name__ == '__main__': sys.exit(main())