## 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, collections, traceback, shutil, time from PyQt4.QtCore import Qt, SIGNAL, QObject, QCoreApplication, \ QVariant, QThread, QString 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, launch, Settings from libprs500.ptempfile import PersistentTemporaryFile from libprs500.ebooks.metadata.meta import get_metadata, get_filename_pat, set_filename_pat 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, LRFBulkDialog from libprs500.gui2.dialogs.config import ConfigDialog from libprs500.gui2.dialogs.search import SearchDialog from libprs500.gui2.dialogs.user_profiles import UserProfiles from libprs500.gui2.dialogs.choose_format import ChooseFormatDialog 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 from libprs500.ebooks.lrf import preferred_source_formats as LRF_PREFERRED_SOURCE_FORMATS 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.viewer_job_id = 1 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, Qt.QueuedConnection) QObject.connect(self.job_manager, SIGNAL('job_done(int)'), self.status_bar.job_done, Qt.QueuedConnection) ####################### 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 self.add_menu = QMenu() self.add_menu.addAction(_('Add books from a single directory')) self.add_menu.addAction(_('Add books recursively (One book per directory, assumes every ebook file is the same book in a different format)')) self.add_menu.addAction(_('Add books recursively (Multiple books per directory, assumes every ebook file is a different book)')) self.action_add.setMenu(self.add_menu) QObject.connect(self.action_add, SIGNAL("triggered(bool)"), self.add_books) QObject.connect(self.add_menu.actions()[0], SIGNAL("triggered(bool)"), self.add_books) QObject.connect(self.add_menu.actions()[1], SIGNAL("triggered(bool)"), self.add_recursive_single) QObject.connect(self.add_menu.actions()[2], SIGNAL("triggered(bool)"), self.add_recursive_multiple) 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.save_menu = QMenu() self.save_menu.addAction(_('Save to disk')) self.save_menu.addAction(_('Save to disk in a single directory')) self.view_menu = QMenu() self.view_menu.addAction(_('View')) self.view_menu.addAction(_('View specific format')) self.action_view.setMenu(self.view_menu) QObject.connect(self.action_save, SIGNAL("triggered(bool)"), self.save_to_disk) QObject.connect(self.save_menu.actions()[0], SIGNAL("triggered(bool)"), self.save_to_disk) QObject.connect(self.save_menu.actions()[1], SIGNAL("triggered(bool)"), self.save_to_single_dir) QObject.connect(self.action_view, SIGNAL("triggered(bool)"), self.view_book) QObject.connect(self.view_menu.actions()[0], SIGNAL("triggered(bool)"), self.view_book) QObject.connect(self.view_menu.actions()[1], SIGNAL("triggered(bool)"), self.view_specific_format) self.action_sync.setMenu(sm) self.action_edit.setMenu(md) self.action_save.setMenu(self.save_menu) self.news_menu = NewsMenu(self.customize_feeds) 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.widgetForAction(self.action_save).setPopupMode(QToolButton.MenuButtonPopup) self.tool_bar.widgetForAction(self.action_add).setPopupMode(QToolButton.MenuButtonPopup) self.tool_bar.widgetForAction(self.action_view).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) QObject.connect(self.library_view, SIGNAL('files_dropped(PyQt_PyObject)'), self.files_dropped) 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) self.news_menu.set_custom_feeds(self.library_view.model().db.get_feeds()) 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_recursive(self, single): root = choose_dir(self, 'recursive book import root dir dialog', 'Select root folder') if not root: return duplicates = self.library_view.model().db.recursive_import(root, single) if duplicates: files = _('
Books with the same title as the following already exist in the database. Add them anyway?
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+ '
\nCould not convert %d of %d books, because no suitable source format was found.
An 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 = Settings()
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()
if hasattr(d, 'directories'):
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')
if log:
print >>sys.stderr, log.encode('utf8')
print >>sys.stderr, exception
print >>sys.stderr, formatted_traceback.encode('utf8')
msg = u'%s: %s
'%exception
msg += u'Failed to perform job: '+description
msg += u'
Detailed traceback:
'
msg += formatted_traceback + ''
msg += 'Log:
'
if log:
msg += log
ConversionErrorDialog(self, 'Conversion Error', msg, show=True)
def read_settings(self):
settings = Settings()
settings.beginGroup("Main Window")
geometry = settings.value('main window geometry', QVariant()).toByteArray()
self.restoreGeometry(geometry)
settings.endGroup()
dbpath = os.path.join(os.path.expanduser('~'), 'library1.db').decode(sys.getfilesystemencoding())
self.database_path = qstring_to_unicode(settings.value("database path",
QVariant(QString.fromUtf8(dbpath.encode('utf-8')))).toString())
set_sidebar_directories(None)
set_filename_pat(qstring_to_unicode(settings.value('filename pattern', QVariant(get_filename_pat())).toString()))
def write_settings(self):
settings = Settings()
settings.beginGroup("Main Window")
settings.setValue("main window geometry", QVariant(self.saveGeometry()))
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.job_manager.terminate_all_jobs()
self.write_settings()
self.detector.keep_going = False
self.hide()
time.sleep(2)
self.detector.terminate()
e.accept()
def update_found(self, version):
os = 'windows' if iswindows else 'osx' if isosx else 'linux'
url = 'http://%s.kovidgoyal.net/download_%s'%(__appname__, 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
from libprs500 import singleinstance
pid = os.fork() if islinux else -1
if pid <= 0:
app = QApplication(args)
QCoreApplication.setOrganizationName(ORG_NAME)
QCoreApplication.setApplicationName(APP_UID)
if not singleinstance('mainGUI'):
QMessageBox.critical(None, 'Cannot Start '+__appname__,
'
%s is already running.
'%__appname__)
return 1
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())