1#!/usr/local/bin/python3.8
2# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
3
4
5__license__   = 'GPL v3'
6__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
7__docformat__ = 'restructuredtext en'
8
9import os
10from functools import partial
11
12from qt.core import (Qt, QComboBox, QLabel, QSpinBox, QDoubleSpinBox,
13        QDateTime, QGroupBox, QVBoxLayout, QSizePolicy, QGridLayout, QUrl,
14        QSpacerItem, QIcon, QCheckBox, QWidget, QHBoxLayout, QLineEdit,
15        QMessageBox, QToolButton, QPlainTextEdit, QApplication, QStyle, QDialog)
16
17from calibre.utils.date import qt_to_dt, now, as_local_time, as_utc, internal_iso_format_string
18from calibre.gui2.complete2 import EditWithComplete as EWC
19from calibre.gui2.comments_editor import Editor as CommentsEditor
20from calibre.gui2 import UNDEFINED_QDATETIME, error_dialog, elided_text
21from calibre.gui2.dialogs.tag_editor import TagEditor
22from calibre.utils.config import tweaks
23from calibre.utils.icu import sort_key
24from calibre.library.comments import comments_to_html
25from calibre.gui2.library.delegates import ClearingDoubleSpinBox, ClearingSpinBox
26from calibre.gui2.widgets2 import RatingEditor, DateTimeEdit as DateTimeEditBase
27
28
29class EditWithComplete(EWC):
30
31    def __init__(self, *a, **kw):
32        super().__init__(*a, **kw)
33        self.set_clear_button_enabled(False)
34
35
36def safe_disconnect(signal):
37    try:
38        signal.disconnect()
39    except Exception:
40        pass
41
42
43def label_string(txt):
44    if txt:
45        try:
46            if txt[0].isalnum():
47                return '&' + txt
48        except:
49            pass
50    return txt
51
52
53def get_tooltip(col_metadata, add_index=False):
54    key = col_metadata['label'] + ('_index' if add_index else '')
55    label = col_metadata['name'] + (_(' index') if add_index else '')
56    description = col_metadata.get('display', {}).get('description', '')
57    return '{} (#{}){} {}'.format(
58                  label, key, ':' if description else '', description).strip()
59
60
61class Base:
62
63    def __init__(self, db, col_id, parent=None):
64        self.db, self.col_id = db, col_id
65        self.book_id = None
66        self.col_metadata = db.custom_column_num_map[col_id]
67        self.initial_val = self.widgets = None
68        self.signals_to_disconnect = []
69        self.setup_ui(parent)
70        description = get_tooltip(self.col_metadata)
71        try:
72            self.widgets[0].setToolTip(description)
73            self.widgets[1].setToolTip(description)
74        except:
75            try:
76                self.widgets[1].setToolTip(description)
77            except:
78                pass
79
80    def finish_ui_setup(self, parent, edit_widget):
81        self.was_none = False
82        w = QWidget(parent)
83        self.widgets.append(w)
84        l = QHBoxLayout()
85        l.setContentsMargins(0, 0, 0, 0)
86        w.setLayout(l)
87        self.editor = editor = edit_widget(parent)
88        l.addWidget(editor)
89        self.clear_button = QToolButton(parent)
90        self.clear_button.setIcon(QIcon(I('trash.png')))
91        self.clear_button.clicked.connect(self.set_to_undefined)
92        self.clear_button.setToolTip(_('Clear {0}').format(self.col_metadata['name']))
93        l.addWidget(self.clear_button)
94
95    def initialize(self, book_id):
96        self.book_id = book_id
97        val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True)
98        val = self.normalize_db_val(val)
99        self.setter(val)
100        self.initial_val = self.current_val  # self.current_val might be different from val thanks to normalization
101
102    @property
103    def current_val(self):
104        return self.normalize_ui_val(self.gui_val)
105
106    @property
107    def gui_val(self):
108        return self.getter()
109
110    def commit(self, book_id, notify=False):
111        val = self.current_val
112        if val != self.initial_val:
113            return self.db.set_custom(book_id, val, num=self.col_id,
114                            notify=notify, commit=False, allow_case_change=True)
115        else:
116            return set()
117
118    def apply_to_metadata(self, mi):
119        mi.set('#' + self.col_metadata['label'], self.current_val)
120
121    def normalize_db_val(self, val):
122        return val
123
124    def normalize_ui_val(self, val):
125        return val
126
127    def break_cycles(self):
128        self.db = self.widgets = self.initial_val = None
129        for signal in self.signals_to_disconnect:
130            safe_disconnect(signal)
131        self.signals_to_disconnect = []
132
133    def connect_data_changed(self, slot):
134        pass
135
136
137class SimpleText(Base):
138
139    def setup_ui(self, parent):
140        self.editor = QLineEdit(parent)
141        self.widgets = [QLabel(label_string(self.col_metadata['name']), parent),
142                        self.editor]
143        self.editor.setClearButtonEnabled(True)
144
145    def setter(self, val):
146        self.editor.setText(str(val or ''))
147
148    def getter(self):
149        return self.editor.text().strip()
150
151    def connect_data_changed(self, slot):
152        self.editor.textChanged.connect(slot)
153        self.signals_to_disconnect.append(self.editor.textChanged)
154
155
156class LongText(Base):
157
158    def setup_ui(self, parent):
159        self._box = QGroupBox(parent)
160        self._box.setTitle(label_string(self.col_metadata['name']))
161        self._layout = QVBoxLayout()
162        self._tb = QPlainTextEdit(self._box)
163        self._tb.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
164        self._layout.addWidget(self._tb)
165        self._box.setLayout(self._layout)
166        self.widgets = [self._box]
167
168    def setter(self, val):
169        self._tb.setPlainText(str(val or ''))
170
171    def getter(self):
172        return self._tb.toPlainText()
173
174    def connect_data_changed(self, slot):
175        self._tb.textChanged.connect(slot)
176        self.signals_to_disconnect.append(self._tb.textChanged)
177
178
179class Bool(Base):
180
181    def setup_ui(self, parent):
182        name = self.col_metadata['name']
183        self.widgets = [QLabel(label_string(name), parent)]
184        w = QWidget(parent)
185        self.widgets.append(w)
186
187        l = QHBoxLayout()
188        l.setContentsMargins(0, 0, 0, 0)
189        w.setLayout(l)
190        self.combobox = QComboBox(parent)
191        l.addWidget(self.combobox)
192
193        c = QToolButton(parent)
194        c.setIcon(QIcon(I('ok.png')))
195        c.setToolTip(_('Set {} to yes').format(name))
196        l.addWidget(c)
197        c.clicked.connect(self.set_to_yes)
198
199        c = QToolButton(parent)
200        c.setIcon(QIcon(I('list_remove.png')))
201        c.setToolTip(_('Set {} to no').format(name))
202        l.addWidget(c)
203        c.clicked.connect(self.set_to_no)
204
205        if self.db.new_api.pref('bools_are_tristate'):
206            c = QToolButton(parent)
207            c.setIcon(QIcon(I('trash.png')))
208            c.setToolTip(_('Clear {}').format(name))
209            l.addWidget(c)
210            c.clicked.connect(self.set_to_cleared)
211
212        w = self.combobox
213        items = [_('Yes'), _('No'), _('Undefined')]
214        icons = [I('ok.png'), I('list_remove.png'), I('blank.png')]
215        if not self.db.new_api.pref('bools_are_tristate'):
216            items = items[:-1]
217            icons = icons[:-1]
218        for icon, text in zip(icons, items):
219            w.addItem(QIcon(icon), text)
220
221    def setter(self, val):
222        val = {None: 2, False: 1, True: 0}[val]
223        if not self.db.new_api.pref('bools_are_tristate') and val == 2:
224            val = 1
225        self.combobox.setCurrentIndex(val)
226
227    def getter(self):
228        val = self.combobox.currentIndex()
229        return {2: None, 1: False, 0: True}[val]
230
231    def set_to_yes(self):
232        self.combobox.setCurrentIndex(0)
233
234    def set_to_no(self):
235        self.combobox.setCurrentIndex(1)
236
237    def set_to_cleared(self):
238        self.combobox.setCurrentIndex(2)
239
240    def connect_data_changed(self, slot):
241        self.combobox.currentTextChanged.connect(slot)
242        self.signals_to_disconnect.append(self.combobox.currentTextChanged)
243
244
245class Int(Base):
246
247    def setup_ui(self, parent):
248        self.widgets = [QLabel(label_string(self.col_metadata['name']), parent)]
249        self.finish_ui_setup(parent, ClearingSpinBox)
250        self.editor.setRange(-1000000, 100000000)
251
252    def finish_ui_setup(self, parent, edit_widget):
253        Base.finish_ui_setup(self, parent, edit_widget)
254        self.editor.setSpecialValueText(_('Undefined'))
255        self.editor.setSingleStep(1)
256        self.editor.valueChanged.connect(self.valueChanged)
257
258    def setter(self, val):
259        if val is None:
260            val = self.editor.minimum()
261        self.editor.setValue(val)
262        self.was_none = val == self.editor.minimum()
263
264    def getter(self):
265        val = self.editor.value()
266        if val == self.editor.minimum():
267            val = None
268        return val
269
270    def valueChanged(self, to_what):
271        if self.was_none and to_what == -999999:
272            self.setter(0)
273        self.was_none = to_what == self.editor.minimum()
274
275    def connect_data_changed(self, slot):
276        self.editor.valueChanged.connect(slot)
277        self.signals_to_disconnect.append(self.editor.valueChanged)
278
279    def set_to_undefined(self):
280        self.editor.setValue(-1000000)
281
282
283class Float(Int):
284
285    def setup_ui(self, parent):
286        self.widgets = [QLabel(label_string(self.col_metadata['name']), parent)]
287        self.finish_ui_setup(parent, ClearingDoubleSpinBox)
288        self.editor.setRange(-1000000., float(100000000))
289        self.editor.setDecimals(2)
290
291
292class Rating(Base):
293
294    def setup_ui(self, parent):
295        allow_half_stars = self.col_metadata['display'].get('allow_half_stars', False)
296        self.widgets = [QLabel(label_string(self.col_metadata['name']), parent)]
297        self.finish_ui_setup(parent, partial(RatingEditor, is_half_star=allow_half_stars))
298
299    def set_to_undefined(self):
300        self.editor.setCurrentIndex(0)
301
302    def setter(self, val):
303        val = max(0, min(int(val or 0), 10))
304        self.editor.rating_value = val
305
306    def getter(self):
307        return self.editor.rating_value or None
308
309    def connect_data_changed(self, slot):
310        self.editor.currentTextChanged.connect(slot)
311        self.signals_to_disconnect.append(self.editor.currentTextChanged)
312
313
314class DateTimeEdit(DateTimeEditBase):
315
316    def focusInEvent(self, x):
317        self.setSpecialValueText('')
318        DateTimeEditBase.focusInEvent(self, x)
319
320    def focusOutEvent(self, x):
321        self.setSpecialValueText(_('Undefined'))
322        DateTimeEditBase.focusOutEvent(self, x)
323
324    def set_to_today(self):
325        self.setDateTime(now())
326
327    def set_to_clear(self):
328        self.setDateTime(now())
329        self.setDateTime(UNDEFINED_QDATETIME)
330
331
332class DateTime(Base):
333
334    def setup_ui(self, parent):
335        cm = self.col_metadata
336        self.widgets = [QLabel(label_string(cm['name']), parent)]
337        w = QWidget(parent)
338        self.widgets.append(w)
339        l = QHBoxLayout()
340        l.setContentsMargins(0, 0, 0, 0)
341        w.setLayout(l)
342        self.dte = dte = DateTimeEdit(parent)
343        format_ = cm['display'].get('date_format','')
344        if not format_:
345            format_ = 'dd MMM yyyy hh:mm'
346        elif format_ == 'iso':
347            format_ = internal_iso_format_string()
348        dte.setDisplayFormat(format_)
349        dte.setCalendarPopup(True)
350        dte.setMinimumDateTime(UNDEFINED_QDATETIME)
351        dte.setSpecialValueText(_('Undefined'))
352        l.addWidget(dte)
353
354        self.today_button = QToolButton(parent)
355        self.today_button.setText(_('Today'))
356        self.today_button.clicked.connect(dte.set_to_today)
357        l.addWidget(self.today_button)
358
359        self.clear_button = QToolButton(parent)
360        self.clear_button.setIcon(QIcon(I('trash.png')))
361        self.clear_button.clicked.connect(dte.set_to_clear)
362        self.clear_button.setToolTip(_('Clear {0}').format(self.col_metadata['name']))
363        l.addWidget(self.clear_button)
364
365    def setter(self, val):
366        if val is None:
367            val = self.dte.minimumDateTime()
368        else:
369            val = QDateTime(val)
370        self.dte.setDateTime(val)
371
372    def getter(self):
373        val = self.dte.dateTime()
374        if val <= UNDEFINED_QDATETIME:
375            val = None
376        else:
377            val = qt_to_dt(val)
378        return val
379
380    def normalize_db_val(self, val):
381        return as_local_time(val) if val is not None else None
382
383    def normalize_ui_val(self, val):
384        return as_utc(val) if val is not None else None
385
386    def connect_data_changed(self, slot):
387        self.dte.dateTimeChanged.connect(slot)
388        self.signals_to_disconnect.append(self.dte.dateTimeChanged)
389
390
391class Comments(Base):
392
393    def setup_ui(self, parent):
394        self._box = QGroupBox(parent)
395        self._box.setTitle(label_string(self.col_metadata['name']))
396        self._layout = QVBoxLayout()
397        self._tb = CommentsEditor(self._box, toolbar_prefs_name='metadata-comments-editor-widget-hidden-toolbars')
398        self._tb.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
399        # self._tb.setTabChangesFocus(True)
400        self._layout.addWidget(self._tb)
401        self._box.setLayout(self._layout)
402        self.widgets = [self._box]
403
404    def initialize(self, book_id):
405        path = self.db.abspath(book_id, index_is_id=True)
406        if path:
407            self._tb.set_base_url(QUrl.fromLocalFile(os.path.join(path, 'metadata.html')))
408        return Base.initialize(self, book_id)
409
410    def setter(self, val):
411        if not val or not val.strip():
412            val = ''
413        else:
414            val = comments_to_html(val)
415        self._tb.html = val
416        self._tb.wyswyg_dirtied()
417
418    def getter(self):
419        val = str(self._tb.html).strip()
420        if not val:
421            val = None
422        return val
423
424    @property
425    def tab(self):
426        return self._tb.tab
427
428    @tab.setter
429    def tab(self, val):
430        self._tb.tab = val
431
432    def connect_data_changed(self, slot):
433        self._tb.data_changed.connect(slot)
434        self.signals_to_disconnect.append(self._tb.data_changed)
435
436
437class MultipleWidget(QWidget):
438
439    def __init__(self, parent):
440        QWidget.__init__(self, parent)
441        layout = QHBoxLayout()
442        layout.setSpacing(5)
443        layout.setContentsMargins(0, 0, 0, 0)
444
445        self.tags_box = EditWithComplete(parent)
446        layout.addWidget(self.tags_box, stretch=1000)
447        self.editor_button = QToolButton(self)
448        self.editor_button.setToolTip(_('Open Item editor. If CTRL or SHIFT is pressed, open Manage items'))
449        self.editor_button.setIcon(QIcon(I('chapters.png')))
450        layout.addWidget(self.editor_button)
451        self.setLayout(layout)
452
453    def get_editor_button(self):
454        return self.editor_button
455
456    def update_items_cache(self, values):
457        self.tags_box.update_items_cache(values)
458
459    def clear(self):
460        self.tags_box.clear()
461
462    def setEditText(self):
463        self.tags_box.setEditText()
464
465    def addItem(self, itm):
466        self.tags_box.addItem(itm)
467
468    def set_separator(self, sep):
469        self.tags_box.set_separator(sep)
470
471    def set_add_separator(self, sep):
472        self.tags_box.set_add_separator(sep)
473
474    def set_space_before_sep(self, v):
475        self.tags_box.set_space_before_sep(v)
476
477    def setSizePolicy(self, v1, v2):
478        self.tags_box.setSizePolicy(v1, v2)
479
480    def setText(self, v):
481        self.tags_box.setText(v)
482
483    def text(self):
484        return self.tags_box.text()
485
486
487def _save_dialog(parent, title, msg, det_msg=''):
488    d = QMessageBox(parent)
489    d.setWindowTitle(title)
490    d.setText(msg)
491    d.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel)
492    return d.exec()
493
494
495class Text(Base):
496
497    def setup_ui(self, parent):
498        self.sep = self.col_metadata['multiple_seps']
499        self.key = self.db.field_metadata.label_to_key(self.col_metadata['label'],
500                                                       prefer_custom=True)
501        self.parent = parent
502
503        if self.col_metadata['is_multiple']:
504            w = MultipleWidget(parent)
505            w.set_separator(self.sep['ui_to_list'])
506            if self.sep['ui_to_list'] == '&':
507                w.set_space_before_sep(True)
508                w.set_add_separator(tweaks['authors_completer_append_separator'])
509            w.get_editor_button().clicked.connect(self.edit)
510            w.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
511            self.set_to_undefined = w.clear
512        else:
513            w = EditWithComplete(parent)
514            w.set_separator(None)
515            w.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
516            w.setMinimumContentsLength(25)
517            self.set_to_undefined = w.clearEditText
518        self.widgets = [QLabel(label_string(self.col_metadata['name']), parent)]
519        self.finish_ui_setup(parent, lambda parent: w)
520
521    def initialize(self, book_id):
522        values = list(self.db.all_custom(num=self.col_id))
523        values.sort(key=sort_key)
524        self.book_id = book_id
525        self.editor.clear()
526        self.editor.update_items_cache(values)
527        val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True)
528        if isinstance(val, list):
529            if not self.col_metadata.get('display', {}).get('is_names', False):
530                val.sort(key=sort_key)
531        val = self.normalize_db_val(val)
532
533        if self.col_metadata['is_multiple']:
534            self.setter(val)
535        else:
536            self.editor.setText(val)
537        self.initial_val = self.current_val
538
539    def setter(self, val):
540        if self.col_metadata['is_multiple']:
541            if not val:
542                val = []
543            self.editor.setText(self.sep['list_to_ui'].join(val))
544
545    def getter(self):
546        if self.col_metadata['is_multiple']:
547            val = str(self.editor.text()).strip()
548            ans = [x.strip() for x in val.split(self.sep['ui_to_list']) if x.strip()]
549            if not ans:
550                ans = None
551            return ans
552        val = str(self.editor.currentText()).strip()
553        if not val:
554            val = None
555        return val
556
557    def edit(self):
558        ctrl_or_shift_pressed = (QApplication.keyboardModifiers() &
559                (Qt.KeyboardModifier.ControlModifier + Qt.KeyboardModifier.ShiftModifier))
560        if (self.getter() != self.initial_val and (self.getter() or self.initial_val)):
561            d = _save_dialog(self.parent, _('Values changed'),
562                    _('You have changed the values. In order to use this '
563                       'editor, you must either discard or apply these '
564                       'changes. Apply changes?'))
565            if d == QMessageBox.StandardButton.Cancel:
566                return
567            if d == QMessageBox.StandardButton.Yes:
568                self.commit(self.book_id)
569                self.db.commit()
570                self.initial_val = self.current_val
571            else:
572                self.setter(self.initial_val)
573        if ctrl_or_shift_pressed:
574            from calibre.gui2.ui import get_gui
575            get_gui().do_tags_list_edit(None, self.key)
576            self.initialize(self.book_id)
577        else:
578            d = TagEditor(self.parent, self.db, self.book_id, self.key)
579            if d.exec() == QDialog.DialogCode.Accepted:
580                self.setter(d.tags)
581
582    def connect_data_changed(self, slot):
583        if self.col_metadata['is_multiple']:
584            s = self.editor.tags_box.currentTextChanged
585        else:
586            s = self.editor.currentTextChanged
587        s.connect(slot)
588        self.signals_to_disconnect.append(s)
589
590
591class Series(Base):
592
593    def setup_ui(self, parent):
594        w = EditWithComplete(parent)
595        w.set_separator(None)
596        w.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
597        w.setMinimumContentsLength(25)
598        self.name_widget = w
599        self.widgets = [QLabel(label_string(self.col_metadata['name']), parent)]
600        self.finish_ui_setup(parent, lambda parent: w)
601        w.editTextChanged.connect(self.series_changed)
602
603        w = QLabel(label_string(self.col_metadata['name'])+_(' index'), parent)
604        w.setToolTip(get_tooltip(self.col_metadata, add_index=True))
605        self.widgets.append(w)
606        w = QDoubleSpinBox(parent)
607        w.setRange(-10000., float(100000000))
608        w.setDecimals(2)
609        w.setSingleStep(1)
610        self.idx_widget=w
611        w.setToolTip(get_tooltip(self.col_metadata, add_index=True))
612        self.widgets.append(w)
613
614    def set_to_undefined(self):
615        self.name_widget.clearEditText()
616        self.idx_widget.setValue(1.0)
617
618    def initialize(self, book_id):
619        values = list(self.db.all_custom(num=self.col_id))
620        values.sort(key=sort_key)
621        val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True)
622        s_index = self.db.get_custom_extra(book_id, num=self.col_id, index_is_id=True)
623        try:
624            s_index = float(s_index)
625        except (ValueError, TypeError):
626            s_index = 1.0
627        self.idx_widget.setValue(s_index)
628        val = self.normalize_db_val(val)
629        self.name_widget.blockSignals(True)
630        self.name_widget.update_items_cache(values)
631        self.name_widget.setText(val)
632        self.name_widget.blockSignals(False)
633        self.initial_val, self.initial_index = self.current_val
634
635    def getter(self):
636        n = str(self.name_widget.currentText()).strip()
637        i = self.idx_widget.value()
638        return n, i
639
640    def series_changed(self, val):
641        val, s_index = self.gui_val
642        if tweaks['series_index_auto_increment'] == 'no_change':
643            pass
644        elif tweaks['series_index_auto_increment'] == 'const':
645            s_index = 1.0
646        else:
647            s_index = self.db.get_next_cc_series_num_for(val,
648                                                     num=self.col_id)
649        self.idx_widget.setValue(s_index)
650
651    @property
652    def current_val(self):
653        val, s_index = self.gui_val
654        val = self.normalize_ui_val(val)
655        return val, s_index
656
657    def commit(self, book_id, notify=False):
658        val, s_index = self.current_val
659        if val != self.initial_val or s_index != self.initial_index:
660            if not val:
661                val = s_index = None
662            return self.db.set_custom(book_id, val, extra=s_index, num=self.col_id,
663                               notify=notify, commit=False, allow_case_change=True)
664        else:
665            return set()
666
667    def apply_to_metadata(self, mi):
668        val, s_index = self.current_val
669        mi.set('#' + self.col_metadata['label'], val, extra=s_index)
670
671    def connect_data_changed(self, slot):
672        for s in self.name_widget.editTextChanged, self.idx_widget.valueChanged:
673            s.connect(slot)
674            self.signals_to_disconnect.append(s)
675
676
677class Enumeration(Base):
678
679    def setup_ui(self, parent):
680        self.parent = parent
681        self.widgets = [QLabel(label_string(self.col_metadata['name']), parent)]
682        self.finish_ui_setup(parent, QComboBox)
683        vals = self.col_metadata['display']['enum_values']
684        self.editor.addItem('')
685        for v in vals:
686            self.editor.addItem(v)
687
688    def initialize(self, book_id):
689        val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True)
690        val = self.normalize_db_val(val)
691        idx = self.editor.findText(val)
692        if idx < 0:
693            error_dialog(self.parent, '',
694                    _('The enumeration "{0}" contains an invalid value '
695                      'that will be set to the default').format(
696                                            self.col_metadata['name']),
697                    show=True, show_copy_button=False)
698
699            idx = 0
700        self.editor.setCurrentIndex(idx)
701        self.initial_val = self.current_val
702
703    def setter(self, val):
704        self.editor.setCurrentIndex(self.editor.findText(val))
705
706    def getter(self):
707        return str(self.editor.currentText())
708
709    def normalize_db_val(self, val):
710        if val is None:
711            val = ''
712        return val
713
714    def normalize_ui_val(self, val):
715        if not val:
716            val = None
717        return val
718
719    def set_to_undefined(self):
720        self.editor.setCurrentIndex(0)
721
722    def connect_data_changed(self, slot):
723        self.editor.currentIndexChanged.connect(slot)
724        self.signals_to_disconnect.append(self.editor.currentIndexChanged)
725
726
727def comments_factory(db, key, parent):
728    fm = db.custom_column_num_map[key]
729    ctype = fm.get('display', {}).get('interpret_as', 'html')
730    if ctype == 'short-text':
731        return SimpleText(db, key, parent)
732    if ctype in ('long-text', 'markdown'):
733        return LongText(db, key, parent)
734    return Comments(db, key, parent)
735
736
737widgets = {
738        'bool' : Bool,
739        'rating' : Rating,
740        'int': Int,
741        'float': Float,
742        'datetime': DateTime,
743        'text' : Text,
744        'comments': comments_factory,
745        'series': Series,
746        'enumeration': Enumeration
747}
748
749
750def field_sort_key(y, fm=None):
751    m1 = fm[y]
752    name = icu_lower(m1['name'])
753    n1 = 'zzzzz' + name if m1['datatype'] == 'comments' and m1.get('display', {}).get('interpret_as') != 'short-text' else name
754    return sort_key(n1)
755
756
757def populate_metadata_page(layout, db, book_id, bulk=False, two_column=False, parent=None):
758    def widget_factory(typ, key):
759        if bulk:
760            w = bulk_widgets[typ](db, key, parent)
761        else:
762            w = widgets[typ](db, key, parent)
763        if book_id is not None:
764            w.initialize(book_id)
765        return w
766    fm = db.field_metadata
767
768    # Get list of all non-composite custom fields. We must make widgets for these
769    fields = fm.custom_field_keys(include_composites=False)
770    cols_to_display = fields
771    cols_to_display.sort(key=partial(field_sort_key, fm=fm))
772
773    # This will contain the fields in the order to display them
774    cols = []
775
776    # The fields named here must be first in the widget list
777    tweak_cols = tweaks['metadata_edit_custom_column_order']
778    comments_in_tweak = 0
779    for key in (tweak_cols or ()):
780        # Add the key if it really exists in the database
781        if key in cols_to_display:
782            cols.append(key)
783            if fm[key]['datatype'] == 'comments' and fm[key].get('display', {}).get('interpret_as') != 'short-text':
784                comments_in_tweak += 1
785
786    # Add all the remaining fields
787    comments_not_in_tweak = 0
788    for key in cols_to_display:
789        if key not in cols:
790            cols.append(key)
791            if fm[key]['datatype'] == 'comments' and fm[key].get('display', {}).get('interpret_as') != 'short-text':
792                comments_not_in_tweak += 1
793
794    count = len(cols)
795    layout_rows_for_comments = 9
796    if two_column:
797        turnover_point = int(((count - comments_not_in_tweak + 1) +
798                                int(comments_in_tweak*(layout_rows_for_comments-1)))/2)
799    else:
800        # Avoid problems with multi-line widgets
801        turnover_point = count + 1000
802    ans = []
803    column = row = base_row = max_row = 0
804    label_width = 0
805    do_elision = tweaks['metadata_edit_elide_labels']
806    elide_pos = tweaks['metadata_edit_elision_point']
807    elide_pos = elide_pos if elide_pos in {'left', 'middle', 'right'} else 'right'
808    # make room on the right side for the scrollbar
809    sb_width = QApplication.instance().style().pixelMetric(QStyle.PixelMetric.PM_ScrollBarExtent)
810    layout.setContentsMargins(0, 0, sb_width, 0)
811    for key in cols:
812        if not fm[key]['is_editable']:
813            continue  # The job spy plugin can change is_editable
814        dt = fm[key]['datatype']
815        if dt == 'composite' or (bulk and dt == 'comments'):
816            continue
817        is_comments = dt == 'comments' and fm[key].get('display', {}).get('interpret_as') != 'short-text'
818        w = widget_factory(dt, fm[key]['colnum'])
819        ans.append(w)
820        if two_column and is_comments:
821            # Here for compatibility with old layout. Comments always started
822            # in the left column
823            comments_in_tweak -= 1
824            # no special processing if the comment field was named in the tweak
825            if comments_in_tweak < 0 and comments_not_in_tweak > 0:
826                # Force a turnover, adding comments widgets below max_row.
827                # Save the row to return to if we turn over again
828                column = 0
829                row = max_row
830                base_row = row
831                turnover_point = row + int((comments_not_in_tweak * layout_rows_for_comments)/2)
832                comments_not_in_tweak = 0
833
834        l = QGridLayout()
835        if is_comments:
836            layout.addLayout(l, row, column, layout_rows_for_comments, 1)
837            layout.setColumnStretch(column, 100)
838            row += layout_rows_for_comments
839        else:
840            layout.addLayout(l, row, column, 1, 1)
841            layout.setColumnStretch(column, 100)
842            row += 1
843        for c in range(0, len(w.widgets), 2):
844            if not is_comments:
845                # Set the label column width to a fixed size. Elide labels that
846                # don't fit
847                wij = w.widgets[c]
848                if label_width == 0:
849                    font_metrics = wij.fontMetrics()
850                    colon_width = font_metrics.width(':')
851                    if bulk:
852                        label_width = (font_metrics.averageCharWidth() *
853                               tweaks['metadata_edit_bulk_cc_label_length']) - colon_width
854                    else:
855                        label_width = (font_metrics.averageCharWidth() *
856                               tweaks['metadata_edit_single_cc_label_length']) - colon_width
857                wij.setMaximumWidth(label_width)
858                if c == 0:
859                    wij.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Preferred)
860                    l.setColumnMinimumWidth(0, label_width)
861                wij.setAlignment(Qt.AlignmentFlag.AlignRight|Qt.AlignmentFlag.AlignVCenter)
862                t = str(wij.text())
863                if t:
864                    if do_elision:
865                        wij.setText(elided_text(t, font=font_metrics,
866                                            width=label_width, pos=elide_pos) + ':')
867                    else:
868                        wij.setText(t + ':')
869                        wij.setWordWrap(True)
870                wij.setBuddy(w.widgets[c+1])
871                l.addWidget(wij, c, 0)
872                l.addWidget(w.widgets[c+1], c, 1)
873            else:
874                l.addWidget(w.widgets[0], 0, 0, 1, 2)
875        max_row = max(max_row, row)
876        if row >= turnover_point:
877            column = 1
878            turnover_point = count + 1000
879            row = base_row
880
881    items = []
882    if len(ans) > 0:
883        items.append(QSpacerItem(10, 10, QSizePolicy.Policy.Minimum,
884            QSizePolicy.Policy.Expanding))
885        layout.addItem(items[-1], layout.rowCount(), 0, 1, 1)
886        layout.setRowStretch(layout.rowCount()-1, 100)
887    return ans, items
888
889
890class BulkBase(Base):
891
892    @property
893    def gui_val(self):
894        if not hasattr(self, '_cached_gui_val_'):
895            self._cached_gui_val_ = self.getter()
896        return self._cached_gui_val_
897
898    def get_initial_value(self, book_ids):
899        values = set()
900        for book_id in book_ids:
901            val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True)
902            if isinstance(val, list):
903                val = frozenset(val)
904            values.add(val)
905            if len(values) > 1:
906                break
907        ans = None
908        if len(values) == 1:
909            ans = next(iter(values))
910        if isinstance(ans, frozenset):
911            ans = list(ans)
912        return ans
913
914    def finish_ui_setup(self, parent, is_bool=False, add_edit_tags_button=(False,)):
915        self.was_none = False
916        l = self.widgets[1].layout()
917        if not is_bool or self.bools_are_tristate:
918            self.clear_button = QToolButton(parent)
919            self.clear_button.setIcon(QIcon(I('trash.png')))
920            self.clear_button.setToolTip(_('Clear {0}').format(self.col_metadata['name']))
921            self.clear_button.clicked.connect(self.set_to_undefined)
922            l.insertWidget(1, self.clear_button)
923        if is_bool:
924            self.set_no_button = QToolButton(parent)
925            self.set_no_button.setIcon(QIcon(I('list_remove.png')))
926            self.set_no_button.clicked.connect(lambda:self.main_widget.setCurrentIndex(1))
927            self.set_no_button.setToolTip(_('Set {0} to No').format(self.col_metadata['name']))
928            l.insertWidget(1, self.set_no_button)
929            self.set_yes_button = QToolButton(parent)
930            self.set_yes_button.setIcon(QIcon(I('ok.png')))
931            self.set_yes_button.clicked.connect(lambda:self.main_widget.setCurrentIndex(0))
932            self.set_yes_button.setToolTip(_('Set {0} to Yes').format(self.col_metadata['name']))
933            l.insertWidget(1, self.set_yes_button)
934        if add_edit_tags_button[0]:
935            self.edit_tags_button = QToolButton(parent)
936            self.edit_tags_button.setToolTip(_('Open Item editor'))
937            self.edit_tags_button.setIcon(QIcon(I('chapters.png')))
938            self.edit_tags_button.clicked.connect(add_edit_tags_button[1])
939            l.insertWidget(1, self.edit_tags_button)
940        l.insertStretch(2)
941
942    def initialize(self, book_ids):
943        self.initial_val = val = self.get_initial_value(book_ids)
944        val = self.normalize_db_val(val)
945        self.setter(val)
946
947    def commit(self, book_ids, notify=False):
948        if not self.a_c_checkbox.isChecked():
949            return
950        val = self.gui_val
951        val = self.normalize_ui_val(val)
952        self.db.set_custom_bulk(book_ids, val, num=self.col_id, notify=notify)
953
954    def make_widgets(self, parent, main_widget_class):
955        w = QWidget(parent)
956        self.widgets = [QLabel(label_string(self.col_metadata['name']), w), w]
957        l = QHBoxLayout()
958        l.setContentsMargins(0, 0, 0, 0)
959        w.setLayout(l)
960        self.main_widget = main_widget_class(w)
961        l.addWidget(self.main_widget)
962        l.setStretchFactor(self.main_widget, 10)
963        self.a_c_checkbox = QCheckBox(_('Apply changes'), w)
964        l.addWidget(self.a_c_checkbox)
965        self.ignore_change_signals = True
966
967        # connect to the various changed signals so we can auto-update the
968        # apply changes checkbox
969        if hasattr(self.main_widget, 'editTextChanged'):
970            # editable combobox widgets
971            self.main_widget.editTextChanged.connect(self.a_c_checkbox_changed)
972        if hasattr(self.main_widget, 'textChanged'):
973            # lineEdit widgets
974            self.main_widget.textChanged.connect(self.a_c_checkbox_changed)
975        if hasattr(self.main_widget, 'currentIndexChanged'):
976            # combobox widgets
977            self.main_widget.currentIndexChanged[int].connect(self.a_c_checkbox_changed)
978        if hasattr(self.main_widget, 'valueChanged'):
979            # spinbox widgets
980            self.main_widget.valueChanged.connect(self.a_c_checkbox_changed)
981        if hasattr(self.main_widget, 'dateTimeChanged'):
982            # dateEdit widgets
983            self.main_widget.dateTimeChanged.connect(self.a_c_checkbox_changed)
984
985    def a_c_checkbox_changed(self):
986        if not self.ignore_change_signals:
987            self.a_c_checkbox.setChecked(True)
988
989
990class BulkBool(BulkBase, Bool):
991
992    def get_initial_value(self, book_ids):
993        value = None
994        for book_id in book_ids:
995            val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True)
996            if not self.db.new_api.pref('bools_are_tristate') and val is None:
997                val = False
998            if value is not None and value != val:
999                return None
1000            value = val
1001        return value
1002
1003    def setup_ui(self, parent):
1004        self.make_widgets(parent, QComboBox)
1005        items = [_('Yes'), _('No')]
1006        self.bools_are_tristate = self.db.new_api.pref('bools_are_tristate')
1007        if not self.bools_are_tristate:
1008            items.append('')
1009        else:
1010            items.append(_('Undefined'))
1011        icons = [I('ok.png'), I('list_remove.png'), I('blank.png')]
1012        self.main_widget.blockSignals(True)
1013        for icon, text in zip(icons, items):
1014            self.main_widget.addItem(QIcon(icon), text)
1015        self.main_widget.blockSignals(False)
1016        self.finish_ui_setup(parent, is_bool=True)
1017
1018    def set_to_undefined(self):
1019        # Only called if bools are tristate
1020        self.main_widget.setCurrentIndex(2)
1021
1022    def getter(self):
1023        val = self.main_widget.currentIndex()
1024        if not self.bools_are_tristate:
1025            return {2: False, 1: False, 0: True}[val]
1026        else:
1027            return {2: None, 1: False, 0: True}[val]
1028
1029    def setter(self, val):
1030        val = {None: 2, False: 1, True: 0}[val]
1031        self.main_widget.setCurrentIndex(val)
1032        self.ignore_change_signals = False
1033
1034    def commit(self, book_ids, notify=False):
1035        if not self.a_c_checkbox.isChecked():
1036            return
1037        val = self.gui_val
1038        val = self.normalize_ui_val(val)
1039        if not self.bools_are_tristate and val is None:
1040            val = False
1041        self.db.set_custom_bulk(book_ids, val, num=self.col_id, notify=notify)
1042
1043    def a_c_checkbox_changed(self):
1044        if not self.ignore_change_signals:
1045            if not self.bools_are_tristate and self.main_widget.currentIndex() == 2:
1046                self.a_c_checkbox.setChecked(False)
1047            else:
1048                self.a_c_checkbox.setChecked(True)
1049
1050
1051class BulkInt(BulkBase):
1052
1053    def setup_ui(self, parent):
1054        self.make_widgets(parent, QSpinBox)
1055        self.main_widget.setRange(-1000000, 100000000)
1056        self.finish_ui_setup(parent)
1057
1058    def finish_ui_setup(self, parent):
1059        BulkBase.finish_ui_setup(self, parent)
1060        self.main_widget.setSpecialValueText(_('Undefined'))
1061        self.main_widget.setSingleStep(1)
1062        self.main_widget.valueChanged.connect(self.valueChanged)
1063
1064    def setter(self, val):
1065        if val is None:
1066            val = self.main_widget.minimum()
1067        self.main_widget.setValue(val)
1068        self.ignore_change_signals = False
1069        self.was_none = val == self.main_widget.minimum()
1070
1071    def getter(self):
1072        val = self.main_widget.value()
1073        if val == self.main_widget.minimum():
1074            val = None
1075        return val
1076
1077    def valueChanged(self, to_what):
1078        if self.was_none and to_what == -999999:
1079            self.setter(0)
1080        self.was_none = to_what == self.main_widget.minimum()
1081
1082    def set_to_undefined(self):
1083        self.main_widget.setValue(-1000000)
1084
1085
1086class BulkFloat(BulkInt):
1087
1088    def setup_ui(self, parent):
1089        self.make_widgets(parent, QDoubleSpinBox)
1090        self.main_widget.setRange(-1000000., float(100000000))
1091        self.main_widget.setDecimals(2)
1092        self.finish_ui_setup(parent)
1093
1094    def set_to_undefined(self):
1095        self.main_widget.setValue(-1000000.)
1096
1097
1098class BulkRating(BulkBase):
1099
1100    def setup_ui(self, parent):
1101        allow_half_stars = self.col_metadata['display'].get('allow_half_stars', False)
1102        self.make_widgets(parent, partial(RatingEditor, is_half_star=allow_half_stars))
1103        self.finish_ui_setup(parent)
1104
1105    def set_to_undefined(self):
1106        self.main_widget.setCurrentIndex(0)
1107
1108    def setter(self, val):
1109        val = max(0, min(int(val or 0), 10))
1110        self.main_widget.rating_value = val
1111        self.ignore_change_signals = False
1112
1113    def getter(self):
1114        return self.main_widget.rating_value or None
1115
1116
1117class BulkDateTime(BulkBase):
1118
1119    def setup_ui(self, parent):
1120        cm = self.col_metadata
1121        self.make_widgets(parent, DateTimeEdit)
1122        l = self.widgets[1].layout()
1123        self.today_button = QToolButton(parent)
1124        self.today_button.setText(_('Today'))
1125        l.insertWidget(1, self.today_button)
1126        self.clear_button = QToolButton(parent)
1127        self.clear_button.setIcon(QIcon(I('trash.png')))
1128        self.clear_button.setToolTip(_('Clear {0}').format(self.col_metadata['name']))
1129        l.insertWidget(2, self.clear_button)
1130        l.insertStretch(3)
1131
1132        w = self.main_widget
1133        format_ = cm['display'].get('date_format','')
1134        if not format_:
1135            format_ = 'dd MMM yyyy'
1136        elif format_ == 'iso':
1137            format_ = internal_iso_format_string()
1138        w.setDisplayFormat(format_)
1139        w.setCalendarPopup(True)
1140        w.setMinimumDateTime(UNDEFINED_QDATETIME)
1141        w.setSpecialValueText(_('Undefined'))
1142        self.today_button.clicked.connect(w.set_to_today)
1143        self.clear_button.clicked.connect(w.set_to_clear)
1144
1145    def setter(self, val):
1146        if val is None:
1147            val = self.main_widget.minimumDateTime()
1148        else:
1149            val = QDateTime(val)
1150        self.main_widget.setDateTime(val)
1151        self.ignore_change_signals = False
1152
1153    def getter(self):
1154        val = self.main_widget.dateTime()
1155        if val <= UNDEFINED_QDATETIME:
1156            val = None
1157        else:
1158            val = qt_to_dt(val)
1159        return val
1160
1161    def normalize_db_val(self, val):
1162        return as_local_time(val) if val is not None else None
1163
1164    def normalize_ui_val(self, val):
1165        return as_utc(val) if val is not None else None
1166
1167
1168class BulkSeries(BulkBase):
1169
1170    def setup_ui(self, parent):
1171        self.make_widgets(parent, EditWithComplete)
1172        values = self.all_values = list(self.db.all_custom(num=self.col_id))
1173        values.sort(key=sort_key)
1174        self.main_widget.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
1175        self.main_widget.setMinimumContentsLength(25)
1176        self.widgets.append(QLabel('', parent))
1177        w = QWidget(parent)
1178        layout = QHBoxLayout(w)
1179        layout.setContentsMargins(0, 0, 0, 0)
1180        self.remove_series = QCheckBox(parent)
1181        self.remove_series.setText(_('Clear series'))
1182        layout.addWidget(self.remove_series)
1183        self.idx_widget = QCheckBox(parent)
1184        self.idx_widget.setText(_('Automatically number books'))
1185        self.idx_widget.setToolTip('<p>' + _(
1186            'If not checked, the series number for the books will be set to 1. '
1187            'If checked, selected books will be automatically numbered, '
1188            'in the order you selected them. So if you selected '
1189            'Book A and then Book B, Book A will have series number 1 '
1190            'and Book B series number 2.') + '</p>')
1191        layout.addWidget(self.idx_widget)
1192        self.force_number = QCheckBox(parent)
1193        self.force_number.setText(_('Force numbers to start with '))
1194        self.force_number.setToolTip('<p>' + _(
1195            'Series will normally be renumbered from the highest '
1196            'number in the database for that series. Checking this '
1197            'box will tell calibre to start numbering from the value '
1198            'in the box') + '</p>')
1199        layout.addWidget(self.force_number)
1200        self.series_start_number = QDoubleSpinBox(parent)
1201        self.series_start_number.setMinimum(0.0)
1202        self.series_start_number.setMaximum(9999999.0)
1203        self.series_start_number.setProperty("value", 1.0)
1204        layout.addWidget(self.series_start_number)
1205        self.series_increment = QDoubleSpinBox(parent)
1206        self.series_increment.setMinimum(0.00)
1207        self.series_increment.setMaximum(99999.0)
1208        self.series_increment.setProperty("value", 1.0)
1209        self.series_increment.setToolTip('<p>' + _(
1210            'The amount by which to increment the series number '
1211            'for successive books. Only applicable when using '
1212            'force series numbers.') + '</p>')
1213        self.series_increment.setPrefix('+')
1214        layout.addWidget(self.series_increment)
1215        layout.addItem(QSpacerItem(20, 10, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum))
1216        self.widgets.append(w)
1217        self.idx_widget.stateChanged.connect(self.a_c_checkbox_changed)
1218        self.force_number.stateChanged.connect(self.a_c_checkbox_changed)
1219        self.series_start_number.valueChanged.connect(self.a_c_checkbox_changed)
1220        self.series_increment.valueChanged.connect(self.a_c_checkbox_changed)
1221        self.remove_series.stateChanged.connect(self.a_c_checkbox_changed)
1222        self.main_widget
1223        self.ignore_change_signals = False
1224
1225    def a_c_checkbox_changed(self):
1226        def disable_numbering_checkboxes(idx_widget_enable):
1227            if idx_widget_enable:
1228                self.idx_widget.setEnabled(True)
1229            else:
1230                self.idx_widget.setChecked(False)
1231                self.idx_widget.setEnabled(False)
1232            self.force_number.setChecked(False)
1233            self.force_number.setEnabled(False)
1234            self.series_start_number.setEnabled(False)
1235            self.series_increment.setEnabled(False)
1236
1237        if self.ignore_change_signals:
1238            return
1239        self.ignore_change_signals = True
1240        apply_changes = False
1241        if self.remove_series.isChecked():
1242            self.main_widget.setText('')
1243            self.main_widget.setEnabled(False)
1244            disable_numbering_checkboxes(idx_widget_enable=False)
1245            apply_changes = True
1246        elif self.main_widget.text():
1247            self.remove_series.setEnabled(False)
1248            self.idx_widget.setEnabled(True)
1249            apply_changes = True
1250        else:  # no text, no clear. Basically reinitialize
1251            self.main_widget.setEnabled(True)
1252            self.remove_series.setEnabled(True)
1253            disable_numbering_checkboxes(idx_widget_enable=False)
1254            apply_changes = False
1255
1256        self.force_number.setEnabled(self.idx_widget.isChecked())
1257        self.series_start_number.setEnabled(self.force_number.isChecked())
1258        self.series_increment.setEnabled(self.force_number.isChecked())
1259
1260        self.ignore_change_signals = False
1261        self.a_c_checkbox.setChecked(apply_changes)
1262
1263    def initialize(self, book_id):
1264        self.idx_widget.setChecked(False)
1265        self.main_widget.set_separator(None)
1266        self.main_widget.update_items_cache(self.all_values)
1267        self.main_widget.setEditText('')
1268        self.a_c_checkbox.setChecked(False)
1269
1270    def getter(self):
1271        n = str(self.main_widget.currentText()).strip()
1272        autonumber = self.idx_widget.checkState()
1273        force = self.force_number.checkState()
1274        start = self.series_start_number.value()
1275        remove = self.remove_series.checkState()
1276        increment = self.series_increment.value()
1277        return n, autonumber, force, start, remove, increment
1278
1279    def commit(self, book_ids, notify=False):
1280        if not self.a_c_checkbox.isChecked():
1281            return
1282        val, update_indices, force_start, at_value, clear, increment = self.gui_val
1283        val = None if clear else self.normalize_ui_val(val)
1284        if clear or val != '':
1285            extras = []
1286            for book_id in book_ids:
1287                if clear:
1288                    extras.append(None)
1289                    continue
1290                if update_indices:
1291                    if force_start:
1292                        s_index = at_value
1293                        at_value += increment
1294                    elif tweaks['series_index_auto_increment'] != 'const':
1295                        s_index = self.db.get_next_cc_series_num_for(val, num=self.col_id)
1296                    else:
1297                        s_index = 1.0
1298                else:
1299                    s_index = self.db.get_custom_extra(book_id, num=self.col_id,
1300                                                       index_is_id=True)
1301                extras.append(s_index)
1302            self.db.set_custom_bulk(book_ids, val, extras=extras,
1303                                   num=self.col_id, notify=notify)
1304
1305
1306class BulkEnumeration(BulkBase, Enumeration):
1307
1308    def get_initial_value(self, book_ids):
1309        value = None
1310        first = True
1311        dialog_shown = False
1312        for book_id in book_ids:
1313            val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True)
1314            if val and val not in self.col_metadata['display']['enum_values']:
1315                if not dialog_shown:
1316                    error_dialog(self.parent, '',
1317                            _('The enumeration "{0}" contains invalid values '
1318                              'that will not appear in the list').format(
1319                                                    self.col_metadata['name']),
1320                            show=True, show_copy_button=False)
1321                    dialog_shown = True
1322            if first:
1323                value = val
1324                first = False
1325            elif value != val:
1326                value = None
1327        if not value:
1328            self.ignore_change_signals = False
1329        return value
1330
1331    def setup_ui(self, parent):
1332        self.parent = parent
1333        self.make_widgets(parent, QComboBox)
1334        self.finish_ui_setup(parent)
1335        vals = self.col_metadata['display']['enum_values']
1336        self.main_widget.blockSignals(True)
1337        self.main_widget.addItem('')
1338        self.main_widget.addItems(vals)
1339        self.main_widget.blockSignals(False)
1340
1341    def set_to_undefined(self):
1342        self.main_widget.setCurrentIndex(0)
1343
1344    def getter(self):
1345        return str(self.main_widget.currentText())
1346
1347    def setter(self, val):
1348        if val is None:
1349            self.main_widget.setCurrentIndex(0)
1350        else:
1351            self.main_widget.setCurrentIndex(self.main_widget.findText(val))
1352        self.ignore_change_signals = False
1353
1354
1355class RemoveTags(QWidget):
1356
1357    def __init__(self, parent, values):
1358        QWidget.__init__(self, parent)
1359        layout = QHBoxLayout()
1360        layout.setSpacing(5)
1361        layout.setContentsMargins(0, 0, 0, 0)
1362
1363        self.tags_box = EditWithComplete(parent)
1364        self.tags_box.update_items_cache(values)
1365        layout.addWidget(self.tags_box, stretch=3)
1366        self.remove_tags_button = QToolButton(parent)
1367        self.remove_tags_button.setToolTip(_('Open Item editor'))
1368        self.remove_tags_button.setIcon(QIcon(I('chapters.png')))
1369        layout.addWidget(self.remove_tags_button)
1370        self.checkbox = QCheckBox(_('Remove all tags'), parent)
1371        layout.addWidget(self.checkbox)
1372        layout.addStretch(1)
1373        self.setLayout(layout)
1374        self.checkbox.stateChanged[int].connect(self.box_touched)
1375
1376    def box_touched(self, state):
1377        if state:
1378            self.tags_box.setText('')
1379            self.tags_box.setEnabled(False)
1380        else:
1381            self.tags_box.setEnabled(True)
1382
1383
1384class BulkText(BulkBase):
1385
1386    def setup_ui(self, parent):
1387        values = self.all_values = list(self.db.all_custom(num=self.col_id))
1388        values.sort(key=sort_key)
1389        is_tags = False
1390        if self.col_metadata['is_multiple']:
1391            is_tags = not self.col_metadata['display'].get('is_names', False)
1392            self.make_widgets(parent, EditWithComplete)
1393            self.main_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
1394            self.adding_widget = self.main_widget
1395
1396            if is_tags:
1397                w = RemoveTags(parent, values)
1398                w.remove_tags_button.clicked.connect(self.edit_remove)
1399                l = QLabel(label_string(self.col_metadata['name'])+': ' +
1400                                           _('tags to remove'), parent)
1401                tt = get_tooltip(self.col_metadata) + ': ' + _('tags to remove')
1402                l.setToolTip(tt)
1403                self.widgets.append(l)
1404                w.setToolTip(tt)
1405                self.widgets.append(w)
1406                self.removing_widget = w
1407                self.main_widget.set_separator(',')
1408                w.tags_box.textChanged.connect(self.a_c_checkbox_changed)
1409                w.checkbox.stateChanged.connect(self.a_c_checkbox_changed)
1410            else:
1411                self.main_widget.set_separator('&')
1412                self.main_widget.set_space_before_sep(True)
1413                self.main_widget.set_add_separator(
1414                                tweaks['authors_completer_append_separator'])
1415        else:
1416            self.make_widgets(parent, EditWithComplete)
1417            self.main_widget.set_separator(None)
1418            self.main_widget.setSizeAdjustPolicy(
1419                        QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
1420            self.main_widget.setMinimumContentsLength(25)
1421        self.ignore_change_signals = False
1422        self.parent = parent
1423        self.finish_ui_setup(parent, add_edit_tags_button=(is_tags,self.edit_add))
1424
1425    def set_to_undefined(self):
1426        self.main_widget.clearEditText()
1427
1428    def initialize(self, book_ids):
1429        self.main_widget.update_items_cache(self.all_values)
1430        if not self.col_metadata['is_multiple']:
1431            val = self.get_initial_value(book_ids)
1432            self.initial_val = val = self.normalize_db_val(val)
1433            self.ignore_change_signals = True
1434            self.main_widget.blockSignals(True)
1435            self.main_widget.setText(val)
1436            self.main_widget.blockSignals(False)
1437            self.ignore_change_signals = False
1438
1439    def commit(self, book_ids, notify=False):
1440        if not self.a_c_checkbox.isChecked():
1441            return
1442        if self.col_metadata['is_multiple']:
1443            ism = self.col_metadata['multiple_seps']
1444            if self.col_metadata['display'].get('is_names', False):
1445                val = self.gui_val
1446                add = [v.strip() for v in val.split(ism['ui_to_list']) if v.strip()]
1447                self.db.set_custom_bulk(book_ids, add, num=self.col_id)
1448            else:
1449                remove_all, adding, rtext = self.gui_val
1450                remove = set()
1451                if remove_all:
1452                    remove = set(self.db.all_custom(num=self.col_id))
1453                else:
1454                    txt = rtext
1455                    if txt:
1456                        remove = {v.strip() for v in txt.split(ism['ui_to_list'])}
1457                txt = adding
1458                if txt:
1459                    add = {v.strip() for v in txt.split(ism['ui_to_list'])}
1460                else:
1461                    add = set()
1462                self.db.set_custom_bulk_multiple(book_ids, add=add,
1463                                            remove=remove, num=self.col_id)
1464        else:
1465            val = self.gui_val
1466            val = self.normalize_ui_val(val)
1467            self.db.set_custom_bulk(book_ids, val, num=self.col_id, notify=notify)
1468
1469    def getter(self):
1470        if self.col_metadata['is_multiple']:
1471            if not self.col_metadata['display'].get('is_names', False):
1472                return self.removing_widget.checkbox.isChecked(), \
1473                        str(self.adding_widget.text()), \
1474                        str(self.removing_widget.tags_box.text())
1475            return str(self.adding_widget.text())
1476        val = str(self.main_widget.currentText()).strip()
1477        if not val:
1478            val = None
1479        return val
1480
1481    def edit_remove(self):
1482        self.edit(widget=self.removing_widget.tags_box)
1483
1484    def edit_add(self):
1485        self.edit(widget=self.main_widget)
1486
1487    def edit(self, widget):
1488        if widget.text():
1489            d = _save_dialog(self.parent, _('Values changed'),
1490                    _('You have entered values. In order to use this '
1491                       'editor you must first discard them. '
1492                       'Discard the values?'))
1493            if d == QMessageBox.StandardButton.Cancel or d == QMessageBox.StandardButton.No:
1494                return
1495            widget.setText('')
1496        d = TagEditor(self.parent, self.db, key=('#'+self.col_metadata['label']))
1497        if d.exec() == QDialog.DialogCode.Accepted:
1498            val = d.tags
1499            if not val:
1500                val = []
1501            widget.setText(self.col_metadata['multiple_seps']['list_to_ui'].join(val))
1502
1503
1504bulk_widgets = {
1505        'bool' : BulkBool,
1506        'rating' : BulkRating,
1507        'int': BulkInt,
1508        'float': BulkFloat,
1509        'datetime': BulkDateTime,
1510        'text' : BulkText,
1511        'series': BulkSeries,
1512        'enumeration': BulkEnumeration,
1513}
1514