## 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, textwrap, cStringIO, collections, traceback, shutil from functools import partial from PyQt4.QtCore import Qt, SIGNAL, QObject, QCoreApplication, \ QSettings, QVariant, QSize, QThread from PyQt4.QtGui import QPixmap, QColor, QPainter, QMenu, QIcon, QMessageBox, \ QToolButton, QDialog from PyQt4.QtSvg import QSvgRenderer from libprs500 import __version__, __appname__, islinux, sanitize_file_name from libprs500.ptempfile import PersistentTemporaryFile from libprs500.ebooks.metadata.meta import get_metadata from libprs500.ebooks.lrf.web.convert_from import main as web2lrf from libprs500.ebooks.lrf.any.convert_from import main as _any2lrf from libprs500.devices.errors import FreeSpaceError from libprs500.devices.interface import Device from libprs500.gui2 import APP_UID, warning_dialog, choose_files, error_dialog, \ initialize_file_icon_provider, question_dialog,\ pixmap_to_data, choose_dir, ORG_NAME, \ qstring_to_unicode, set_sidebar_directories from libprs500 import iswindows, isosx from libprs500.library.database import LibraryDatabase from libprs500.gui2.update import CheckForUpdates from libprs500.gui2.main_window import MainWindow 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.news import NewsMenu from libprs500.gui2.dialogs.metadata_single import MetadataSingleDialog from libprs500.gui2.dialogs.metadata_bulk import MetadataBulkDialog from libprs500.gui2.dialogs.jobs import JobsDialog from libprs500.gui2.dialogs.conversion_error import ConversionErrorDialog from libprs500.gui2.dialogs.lrf_single import LRFSingleDialog from libprs500.gui2.dialogs.config import ConfigDialog from libprs500.gui2.dialogs.search import SearchDialog from libprs500.gui2.lrf_renderer.main import file_renderer from libprs500.gui2.lrf_renderer.main import option_parser as lrfviewerop from libprs500.library.database import DatabaseLocked from libprs500.ebooks.metadata.meta import set_metadata from libprs500.ebooks.metadata import MetaInformation from libprs500.ebooks import BOOK_EXTENSIONS any2lrf = partial(_any2lrf, gui_mode=True) class Main(MainWindow, 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, parent=None): MainWindow.__init__(self, parent) Ui_MainWindow.__init__(self) self.setupUi(self) self.setWindowTitle(__appname__) self.read_settings() self.job_manager = JobManager() self.jobs_dialog = JobsDialog(self, self.job_manager) self.device_manager = None self.upload_memory = {} self.delete_memory = {} self.conversion_jobs = {} self.persistent_files = [] self.default_thumbnail = None self.device_error_dialog = ConversionErrorDialog(self, _('Error communicating with device'), ' ') self.device_error_dialog.setModal(Qt.NonModal) self.tb_wrapper = textwrap.TextWrapper(width=40) self.device_connected = False self.viewers = collections.deque() ####################### 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 = qstring_to_unicode(self.vanity.text().arg(__version__)).replace('%2', '%(version)s').replace('%3', '%(device)s') self.latest_version = ' ' self.vanity.setText(self.vanity_template%dict(version=' ', device=' ')) self.device_info = ' ' self.update_checker = CheckForUpdates() QObject.connect(self.update_checker, SIGNAL('update_found(PyQt_PyObject)'), self.update_found) self.update_checker.start() ####################### Status Bar ##################### self.status_bar = StatusBar(self.jobs_dialog) self.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) QObject.connect(self.action_save, SIGNAL("triggered(bool)"), self.save_to_disk) QObject.connect(self.action_view, SIGNAL("triggered(bool)"), self.view_book) self.action_sync.setMenu(sm) self.action_edit.setMenu(md) self.news_menu = NewsMenu() self.action_news.setMenu(self.news_menu) QObject.connect(self.news_menu, SIGNAL('fetch_news(PyQt_PyObject)'), self.fetch_news) cm = QMenu() cm.addAction(_('Convert individually')) cm.addAction(_('Bulk convert')) cm.addSeparator() cm.addAction(_('Set conversion defaults')) self.action_convert.setMenu(cm) QObject.connect(cm.actions()[0], SIGNAL('triggered(bool)'), self.convert_single) QObject.connect(cm.actions()[1], SIGNAL('triggered(bool)'), self.convert_bulk) QObject.connect(cm.actions()[3], SIGNAL('triggered(bool)'), self.set_conversion_defaults) QObject.connect(self.action_convert, SIGNAL('triggered(bool)'), self.convert_single) self.convert_menu = cm self.tool_bar.widgetForAction(self.action_news).setPopupMode(QToolButton.InstantPopup) self.tool_bar.widgetForAction(self.action_edit).setPopupMode(QToolButton.MenuButtonPopup) self.tool_bar.widgetForAction(self.action_sync).setPopupMode(QToolButton.MenuButtonPopup) self.tool_bar.widgetForAction(self.action_convert).setPopupMode(QToolButton.MenuButtonPopup) self.tool_bar.setContextMenuPolicy(Qt.PreventContextMenu) QObject.connect(self.config_button, SIGNAL('clicked(bool)'), self.do_config) QObject.connect(self.advanced_search_button, SIGNAL('clicked(bool)'), self.do_advanced_search) ####################### 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) self.show() self.stack.setCurrentIndex(0) self.library_view.migrate_database() self.library_view.sortByColumn(3, Qt.DescendingOrder) if not self.library_view.restore_column_widths(): 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, device, connected): ''' Called when a device is connected to the computer. ''' if connected: self.device_manager = DeviceManager(device) func = self.device_manager.info_func() self.job_manager.run_device_job(self.info_read, func) self.set_default_thumbnail(device.THUMBNAIL_HEIGHT) self.status_bar.showMessage('Device: '+device.__class__.__name__+' detected.', 3000) self.action_sync.setEnabled(True) self.device_connected = True else: self.device_connected = False self.job_manager.terminate_device_jobs() self.device_manager.device_removed() self.location_view.model().update_devices() self.action_sync.setEnabled(False) self.vanity.setText(self.vanity_template%dict(version=self.latest_version, device=' ')) self.device_info = ' ' 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.device_info = 'Connected '+' '.join(info[:-1]) self.vanity.setText(self.vanity_template%dict(version=self.latest_version, device=self.device_info)) 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) if not view.restore_column_widths(): view.resizeColumnsToContents() view.resizeRowsToContents() view.resize_on_select = not view.isVisible() ############################################################################ ############################# 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, 'add books dialog dir', 'Select books', filters=[('Books', BOOK_EXTENSIONS)]) if not books: return to_device = self.stack.currentIndex() != 0 self._add_books(books, to_device) if to_device: self.status_bar.showMessage('Uploading books to device.', 2000) def _add_books(self, paths, to_device): on_card = False if self.stack.currentIndex() != 2 else True # Get format and metadata information formats, metadata, names, infos = [], [], [], [] for book in paths: 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)) if not mi.authors: mi.authors = ['Unknown'] infos.append({'title':mi.title, 'authors':', '.join(mi.authors), 'cover':self.default_thumbnail, 'tags':[]}) if not to_device: model = self.current_view().model() duplicates = model.add_books(paths, formats, metadata) if duplicates: files = _('
Books with the same title as the following already exist in the database. Add them anyway?
Cannot upload books to device there is no more free space available ')+where+ '
\nAn invalid database already exists at %s, delete it before trying to move the existing database.
Error: %s')%(newloc, str(err)))
newloc = self.database_path
self.database_path = newloc
settings = QSettings()
settings.setValue("database path", QVariant(self.database_path))
os.unlink(src.name)
except Exception, err:
traceback.print_exc()
d = error_dialog(self, _('Could not move database'), unicode(err))
d.exec_()
finally:
self.unsetCursor()
self.library_view.setEnabled(True)
self.status_bar.clearMessage()
self.search.clear_to_help()
self.status_bar.reset_info()
self.library_view.set_database(self.database_path)
self.library_view.sortByColumn(3, Qt.DescendingOrder)
self.library_view.resizeRowsToContents()
set_sidebar_directories(d.directories)
self.library_view.model().read_config()
############################################################################
############################################################################
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()
if not view.restore_column_widths():
view.resizeColumnsToContents()
view.resize_on_select = False
self.status_bar.reset_info()
self.current_view().clearSelection()
if location == 'library':
if self.device_connected:
self.action_sync.setEnabled(True)
self.action_edit.setEnabled(True)
self.action_convert.setEnabled(True)
self.action_view.setEnabled(True)
else:
self.action_sync.setEnabled(False)
self.action_edit.setEnabled(False)
self.action_convert.setEnabled(False)
self.action_view.setEnabled(False)
def device_job_exception(self, id, description, exception, formatted_traceback):
'''
Handle exceptions in threaded device jobs.
'''
if 'Could not read 32 bytes on the control bus.' in str(exception):
error_dialog(self, _('Error talking to device'),
_('There was a temporary error talking to the device. Please unplug and reconnect the device and or reboot.')).show()
return
print >>sys.stderr, 'Error in job:', description.encode('utf8')
print >>sys.stderr, exception
print >>sys.stderr, formatted_traceback.encode('utf8')
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 += formatted_traceback
self.device_error_dialog.set_message(msg)
self.device_error_dialog.show()
def conversion_job_exception(self, id, description, exception, formatted_traceback, log):
print >>sys.stderr, 'Error in job:', description.encode('utf8')
print >>sys.stderr, log.encode('utf8')
print >>sys.stderr, exception
print >>sys.stderr, formatted_traceback.encode('utf8')
msg = u'%s: '%(exception.__class__.__name__,) + unicode(str(exception), 'utf8', 'replace') + u'
'
msg += u'Failed to perform job: '+description
msg += u'
Detailed traceback:
'
msg += formatted_traceback + ''
msg += 'Log:
'
msg += log
ConversionErrorDialog(self, 'Conversion Error', msg, show=True)
def read_settings(self):
settings = QSettings()
settings.beginGroup("Main Window")
self.resize(settings.value("size", QVariant(QSize(800, 600))).toSize())
settings.endGroup()
self.database_path = qstring_to_unicode(settings.value("database path",
QVariant(os.path.join(os.path.expanduser('~'),'library1.db'))).toString())
set_sidebar_directories(None)
def write_settings(self):
settings = QSettings()
settings.beginGroup("Main Window")
settings.setValue("size", QVariant(self.size()))
settings.endGroup()
settings.beginGroup('Book Views')
self.library_view.write_settings()
if self.device_connected:
self.memory_view.write_settings()
settings.endGroup()
def closeEvent(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)
d.setIconPixmap(QPixmap(':/images/dialog_warning.svg'))
d.setDefaultButton(QMessageBox.No)
if d.exec_() != QMessageBox.Yes:
e.ignore()
return
self.write_settings()
e.accept()
def update_found(self, version):
os = 'windows' if iswindows else 'osx' if isosx else 'linux'
url = 'https://libprs500.kovidgoyal.net/download_'+os
self.latest_version = 'Latest version: %s'%(url, version)
self.vanity.setText(self.vanity_template%(dict(version=self.latest_version,
device=self.device_info)))
self.vanity.update()
def main(args=sys.argv):
from PyQt4.Qt import QApplication
pid = os.fork() if islinux else -1
if pid <= 0:
app = QApplication(args)
QCoreApplication.setOrganizationName(ORG_NAME)
QCoreApplication.setApplicationName(APP_UID)
initialize_file_icon_provider()
try:
main = Main()
except DatabaseLocked, err:
QMessageBox.critical(None, 'Cannot Start '+__appname__,
'
Another program is using the database.
Perhaps %s is already running?
If not try deleting the file %s'%(__appname__, err.lock_file_path))
return 1
sys.excepthook = main.unhandled_exception
return app.exec_()
return 0
if __name__ == '__main__':
sys.exit(main())