diff --git a/resources/images/marked-text.png b/resources/images/marked-text.png new file mode 100644 index 0000000000..cf25d4528d Binary files /dev/null and b/resources/images/marked-text.png differ diff --git a/src/calibre/db/view.py b/src/calibre/db/view.py index 7b38abb13b..fd1287c15b 100644 --- a/src/calibre/db/view.py +++ b/src/calibre/db/view.py @@ -370,8 +370,9 @@ class View: ''' old_marked_ids = set(self.marked_ids) if not hasattr(id_dict, 'items'): - # Simple list. Make it a dict of string 'true' - self.marked_ids = dict.fromkeys(id_dict, 'true') + # Simple list. Make it a dict entry of string 'true' + self.marked_ids = {k: (self.marked_ids[k] if k in self.marked_ids else 'true') + for k in id_dict} else: # Ensure that all the items in the dict are text self.marked_ids = {k: str(v) for k, v in iteritems(id_dict)} diff --git a/src/calibre/gui2/actions/mark_books.py b/src/calibre/gui2/actions/mark_books.py index b65538d60b..142f562a66 100644 --- a/src/calibre/gui2/actions/mark_books.py +++ b/src/calibre/gui2/actions/mark_books.py @@ -4,12 +4,81 @@ __license__ = 'GPL v3' __copyright__ = '2013, Kovid Goyal ' +from collections import defaultdict from functools import partial -from qt.core import QTimer, QApplication, Qt, QEvent +from qt.core import (QTimer, QApplication, Qt, QEvent, QDialog, QMenu, QIcon, + QDialogButtonBox, QPushButton, QLabel, QGridLayout) from calibre.gui2 import error_dialog from calibre.gui2.actions import InterfaceAction +from calibre.gui2.widgets2 import HistoryComboBox +from calibre.utils.icu import sort_key + + +class MyHistoryComboBox(HistoryComboBox): + # This is here so we can change the following two class variables without + # affecting other users of the HistoryComboBox class + max_history_items = 10 + min_history_entry_length = 1 + + +class MarkWithTextDialog(QDialog): + + def __init__(self, gui): + QDialog.__init__(self, parent=gui) + self.gui = gui + self.setWindowTitle(_('Mark books with text label')) + layout = QGridLayout() + layout.setColumnStretch(1, 10) + self.setLayout(layout) + + self.text_box = textbox = MyHistoryComboBox() + textbox.initialize('mark_with_text') + + history = textbox.all_items + button_rows = min(4, len(history)) + for i in range(0, button_rows): + if i == 0: + layout.addWidget(QLabel(_('Recently used values:')), 0, 0, 1, 2) + button = QPushButton() + text = history[i] + button.setText(text) + button.clicked.connect(partial(self.button_pushed, text=text)) + row = i + 1 + layout.addWidget(button, row, 1) + label = QLabel('&' + str(row)) + label.setBuddy(button) + layout.addWidget(label, row, 0) + if button_rows > 0: + layout.addWidget(QLabel(_('Enter a value:')), button_rows+1, 0, 1, 2) + label = QLabel('&' + str(button_rows+1)) + else: + label = QLabel('') + label.setBuddy(textbox) + layout.addWidget(label, button_rows+2, 0, 1, 1) + layout.addWidget(textbox, button_rows+2, 1) + textbox.setFocus() + button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | + QDialogButtonBox.StandardButton.Cancel) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box, button_rows+3, 0, 1, 2) + + def text(self): + return self.text_box.text() + + def button_pushed(self, checked, text=''): + self.text_box.setText(text) + self.text_box.save_history() + self.accept() + + def accept(self): + if not self.text_box.text(): + d = error_dialog(self.gui, _('Value cannot be empty'), _('You must provide a value')) + d.exec_() + else: + super().accept() class MarkBooksAction(InterfaceAction): @@ -49,12 +118,18 @@ class MarkBooksAction(InterfaceAction): self.toggle_ids(book_ids) def genesis(self): + self.search_icon = QIcon.ic('search.png') self.qaction.triggered.connect(self.toggle_selected) self.menu = m = self.qaction.menu() m.aboutToShow.connect(self.about_to_show_menu) ma = partial(self.create_menu_action, m) + self.show_marked_action = a = ma('mark_with_text', _('Mark books with text label'), icon='marked-text.png') + a.triggered.connect(self.mark_with_text) self.show_marked_action = a = ma('show-marked', _('Show marked books'), icon='search.png', shortcut='Shift+Ctrl+M') a.triggered.connect(self.show_marked) + self.show_marked_with_text = QMenu(_('Show marked books with text label')) + self.show_marked_with_text.setIcon(self.search_icon) + m.addMenu(self.show_marked_with_text) self.clear_marked_action = a = ma('clear-all-marked', _('Clear all marked books'), icon='clear_left.png') a.triggered.connect(self.clear_all_marked) m.addSeparator() @@ -86,9 +161,22 @@ class MarkBooksAction(InterfaceAction): def about_to_show_menu(self): db = self.gui.current_db - num = len(frozenset(db.data.marked_ids).intersection(db.new_api.all_book_ids())) + marked_ids = db.data.marked_ids + num = len(frozenset(marked_ids).intersection(db.new_api.all_book_ids())) text = _('Show marked book') if num == 1 else (_('Show marked books') + (' (%d)' % num)) self.show_marked_action.setText(text) + counts = dict() + for v in marked_ids.values(): + counts[v] = counts.get(v, 0) + 1 + labels = sorted(counts.keys(), key=sort_key) + self.show_marked_with_text.clear() + if len(labels): + self.show_marked_with_text.setEnabled(True) + for t in labels: + ac = self.show_marked_with_text.addAction(self.search_icon, f'{t} ({counts[t]})') + ac.triggered.connect(partial(self.show_marked_text, txt=t)) + else: + self.show_marked_with_text.setEnabled(False) def location_selected(self, loc): enabled = loc == 'library' @@ -116,6 +204,9 @@ class MarkBooksAction(InterfaceAction): def show_marked(self): self.gui.search.set_search_string('marked:true') + def show_marked_text(self, txt=None): + self.gui.search.set_search_string(f'marked:"={txt}"') + def clear_all_marked(self): self.gui.current_db.data.set_marked_ids(()) if str(self.gui.search.text()).startswith('marked:'): @@ -139,3 +230,18 @@ class MarkBooksAction(InterfaceAction): else: mids.pop(book_id, None) db.data.set_marked_ids(mids) + + def mark_with_text(self): + book_ids = self._get_selected_ids() + if not book_ids: + return + dialog = MarkWithTextDialog(self.gui) + if dialog.exec_() != QDialog.DialogCode.Accepted: + return + txt = dialog.text() + txt = txt if txt else 'true' + db = self.gui.current_db + mids = db.data.marked_ids.copy() + for book_id in book_ids: + mids[book_id] = txt + db.data.set_marked_ids(mids) diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index 22e512ea47..325f11926c 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -232,6 +232,7 @@ class BooksModel(QAbstractTableModel): # {{{ # remember that the cover grid view needs a larger version of the icon, # anyway) self.marked_icon = QIcon(I('marked.png')) + self.marked_text_icon = QIcon(I('marked-text.png')) self.bool_blank_icon_as_icon = QIcon(self.bool_blank_icon) self.row_decoration = None self.device_connected = False @@ -1072,7 +1073,12 @@ class BooksModel(QAbstractTableModel): # {{{ return (section+1) if role == Qt.ItemDataRole.DecorationRole: try: - return self.marked_icon if self.db.data.get_marked(self.db.data.index_to_id(section)) else self.row_decoration + m = self.db.data.get_marked(self.db.data.index_to_id(section)) + if m: + i = self.marked_icon if m == 'true' else self.marked_text_icon + else: + i = self.row_decoration + return i except (ValueError, IndexError): pass return None diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py index e2a67929cf..5253c61acd 100644 --- a/src/calibre/gui2/library/views.py +++ b/src/calibre/gui2/library/views.py @@ -946,6 +946,8 @@ class BooksView(QTableView): # {{{ # This is needed otherwise Qt does not always update the # viewport correctly. See https://bugs.launchpad.net/bugs/1404697 self.row_header.viewport().update() + # refresh the rows because there might be a composite that uses marked_books() + self.model().refresh_rows(changed) else: # Marked items have either appeared or all been removed self.model().set_row_decoration(current_marked)