diff --git a/src/calibre/customize/profiles.py b/src/calibre/customize/profiles.py index 98653a4d50..222f6128e0 100644 --- a/src/calibre/customize/profiles.py +++ b/src/calibre/customize/profiles.py @@ -240,6 +240,9 @@ class OutputProfile(Plugin): # Device supports displaying a nested TOC supports_nested_toc = True + # If True output should be optimized for a touchscreen interface + touchscreen = False + @classmethod def tags_to_string(cls, tags): return escape(', '.join(tags)) @@ -254,6 +257,7 @@ class iPadOutput(OutputProfile): comic_screen_size = (768, 1024) dpi = 132.0 supports_nested_toc = False + touchscreen = True class SonyReaderOutput(OutputProfile): diff --git a/src/calibre/devices/apple/driver.py b/src/calibre/devices/apple/driver.py index aedc35b105..8d1037bc44 100644 --- a/src/calibre/devices/apple/driver.py +++ b/src/calibre/devices/apple/driver.py @@ -1229,8 +1229,10 @@ class ITUNES(DevicePlugin): self.iTunes.delete(cached_book['lib_book']) elif iswindows: - # Assume we're wrapped in a pythoncom - # Windows stores the book under a common author directory, so we just delete the .epub + ''' + Assume we're wrapped in a pythoncom + Windows stores the book under a common author directory, so we just delete the .epub + ''' if DEBUG: self.log.info("ITUNES._remove_from_iTunes(): '%s'" % cached_book['title']) book = self._find_library_book(cached_book) diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py index 07a5e877b1..bddefe97f8 100644 --- a/src/calibre/gui2/__init__.py +++ b/src/calibre/gui2/__init__.py @@ -97,7 +97,8 @@ def _config(): help=_('Overwrite author and title with new metadata')) c.add_opt('enforce_cpu_limit', default=True, help=_('Limit max simultaneous jobs to number of CPUs')) - + c.add_opt('tag_browser_hidden_categories', default=set(), + help=_('tag browser categories not to display')) return ConfigProxy(c) config = _config() diff --git a/src/calibre/gui2/dialogs/config/__init__.py b/src/calibre/gui2/dialogs/config/__init__.py index 5e10ea1621..5d855b5263 100644 --- a/src/calibre/gui2/dialogs/config/__init__.py +++ b/src/calibre/gui2/dialogs/config/__init__.py @@ -14,7 +14,7 @@ from PyQt4.Qt import QDialog, QListWidgetItem, QIcon, \ from calibre.constants import iswindows, isosx from calibre.gui2.dialogs.config.config_ui import Ui_Dialog from calibre.gui2.dialogs.config.create_custom_column import CreateCustomColumn -from calibre.gui2 import choose_dir, error_dialog, config, \ +from calibre.gui2 import choose_dir, error_dialog, config, gprefs, \ ALL_COLUMNS, NONE, info_dialog, choose_files, \ warning_dialog, ResizableDialog, question_dialog from calibre.utils.config import prefs @@ -480,6 +480,8 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): self.opt_enforce_cpu_limit.setChecked(config['enforce_cpu_limit']) self.device_detection_button.clicked.connect(self.debug_device_detection) self.port.editingFinished.connect(self.check_port_value) + self.show_splash_screen.setChecked(gprefs.get('show_splash_screen', + True)) def check_port_value(self, *args): port = self.port.value() @@ -852,6 +854,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): config['get_social_metadata'] = self.opt_get_social_metadata.isChecked() config['overwrite_author_title_metadata'] = self.opt_overwrite_author_title_metadata.isChecked() config['enforce_cpu_limit'] = bool(self.opt_enforce_cpu_limit.isChecked()) + gprefs['show_splash_screen'] = bool(self.show_splash_screen.isChecked()) fmts = [] for i in range(self.viewer.count()): if self.viewer.item(i).checkState() == Qt.Checked: diff --git a/src/calibre/gui2/dialogs/config/config.ui b/src/calibre/gui2/dialogs/config/config.ui index 5d84e2e2af..db748dae7e 100644 --- a/src/calibre/gui2/dialogs/config/config.ui +++ b/src/calibre/gui2/dialogs/config/config.ui @@ -331,8 +331,8 @@ - - + + Use &Roman numerals for series number @@ -342,28 +342,35 @@ - + Enable system &tray icon (needs restart) - + Show &notifications in system tray - + + + + Show &splash screen at startup + + + + Show cover &browser in a separate window (needs restart) - + Search as you type @@ -373,21 +380,21 @@ - + Automatically send downloaded &news to ebook reader - + &Delete news from library when it is automatically sent to reader - + @@ -404,7 +411,7 @@ - + Toolbar @@ -452,7 +459,7 @@ - + @@ -527,12 +534,12 @@ - - ... - Add a user-defined column + + ... + :/images/plus.svg:/images/plus.svg diff --git a/src/calibre/gui2/dialogs/saved_search_editor.py b/src/calibre/gui2/dialogs/saved_search_editor.py new file mode 100644 index 0000000000..a9382201b9 --- /dev/null +++ b/src/calibre/gui2/dialogs/saved_search_editor.py @@ -0,0 +1,86 @@ +__license__ = 'GPL v3' + +__copyright__ = '2008, Kovid Goyal ' + + +from PyQt4.QtCore import SIGNAL, Qt +from PyQt4.QtGui import QDialog, QIcon, QListWidgetItem + +from calibre.gui2.dialogs.saved_search_editor_ui import Ui_SavedSearchEditor +from calibre.utils.config import prefs +from calibre.utils.search_query_parser import saved_searches +from calibre.gui2.dialogs.confirm_delete import confirm +from calibre.constants import islinux + +class SavedSearchEditor(QDialog, Ui_SavedSearchEditor): + + def __init__(self, window, initial_search=None): + QDialog.__init__(self, window) + Ui_SavedSearchEditor.__init__(self) + self.setupUi(self) + + self.connect(self.add_search_button, SIGNAL('clicked()'), self.add_search) + self.connect(self.search_name_box, SIGNAL('currentIndexChanged(int)'), + self.current_index_changed) + self.connect(self.delete_search_button, SIGNAL('clicked()'), self.del_search) + + self.current_search_name = None + self.searches = {} + self.searches_to_delete = [] + for name in saved_searches.names(): + self.searches[name] = saved_searches.lookup(name) + + self.populate_search_list() + if initial_search is not None and initial_search in self.searches: + self.select_search(initial_search) + + def populate_search_list(self): + self.search_name_box.clear() + for name in sorted(self.searches.keys()): + self.search_name_box.addItem(name) + + def add_search(self): + search_name = unicode(self.input_box.text()).strip() + if search_name == '': + return False + if search_name not in self.searches: + self.searches[search_name] = '' + self.populate_search_list() + self.select_search(search_name) + else: + self.select_search(search_name) + return True + + def del_search(self): + if self.current_search_name is not None: + if not confirm('

