%PDF- %PDF-
Direktori : /lib/calibre/calibre/gui2/ |
Current File : //lib/calibre/calibre/gui2/custom_column_widgets.py |
#!/usr/bin/env python3 __license__ = 'GPL v3' __copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>' __docformat__ = 'restructuredtext en' import os from collections import OrderedDict from functools import partial from qt.core import (Qt, QComboBox, QLabel, QSpinBox, QDoubleSpinBox, QDateTime, QGroupBox, QVBoxLayout, QSizePolicy, QGridLayout, QUrl, QSpacerItem, QIcon, QCheckBox, QWidget, QHBoxLayout, QLineEdit, QMessageBox, QToolButton, QPlainTextEdit, QApplication, QStyle, QDialog) from calibre.utils.date import qt_to_dt, now, as_local_time, as_utc, internal_iso_format_string from calibre.gui2.complete2 import EditWithComplete as EWC from calibre.gui2.comments_editor import Editor as CommentsEditor from calibre.gui2 import UNDEFINED_QDATETIME, error_dialog, elided_text, gprefs from calibre.gui2.dialogs.tag_editor import TagEditor from calibre.utils.config import tweaks from calibre.utils.icu import sort_key from calibre.library.comments import comments_to_html from calibre.gui2.library.delegates import ClearingDoubleSpinBox, ClearingSpinBox from calibre.gui2.widgets2 import RatingEditor, DateTimeEdit as DateTimeEditBase class EditWithComplete(EWC): def __init__(self, *a, **kw): super().__init__(*a, **kw) self.set_clear_button_enabled(False) def safe_disconnect(signal): try: signal.disconnect() except Exception: pass def label_string(txt): if txt: try: if txt[0].isalnum(): return '&' + txt except: pass return txt def get_tooltip(col_metadata, add_index=False): key = col_metadata['label'] + ('_index' if add_index else '') label = col_metadata['name'] + (_(' index') if add_index else '') description = col_metadata.get('display', {}).get('description', '') return '{} (#{}){} {}'.format( label, key, ':' if description else '', description).strip() class Base: def __init__(self, db, col_id, parent=None): self.db, self.col_id = db, col_id self.book_id = None self.col_metadata = db.custom_column_num_map[col_id] self.initial_val = self.widgets = None self.signals_to_disconnect = [] self.setup_ui(parent) description = get_tooltip(self.col_metadata) try: self.widgets[0].setToolTip(description) self.widgets[1].setToolTip(description) except: try: self.widgets[1].setToolTip(description) except: pass def finish_ui_setup(self, parent, edit_widget): self.was_none = False w = QWidget(parent) self.widgets.append(w) l = QHBoxLayout() l.setContentsMargins(0, 0, 0, 0) w.setLayout(l) self.editor = editor = edit_widget(parent) l.addWidget(editor) self.clear_button = QToolButton(parent) self.clear_button.setIcon(QIcon(I('trash.png'))) self.clear_button.clicked.connect(self.set_to_undefined) self.clear_button.setToolTip(_('Clear {0}').format(self.col_metadata['name'])) l.addWidget(self.clear_button) def initialize(self, book_id): self.book_id = book_id val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True) val = self.normalize_db_val(val) self.setter(val) self.initial_val = self.current_val # self.current_val might be different from val thanks to normalization @property def current_val(self): return self.normalize_ui_val(self.gui_val) @property def gui_val(self): return self.getter() def commit(self, book_id, notify=False): val = self.current_val if val != self.initial_val: return self.db.set_custom(book_id, val, num=self.col_id, notify=notify, commit=False, allow_case_change=True) else: return set() def apply_to_metadata(self, mi): mi.set('#' + self.col_metadata['label'], self.current_val) def normalize_db_val(self, val): return val def normalize_ui_val(self, val): return val def break_cycles(self): self.db = self.widgets = self.initial_val = None for signal in self.signals_to_disconnect: safe_disconnect(signal) self.signals_to_disconnect = [] def connect_data_changed(self, slot): pass class SimpleText(Base): def setup_ui(self, parent): self.editor = QLineEdit(parent) self.widgets = [QLabel(label_string(self.col_metadata['name']), parent), self.editor] self.editor.setClearButtonEnabled(True) def setter(self, val): self.editor.setText(str(val or '')) def getter(self): return self.editor.text().strip() def connect_data_changed(self, slot): self.editor.textChanged.connect(slot) self.signals_to_disconnect.append(self.editor.textChanged) class LongText(Base): def setup_ui(self, parent): self._box = QGroupBox(parent) self._box.setTitle(label_string(self.col_metadata['name'])) self._layout = QVBoxLayout() self._tb = QPlainTextEdit(self._box) self._tb.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) self._layout.addWidget(self._tb) self._box.setLayout(self._layout) self.widgets = [self._box] def setter(self, val): self._tb.setPlainText(str(val or '')) def getter(self): return self._tb.toPlainText() def connect_data_changed(self, slot): self._tb.textChanged.connect(slot) self.signals_to_disconnect.append(self._tb.textChanged) class Bool(Base): def setup_ui(self, parent): name = self.col_metadata['name'] self.widgets = [QLabel(label_string(name), parent)] w = QWidget(parent) self.widgets.append(w) l = QHBoxLayout() l.setContentsMargins(0, 0, 0, 0) w.setLayout(l) self.combobox = QComboBox(parent) l.addWidget(self.combobox) c = QToolButton(parent) c.setIcon(QIcon(I('ok.png'))) c.setToolTip(_('Set {} to yes').format(name)) l.addWidget(c) c.clicked.connect(self.set_to_yes) c = QToolButton(parent) c.setIcon(QIcon(I('list_remove.png'))) c.setToolTip(_('Set {} to no').format(name)) l.addWidget(c) c.clicked.connect(self.set_to_no) if self.db.new_api.pref('bools_are_tristate'): c = QToolButton(parent) c.setIcon(QIcon(I('trash.png'))) c.setToolTip(_('Clear {}').format(name)) l.addWidget(c) c.clicked.connect(self.set_to_cleared) w = self.combobox items = [_('Yes'), _('No'), _('Undefined')] icons = [I('ok.png'), I('list_remove.png'), I('blank.png')] if not self.db.new_api.pref('bools_are_tristate'): items = items[:-1] icons = icons[:-1] for icon, text in zip(icons, items): w.addItem(QIcon(icon), text) def setter(self, val): val = {None: 2, False: 1, True: 0}[val] if not self.db.new_api.pref('bools_are_tristate') and val == 2: val = 1 self.combobox.setCurrentIndex(val) def getter(self): val = self.combobox.currentIndex() return {2: None, 1: False, 0: True}[val] def set_to_yes(self): self.combobox.setCurrentIndex(0) def set_to_no(self): self.combobox.setCurrentIndex(1) def set_to_cleared(self): self.combobox.setCurrentIndex(2) def connect_data_changed(self, slot): self.combobox.currentTextChanged.connect(slot) self.signals_to_disconnect.append(self.combobox.currentTextChanged) class Int(Base): def setup_ui(self, parent): self.widgets = [QLabel(label_string(self.col_metadata['name']), parent)] self.finish_ui_setup(parent, ClearingSpinBox) self.editor.setRange(-1000000, 100000000) def finish_ui_setup(self, parent, edit_widget): Base.finish_ui_setup(self, parent, edit_widget) self.editor.setSpecialValueText(_('Undefined')) self.editor.setSingleStep(1) self.editor.valueChanged.connect(self.valueChanged) def setter(self, val): if val is None: val = self.editor.minimum() self.editor.setValue(val) self.was_none = val == self.editor.minimum() def getter(self): val = self.editor.value() if val == self.editor.minimum(): val = None return val def valueChanged(self, to_what): if self.was_none and to_what == -999999: self.setter(0) self.was_none = to_what == self.editor.minimum() def connect_data_changed(self, slot): self.editor.valueChanged.connect(slot) self.signals_to_disconnect.append(self.editor.valueChanged) def set_to_undefined(self): self.editor.setValue(-1000000) class Float(Int): def setup_ui(self, parent): self.widgets = [QLabel(label_string(self.col_metadata['name']), parent)] self.finish_ui_setup(parent, ClearingDoubleSpinBox) self.editor.setRange(-1000000., float(100000000)) self.editor.setDecimals(2) class Rating(Base): def setup_ui(self, parent): allow_half_stars = self.col_metadata['display'].get('allow_half_stars', False) self.widgets = [QLabel(label_string(self.col_metadata['name']), parent)] self.finish_ui_setup(parent, partial(RatingEditor, is_half_star=allow_half_stars)) def set_to_undefined(self): self.editor.setCurrentIndex(0) def setter(self, val): val = max(0, min(int(val or 0), 10)) self.editor.rating_value = val def getter(self): return self.editor.rating_value or None def connect_data_changed(self, slot): self.editor.currentTextChanged.connect(slot) self.signals_to_disconnect.append(self.editor.currentTextChanged) class DateTimeEdit(DateTimeEditBase): def focusInEvent(self, x): self.setSpecialValueText('') DateTimeEditBase.focusInEvent(self, x) def focusOutEvent(self, x): self.setSpecialValueText(_('Undefined')) DateTimeEditBase.focusOutEvent(self, x) def set_to_today(self): self.setDateTime(now()) def set_to_clear(self): self.setDateTime(now()) self.setDateTime(UNDEFINED_QDATETIME) class DateTime(Base): def setup_ui(self, parent): cm = self.col_metadata self.widgets = [QLabel(label_string(cm['name']), parent)] w = QWidget(parent) self.widgets.append(w) l = QHBoxLayout() l.setContentsMargins(0, 0, 0, 0) w.setLayout(l) self.dte = dte = DateTimeEdit(parent) format_ = cm['display'].get('date_format','') if not format_: format_ = 'dd MMM yyyy hh:mm' elif format_ == 'iso': format_ = internal_iso_format_string() dte.setDisplayFormat(format_) dte.setCalendarPopup(True) dte.setMinimumDateTime(UNDEFINED_QDATETIME) dte.setSpecialValueText(_('Undefined')) l.addWidget(dte) self.today_button = QToolButton(parent) self.today_button.setText(_('Today')) self.today_button.clicked.connect(dte.set_to_today) l.addWidget(self.today_button) self.clear_button = QToolButton(parent) self.clear_button.setIcon(QIcon(I('trash.png'))) self.clear_button.clicked.connect(dte.set_to_clear) self.clear_button.setToolTip(_('Clear {0}').format(self.col_metadata['name'])) l.addWidget(self.clear_button) def setter(self, val): if val is None: val = self.dte.minimumDateTime() else: val = QDateTime(val) self.dte.setDateTime(val) def getter(self): val = self.dte.dateTime() if val <= UNDEFINED_QDATETIME: val = None else: val = qt_to_dt(val) return val def normalize_db_val(self, val): return as_local_time(val) if val is not None else None def normalize_ui_val(self, val): return as_utc(val) if val is not None else None def connect_data_changed(self, slot): self.dte.dateTimeChanged.connect(slot) self.signals_to_disconnect.append(self.dte.dateTimeChanged) class Comments(Base): def setup_ui(self, parent): self._box = QGroupBox(parent) self._box.setTitle(label_string(self.col_metadata['name'])) self._layout = QVBoxLayout() self._tb = CommentsEditor(self._box, toolbar_prefs_name='metadata-comments-editor-widget-hidden-toolbars') self._tb.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) # self._tb.setTabChangesFocus(True) self._layout.addWidget(self._tb) self._box.setLayout(self._layout) self.widgets = [self._box] def initialize(self, book_id): path = self.db.abspath(book_id, index_is_id=True) if path: self._tb.set_base_url(QUrl.fromLocalFile(os.path.join(path, 'metadata.html'))) return Base.initialize(self, book_id) def setter(self, val): if not val or not val.strip(): val = '' else: val = comments_to_html(val) self._tb.html = val self._tb.wyswyg_dirtied() def getter(self): val = str(self._tb.html).strip() if not val: val = None return val @property def tab(self): return self._tb.tab @tab.setter def tab(self, val): self._tb.tab = val def connect_data_changed(self, slot): self._tb.data_changed.connect(slot) self.signals_to_disconnect.append(self._tb.data_changed) class MultipleWidget(QWidget): def __init__(self, parent): QWidget.__init__(self, parent) layout = QHBoxLayout() layout.setSpacing(5) layout.setContentsMargins(0, 0, 0, 0) self.tags_box = EditWithComplete(parent) layout.addWidget(self.tags_box, stretch=1000) self.editor_button = QToolButton(self) self.editor_button.setToolTip(_('Open Item editor. If CTRL or SHIFT is pressed, open Manage items')) self.editor_button.setIcon(QIcon(I('chapters.png'))) layout.addWidget(self.editor_button) self.setLayout(layout) def get_editor_button(self): return self.editor_button def update_items_cache(self, values): self.tags_box.update_items_cache(values) def clear(self): self.tags_box.clear() def setEditText(self): self.tags_box.setEditText() def addItem(self, itm): self.tags_box.addItem(itm) def set_separator(self, sep): self.tags_box.set_separator(sep) def set_add_separator(self, sep): self.tags_box.set_add_separator(sep) def set_space_before_sep(self, v): self.tags_box.set_space_before_sep(v) def setSizePolicy(self, v1, v2): self.tags_box.setSizePolicy(v1, v2) def setText(self, v): self.tags_box.setText(v) def text(self): return self.tags_box.text() def _save_dialog(parent, title, msg, det_msg=''): d = QMessageBox(parent) d.setWindowTitle(title) d.setText(msg) d.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel) return d.exec() class Text(Base): def setup_ui(self, parent): self.sep = self.col_metadata['multiple_seps'] self.key = self.db.field_metadata.label_to_key(self.col_metadata['label'], prefer_custom=True) self.parent = parent if self.col_metadata['is_multiple']: w = MultipleWidget(parent) w.set_separator(self.sep['ui_to_list']) if self.sep['ui_to_list'] == '&': w.set_space_before_sep(True) w.set_add_separator(tweaks['authors_completer_append_separator']) w.get_editor_button().clicked.connect(self.edit) w.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) self.set_to_undefined = w.clear else: w = EditWithComplete(parent) w.set_separator(None) w.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) w.setMinimumContentsLength(25) self.set_to_undefined = w.clearEditText self.widgets = [QLabel(label_string(self.col_metadata['name']), parent)] self.finish_ui_setup(parent, lambda parent: w) def initialize(self, book_id): values = list(self.db.all_custom(num=self.col_id)) values.sort(key=sort_key) self.book_id = book_id self.editor.clear() self.editor.update_items_cache(values) val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True) if isinstance(val, list): if not self.col_metadata.get('display', {}).get('is_names', False): val.sort(key=sort_key) val = self.normalize_db_val(val) if self.col_metadata['is_multiple']: self.setter(val) else: self.editor.setText(val) self.initial_val = self.current_val def setter(self, val): if self.col_metadata['is_multiple']: if not val: val = [] self.editor.setText(self.sep['list_to_ui'].join(val)) def getter(self): if self.col_metadata['is_multiple']: val = str(self.editor.text()).strip() ans = [x.strip() for x in val.split(self.sep['ui_to_list']) if x.strip()] if not ans: ans = None return ans val = str(self.editor.currentText()).strip() if not val: val = None return val def edit(self): ctrl_or_shift_pressed = (QApplication.keyboardModifiers() & (Qt.KeyboardModifier.ControlModifier + Qt.KeyboardModifier.ShiftModifier)) if (self.getter() != self.initial_val and (self.getter() or self.initial_val)): d = _save_dialog(self.parent, _('Values changed'), _('You have changed the values. In order to use this ' 'editor, you must either discard or apply these ' 'changes. Apply changes?')) if d == QMessageBox.StandardButton.Cancel: return if d == QMessageBox.StandardButton.Yes: self.commit(self.book_id) self.db.commit() self.initial_val = self.current_val else: self.setter(self.initial_val) if ctrl_or_shift_pressed: from calibre.gui2.ui import get_gui get_gui().do_tags_list_edit(None, self.key) self.initialize(self.book_id) else: d = TagEditor(self.parent, self.db, self.book_id, self.key) if d.exec() == QDialog.DialogCode.Accepted: self.setter(d.tags) def connect_data_changed(self, slot): if self.col_metadata['is_multiple']: s = self.editor.tags_box.currentTextChanged else: s = self.editor.currentTextChanged s.connect(slot) self.signals_to_disconnect.append(s) class Series(Base): def setup_ui(self, parent): w = EditWithComplete(parent) w.set_separator(None) w.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) w.setMinimumContentsLength(25) self.name_widget = w self.widgets = [QLabel(label_string(self.col_metadata['name']), parent)] self.finish_ui_setup(parent, lambda parent: w) w.editTextChanged.connect(self.series_changed) w = QLabel(label_string(self.col_metadata['name'])+_(' index'), parent) w.setToolTip(get_tooltip(self.col_metadata, add_index=True)) self.widgets.append(w) w = QDoubleSpinBox(parent) w.setRange(-10000., float(100000000)) w.setDecimals(2) w.setSingleStep(1) self.idx_widget=w w.setToolTip(get_tooltip(self.col_metadata, add_index=True)) self.widgets.append(w) def set_to_undefined(self): self.name_widget.clearEditText() self.idx_widget.setValue(1.0) def initialize(self, book_id): values = list(self.db.all_custom(num=self.col_id)) values.sort(key=sort_key) val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True) s_index = self.db.get_custom_extra(book_id, num=self.col_id, index_is_id=True) try: s_index = float(s_index) except (ValueError, TypeError): s_index = 1.0 self.idx_widget.setValue(s_index) val = self.normalize_db_val(val) self.name_widget.blockSignals(True) self.name_widget.update_items_cache(values) self.name_widget.setText(val) self.name_widget.blockSignals(False) self.initial_val, self.initial_index = self.current_val def getter(self): n = str(self.name_widget.currentText()).strip() i = self.idx_widget.value() return n, i def series_changed(self, val): val, s_index = self.gui_val if tweaks['series_index_auto_increment'] == 'no_change': pass elif tweaks['series_index_auto_increment'] == 'const': s_index = 1.0 else: s_index = self.db.get_next_cc_series_num_for(val, num=self.col_id) self.idx_widget.setValue(s_index) @property def current_val(self): val, s_index = self.gui_val val = self.normalize_ui_val(val) return val, s_index def commit(self, book_id, notify=False): val, s_index = self.current_val if val != self.initial_val or s_index != self.initial_index: if not val: val = s_index = None return self.db.set_custom(book_id, val, extra=s_index, num=self.col_id, notify=notify, commit=False, allow_case_change=True) else: return set() def apply_to_metadata(self, mi): val, s_index = self.current_val mi.set('#' + self.col_metadata['label'], val, extra=s_index) def connect_data_changed(self, slot): for s in self.name_widget.editTextChanged, self.idx_widget.valueChanged: s.connect(slot) self.signals_to_disconnect.append(s) class Enumeration(Base): def setup_ui(self, parent): self.parent = parent self.widgets = [QLabel(label_string(self.col_metadata['name']), parent)] self.finish_ui_setup(parent, QComboBox) vals = self.col_metadata['display']['enum_values'] self.editor.addItem('') for v in vals: self.editor.addItem(v) def initialize(self, book_id): val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True) val = self.normalize_db_val(val) idx = self.editor.findText(val) if idx < 0: error_dialog(self.parent, '', _('The enumeration "{0}" contains an invalid value ' 'that will be set to the default').format( self.col_metadata['name']), show=True, show_copy_button=False) idx = 0 self.editor.setCurrentIndex(idx) self.initial_val = self.current_val def setter(self, val): self.editor.setCurrentIndex(self.editor.findText(val)) def getter(self): return str(self.editor.currentText()) def normalize_db_val(self, val): if val is None: val = '' return val def normalize_ui_val(self, val): if not val: val = None return val def set_to_undefined(self): self.editor.setCurrentIndex(0) def connect_data_changed(self, slot): self.editor.currentIndexChanged.connect(slot) self.signals_to_disconnect.append(self.editor.currentIndexChanged) def comments_factory(db, key, parent): fm = db.custom_column_num_map[key] ctype = fm.get('display', {}).get('interpret_as', 'html') if ctype == 'short-text': return SimpleText(db, key, parent) if ctype in ('long-text', 'markdown'): return LongText(db, key, parent) return Comments(db, key, parent) widgets = { 'bool' : Bool, 'rating' : Rating, 'int': Int, 'float': Float, 'datetime': DateTime, 'text' : Text, 'comments': comments_factory, 'series': Series, 'enumeration': Enumeration } def field_sort_key(y, fm=None): m1 = fm[y] name = icu_lower(m1['name']) n1 = 'zzzzz' + name if column_is_comments(y, fm) else name return sort_key(n1) def column_is_comments(key, fm): return (fm[key]['datatype'] == 'comments' and fm[key].get('display', {}).get('interpret_as') != 'short-text') def get_field_list(db, use_defaults=False): fm = db.field_metadata fields = fm.custom_field_keys(include_composites=False) displayable = db.prefs.get('edit_metadata_custom_columns_to_display', None) if use_defaults or displayable is None: fields.sort(key=partial(field_sort_key, fm=fm)) return [(k, True) for k in fields] else: field_set = set(fields) result = OrderedDict({k:v for k,v in displayable if k in field_set}) for k in fields: if k not in result: result[k] = True return [(k,v) for k,v in result.items()] def populate_metadata_page(layout, db, book_id, bulk=False, two_column=False, parent=None): def widget_factory(typ, key): if bulk: w = bulk_widgets[typ](db, key, parent) else: w = widgets[typ](db, key, parent) if book_id is not None: w.initialize(book_id) return w fm = db.field_metadata # Get list of all non-composite custom fields. We must make widgets for these cols = [k[0] for k in get_field_list(db, use_defaults=db.prefs['edit_metadata_ignore_display_order']) if k[1]] # This deals with the historical behavior where comments fields go to the # bottom, starting on the left hand side. If a comment field is moved to # somewhere else then it isn't moved to either side. comments_at_end = 0 for k in cols[::-1]: if not column_is_comments(k, fm): break comments_at_end += 1 comments_not_at_end = len([k for k in cols if column_is_comments(k, fm)]) - comments_at_end count = len(cols) layout_rows_for_comments = 9 if two_column: turnover_point = int(((count - comments_at_end + 1) + int(comments_not_at_end*(layout_rows_for_comments-1)))/2) else: # Avoid problems with multi-line widgets turnover_point = count + 1000 ans = [] column = row = base_row = max_row = 0 label_width = 0 do_elision = gprefs['edit_metadata_elide_labels'] elide_pos = gprefs['edit_metadata_elision_point'] elide_pos = elide_pos if elide_pos in {'left', 'middle', 'right'} else 'right' # make room on the right side for the scrollbar sb_width = QApplication.instance().style().pixelMetric(QStyle.PixelMetric.PM_ScrollBarExtent) layout.setContentsMargins(0, 0, sb_width, 0) for key in cols: if not fm[key]['is_editable']: continue # The job spy plugin can change is_editable dt = fm[key]['datatype'] if dt == 'composite' or (bulk and dt == 'comments'): continue is_comments = column_is_comments(key, fm) w = widget_factory(dt, fm[key]['colnum']) ans.append(w) if two_column and is_comments: # Here for compatibility with old layout. Comments always started # in the left column comments_not_at_end -= 1 # no special processing if the comment field was named in the tweak if comments_not_at_end < 0 and comments_at_end > 0: # Force a turnover, adding comments widgets below max_row. # Save the row to return to if we turn over again column = 0 row = max_row base_row = row turnover_point = row + int((comments_at_end * layout_rows_for_comments)/2) comments_at_end = 0 l = QGridLayout() if is_comments: layout.addLayout(l, row, column, layout_rows_for_comments, 1) layout.setColumnStretch(column, 100) row += layout_rows_for_comments else: layout.addLayout(l, row, column, 1, 1) layout.setColumnStretch(column, 100) row += 1 for c in range(0, len(w.widgets), 2): if not is_comments: # Set the label column width to a fixed size. Elide labels that # don't fit wij = w.widgets[c] if label_width == 0: font_metrics = wij.fontMetrics() colon_width = font_metrics.width(':') if bulk: label_width = (font_metrics.averageCharWidth() * gprefs['edit_metadata_bulk_cc_label_length']) - colon_width else: label_width = (font_metrics.averageCharWidth() * gprefs['edit_metadata_single_cc_label_length']) - colon_width wij.setMaximumWidth(label_width) if c == 0: wij.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Preferred) l.setColumnMinimumWidth(0, label_width) wij.setAlignment(Qt.AlignmentFlag.AlignRight|Qt.AlignmentFlag.AlignVCenter) t = str(wij.text()) if t: if do_elision: wij.setText(elided_text(t, font=font_metrics, width=label_width, pos=elide_pos) + ':') else: wij.setText(t + ':') wij.setWordWrap(True) wij.setBuddy(w.widgets[c+1]) l.addWidget(wij, c, 0) l.addWidget(w.widgets[c+1], c, 1) else: l.addWidget(w.widgets[0], 0, 0, 1, 2) max_row = max(max_row, row) if row >= turnover_point: column = 1 turnover_point = count + 1000 row = base_row items = [] if len(ans) > 0: items.append(QSpacerItem(10, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)) layout.addItem(items[-1], layout.rowCount(), 0, 1, 1) layout.setRowStretch(layout.rowCount()-1, 100) return ans, items class BulkBase(Base): @property def gui_val(self): if not hasattr(self, '_cached_gui_val_'): self._cached_gui_val_ = self.getter() return self._cached_gui_val_ def get_initial_value(self, book_ids): values = set() for book_id in book_ids: val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True) if isinstance(val, list): val = frozenset(val) values.add(val) if len(values) > 1: break ans = None if len(values) == 1: ans = next(iter(values)) if isinstance(ans, frozenset): ans = list(ans) return ans def finish_ui_setup(self, parent, is_bool=False, add_edit_tags_button=(False,)): self.was_none = False l = self.widgets[1].layout() if not is_bool or self.bools_are_tristate: self.clear_button = QToolButton(parent) self.clear_button.setIcon(QIcon(I('trash.png'))) self.clear_button.setToolTip(_('Clear {0}').format(self.col_metadata['name'])) self.clear_button.clicked.connect(self.set_to_undefined) l.insertWidget(1, self.clear_button) if is_bool: self.set_no_button = QToolButton(parent) self.set_no_button.setIcon(QIcon(I('list_remove.png'))) self.set_no_button.clicked.connect(lambda:self.main_widget.setCurrentIndex(1)) self.set_no_button.setToolTip(_('Set {0} to No').format(self.col_metadata['name'])) l.insertWidget(1, self.set_no_button) self.set_yes_button = QToolButton(parent) self.set_yes_button.setIcon(QIcon(I('ok.png'))) self.set_yes_button.clicked.connect(lambda:self.main_widget.setCurrentIndex(0)) self.set_yes_button.setToolTip(_('Set {0} to Yes').format(self.col_metadata['name'])) l.insertWidget(1, self.set_yes_button) if add_edit_tags_button[0]: self.edit_tags_button = QToolButton(parent) self.edit_tags_button.setToolTip(_('Open Item editor')) self.edit_tags_button.setIcon(QIcon(I('chapters.png'))) self.edit_tags_button.clicked.connect(add_edit_tags_button[1]) l.insertWidget(1, self.edit_tags_button) l.insertStretch(2) def initialize(self, book_ids): self.initial_val = val = self.get_initial_value(book_ids) val = self.normalize_db_val(val) self.setter(val) def commit(self, book_ids, notify=False): if not self.a_c_checkbox.isChecked(): return val = self.gui_val val = self.normalize_ui_val(val) self.db.set_custom_bulk(book_ids, val, num=self.col_id, notify=notify) def make_widgets(self, parent, main_widget_class): w = QWidget(parent) self.widgets = [QLabel(label_string(self.col_metadata['name']), w), w] l = QHBoxLayout() l.setContentsMargins(0, 0, 0, 0) w.setLayout(l) self.main_widget = main_widget_class(w) l.addWidget(self.main_widget) l.setStretchFactor(self.main_widget, 10) self.a_c_checkbox = QCheckBox(_('Apply changes'), w) l.addWidget(self.a_c_checkbox) self.ignore_change_signals = True # connect to the various changed signals so we can auto-update the # apply changes checkbox if hasattr(self.main_widget, 'editTextChanged'): # editable combobox widgets self.main_widget.editTextChanged.connect(self.a_c_checkbox_changed) if hasattr(self.main_widget, 'textChanged'): # lineEdit widgets self.main_widget.textChanged.connect(self.a_c_checkbox_changed) if hasattr(self.main_widget, 'currentIndexChanged'): # combobox widgets self.main_widget.currentIndexChanged[int].connect(self.a_c_checkbox_changed) if hasattr(self.main_widget, 'valueChanged'): # spinbox widgets self.main_widget.valueChanged.connect(self.a_c_checkbox_changed) if hasattr(self.main_widget, 'dateTimeChanged'): # dateEdit widgets self.main_widget.dateTimeChanged.connect(self.a_c_checkbox_changed) def a_c_checkbox_changed(self): if not self.ignore_change_signals: self.a_c_checkbox.setChecked(True) class BulkBool(BulkBase, Bool): def get_initial_value(self, book_ids): value = None for book_id in book_ids: val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True) if not self.db.new_api.pref('bools_are_tristate') and val is None: val = False if value is not None and value != val: return None value = val return value def setup_ui(self, parent): self.make_widgets(parent, QComboBox) items = [_('Yes'), _('No')] self.bools_are_tristate = self.db.new_api.pref('bools_are_tristate') if not self.bools_are_tristate: items.append('') else: items.append(_('Undefined')) icons = [I('ok.png'), I('list_remove.png'), I('blank.png')] self.main_widget.blockSignals(True) for icon, text in zip(icons, items): self.main_widget.addItem(QIcon(icon), text) self.main_widget.blockSignals(False) self.finish_ui_setup(parent, is_bool=True) def set_to_undefined(self): # Only called if bools are tristate self.main_widget.setCurrentIndex(2) def getter(self): val = self.main_widget.currentIndex() if not self.bools_are_tristate: return {2: False, 1: False, 0: True}[val] else: return {2: None, 1: False, 0: True}[val] def setter(self, val): val = {None: 2, False: 1, True: 0}[val] self.main_widget.setCurrentIndex(val) self.ignore_change_signals = False def commit(self, book_ids, notify=False): if not self.a_c_checkbox.isChecked(): return val = self.gui_val val = self.normalize_ui_val(val) if not self.bools_are_tristate and val is None: val = False self.db.set_custom_bulk(book_ids, val, num=self.col_id, notify=notify) def a_c_checkbox_changed(self): if not self.ignore_change_signals: if not self.bools_are_tristate and self.main_widget.currentIndex() == 2: self.a_c_checkbox.setChecked(False) else: self.a_c_checkbox.setChecked(True) class BulkInt(BulkBase): def setup_ui(self, parent): self.make_widgets(parent, QSpinBox) self.main_widget.setRange(-1000000, 100000000) self.finish_ui_setup(parent) def finish_ui_setup(self, parent): BulkBase.finish_ui_setup(self, parent) self.main_widget.setSpecialValueText(_('Undefined')) self.main_widget.setSingleStep(1) self.main_widget.valueChanged.connect(self.valueChanged) def setter(self, val): if val is None: val = self.main_widget.minimum() self.main_widget.setValue(val) self.ignore_change_signals = False self.was_none = val == self.main_widget.minimum() def getter(self): val = self.main_widget.value() if val == self.main_widget.minimum(): val = None return val def valueChanged(self, to_what): if self.was_none and to_what == -999999: self.setter(0) self.was_none = to_what == self.main_widget.minimum() def set_to_undefined(self): self.main_widget.setValue(-1000000) class BulkFloat(BulkInt): def setup_ui(self, parent): self.make_widgets(parent, QDoubleSpinBox) self.main_widget.setRange(-1000000., float(100000000)) self.main_widget.setDecimals(2) self.finish_ui_setup(parent) def set_to_undefined(self): self.main_widget.setValue(-1000000.) class BulkRating(BulkBase): def setup_ui(self, parent): allow_half_stars = self.col_metadata['display'].get('allow_half_stars', False) self.make_widgets(parent, partial(RatingEditor, is_half_star=allow_half_stars)) self.finish_ui_setup(parent) def set_to_undefined(self): self.main_widget.setCurrentIndex(0) def setter(self, val): val = max(0, min(int(val or 0), 10)) self.main_widget.rating_value = val self.ignore_change_signals = False def getter(self): return self.main_widget.rating_value or None class BulkDateTime(BulkBase): def setup_ui(self, parent): cm = self.col_metadata self.make_widgets(parent, DateTimeEdit) l = self.widgets[1].layout() self.today_button = QToolButton(parent) self.today_button.setText(_('Today')) l.insertWidget(1, self.today_button) self.clear_button = QToolButton(parent) self.clear_button.setIcon(QIcon(I('trash.png'))) self.clear_button.setToolTip(_('Clear {0}').format(self.col_metadata['name'])) l.insertWidget(2, self.clear_button) l.insertStretch(3) w = self.main_widget format_ = cm['display'].get('date_format','') if not format_: format_ = 'dd MMM yyyy' elif format_ == 'iso': format_ = internal_iso_format_string() w.setDisplayFormat(format_) w.setCalendarPopup(True) w.setMinimumDateTime(UNDEFINED_QDATETIME) w.setSpecialValueText(_('Undefined')) self.today_button.clicked.connect(w.set_to_today) self.clear_button.clicked.connect(w.set_to_clear) def setter(self, val): if val is None: val = self.main_widget.minimumDateTime() else: val = QDateTime(val) self.main_widget.setDateTime(val) self.ignore_change_signals = False def getter(self): val = self.main_widget.dateTime() if val <= UNDEFINED_QDATETIME: val = None else: val = qt_to_dt(val) return val def normalize_db_val(self, val): return as_local_time(val) if val is not None else None def normalize_ui_val(self, val): return as_utc(val) if val is not None else None class BulkSeries(BulkBase): def setup_ui(self, parent): self.make_widgets(parent, EditWithComplete) values = self.all_values = list(self.db.all_custom(num=self.col_id)) values.sort(key=sort_key) self.main_widget.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) self.main_widget.setMinimumContentsLength(25) self.widgets.append(QLabel('', parent)) w = QWidget(parent) layout = QHBoxLayout(w) layout.setContentsMargins(0, 0, 0, 0) self.remove_series = QCheckBox(parent) self.remove_series.setText(_('Clear series')) layout.addWidget(self.remove_series) self.idx_widget = QCheckBox(parent) self.idx_widget.setText(_('Automatically number books')) self.idx_widget.setToolTip('<p>' + _( 'If not checked, the series number for the books will be set to 1. ' 'If checked, selected books will be automatically numbered, ' 'in the order you selected them. So if you selected ' 'Book A and then Book B, Book A will have series number 1 ' 'and Book B series number 2.') + '</p>') layout.addWidget(self.idx_widget) self.force_number = QCheckBox(parent) self.force_number.setText(_('Force numbers to start with ')) self.force_number.setToolTip('<p>' + _( 'Series will normally be renumbered from the highest ' 'number in the database for that series. Checking this ' 'box will tell calibre to start numbering from the value ' 'in the box') + '</p>') layout.addWidget(self.force_number) self.series_start_number = QDoubleSpinBox(parent) self.series_start_number.setMinimum(0.0) self.series_start_number.setMaximum(9999999.0) self.series_start_number.setProperty("value", 1.0) layout.addWidget(self.series_start_number) self.series_increment = QDoubleSpinBox(parent) self.series_increment.setMinimum(0.00) self.series_increment.setMaximum(99999.0) self.series_increment.setProperty("value", 1.0) self.series_increment.setToolTip('<p>' + _( 'The amount by which to increment the series number ' 'for successive books. Only applicable when using ' 'force series numbers.') + '</p>') self.series_increment.setPrefix('+') layout.addWidget(self.series_increment) layout.addItem(QSpacerItem(20, 10, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)) self.widgets.append(w) self.idx_widget.stateChanged.connect(self.a_c_checkbox_changed) self.force_number.stateChanged.connect(self.a_c_checkbox_changed) self.series_start_number.valueChanged.connect(self.a_c_checkbox_changed) self.series_increment.valueChanged.connect(self.a_c_checkbox_changed) self.remove_series.stateChanged.connect(self.a_c_checkbox_changed) self.main_widget self.ignore_change_signals = False def a_c_checkbox_changed(self): def disable_numbering_checkboxes(idx_widget_enable): if idx_widget_enable: self.idx_widget.setEnabled(True) else: self.idx_widget.setChecked(False) self.idx_widget.setEnabled(False) self.force_number.setChecked(False) self.force_number.setEnabled(False) self.series_start_number.setEnabled(False) self.series_increment.setEnabled(False) if self.ignore_change_signals: return self.ignore_change_signals = True apply_changes = False if self.remove_series.isChecked(): self.main_widget.setText('') self.main_widget.setEnabled(False) disable_numbering_checkboxes(idx_widget_enable=False) apply_changes = True elif self.main_widget.text(): self.remove_series.setEnabled(False) self.idx_widget.setEnabled(True) apply_changes = True else: # no text, no clear. Basically reinitialize self.main_widget.setEnabled(True) self.remove_series.setEnabled(True) disable_numbering_checkboxes(idx_widget_enable=False) apply_changes = False self.force_number.setEnabled(self.idx_widget.isChecked()) self.series_start_number.setEnabled(self.force_number.isChecked()) self.series_increment.setEnabled(self.force_number.isChecked()) self.ignore_change_signals = False self.a_c_checkbox.setChecked(apply_changes) def initialize(self, book_id): self.idx_widget.setChecked(False) self.main_widget.set_separator(None) self.main_widget.update_items_cache(self.all_values) self.main_widget.setEditText('') self.a_c_checkbox.setChecked(False) def getter(self): n = str(self.main_widget.currentText()).strip() autonumber = self.idx_widget.checkState() force = self.force_number.checkState() start = self.series_start_number.value() remove = self.remove_series.checkState() increment = self.series_increment.value() return n, autonumber, force, start, remove, increment def commit(self, book_ids, notify=False): if not self.a_c_checkbox.isChecked(): return val, update_indices, force_start, at_value, clear, increment = self.gui_val val = None if clear else self.normalize_ui_val(val) if clear or val != '': extras = [] for book_id in book_ids: if clear: extras.append(None) continue if update_indices: if force_start: s_index = at_value at_value += increment elif tweaks['series_index_auto_increment'] != 'const': s_index = self.db.get_next_cc_series_num_for(val, num=self.col_id) else: s_index = 1.0 else: s_index = self.db.get_custom_extra(book_id, num=self.col_id, index_is_id=True) extras.append(s_index) self.db.set_custom_bulk(book_ids, val, extras=extras, num=self.col_id, notify=notify) class BulkEnumeration(BulkBase, Enumeration): def get_initial_value(self, book_ids): value = None first = True dialog_shown = False for book_id in book_ids: val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True) if val and val not in self.col_metadata['display']['enum_values']: if not dialog_shown: error_dialog(self.parent, '', _('The enumeration "{0}" contains invalid values ' 'that will not appear in the list').format( self.col_metadata['name']), show=True, show_copy_button=False) dialog_shown = True if first: value = val first = False elif value != val: value = None if not value: self.ignore_change_signals = False return value def setup_ui(self, parent): self.parent = parent self.make_widgets(parent, QComboBox) self.finish_ui_setup(parent) vals = self.col_metadata['display']['enum_values'] self.main_widget.blockSignals(True) self.main_widget.addItem('') self.main_widget.addItems(vals) self.main_widget.blockSignals(False) def set_to_undefined(self): self.main_widget.setCurrentIndex(0) def getter(self): return str(self.main_widget.currentText()) def setter(self, val): if val is None: self.main_widget.setCurrentIndex(0) else: self.main_widget.setCurrentIndex(self.main_widget.findText(val)) self.ignore_change_signals = False class RemoveTags(QWidget): def __init__(self, parent, values): QWidget.__init__(self, parent) layout = QHBoxLayout() layout.setSpacing(5) layout.setContentsMargins(0, 0, 0, 0) self.tags_box = EditWithComplete(parent) self.tags_box.update_items_cache(values) layout.addWidget(self.tags_box, stretch=3) self.remove_tags_button = QToolButton(parent) self.remove_tags_button.setToolTip(_('Open Item editor')) self.remove_tags_button.setIcon(QIcon(I('chapters.png'))) layout.addWidget(self.remove_tags_button) self.checkbox = QCheckBox(_('Remove all tags'), parent) layout.addWidget(self.checkbox) layout.addStretch(1) self.setLayout(layout) self.checkbox.stateChanged[int].connect(self.box_touched) def box_touched(self, state): if state: self.tags_box.setText('') self.tags_box.setEnabled(False) else: self.tags_box.setEnabled(True) class BulkText(BulkBase): def setup_ui(self, parent): values = self.all_values = list(self.db.all_custom(num=self.col_id)) values.sort(key=sort_key) is_tags = False if self.col_metadata['is_multiple']: is_tags = not self.col_metadata['display'].get('is_names', False) self.make_widgets(parent, EditWithComplete) self.main_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred) self.adding_widget = self.main_widget if is_tags: w = RemoveTags(parent, values) w.remove_tags_button.clicked.connect(self.edit_remove) l = QLabel(label_string(self.col_metadata['name'])+': ' + _('tags to remove'), parent) tt = get_tooltip(self.col_metadata) + ': ' + _('tags to remove') l.setToolTip(tt) self.widgets.append(l) w.setToolTip(tt) self.widgets.append(w) self.removing_widget = w self.main_widget.set_separator(',') w.tags_box.textChanged.connect(self.a_c_checkbox_changed) w.checkbox.stateChanged.connect(self.a_c_checkbox_changed) else: self.main_widget.set_separator('&') self.main_widget.set_space_before_sep(True) self.main_widget.set_add_separator( tweaks['authors_completer_append_separator']) else: self.make_widgets(parent, EditWithComplete) self.main_widget.set_separator(None) self.main_widget.setSizeAdjustPolicy( QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) self.main_widget.setMinimumContentsLength(25) self.ignore_change_signals = False self.parent = parent self.finish_ui_setup(parent, add_edit_tags_button=(is_tags,self.edit_add)) def set_to_undefined(self): self.main_widget.clearEditText() def initialize(self, book_ids): self.main_widget.update_items_cache(self.all_values) if not self.col_metadata['is_multiple']: val = self.get_initial_value(book_ids) self.initial_val = val = self.normalize_db_val(val) self.ignore_change_signals = True self.main_widget.blockSignals(True) self.main_widget.setText(val) self.main_widget.blockSignals(False) self.ignore_change_signals = False def commit(self, book_ids, notify=False): if not self.a_c_checkbox.isChecked(): return if self.col_metadata['is_multiple']: ism = self.col_metadata['multiple_seps'] if self.col_metadata['display'].get('is_names', False): val = self.gui_val add = [v.strip() for v in val.split(ism['ui_to_list']) if v.strip()] self.db.set_custom_bulk(book_ids, add, num=self.col_id) else: remove_all, adding, rtext = self.gui_val remove = set() if remove_all: remove = set(self.db.all_custom(num=self.col_id)) else: txt = rtext if txt: remove = {v.strip() for v in txt.split(ism['ui_to_list'])} txt = adding if txt: add = {v.strip() for v in txt.split(ism['ui_to_list'])} else: add = set() self.db.set_custom_bulk_multiple(book_ids, add=add, remove=remove, num=self.col_id) else: val = self.gui_val val = self.normalize_ui_val(val) self.db.set_custom_bulk(book_ids, val, num=self.col_id, notify=notify) def getter(self): if self.col_metadata['is_multiple']: if not self.col_metadata['display'].get('is_names', False): return self.removing_widget.checkbox.isChecked(), \ str(self.adding_widget.text()), \ str(self.removing_widget.tags_box.text()) return str(self.adding_widget.text()) val = str(self.main_widget.currentText()).strip() if not val: val = None return val def edit_remove(self): self.edit(widget=self.removing_widget.tags_box) def edit_add(self): self.edit(widget=self.main_widget) def edit(self, widget): if widget.text(): d = _save_dialog(self.parent, _('Values changed'), _('You have entered values. In order to use this ' 'editor you must first discard them. ' 'Discard the values?')) if d == QMessageBox.StandardButton.Cancel or d == QMessageBox.StandardButton.No: return widget.setText('') d = TagEditor(self.parent, self.db, key=('#'+self.col_metadata['label'])) if d.exec() == QDialog.DialogCode.Accepted: val = d.tags if not val: val = [] widget.setText(self.col_metadata['multiple_seps']['list_to_ui'].join(val)) bulk_widgets = { 'bool' : BulkBool, 'rating' : BulkRating, 'int': BulkInt, 'float': BulkFloat, 'datetime': BulkDateTime, 'text' : BulkText, 'series': BulkSeries, 'enumeration': BulkEnumeration, }