'+_('The current saved search will be ' + 'permanently deleted. Are you sure?') + +'

', 'saved_search_editor_delete', self): + return + del self.searches[self.current_search_name] + self.searches_to_delete.append(self.current_search_name) + self.current_search_name = None + self.search_name_box.removeItem(self.search_name_box.currentIndex()) + + def select_search(self, name): + self.search_name_box.setCurrentIndex(self.search_name_box.findText(name)) + + def current_index_changed(self, idx): + if self.current_search_name: + self.searches[self.current_search_name] = unicode(self.search_text.toPlainText()) + name = unicode(self.search_name_box.itemText(idx)) + if name: + self.current_search_name = name + self.search_text.setPlainText(self.searches[name]) + else: + self.current_search_name = None + self.search_text.setPlainText('') + + def accept(self): + if self.current_search_name: + self.searches[self.current_search_name] = unicode(self.search_text.toPlainText()) + for name in self.searches_to_delete: + saved_searches.delete(name) + for name in self.searches: + saved_searches.add(name, self.searches[name]) + QDialog.accept(self) diff --git a/src/calibre/gui2/dialogs/saved_search_editor.ui b/src/calibre/gui2/dialogs/saved_search_editor.ui new file mode 100644 index 0000000000..6d98d25667 --- /dev/null +++ b/src/calibre/gui2/dialogs/saved_search_editor.ui @@ -0,0 +1,185 @@ + + + SavedSearchEditor + + + + 0 + 0 + 548 + 148 + + + + Saved Search Editor + + + + :/images/chapters.svg:/images/chapters.svg + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + true + + + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + Saved Search: + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + search_name_box + + + + + + + + 160 + 0 + + + + + 145 + 0 + + + + Select a saved search to edit + + + false + + + + + + + Delete this selected saved search + + + ... + + + + :/images/minus.svg:/images/minus.svg + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 60 + 0 + + + + Enter a new saved search name. + + + + + + + Add the new saved search + + + ... + + + + :/images/plus.svg:/images/plus.svg + + + + + + + + + Change the contents of the saved search + + + + + + + + + + + buttonBox + accepted() + SavedSearchEditor + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + SavedSearchEditor + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/calibre/gui2/dialogs/tag_categories.py b/src/calibre/gui2/dialogs/tag_categories.py index fcf517e571..b7d64226ab 100644 --- a/src/calibre/gui2/dialogs/tag_categories.py +++ b/src/calibre/gui2/dialogs/tag_categories.py @@ -24,13 +24,12 @@ class Item: class TagCategories(QDialog, Ui_TagCategories): category_labels_orig = ['', 'authors', 'series', 'publishers', 'tags'] - def __init__(self, window, db, index=None): + def __init__(self, window, db, on_category=None): QDialog.__init__(self, window) Ui_TagCategories.__init__(self) self.setupUi(self) self.db = db - self.index = index self.applied_items = [] cc_icon = QIcon(I('column.svg')) @@ -102,8 +101,10 @@ class TagCategories(QDialog, Ui_TagCategories): self.connect(self.applied_items_box, SIGNAL('itemActivated(QListWidgetItem*)'), self.unapply_tags) self.populate_category_list() - return - self.select_category(0) + if on_category is not None: + l = self.category_box.findText(on_category) + if l >= 0: + self.category_box.setCurrentIndex(l) def make_list_widget(self, item): n = item.name if item.exists else item.name + _(' (not on any book)') diff --git a/src/calibre/gui2/dialogs/tag_categories.ui b/src/calibre/gui2/dialogs/tag_categories.ui index 2904b2464e..d280d5061a 100644 --- a/src/calibre/gui2/dialogs/tag_categories.ui +++ b/src/calibre/gui2/dialogs/tag_categories.ui @@ -11,7 +11,7 @@
- Tag Editor + User Categories Editor diff --git a/src/calibre/gui2/dialogs/tag_list_editor.py b/src/calibre/gui2/dialogs/tag_list_editor.py new file mode 100644 index 0000000000..c2cc1d7116 --- /dev/null +++ b/src/calibre/gui2/dialogs/tag_list_editor.py @@ -0,0 +1,89 @@ +__license__ = 'GPL v3' +__copyright__ = '2008, Kovid Goyal ' +from PyQt4.QtCore import SIGNAL, Qt +from PyQt4.QtGui import QDialog, QListWidgetItem + +from calibre.gui2.dialogs.tag_list_editor_ui import Ui_TagListEditor +from calibre.gui2 import question_dialog, error_dialog + +class TagListEditor(QDialog, Ui_TagListEditor): + + def tag_cmp(self, x, y): + return cmp(x.lower(), y.lower()) + + def __init__(self, window, db, tag_to_match): + QDialog.__init__(self, window) + Ui_TagListEditor.__init__(self) + self.setupUi(self) + + self.to_rename = {} + self.to_delete = [] + self.db = db + self.all_tags = {} + for k,v in db.get_tags_with_ids(): + self.all_tags[v] = k + for tag in sorted(self.all_tags.keys(), cmp=self.tag_cmp): + item = QListWidgetItem(tag) + item.setData(Qt.UserRole, self.all_tags[tag]) + self.available_tags.addItem(item) + + items = self.available_tags.findItems(tag_to_match, Qt.MatchExactly) + if len(items) == 1: + self.available_tags.setCurrentItem(items[0]) + + self.connect(self.delete_button, SIGNAL('clicked()'), self.delete_tags) + self.connect(self.rename_button, SIGNAL('clicked()'), self.rename_tag) + self.connect(self.available_tags, SIGNAL('itemDoubleClicked(QListWidgetItem *)'), self._rename_tag) + self.connect(self.available_tags, SIGNAL('itemChanged(QListWidgetItem *)'), self.finish_editing) + + def finish_editing(self, item): + if item.text() != self.item_before_editing.text(): + if item.text() in self.all_tags.keys() or item.text() in self.to_rename.keys(): + error_dialog(self, 'Tag already used', + 'The tag %s is already used.'%(item.text())).exec_() + item.setText(self.item_before_editing.text()) + return + id,ign = self.item_before_editing.data(Qt.UserRole).toInt() + self.to_rename[item.text()] = id + + def rename_tag(self): + item = self.available_tags.currentItem() + self._rename_tag(item) + + def _rename_tag(self, item): + if item is None: + error_dialog(self, 'No tag selected', 'You must select one tag from the list of Available tags.').exec_() + return + self.item_before_editing = item.clone() + item.setFlags (item.flags() | Qt.ItemIsEditable); + self.available_tags.editItem(item) + + def delete_tags(self, item=None): + confirms, deletes = [], [] + items = self.available_tags.selectedItems() if item is None else [item] + if not items: + error_dialog(self, 'No tags selected', 'You must select at least one tag from the list of Available tags.').exec_() + return + for item in items: + if self.db.is_tag_used(unicode(item.text())): + confirms.append(item) + else: + deletes.append(item) + if confirms: + ct = ', '.join([unicode(item.text()) for item in confirms]) + if question_dialog(self, _('Are your sure?'), + '

'+_('The following tags are used by one or more books. ' + 'Are you certain you want to delete them?')+'
'+ct): + deletes += confirms + + for item in deletes: + self.to_delete.append(item) + self.available_tags.takeItem(self.available_tags.row(item)) + + def accept(self): + for text in self.to_rename: + self.db.rename_tag(self.to_rename[text], unicode(text)) + for item in self.to_delete: + self.db.delete_tag(unicode(item.text())) + QDialog.accept(self) + diff --git a/src/calibre/gui2/dialogs/tag_list_editor.ui b/src/calibre/gui2/dialogs/tag_list_editor.ui new file mode 100644 index 0000000000..383dc875ac --- /dev/null +++ b/src/calibre/gui2/dialogs/tag_list_editor.ui @@ -0,0 +1,163 @@ + + + TagListEditor + + + + 0 + 0 + 397 + 335 + + + + Tag Editor + + + + :/images/chapters.svg:/images/chapters.svg + + + + + + + + + + Tags in use + + + available_tags + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + Delete tag from database. This will unapply the tag from all books and then remove it from the database. + + + ... + + + + :/images/trash.svg:/images/trash.svg + + + + 32 + 32 + + + + + + + + Rename the tag everywhere it is used. + + + ... + + + + :/images/edit_input.svg:/images/edit_input.svg + + + + 32 + 32 + + + + Ctrl+S + + + + + + + + + true + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + TagListEditor + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + TagListEditor + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/calibre/gui2/main.py b/src/calibre/gui2/main.py index 73f7f3839d..29ae1875c8 100644 --- a/src/calibre/gui2/main.py +++ b/src/calibre/gui2/main.py @@ -5,13 +5,15 @@ import sys, os, time, socket, traceback from functools import partial from PyQt4.Qt import QCoreApplication, QIcon, QMessageBox, QObject, QTimer, \ - QThread, pyqtSignal, Qt, QProgressDialog, QString + QThread, pyqtSignal, Qt, QProgressDialog, QString, QPixmap, \ + QSplashScreen, QApplication from calibre import prints, plugins -from calibre.constants import iswindows, __appname__, isosx, filesystem_encoding +from calibre.constants import iswindows, __appname__, isosx, DEBUG, \ + filesystem_encoding from calibre.utils.ipc import ADDRESS, RC from calibre.gui2 import ORG_NAME, APP_UID, initialize_file_icon_provider, \ - Application, choose_dir, error_dialog, question_dialog + Application, choose_dir, error_dialog, question_dialog, gprefs from calibre.gui2.main_window import option_parser as _option_parser from calibre.utils.config import prefs, dynamic from calibre.library.database2 import LibraryDatabase2 @@ -113,16 +115,25 @@ class GuiRunner(QObject): initialization''' def __init__(self, opts, args, actions, listener, app): + self.startup_time = time.time() self.opts, self.args, self.listener, self.app = opts, args, listener, app self.actions = actions self.main = None QObject.__init__(self) + self.splash_screen = None self.timer = QTimer.singleShot(1, self.initialize) + if DEBUG: + prints('Starting up...') def start_gui(self): from calibre.gui2.ui import Main main = Main(self.opts) + if self.splash_screen is not None: + self.splash_screen.showMessage(_('Initializing user interface...')) + self.splash_screen.finish(main) main.initialize(self.library_path, self.db, self.listener, self.actions) + if DEBUG: + prints('Started up in', time.time() - self.startup_time) add_filesystem_book = partial(main.add_filesystem_book, allow_device=False) sys.excepthook = main.unhandled_exception if len(self.args) > 1: @@ -143,7 +154,7 @@ class GuiRunner(QObject): if db is None and tb is not None: # DB Repair failed - error_dialog(None, _('Repairing failed'), + error_dialog(self.splash_screen, _('Repairing failed'), _('The database repair failed. Starting with ' 'a new empty library.'), det_msg=tb, show=True) @@ -160,7 +171,7 @@ class GuiRunner(QObject): os.makedirs(x) except: x = os.path.expanduser('~') - candidate = choose_dir(None, 'choose calibre library', + candidate = choose_dir(self.splash_screen, 'choose calibre library', _('Choose a location for your new calibre e-book library'), default_dir=x) @@ -171,7 +182,7 @@ class GuiRunner(QObject): self.library_path = candidate db = LibraryDatabase2(candidate) except: - error_dialog(None, _('Bad database location'), + error_dialog(self.splash_screen, _('Bad database location'), _('Bad database location %r. calibre will now quit.' )%self.library_path, det_msg=traceback.format_exc(), show=True) @@ -185,7 +196,7 @@ class GuiRunner(QObject): try: db = LibraryDatabase2(self.library_path) except (sqlite.Error, DatabaseException): - repair = question_dialog(None, _('Corrupted database'), + repair = question_dialog(self.splash_screen, _('Corrupted database'), _('Your calibre database appears to be corrupted. Do ' 'you want calibre to try and repair it automatically? ' 'If you say No, a new empty calibre library will be created.'), @@ -204,14 +215,27 @@ class GuiRunner(QObject): self.repair.start() return except: - error_dialog(None, _('Bad database location'), + error_dialog(self.splash_screen, _('Bad database location'), _('Bad database location %r. Will start with ' ' a new, empty calibre library')%self.library_path, det_msg=traceback.format_exc(), show=True) self.initialize_db_stage2(db, None) + def show_splash_screen(self): + self.splash_pixmap = QPixmap() + self.splash_pixmap.load(I('library.png')) + self.splash_screen = QSplashScreen(self.splash_pixmap, + Qt.SplashScreen|Qt.WindowStaysOnTopHint) + self.splash_screen.showMessage(_('Starting %s: Loading books...') % + __appname__) + self.splash_screen.show() + QApplication.instance().processEvents() + def initialize(self, *args): + if gprefs.get('show_splash_screen', True): + self.show_splash_screen() + self.library_path = get_library_path() if self.library_path is None: self.initialization_failed() diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 15270e14b1..11db157ed4 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -8,10 +8,11 @@ Browsing book collection by tags. ''' from itertools import izip +from functools import partial from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, \ QFont, QSize, QIcon, QPoint, \ - QAbstractItemModel, QVariant, QModelIndex + QAbstractItemModel, QVariant, QModelIndex, QMenu from calibre.gui2 import config, NONE from calibre.utils.config import prefs from calibre.library.field_metadata import TagsIcons @@ -19,9 +20,12 @@ from calibre.utils.search_query_parser import saved_searches class TagsView(QTreeView): # {{{ - need_refresh = pyqtSignal() - restriction_set = pyqtSignal(object) - tags_marked = pyqtSignal(object, object) + need_refresh = pyqtSignal() + restriction_set = pyqtSignal(object) + tags_marked = pyqtSignal(object, object) + user_category_edit = pyqtSignal(object) + tag_list_edit = pyqtSignal(object) + saved_search_edit = pyqtSignal(object) def __init__(self, *args): QTreeView.__init__(self, *args) @@ -31,13 +35,16 @@ class TagsView(QTreeView): # {{{ self.tag_match = None def set_database(self, db, tag_match, popularity, restriction): - self._model = TagsModel(db, parent=self) + self.hidden_categories = config['tag_browser_hidden_categories'] + self._model = TagsModel(db, parent=self, hidden_categories=self.hidden_categories) self.popularity = popularity self.restriction = restriction self.tag_match = tag_match self.db = db self.setModel(self._model) + self.setContextMenuPolicy(Qt.CustomContextMenu) self.clicked.connect(self.toggle) + self.customContextMenuRequested.connect(self.show_context_menu) self.popularity.setChecked(config['sort_by_popularity']) self.popularity.stateChanged.connect(self.sort_changed) self.restriction.activated[str].connect(self.search_restriction_set) @@ -45,10 +52,6 @@ class TagsView(QTreeView): # {{{ db.add_listener(self.database_changed) self.saved_searches_changed(recount=False) - def create_tag_category(self, name, tag_list): - self._model.create_tag_category(name, tag_list) - self.recount() - def database_changed(self, event, ids): self.need_refresh.emit() @@ -72,12 +75,87 @@ class TagsView(QTreeView): # {{{ self.recount() # Must happen after the emission of the restriction_set signal self.tags_marked.emit(self._model.tokens(), self.match_all) + def mouseReleaseEvent(self, event): + # Swallow everything except leftButton so context menus work correctly + if event.button() == Qt.LeftButton: + QTreeView.mouseReleaseEvent(self, event) + def toggle(self, index): modifiers = int(QApplication.keyboardModifiers()) exclusive = modifiers not in (Qt.CTRL, Qt.SHIFT) if self._model.toggle(index, exclusive): self.tags_marked.emit(self._model.tokens(), self.match_all) + def context_menu_handler(self, action=None, category=None): + if not action: + return + try: + if action == 'manage_tags': + self.tag_list_edit.emit(category) + return + if action == 'manage_categories': + self.user_category_edit.emit(category) + return + if action == 'manage_searches': + self.saved_search_edit.emit(category) + return + if action == 'hide': + self.hidden_categories.add(category) + elif action == 'show': + self.hidden_categories.discard(category) + elif action == 'defaults': + self.hidden_categories.clear() + config.set('tag_browser_hidden_categories', self.hidden_categories) + self.set_new_model() + except: + return + + def show_context_menu(self, point): + index = self.indexAt(point) + if not index.isValid(): + return False + item = index.internalPointer() + tag_name = '' + if item.type == TagTreeItem.TAG: + tag_name = item.tag.name + item = item.parent + if item.type == TagTreeItem.CATEGORY: + category = unicode(item.name.toString()) + self.context_menu = QMenu(self) + self.context_menu.addAction(_('Hide %s') % category, + partial(self.context_menu_handler, action='hide', category=category)) + + if self.hidden_categories: + self.context_menu.addSeparator() + m = self.context_menu.addMenu(_('Show category')) + for col in self.hidden_categories: + m.addAction(col, + partial(self.context_menu_handler, action='show', category=col)) + self.context_menu.addSeparator() + self.context_menu.addAction(_('Restore defaults'), + partial(self.context_menu_handler, action='defaults')) + + self.context_menu.addSeparator() + self.context_menu.addAction(_('Manage Tags'), + partial(self.context_menu_handler, action='manage_tags', + category=tag_name)) + + if category in prefs['user_categories'].keys(): + self.context_menu.addAction(_('Manage User Categories'), + partial(self.context_menu_handler, action='manage_categories', + category=category)) + else: + self.context_menu.addAction(_('Manage User Categories'), + partial(self.context_menu_handler, action='manage_categories', + category=None)) + + self.context_menu.addAction(_('Manage Saved Searches'), + partial(self.context_menu_handler, action='manage_searches', + category=tag_name)) + + self.context_menu.popup(self.mapToGlobal(point)) + return True + def clear(self): self.model().clear_state() @@ -110,13 +188,12 @@ class TagsView(QTreeView): # {{{ self.setCurrentIndex(idx) self.scrollTo(idx, QTreeView.PositionAtCenter) - ''' - If the number of user categories changed, or if custom columns have come or gone, - we must rebuild the model. Reason: it is much easier to do that than to reconstruct - the browser tree. - ''' + # If the number of user categories changed, if custom columns have come or + # gone, or if columns have been hidden or restored, we must rebuild the + # model. Reason: it is much easier than reconstructing the browser tree. def set_new_model(self): - self._model = TagsModel(self.db, parent=self) + self._model = TagsModel(self.db, parent=self, + hidden_categories=self.hidden_categories) self.setModel(self._model) # }}} @@ -200,7 +277,7 @@ class TagTreeItem(object): # {{{ class TagsModel(QAbstractItemModel): # {{{ - def __init__(self, db, parent=None): + def __init__(self, db, parent=None, hidden_categories=None): QAbstractItemModel.__init__(self, parent) # must do this here because 'QPixmap: Must construct a QApplication @@ -220,6 +297,7 @@ class TagsModel(QAbstractItemModel): # {{{ self.icon_state_map = [None, QIcon(I('plus.svg')), QIcon(I('minus.svg'))] self.db = db + self.hidden_categories = hidden_categories self.search_restriction = '' self.ignore_next_search = 0 @@ -237,6 +315,8 @@ class TagsModel(QAbstractItemModel): # {{{ data = self.get_node_tree(config['sort_by_popularity']) self.root_item = TagTreeItem() for i, r in enumerate(self.row_map): + if self.hidden_categories and self.categories[i] in self.hidden_categories: + continue if self.db.field_metadata[r]['kind'] != 'user': tt = _('The lookup/search name is "{0}"').format(r) else: @@ -271,12 +351,16 @@ class TagsModel(QAbstractItemModel): # {{{ def refresh(self): data = self.get_node_tree(config['sort_by_popularity']) # get category data + row_index = -1 for i, r in enumerate(self.row_map): - category = self.root_item.children[i] + if self.hidden_categories and self.categories[i] in self.hidden_categories: + continue + row_index += 1 + category = self.root_item.children[row_index] names = [t.tag.name for t in category.children] states = [t.tag.state for t in category.children] state_map = dict(izip(names, states)) - category_index = self.index(i, 0, QModelIndex()) + category_index = self.index(row_index, 0, QModelIndex()) if len(category.children) > 0: self.beginRemoveRows(category_index, 0, len(category.children)-1) @@ -401,16 +485,20 @@ class TagsModel(QAbstractItemModel): # {{{ def tokens(self): ans = [] tags_seen = set() + row_index = -1 for i, key in enumerate(self.row_map): + if self.hidden_categories and self.categories[i] in self.hidden_categories: + continue + row_index += 1 if key.endswith(':'): # User category, so skip it. The tag will be marked in its real category continue - category_item = self.root_item.children[i] + category_item = self.root_item.children[row_index] for tag_item in category_item.children: tag = tag_item.tag if tag.state > 0: prefix = ' not ' if tag.state == 2 else '' category = key if key != 'news' else 'tag' - if tag.name[0] == u'\u2605': # char is a star. Assume rating + if tag.name and tag.name[0] == u'\u2605': # char is a star. Assume rating ans.append('%s%s:%s'%(prefix, category, len(tag.name))) else: if category == 'tags': diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index 5f7d4b76cd..773f44acd2 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -61,6 +61,8 @@ from calibre.library.database2 import LibraryDatabase2 from calibre.library.caches import CoverCache from calibre.gui2.dialogs.confirm_delete import confirm from calibre.gui2.dialogs.tag_categories import TagCategories +from calibre.gui2.dialogs.tag_list_editor import TagListEditor +from calibre.gui2.dialogs.saved_search_editor import SavedSearchEditor class SaveMenu(QMenu): @@ -542,19 +544,23 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.cover_cache = CoverCache(self.library_path) self.cover_cache.start() self.library_view.model().cover_cache = self.cover_cache - self.connect(self.edit_categories, SIGNAL('clicked()'), self.do_edit_categories) + self.connect(self.edit_categories, SIGNAL('clicked()'), self.do_user_categories_edit) self.tags_view.set_database(db, self.tag_match, self.popularity, self.search_restriction) self.tags_view.tags_marked.connect(self.search.search_from_tags) for x in (self.saved_search.clear_to_help, self.mark_restriction_set): self.tags_view.restriction_set.connect(x) self.tags_view.tags_marked.connect(self.saved_search.clear_to_help) + self.tags_view.tag_list_edit.connect(self.do_tags_list_edit) + self.tags_view.user_category_edit.connect(self.do_user_categories_edit) + self.tags_view.saved_search_edit.connect(self.do_saved_search_edit) self.search.search.connect(self.tags_view.model().reinit) for x in (self.location_view.count_changed, self.tags_view.recount, self.restriction_count_changed): self.library_view.model().count_changed_signal.connect(x) self.connect(self.search, SIGNAL('cleared()'), self.search_box_cleared) - self.connect(self.saved_search, SIGNAL('changed()'), self.tags_view.saved_searches_changed, Qt.QueuedConnection) + self.connect(self.saved_search, SIGNAL('changed()'), + self.tags_view.saved_searches_changed, Qt.QueuedConnection) if not gprefs.get('quick_start_guide_added', False): from calibre.ebooks.metadata import MetaInformation mi = MetaInformation(_('Calibre Quick Start Guide'), ['John Schember']) @@ -647,13 +653,28 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self._add_filesystem_book = Dispatcher(self.__add_filesystem_book) self.keyboard_interrupt.connect(self.quit, type=Qt.QueuedConnection) - def do_edit_categories(self): - d = TagCategories(self, self.library_view.model().db) + def do_user_categories_edit(self, on_category=None): + d = TagCategories(self, self.library_view.model().db, on_category) d.exec_() if d.result() == d.Accepted: self.tags_view.set_new_model() self.tags_view.recount() + def do_tags_list_edit(self, tag): + d = TagListEditor(self, self.library_view.model().db, tag) + d.exec_() + if d.result() == d.Accepted: + self.tags_view.set_new_model() + self.tags_view.recount() + self.library_view.model().refresh() + + def do_saved_search_edit(self, search): + d = SavedSearchEditor(self, search) + d.exec_() + if d.result() == d.Accepted: + self.tags_view.saved_searches_changed(recount=True) + self.saved_search.clear_to_help() + def resizeEvent(self, ev): MainWindow.resizeEvent(self, ev) self.search.setMaximumWidth(self.width()-150) diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index f27a42beee..0544293095 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -648,6 +648,11 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): self.conn.execute(st%dict(ltable='publishers', table='publishers', ltable_col='publisher')) self.conn.execute(st%dict(ltable='tags', table='tags', ltable_col='tag')) self.conn.execute(st%dict(ltable='series', table='series', ltable_col='series')) + for id_, tag in self.conn.get('SELECT id, name FROM tags', all=True): + if not tag.strip(): + self.conn.execute('DELETE FROM books_tags_link WHERE tag=?', + (id_,)) + self.conn.execute('DELETE FROM tags WHERE id=?', (id_,)) self.clean_custom() self.conn.commit() @@ -980,6 +985,20 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): if notify: self.notify('metadata', [id]) + # Convenience method for tags_list_editor + def get_tags_with_ids(self): + result = self.conn.get('SELECT * FROM tags') + if not result: + return {} + r = [] + for k,v in result: + r.append((k,v)) + return r + + def rename_tag(self, id, new): + self.conn.execute('UPDATE tags SET name=? WHERE id=?', (new, id)) + self.conn.commit() + def get_tags(self, id): result = self.conn.get( 'SELECT name FROM tags WHERE id IN (SELECT tag FROM books_tags_link WHERE book=?)', diff --git a/src/calibre/manual/news_recipe.rst b/src/calibre/manual/news_recipe.rst index 8f39cee387..c840cefb53 100644 --- a/src/calibre/manual/news_recipe.rst +++ b/src/calibre/manual/news_recipe.rst @@ -115,7 +115,7 @@ Pre/post processing of downloaded HTML .. automethod:: BasicNewsRecipe.postprocess_html - +.. automethod:: BasicNewsRecipe.populate_article_metadata Convenience methods diff --git a/src/calibre/web/feeds/news.py b/src/calibre/web/feeds/news.py index 9faabb2615..79cec4a2a7 100644 --- a/src/calibre/web/feeds/news.py +++ b/src/calibre/web/feeds/news.py @@ -254,7 +254,7 @@ class BasicNewsRecipe(Recipe): #: will remove everythong from `` to ``. preprocess_regexps = [] - #: The CSS that is used to styles the templates, i.e., the navigation bars and + #: The CSS that is used to style the templates, i.e., the navigation bars and #: the Tables of Contents. Rather than overriding this variable, you should #: use `extra_css` in your recipe to customize look and feel. template_css = u''' @@ -517,6 +517,19 @@ class BasicNewsRecipe(Recipe): ''' raise NotImplementedError + def populate_article_metadata(self, article, soup, first): + ''' + Called when each HTML page belonging to article is downloaded. + Intended to be used to get article metadata like author/summary/etc. + from the parsed HTML (soup). + :param article: A object of class :class:`calibre.web.feeds.Article`. + If you chane the sumamry, remeber to also change the + text_summary + :param soup: Parsed HTML belonging to this article + :param first: True iff the parsed HTML is the first page of the article. + ''' + pass + def postprocess_book(self, oeb, opts, log): ''' Run any needed post processing on the parsed downloaded e-book. @@ -544,6 +557,8 @@ class BasicNewsRecipe(Recipe): self.username = options.username self.password = options.password self.lrf = options.lrf + self.output_profile = options.output_profile + self.touchscreen = getattr(self.output_profile, 'touchscreen', False) self.output_dir = os.path.abspath(self.output_dir) if options.test: @@ -597,7 +612,7 @@ class BasicNewsRecipe(Recipe): if self.delay > 0: self.simultaneous_downloads = 1 - self.navbar = templates.NavBarTemplate() + self.navbar = templates.TouchscreenNavBarTemplate() if self.touchscreen else templates.NavBarTemplate() self.failed_downloads = [] self.partial_failures = [] @@ -638,7 +653,15 @@ class BasicNewsRecipe(Recipe): for base in list(soup.findAll(['base', 'iframe'])): base.extract() - return self.postprocess_html(soup, first_fetch) + ans = self.postprocess_html(soup, first_fetch) + try: + article = self.feed_objects[f].articles[a] + except: + self.log.exception('Failed to get article object for postprocessing') + pass + else: + self.populate_article_metadata(article, ans, first_fetch) + return ans def download(self): @@ -674,7 +697,11 @@ class BasicNewsRecipe(Recipe): def feeds2index(self, feeds): templ = templates.IndexTemplate() css = self.template_css + '\n\n' +(self.extra_css if self.extra_css else '') - return templ.generate(self.title, self.timefmt, feeds, + timefmt = self.timefmt + if self.touchscreen: + templ = templates.TouchscreenIndexTemplate() + timefmt = '%A, %d %b %Y' + return templ.generate(self.title, "mastheadImage.jpg", timefmt, feeds, extra_css=css).render(doctype='xhtml') @classmethod @@ -727,6 +754,44 @@ class BasicNewsRecipe(Recipe): templ = templates.FeedTemplate() css = self.template_css + '\n\n' +(self.extra_css if self.extra_css else '') + + if self.touchscreen: + touchscreen_css = u''' + .summary_headline { + font-size:large; font-weight:bold; margin-top:0px; margin-bottom:0px; + } + + .summary_byline { + font-size:small; margin-top:0px; margin-bottom:0px; + } + + .summary_text { + margin-top:0px; margin-bottom:0px; + } + + .feed { + font-family:sans-serif; font-weight:bold; font-size:larger; + } + + .calibre_navbar { + font-family:monospace; + } + hr { + border-color:gray; + border-style:solid; + border-width:thin; + } + + table.toc { + font-size:large; + } + td.article_count { + text-align:right; + } + ''' + + templ = templates.TouchscreenFeedTemplate() + css = touchscreen_css + '\n\n' + (self.extra_css if self.extra_css else '') return templ.generate(feed, self.description_limiter, extra_css=css).render(doctype='xhtml') @@ -820,6 +885,7 @@ class BasicNewsRecipe(Recipe): if hasattr(feed, 'reverse'): feed.reverse() + self.feed_objects = feeds for f, feed in enumerate(feeds): feed_dir = os.path.join(self.output_dir, 'feed_%d'%f) if not os.path.isdir(feed_dir): @@ -1053,6 +1119,9 @@ class BasicNewsRecipe(Recipe): mi = MetaInformation(self.short_title() + strftime(self.timefmt), [__appname__]) mi.publisher = __appname__ mi.author_sort = __appname__ + if self.output_profile.name == 'iPad': + mi.authors = [strftime('%A, %d %B %Y')] + mi.author_sort = strftime('%Y-%m-%d') mi.publication_type = 'periodical:'+self.publication_type mi.timestamp = nowf() mi.comments = self.description diff --git a/src/calibre/web/feeds/templates.py b/src/calibre/web/feeds/templates.py index 4de7c42daa..af0c8da6b4 100644 --- a/src/calibre/web/feeds/templates.py +++ b/src/calibre/web/feeds/templates.py @@ -5,7 +5,8 @@ __copyright__ = '2008, Kovid Goyal ' from lxml import html, etree from lxml.html.builder import HTML, HEAD, TITLE, STYLE, DIV, BODY, \ - STRONG, BR, H1, SPAN, A, HR, UL, LI, H2, IMG, P as PT + STRONG, BR, H1, SPAN, A, HR, UL, LI, H2, IMG, P as PT, \ + TABLE, TD, TR from calibre import preferred_encoding, strftime, isbytestring @@ -89,12 +90,55 @@ class NavBarTemplate(Template): self.root = HTML(head, BODY(navbar)) +class TouchscreenNavBarTemplate(Template): + def _generate(self, bottom, feed, art, number_of_articles_in_feed, + two_levels, url, __appname__, prefix='', center=True, + extra_css=None, style=None): + head = HEAD(TITLE('navbar')) + if style: + head.append(STYLE(style, type='text/css')) + if extra_css: + head.append(STYLE(extra_css, type='text/css')) + if prefix and not prefix.endswith('/'): + prefix += '/' + align = 'center' if center else 'left' + navbar = DIV(CLASS('calibre_navbar', 'calibre_rescale_100', + style='text-align:'+align)) + if bottom: + navbar.append(HR()) + text = 'This article was downloaded by ' + p = PT(text, STRONG(__appname__), A(url, href=url), style='text-align:left') + p[0].tail = ' from ' + navbar.append(BR()) + navbar.append(BR()) + else: + next = 'feed_%d'%(feed+1) if art == number_of_articles_in_feed - 1 \ + else 'article_%d'%(art+1) + up = '../..' if art == number_of_articles_in_feed - 1 else '..' + href = '%s%s/%s/index.html'%(prefix, up, next) + navbar.text = '| ' + navbar.append(A('Next', href=href)) + href = '%s../index.html#article_%d'%(prefix, art) + navbar.iterchildren(reversed=True).next().tail = ' | ' + navbar.append(A('Section Menu', href=href)) + href = '%s../../index.html#feed_%d'%(prefix, feed) + navbar.iterchildren(reversed=True).next().tail = ' | ' + navbar.append(A('Main Menu', href=href)) + if art > 0 and not bottom: + href = '%s../article_%d/index.html'%(prefix, art-1) + navbar.iterchildren(reversed=True).next().tail = ' | ' + navbar.append(A('Previous', href=href)) + navbar.iterchildren(reversed=True).next().tail = ' | ' + if not bottom: + navbar.append(HR()) + + self.root = HTML(head, BODY(navbar)) class IndexTemplate(Template): - def _generate(self, title, datefmt, feeds, extra_css=None, style=None): + def _generate(self, title, masthead, datefmt, feeds, extra_css=None, style=None): if isinstance(datefmt, unicode): datefmt = datefmt.encode(preferred_encoding) date = strftime(datefmt) @@ -110,12 +154,40 @@ class IndexTemplate(Template): href='feed_%d/index.html'%i)), id='feed_%d'%i) ul.append(li) div = DIV( - H1(title, CLASS('calibre_recipe_title', 'calibre_rescale_180')), + PT(IMG(src=masthead,alt="masthead"),style='text-align:center'), PT(date, style='text-align:right'), ul, CLASS('calibre_rescale_100')) self.root = HTML(head, BODY(div)) +class TouchscreenIndexTemplate(Template): + + def _generate(self, title, masthead, datefmt, feeds, extra_css=None, style=None): + if isinstance(datefmt, unicode): + datefmt = datefmt.encode(preferred_encoding) + date = strftime(datefmt) + masthead_img = IMG(src=masthead,alt="masthead") + head = HEAD(TITLE(title)) + if style: + head.append(STYLE(style, type='text/css')) + if extra_css: + head.append(STYLE(extra_css, type='text/css')) + + toc = TABLE(CLASS('toc'),width="100%",border="0",cellpadding="3px") + for i, feed in enumerate(feeds): + if feed: + tr = TR() + tr.append(TD( CLASS('toc_item'), A(feed.title, href='feed_%d/index.html'%i))) + tr.append(TD( CLASS('article_count'),'%d' % len(feed.articles))) + toc.append(tr) + + div = DIV( + PT(masthead_img,style='text-align:center'), + PT(date, style='text-align:center'), + toc, + CLASS('calibre_rescale_100')) + self.root = HTML(head, BODY(div)) + class FeedTemplate(Template): def _generate(self, feed, cutoff, extra_css=None, style=None): @@ -166,6 +238,56 @@ class FeedTemplate(Template): self.root = HTML(head, body) +class TouchscreenFeedTemplate(Template): + + def _generate(self, feed, cutoff, extra_css=None, style=None): + head = HEAD(TITLE(feed.title)) + if style: + head.append(STYLE(style, type='text/css')) + if extra_css: + head.append(STYLE(extra_css, type='text/css')) + body = BODY(style='page-break-before:always') + div = DIV( + H2(feed.title, + CLASS('calibre_feed_title', 'calibre_rescale_160')), + CLASS('calibre_rescale_100') + ) + body.append(div) + if getattr(feed, 'image', None): + div.append(DIV(IMG( + alt = feed.image_alt if feed.image_alt else '', + src = feed.image_url + ), + CLASS('calibre_feed_image'))) + if getattr(feed, 'description', None): + d = DIV(feed.description, CLASS('calibre_feed_description', + 'calibre_rescale_80')) + d.append(BR()) + div.append(d) + + toc = TABLE(CLASS('toc'),width="100%",border="0",cellpadding="3px") + for i, article in enumerate(feed.articles): + if not getattr(article, 'downloaded', False): + continue + tr = TR() + td = TD( + A(article.title, CLASS('article calibre_rescale_100', + href=article.url)) + ) + if article.summary: + td.append(DIV(cutoff(article.text_summary), + CLASS('article_description', 'calibre_rescale_80'))) + tr.append(td) + toc.append(tr) + div.append(toc) + + navbar = DIV('| ', CLASS('calibre_navbar', 'calibre_rescale_100'),style='text-align:center') + link = A('Up one level', href="../index.html") + link.tail = ' |' + navbar.append(link) + div.append(navbar) + + self.root = HTML(head, body) class EmbeddedContent(Template): diff --git a/src/calibre/web/fetch/simple.py b/src/calibre/web/fetch/simple.py index 93fb516f2d..c1f0d912d6 100644 --- a/src/calibre/web/fetch/simple.py +++ b/src/calibre/web/fetch/simple.py @@ -328,6 +328,9 @@ class RecursiveFetcher(object): continue try: data = self.fetch_url(iurl) + if data == 'GIF89a\x01': + # Skip empty GIF files as PIL errors on them anyway + continue except Exception: self.log.exception('Could not fetch image %s'% iurl) continue