#!/usr/bin/env python


__license__   = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid at kovidgoyal.net>'

'''Dialog to create a new custom column'''

import copy
import re
from enum import Enum
from functools import partial

from qt.core import (
    QCheckBox,
    QColor,
    QComboBox,
    QDialog,
    QDialogButtonBox,
    QGridLayout,
    QGroupBox,
    QHBoxLayout,
    QIcon,
    QLabel,
    QLineEdit,
    QRadioButton,
    QSpinBox,
    Qt,
    QToolButton,
    QVBoxLayout,
    QWidget,
)

from calibre.gui2 import error_dialog, question_dialog
from calibre.gui2.dialogs.template_dialog import TemplateDialog
from calibre.gui2.dialogs.template_line_editor import TemplateLineEditor
from calibre.utils.date import UNDEFINED_DATE, parse_date
from calibre.utils.localization import ngettext


class CreateCustomColumn(QDialog):

    # Note: in this class, we are treating is_multiple as the boolean that
    # custom_columns expects to find in its structure. It does not use the dict

    column_types = dict(enumerate((
        {
            'datatype':'text',
            'text':_('Text, column shown in the Tag browser'),
            'is_multiple':False
        },
        {
            'datatype':'*text',
            'text':_('Comma separated text, like tags, shown in the Tag browser'),
            'is_multiple':True
        },
        {
            'datatype':'comments',
            'text':_('Long text, like comments, not shown in the Tag browser'),
            'is_multiple':False
        },
        {
            'datatype':'series',
            'text':_('Text column for keeping series-like information'),
            'is_multiple':False
        },
        {
            'datatype':'enumeration',
            'text':_('Text, but with a fixed set of permitted values'),
            'is_multiple':False
        },
        {
            'datatype':'datetime',
            'text':_('Date'),
            'is_multiple':False
        },
        {
            'datatype':'float',
            'text':_('Floating point numbers'),
            'is_multiple':False
        },
        {
            'datatype':'int',
            'text':_('Integers'),
            'is_multiple':False
        },
        {
            'datatype':'rating',
            'text':_('Ratings, shown with stars'),
            'is_multiple':False
        },
        {
            'datatype':'bool',
            'text':_('Yes/No'),
            'is_multiple':False
        },
        {
            'datatype':'composite',
            'text':_('Column built from other columns'),
            'is_multiple':False
        },
        {
            'datatype':'*composite',
            'text':_('Column built from other columns, behaves like tags'),
            'is_multiple':True
        },
    )))
    column_types_map = {k['datatype']:idx for idx, k in column_types.items()}

    def __init__(self, gui, caller, current_key, standard_colheads, freeze_lookup_name=False):
        QDialog.__init__(self, gui)
        self.orig_column_number = -1
        self.gui = gui
        self.setup_ui()
        self.setWindowTitle(_('Create a custom column'))
        self.heading_label.setText('<b>' + _('Create a custom column'))
        # Remove help icon on title bar
        icon = self.windowIcon()
        self.setWindowFlags(self.windowFlags()&(~Qt.WindowType.WindowContextHelpButtonHint))
        self.setWindowIcon(icon)

        self.simple_error = partial(error_dialog, self, show=True,
            show_copy_button=False)
        for sort_by in [_('Text'), _('Number'), _('Date'), _('Yes/No')]:
            self.composite_sort_by.addItem(sort_by)

        self.caller = caller
        self.caller.cc_column_key = None
        self.editing_col = current_key is not None
        self.standard_colheads = standard_colheads
        self.column_type_box.setMaxVisibleItems(len(self.column_types))
        for t in self.column_types:
            self.column_type_box.addItem(self.column_types[t]['text'])
        self.column_type_box.currentIndexChanged.connect(self.datatype_changed)
        self.composite_in_comments_box.stateChanged.connect(self.composite_show_in_comments_clicked)

        if not self.editing_col:
            self.datatype_changed()
            self.exec()
            return

        self.setWindowTitle(_('Edit custom column'))
        self.heading_label.setText('<b>' + _('Edit custom column'))
        self.shortcuts.setVisible(False)
        col = current_key
        if col not in caller.custcols:
            self.simple_error('', _('The selected column is not a user-defined column'))
            return

        c = caller.custcols[col]
        self.column_name_box.setText(c['label'])
        if freeze_lookup_name:
            self.column_name_box.setEnabled(False)
        self.column_heading_box.setText(c['name'])
        self.column_heading_box.setFocus()
        ct = c['datatype']
        if c['is_multiple']:
            ct = '*' + ct
        self.orig_column_number = c['colnum']
        self.orig_column_name = col
        column_numbers = {self.column_types[x]['datatype']: x for x in self.column_types}
        self.column_type_box.setCurrentIndex(column_numbers[ct])
        self.column_type_box.setEnabled(False)

        self.datatype_changed()

        if ct == 'datetime':
            if c['display'].get('date_format', None):
                self.format_box.setText(c['display'].get('date_format', ''))
        elif ct in ['composite', '*composite']:
            self.composite_box.setText(c['display'].get('composite_template', ''))
            self.store_template_value_in_opf.setChecked(c['display'].get('composite_store_template_value_in_opf', True))
            if c['display'].get('composite_show_in_comments', ''):
                self.composite_in_comments_box.setChecked(True)
                idx = max(0, self.composite_heading_position.findData(c['display'].get('heading_position', 'hide')))
                self.composite_heading_position.setCurrentIndex(idx)
            else:
                self.composite_in_comments_box.setChecked(False)
                sb = c['display'].get('composite_sort', 'text')
                vals = ['text', 'number', 'date', 'bool']
                if sb in vals:
                    sb = vals.index(sb)
                else:
                    sb = 0
                self.composite_sort_by.setCurrentIndex(sb)
                self.composite_make_category.setChecked(
                                    c['display'].get('make_category', False))
                self.composite_contains_html.setChecked(
                                    c['display'].get('contains_html', False))
        elif ct == 'enumeration':
            self.enum_box.setText(','.join(c['display'].get('enum_values', [])))
            self.enum_colors.setText(','.join(c['display'].get('enum_colors', [])))
        elif ct in ['int', 'float']:
            if c['display'].get('number_format', None):
                self.format_box.setText(c['display'].get('number_format', ''))
        elif ct == 'comments':
            idx = max(0, self.comments_heading_position.findData(c['display'].get('heading_position', 'hide')))
            self.comments_heading_position.setCurrentIndex(idx)
            idx = max(0, self.comments_type.findData(c['display'].get('interpret_as', 'html')))
            self.comments_type.setCurrentIndex(idx)
        elif ct == 'rating':
            self.allow_half_stars.setChecked(bool(c['display'].get('allow_half_stars', False)))
        elif ct == 'bool':
            icon = bool(c['display'].get('bools_show_icons', True))
            txt = bool(c['display'].get('bools_show_text', False))
            if icon and txt:
                self.bool_show_both_button.click()
            elif icon:
                self.bool_show_icon_button.click()
            else:
                self.bool_show_text_button.click()

        # Default values
        dv = c['display'].get('default_value', None)
        if dv is not None:
            if ct == 'bool':
                self.default_value.setText(_('Yes') if dv else _('No'))
            elif ct == 'datetime':
                self.default_value.setText(_('Now') if dv == 'now' else dv)
            elif ct == 'rating':
                if self.allow_half_stars.isChecked():
                    self.default_value.setText(str(dv/2))
                else:
                    self.default_value.setText(str(dv//2))
            elif ct in ('int', 'float'):
                self.default_value.setText(str(dv))
            elif ct not in ('composite', '*composite'):
                self.default_value.setText(dv)

        if ct in ['text', 'composite', 'enumeration']:
            self.use_decorations.setChecked(c['display'].get('use_decorations', False))
        elif ct == '*text':
            self.is_names.setChecked(c['display'].get('is_names', False))
        self.description_box.setText(c['display'].get('description', ''))
        self.web_search_template.setText(c['display'].get('web_search_template', ''))
        self.decimals_box.setValue(min(9, max(1, int(c['display'].get('decimals', 2)))))

        all_colors = [str(s) for s in list(QColor.colorNames())]
        self.enum_colors_label.setToolTip('<p>' + ', '.join(all_colors) + '</p>')
        self.exec()

    def shortcut_activated(self, url):  # {{{
        which = str(url).split(':')[-1]
        self.column_type_box.setCurrentIndex({
            'yesno': self.column_types_map['bool'],
            'tags': self.column_types_map['*text'],
            'series': self.column_types_map['series'],
            'rating': self.column_types_map['rating'],
            'people': self.column_types_map['*text'],
            'text': self.column_types_map['comments'],
            }.get(which, self.column_types_map['composite']))
        self.column_name_box.setText(which)
        self.column_heading_box.setText({
            'isbn':'ISBN',
            'formats':_('Formats'),
            'yesno':_('Yes/No'),
            'tags': _('My Tags'),
            'series': _('My Series'),
            'rating': _('My Rating'),
            'people': _('People'),
            'text': _('My Title'),
            'read_progress': _('Read progress'),
        }[which])
        self.is_names.setChecked(which == 'people')
        if self.composite_box.isVisible():
            self.composite_box.setText({
                'isbn': '{identifiers:select(isbn)}',
                'formats': "{:'re(approximate_formats(), ',', ', ')'}",
                'read_progress': '{id:reading_progress()}',
            }[which])
            self.composite_sort_by.setCurrentIndex(0)
            if which == 'read_progress':
                self.composite_sort_by.setCurrentIndex(1)
                self.store_template_value_in_opf.setChecked(False)
                self.description_box.setText(_(
                    'To customize this, read the help for the {0} template function in the calibre User Manual').format(
                        'reading_progress()'))
        if which == 'text':
            self.comments_heading_position.setCurrentIndex(self.comments_heading_position.findData('side'))
            self.comments_type.setCurrentIndex(self.comments_type.findData('short-text'))
    # }}}

    def setup_ui(self):  # {{{
        self.setWindowModality(Qt.WindowModality.ApplicationModal)
        self.setWindowIcon(QIcon.ic('column.png'))
        self.vl = l = QVBoxLayout(self)
        self.heading_label = la = QLabel('')
        l.addWidget(la)
        self.shortcuts = s = QLabel('')
        s.setWordWrap(True)
        s.linkActivated.connect(self.shortcut_activated)
        text = '<p>'+_('Quick create:')
        for col, name in [('isbn', _('ISBN')), ('formats', _('Formats')),
                ('yesno', _('Yes/No')),
                ('tags', _('Tags')), ('series', ngettext('Series', 'Series', 1)), ('rating',
                    _('Rating')), ('people', _('Names')), ('text', _('Short text')),
                          ('read_progress', _('Read progress'))]:
            text += f' <a href="col:{col}">{name}</a>,'
        text = text[:-1]
        s.setText(text)
        l.addWidget(s)
        self.g = g = QGridLayout()
        l.addLayout(g)
        l.addStretch(10)
        bbl = QHBoxLayout()
        txt = QLabel(_('Pressing OK will require restarting calibre even if nothing was changed'))
        txt.setWordWrap(True)
        bbl.addWidget(txt)
        bbl.addStretch(1)
        self.button_box = bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self)
        bb.accepted.connect(self.accept), bb.rejected.connect(self.reject)
        bbl.addWidget(bb)
        l.addLayout(bbl)

        def add_row(text, widget):
            if text is None:
                f = g.addWidget if isinstance(widget, QWidget) else g.addLayout
                f(widget, g.rowCount(), 0, 1, -1)
                return

            row = g.rowCount()
            la = QLabel(text)
            g.addWidget(la, row, 0, 1, 1)
            if isinstance(widget, QWidget):
                la.setBuddy(widget)
                g.addWidget(widget, row, 1, 1, 1)
            else:
                widget.setContentsMargins(0, 0, 0, 0)
                g.addLayout(widget, row, 1, 1, 1)
                for i in range(widget.count()):
                    w = widget.itemAt(i).widget()
                    if isinstance(w, QWidget):
                        la.setBuddy(w)
                        break
            return la

        # Lookup name
        self.column_name_box = cnb = QLineEdit(self)
        cnb.setToolTip(_('Used for searching the column. Must contain only digits and lower case letters.'))
        add_row(_('&Lookup name:'), cnb)

        # Heading
        self.column_heading_box = chb = QLineEdit(self)
        chb.setToolTip(_('Column heading in the library view and category name in the Tag browser'))
        add_row(_('Column &heading:'), chb)

        # Column Type
        h = QHBoxLayout()
        self.column_type_box = ctb = QComboBox(self)
        ctb.setMinimumWidth(70)
        ctb.setToolTip(_('What kind of information will be kept in the column.'))
        h.addWidget(ctb)
        self.use_decorations = ud = QCheckBox(_('Show &checkmarks'), self)
        ud.setToolTip(_("Show check marks in the GUI. Values of 'yes', 'checked', and 'true'\n"
            "will show a green check. Values of 'no', 'unchecked', and 'false' will show a red X.\n"
            "Everything else will show nothing. Note that the values of 'true' and 'false' don't\n"
            "follow calibre's language settings and are always in English."))
        h.addWidget(ud)
        self.is_names = ins = QCheckBox(_('Contains names'), self)
        ins.setToolTip('<p>' + _('Check this box if this column contains names, '
            'like the authors column. If checked, the item separator will be an ampersand '
            '(&) instead of a comma (,), sorting will be done using a computed value '
            'that respects the author sort tweaks (for example converting "Firstname '
            'Lastname" into "Lastname, Firstname"), and item order will be '
            'preserved.')+'</p>')
        h.addWidget(ins)
        add_row(_('&Column type:'), h)

        # Description
        self.description_box = d = QLineEdit(self)
        d.setToolTip(_('Optional text describing what this column is for'))
        add_row(_('D&escription:'), d)

        # bool formatting
        h1 = QHBoxLayout()

        def add_bool_radio_button(txt):
            b = QRadioButton(txt)
            b.clicked.connect(partial(self.bool_radio_button_clicked, b))
            h1.addWidget(b)
            return b
        self.bool_show_icon_button = add_bool_radio_button(_('&Icon'))
        self.bool_show_icon_button.setChecked(True)
        self.bool_show_text_button = add_bool_radio_button(_('&Text'))
        self.bool_show_both_button = add_bool_radio_button(_('&Both'))
        self.bool_button_group = QGroupBox()
        self.bool_button_group.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
        self.bool_button_group.setLayout(h1)
        h = QHBoxLayout()
        h.addWidget(self.bool_button_group)
        self.bool_button_group_label = la = QLabel(_('Choose whether an icon, text, or both is shown in the book list'))
        la.setWordWrap(True)
        h.addWidget(la)
        h.setStretch(1, 10)
        self.bool_show_label = add_row(_('&Show:'), h)

        # Date/number formatting
        h = QHBoxLayout()
        self.format_box = fb = QLineEdit(self)
        h.addWidget(fb)
        self.format_default_label = la = QLabel('')
        la.setOpenExternalLinks(True), la.setWordWrap(True)
        h.addWidget(la)
        self.format_label = add_row('', h)

        # Float number of decimal digits
        h = QHBoxLayout()
        self.decimals_box = fb = QSpinBox(self)
        fb.setRange(1, 9)
        fb.setValue(2)
        h.addWidget(fb)
        self.decimals_default_label = la = QLabel(_(
            'Control the number of decimal digits you can enter when editing this column'))
        la.setWordWrap(True)
        h.addWidget(la)
        self.decimals_label = add_row(_('Decimals when &editing:'), h)

        # Template
        self.composite_box = cb = TemplateLineEditor(self)
        self.composite_default_label = cdl = QLabel(_('Default: (nothing)'))
        cb.setToolTip(_('Field template. Uses the same syntax as save templates.'))
        cdl.setToolTip(_('Similar to save templates. For example, %s') % '{title} {isbn}')
        h = QHBoxLayout()
        h.addWidget(cb)
        self.composite_template_toolbutton = ctb = QToolButton()
        ctb.setIcon(QIcon.ic('edit_input.png'))
        ctb.setToolTip(_('Open the template editor'))
        ctb.clicked.connect(self.composite_template_button_clicked)
        h.addWidget(ctb)
        h.addWidget(cdl)
        self.composite_label = add_row(_('&Template:'), h)

        # Comments properties
        self.comments_heading_position = ct = QComboBox(self)
        for k, text in (
                ('hide', _('No heading')),
                ('above', _('Show heading above the text')),
                ('side', _('Show heading to the side of the text'))
        ):
            ct.addItem(text, k)
        ct.setToolTip('<p>' +
                      _('Choose whether or not the column heading is shown in the Book '
                      'details panel and, if shown, where. Setting this to '
                      "'Show heading to the side of the text' moves the information "
                      "from displayed with other comments to displayed with the "
                      "non-comments columns.") + '</p>')
        self.comments_heading_position_label = add_row(_('Heading position:'), ct)

        self.comments_type = ct = QComboBox(self)
        for k, text in (
                ('html', 'HTML'),
                ('short-text', _('Short text, like a title')),
                ('long-text', _('Plain text')),
                ('markdown', _('Plain text formatted using Markdown'))
        ):
            ct.addItem(text, k)
        ct.setToolTip(_('Choose how the data in this column is interpreted.\n'
                        'This controls how the data is displayed in the Book details panel\n'
                        'and how it is edited.'))
        self.comments_type_label = add_row(_('Interpret this column as:') + ' ', ct)

        # Values for enum type
        self.enum_box = eb = QLineEdit(self)
        eb.setToolTip(_(
            "A comma-separated list of permitted values. The empty value is always\n"
            "included, and is the default. For example, the list 'one,two,three' has\n"
            "four values, the first of them being the empty value."))
        self.enum_default_label = add_row(_('&Values:'), eb)
        self.enum_colors = ec = QLineEdit(self)
        ec.setToolTip(_('A list of color names to use when displaying an item. The\n'
            'list must be empty or contain a color for each value.'))
        self.enum_colors_label = add_row(_('Colors:'), ec)

        # Rating allow half stars
        self.allow_half_stars = ahs = QCheckBox(_('Allow half stars'))
        ahs.setToolTip(_('Allow half star ratings, for example: ') + '★★★⯨')
        add_row(None, ahs)

        # Composite display properties
        l = QHBoxLayout()
        self.composite_sort_by_label = la = QLabel(_('&Sort/search column by') + ':')
        self.composite_sort_by = csb = QComboBox(self)
        la.setBuddy(csb), csb.setToolTip(_('How this column should handled in the GUI when sorting and searching'))
        l.addWidget(la), l.addWidget(csb)
        self.composite_make_category = cmc = QCheckBox(_('Show in Tag browser'))
        cmc.setToolTip(_('If checked, this column will appear in the Tag browser as a category'))
        l.addWidget(cmc)
        self.composite_contains_html = cch = QCheckBox(_('Show as HTML in Book details'))
        cch.setToolTip('<p>' + _(
            'If checked, this column will be displayed as HTML in '
            'Book details and the Content server. This can be used to '
            'construct links with the template language. For example, '
            'the template {0} '
            'will create a field displaying the title in bold large '
            'characters, along with the series, for example <br>"<big><b>'
            'An Oblique Approach</b></big> [Belisarius [1]]". The template {1} '
            'will generate a link to the book on the Beam e-books site.').format(
                '<pre>&lt;big&gt;&lt;b&gt;{title}&lt;/b&gt;&lt;/big&gt;'
                '{series:| [|}{series_index:| [|]]}</pre>',
                '<pre>&lt;a href="https://www.beam-ebooks.de/ebook/{identifiers'
                ':select(beam)}"&gt;Beam book&lt;/a&gt;</pre> '
        ) + '</p>')
        l.addWidget(cch)
        l.addStretch()
        add_row(None, l)

        l = QHBoxLayout()
        self.composite_in_comments_box = cmc = QCheckBox(_('Show with comments in Book details'))
        cmc.setToolTip('<p>' + _('If you check this box then the column contents '
                                 'will show in the Comments section in the Book details. '
                                 'You can indicate whether not to have a header or '
                                 'to put a header above the column. If you want a '
                                 "header beside the data, don't check this box. "
                                 'If this box is checked then the output of the '
                                 'column template must be plain text or html.') + '</p>')
        l.addWidget(cmc)
        self.composite_heading_position = chp = QComboBox(self)
        for k, text in (
                ('hide', _('No heading')),
                ('above', _('Show heading above the text'))
                # we don't offer 'side' because that is what you get if you don't
                # check the box.
        ):
            chp.addItem(text, k)
        chp.setToolTip(_('Choose whether or not the column heading is shown in the Book\n'
                        'details panel and, if shown, where'))
        self.composite_heading_position_label = la = QLabel(_('Column heading:'))
        l.addWidget(la), l.addWidget(chp)
        l.addStretch()
        add_row(None, l)
        l = QHBoxLayout()
        self.store_template_value_in_opf = cmc = QCheckBox(_("Store this column's value in an OPF"))
        cmc.setToolTip('<p>' + _('If you check this box then the result of '
                       "evaluating this column's template will be stored in the backup OPF "
                       'stored in the library. The same is true when sending to a device, '
                       'assuming the format has an OPF. One reason to uncheck this box is '
                       'that the column contains large images.') + '</p>' + '<p>' +
                     _('Note that some background functions require data for a column to '
                       'be in the OPF, for example book jackets. If you uncheck this box '
                       'and some function stops working then check the box.') + '</p>')
        l.addWidget(cmc)
        l.addStretch()
        add_row(None, l)

        # Default value
        self.default_value = dv = QLineEdit(self)
        dv.setToolTip('<p>' + _('Default value when a new book is added to the '
            'library. For Date columns enter the word "Now", or the date as '
            'yyyy-mm-dd. For Yes/No columns enter "Yes" or "No". For Text with '
            'a fixed set of values enter one of the permitted values. For '
            'Rating columns enter a number between 0 and 5.') + '</p>')
        self.default_value_label = add_row(_('&Default value:'), dv)

        l = QHBoxLayout()
        self.web_search_label = QLabel(_('Search tem&plate:'))
        l.addWidget(self.web_search_label)
        wst = self.web_search_template = QLineEdit()
        wst.setToolTip('<p>' + _(
            "Fill in this box if you want clicking on the value in book details to do a "
            "web search instead of searching your calibre library. The book's metadata is "
            "available to the template.</p><p>Additional fields '{0}', `{1}`, and '{2}' are also available "
            "to the template. For multiple-valued (tags-like) columns they are the value being examined, "
            "telling you which value to use to generate the link. The two values '{1}' and '{2}' are "
            "automatically escaped for use in URLs. In '{1}', spaces are replaced by plus signs. In '{2}' "
            "spaces are replaced by '%20'.</p><p> The template functions '{3}' (the easiest to use), "
            "'{4}', '{5}', and '{6}' are useful for constructing the desired URL. There are examples in "
            "the template function documentation.").format(
                'item_value', 'item_value_quoted', 'item_value_no_plus', 'make_url()', 'make_url_extended()',
                'query_string()', 'quote_for_url()') + '</p>')
        l.addWidget(wst)
        self.web_search_label.setBuddy(wst)
        wst_tb = self.web_search_toolbutton = QToolButton()
        wst_tb.setIcon(QIcon.ic('edit_input.png'))
        l.addWidget(wst_tb)
        wst_tb.clicked.connect(self.cws_template_button_clicked)
        add_row(None, l)

        self.resize(self.sizeHint())
    # }}}

    def composite_template_button_clicked(self):
        db = self.gui.current_db.new_api
        lv = self.gui.library_view
        rows = lv.selectionModel().selectedRows()
        if rows:
            mi = [db.get_metadata(lv.model().id(row)) for row in rows[:10]]
        else:
            mi = None
        d = TemplateDialog(parent=self, text=self.composite_box.text(),
                           mi=mi, fm=db.field_metadata)
        d.setWindowTitle(_('Edit template'))
        if d.exec() == QDialog.DialogCode.Accepted:
            self.composite_box.setText(d.rule[1])

    def cws_template_button_clicked(self):
        db = self.gui.current_db.new_api
        lv = self.gui.library_view
        rows = lv.selectionModel().selectedRows()
        from calibre.ebooks.metadata.search_internet import qquote
        if not self.editing_col or not rows:
            vals = [{'item_value': _('Item value'),
                     'item_value_quoted': qquote(_('Item value'), True),
                     'item_value_no_plus': qquote(_('Item value'), False),
                     'lookup_name': _('Lookup name'),'author': _('Author'),
                     'title': _('Title'), 'author_sort': _('Author sort')}]
        else:
            vals = []
            for row in rows:
                book_id = lv.model().id(row)
                mi = db.new_api.get_metadata(book_id)
                mi.set('item_value', _('Item value'))
                mi.set('item_value_quoted', qquote(_('Item value'), True))
                mi.set('item_value_no_plus', qquote(_('Item value'), False))
                vals.append(mi)
        d = TemplateDialog(parent=self, text=self.web_search_template.text(), mi=vals)
        if d.exec() == QDialog.DialogCode.Accepted:
            self.web_search_template.setText(d.rule[1])

    def bool_radio_button_clicked(self, button, clicked):
        if clicked:
            self.bool_button_group.setFocusProxy(button)

    def composite_show_in_comments_clicked(self, state):
        if state == Qt.CheckState.Checked.value:  # state is passed as an int
            self.composite_sort_by.setEnabled(False)
            self.composite_sort_by_label.setEnabled(False)
            self.composite_make_category.setEnabled(False)
            self.composite_contains_html.setEnabled(False)
            self.composite_heading_position.setEnabled(True)
            self.composite_heading_position_label.setEnabled(True)
            self.composite_heading_position.setCurrentIndex(0)
        else:
            self.composite_sort_by.setEnabled(True)
            self.composite_sort_by_label.setEnabled(True)
            self.composite_make_category.setEnabled(True)
            self.composite_contains_html.setEnabled(True)
            self.composite_heading_position.setEnabled(False)
            self.composite_heading_position_label.setEnabled(False)
            self.composite_heading_position.setCurrentIndex(0)

    def datatype_changed(self, *args):
        try:
            col_type = self.column_types[self.column_type_box.currentIndex()]['datatype']
        except Exception:
            col_type = None
        needs_format = col_type in ('datetime', 'int', 'float')
        for x in ('box', 'default_label', 'label'):
            getattr(self, 'format_'+x).setVisible(needs_format)
            getattr(self, 'decimals_'+x).setVisible(col_type == 'float')
        if needs_format:
            if col_type == 'datetime':
                l, dl = _('&Format for dates:'), _('Default: dd MMM yyyy.')
                self.format_box.setToolTip(_(
                    '<p>Date format.</p>'
                    '<p>The formatting codes are:'
                    '<ul>'
                    '<li>d    : the day as number without a leading zero (1 to 31)</li>'
                    '<li>dd   : the day as number with a leading zero (01 to 31)</li>'
                    '<li>ddd  : the abbreviated localized day name (e.g. "Mon" to "Sun").</li>'
                    '<li>dddd : the long localized day name (e.g. "Monday" to "Sunday").</li>'
                    '<li>M    : the <b>month</b> as number without a leading zero (1 to 12).</li>'
                    '<li>MM   : the <b>month</b> as number with a leading zero (01 to 12)</li>'
                    '<li>MMM  : the abbreviated localized <b>month</b> name (e.g. "Jan" to "Dec").</li>'
                    '<li>MMMM : the long localized <b>month</b> name (e.g. "January" to "December").</li>'
                    '<li>yy   : the year as two digit number (00 to 99).</li>'
                    '<li>yyyy : the year as four digit number.</li>'
                    '<li>h    : the hours without a leading 0 (0 to 11 or 0 to 23, depending on am/pm)</li>'
                    '<li>hh   : the hours with a leading 0 (00 to 11 or 00 to 23, depending on am/pm)</li>'
                    '<li>m    : the <b>minutes</b> without a leading 0 (0 to 59)</li>'
                    '<li>mm   : the <b>minutes</b> with a leading 0 (00 to 59)</li>'
                    '<li>s    : the seconds without a leading 0 (0 to 59)</li>'
                    '<li>ss   : the seconds with a leading 0 (00 to 59)</li>'
                    '<li>ap   : use a 12-hour clock instead of a 24-hour clock, with "ap" replaced by the localized string for am or pm</li>'
                    '<li>AP   : use a 12-hour clock instead of a 24-hour clock, with "AP" replaced by the localized string for AM or PM</li>'
                    '<li>iso  : the date with time and timezone. Must be the only format present</li>'
                    '</ul></p>'
                    '<p>For example:\n'
                    '<ul>\n'
                    '<li>ddd, d MMM yyyy gives Mon, 5 Jan 2010</li>\n'
                    '<li>dd MMMM yy gives 05 January 10</li>\n'
                    '</ul> '))
            else:
                l, dl = _('&Format for numbers:'), (
                    '<p>' + _('Default: Not formatted. For format language details see'
                    ' <a href="{}">the Python documentation</a>').format('https://docs.python.org/library/string.html#format-string-syntax'))
                if col_type == 'int':
                    self.format_box.setToolTip('<p>' + _(
                        'Examples: The format <code>{0:0>4d}</code> '
                        'gives a 4-digit number with leading zeros. The format '
                        '<code>{0:d}&nbsp;days</code> prints the number then the word "days"')+ '</p>')
                else:
                    self.format_box.setToolTip('<p>' + _(
                        'Examples: The format <code>{0:.1f}</code> gives a floating '
                        'point number with 1 digit after the decimal point. The format '
                        '<code>Price:&nbsp;$&nbsp;{0:,.2f}</code> prints '
                        '"Price&nbsp;$&nbsp;" then displays the number with 2 digits '
                        'after the decimal point and thousands separated by commas.') + '</p>'
                    )
            self.format_label.setText(l), self.format_default_label.setText(dl)
        for x in ('in_comments_box', 'heading_position', 'heading_position_label',):
            getattr(self, 'composite_'+x).setVisible(col_type == 'composite')
        for x in ('box', 'default_label', 'label', 'sort_by', 'sort_by_label',
                  'make_category', 'contains_html', 'template_toolbutton'):
            getattr(self, 'composite_'+x).setVisible(col_type in ('composite', '*composite'))
        self.composite_heading_position.setEnabled(False)
        self.store_template_value_in_opf.setVisible(col_type == 'composite')
        self.store_template_value_in_opf.setChecked(True)

        for x in ('box', 'default_label', 'colors', 'colors_label'):
            getattr(self, 'enum_'+x).setVisible(col_type == 'enumeration')
        for x in ('value_label', 'value'):
            getattr(self, 'default_'+x).setVisible(col_type not in ['composite', '*composite'])
        self.use_decorations.setVisible(col_type in ['text', 'composite', 'enumeration'])
        self.is_names.setVisible(col_type == '*text')

        is_comments = col_type == 'comments'
        self.comments_heading_position.setVisible(is_comments)
        self.comments_heading_position_label.setVisible(is_comments)
        self.comments_type.setVisible(is_comments)
        self.comments_type_label.setVisible(is_comments)

        has_url_template = col_type in ('text', '*text', 'composite', '*composite', 'series', 'enumeration')
        self.web_search_label.setVisible(has_url_template)
        self.web_search_template.setVisible(has_url_template)
        self.web_search_toolbutton.setVisible(has_url_template)

        self.allow_half_stars.setVisible(col_type == 'rating')

        is_bool = col_type == 'bool'
        self.bool_button_group.setVisible(is_bool)
        self.bool_button_group_label.setVisible(is_bool)
        self.bool_show_label.setVisible(is_bool)

    def accept(self):
        col = str(self.column_name_box.text()).strip()
        if not col:
            return self.simple_error('', _('No lookup name was provided'))
        col = col.removeprefix('#')
        if re.match(r'^\w*$', col) is None or not col[0].isalpha() or col.lower() != col:
            return self.simple_error('', _('The lookup name must contain only '
                    'lower case letters, digits and underscores, and start with a letter'))
        if col.endswith('_index'):
            return self.simple_error('', _('Lookup names cannot end with _index, '
                    'because these names are reserved for the index of a series column.'))
        col_heading = str(self.column_heading_box.text()).strip()
        coldef = self.column_types[self.column_type_box.currentIndex()]
        col_type = coldef['datatype']
        if col_type[0] == '*':
            col_type = col_type[1:]
            is_multiple = True
        else:
            is_multiple = False
        if not col_heading:
            return self.simple_error('', _('No column heading was provided'))

        db = self.gui.library_view.model().db
        key = db.field_metadata.custom_field_prefix+col
        cc = self.caller.custcols
        if key in cc and (not self.editing_col or cc[key]['colnum'] != self.orig_column_number):
            return self.simple_error('', _('The lookup name %s is already used')%col)
        bad_head = False
        for cc in self.caller.custcols.values():
            if cc['name'] == col_heading and cc['colnum'] != self.orig_column_number:
                bad_head = True
                break
        for t in self.standard_colheads:
            if self.standard_colheads[t] == col_heading:
                bad_head = True
        if bad_head:
            if not question_dialog(self, _('Are you sure?'),
                _('The heading {} is already used by another column.'
                ' Creating a second column with the same heading can be confusing.'
                ' Are you sure?').format(col_heading),
                skip_dialog_name='create_custom_column_shared_heading',
                default_yes=False, override_icon='dialog_warning.png'):
                return

        display_dict = {}

        default_val = (str(self.default_value.text()).strip()
                        if col_type != 'composite' else None)

        if col_type == 'datetime':
            if str(self.format_box.text()).strip():
                display_dict = {'date_format':str(self.format_box.text()).strip()}
            else:
                display_dict = {'date_format': None}
            if default_val:
                if default_val == _('Now'):
                    display_dict['default_value'] = 'now'
                else:
                    try:
                        tv = parse_date(default_val)
                    except Exception:
                        tv = UNDEFINED_DATE
                    if tv == UNDEFINED_DATE:
                        return self.simple_error(_('Invalid default value'),
                                 _('The default value must be "Now" or a date'))
                    display_dict['default_value'] = default_val
        elif col_type == 'composite':
            if not str(self.composite_box.text()).strip():
                return self.simple_error('', _('You must enter a template for '
                           'composite columns'))
            if self.composite_in_comments_box.isChecked():
                display_dict = {'composite_template':str(self.composite_box.text()).strip(),
                                'heading_position': self.composite_heading_position.currentData(),
                                'composite_show_in_comments': True,
                            }
            else:
                display_dict = {'composite_template':str(self.composite_box.text()).strip(),
                                'composite_sort': ['text', 'number', 'date', 'bool']
                                            [self.composite_sort_by.currentIndex()],
                                'make_category': self.composite_make_category.isChecked(),
                                'contains_html': self.composite_contains_html.isChecked(),
                                'composite_show_in_comments': False,
                            }
            display_dict['composite_store_template_value_in_opf'] = self.store_template_value_in_opf.isChecked()

        elif col_type == 'enumeration':
            if not str(self.enum_box.text()).strip():
                return self.simple_error('', _('You must enter at least one '
                            'value for enumeration columns'))
            l = [v.strip() for v in str(self.enum_box.text()).split(',') if v.strip()]
            l_lower = [v.lower() for v in l]
            for i,v in enumerate(l_lower):
                if v in l_lower[i+1:]:
                    return self.simple_error('', _('The value "{0}" is in the '
                    'list more than once, perhaps with different case').format(l[i]))
            c = str(self.enum_colors.text())
            if c:
                c = [v.strip() for v in str(self.enum_colors.text()).split(',')]
            else:
                c = []
            if len(c) != 0 and len(c) != len(l):
                return self.simple_error('', _('The colors box must be empty or '
                           'contain the same number of items as the value box'))
            for tc in c:
                if tc not in QColor.colorNames() and not re.match(r'#(?:[0-9a-f]{3}){1,4}',tc,re.I):
                    return self.simple_error('', _('The color {0} is unknown').format(tc))
            display_dict = {'enum_values': l, 'enum_colors': c}
            if default_val:
                if default_val not in l:
                    return self.simple_error(_('Invalid default value'),
                             _('The default value must be one of the permitted values'))
                display_dict['default_value'] = default_val
        elif col_type == 'text' and is_multiple:
            display_dict = {'is_names': self.is_names.isChecked()}
        elif col_type in ['int', 'float']:
            if str(self.format_box.text()).strip():
                display_dict = {'number_format':str(self.format_box.text()).strip()}
            else:
                display_dict = {'number_format': None}
            if col_type == 'float':
                display_dict['decimals'] = int(self.decimals_box.value())
            if default_val:
                try:
                    if col_type == 'int':
                        msg = _('The default value must be an integer')
                        tv = int(default_val)
                        display_dict['default_value'] = tv
                    else:
                        msg = _('The default value must be a real number')
                        tv = float(default_val)
                        display_dict['default_value'] = tv
                except Exception:
                    return self.simple_error(_('Invalid default value'), msg)
        elif col_type == 'comments':
            display_dict['heading_position'] = str(self.comments_heading_position.currentData())
            display_dict['interpret_as'] = str(self.comments_type.currentData())
        elif col_type == 'rating':
            half_stars = bool(self.allow_half_stars.isChecked())
            display_dict['allow_half_stars'] = half_stars
            if default_val:
                try:
                    tv = int((float(default_val) if half_stars else int(default_val)) * 2)
                except Exception:
                    tv = -1
                if tv < 0 or tv > 10:
                    if half_stars:
                        return self.simple_error(_('Invalid default value'),
                             _('The default value must be a real number between 0 and 5.0'))
                    else:
                        return self.simple_error(_('Invalid default value'),
                             _('The default value must be an integer between 0 and 5'))
                display_dict['default_value'] = tv
        elif col_type == 'bool':
            if default_val:
                tv = {_('Yes'): True, _('No'): False}.get(default_val, None)
                if tv is None:
                    return self.simple_error(_('Invalid default value'),
                             _('The default value must be "Yes" or "No"'))
                display_dict['default_value'] = tv
            show_icon = bool(self.bool_show_icon_button.isChecked()) or bool(self.bool_show_both_button.isChecked())
            show_text = bool(self.bool_show_text_button.isChecked()) or bool(self.bool_show_both_button.isChecked())
            display_dict['bools_show_text'] = show_text
            display_dict['bools_show_icons'] = show_icon

        if col_type in ['text', 'composite', 'enumeration'] and not is_multiple:
            display_dict['use_decorations'] = self.use_decorations.checkState() == Qt.CheckState.Checked

        if default_val and 'default_value' not in display_dict:
            display_dict['default_value'] = default_val

        display_dict['description'] = self.description_box.text().strip()
        display_dict['web_search_template'] = self.web_search_template.text().strip()

        if not self.editing_col:
            self.caller.custcols[key] = {
                    'label':col,
                    'name':col_heading,
                    'datatype':col_type,
                    'display':display_dict,
                    'normalized':None,
                    'colnum':None,
                    'is_multiple':is_multiple,
                }
            self.caller.cc_column_key = key
        else:
            cc = self.caller.custcols[self.orig_column_name]
            cc['label'] = col
            cc['name'] = col_heading
            # Remove any previous default value
            cc['display'].pop('default_value', None)
            cc['display'].update(display_dict)
            cc['*edited'] = True
            cc['*must_restart'] = True
            self.caller.cc_column_key = key
        QDialog.accept(self)

    def reject(self):
        QDialog.reject(self)


class CreateNewCustomColumn:
    '''
    Provide an API to create new custom columns.

    Usage:
        from calibre.gui2.preferences.create_custom_column import CreateNewCustomColumn
        creator = CreateNewCustomColumn(gui)
        if creator.must_restart():
                ...
        else:
            result = creator.create_column(....)
            if result[0] == creator.Result.COLUMN_ADDED:

    The parameter 'gui' passed when creating a class instance is the main
    calibre gui (calibre.gui2.ui.get_gui())

    Use the create_column(...) method to open a dialog to create a new custom
    column with given lookup_name, column_heading, datatype, and is_multiple.
    You can create as many columns as you wish with a single instance of the
    CreateNewCustomColumn class. Subsequent class instances will refuse to
    create columns until calibre is restarted, as will calibre Preferences.

    The lookup name must begin with a '#'. All remaining characters must be
    lower case letters, digits or underscores. The character after the '#' must
    be a letter. The lookup name must not end with the suffix '_index'.

    The datatype must be one of calibre's custom column types: 'bool',
    'comments', 'composite', 'datetime', 'enumeration', 'float', 'int',
    'rating', 'series', or 'text'. The datatype can't be changed in the dialog.

    is_multiple tells calibre that the column contains multiple values -- is
    tags-like. The value True is allowed only for 'composite' and 'text' types.

    If generate_unused_lookup_name is False then the provided lookup_name and
    column_heading must not already exist. If generate_unused_lookup_name is
    True then if necessary the method will add the suffix '_n' to the provided
    lookup_name to allocate an unused lookup_name, where 'n' is an integer.
    The same processing is applied to column_heading to make it is unique, using
    the same suffix used for the lookup name if possible. In either case the
    user can change the column heading in the dialog.

    Set freeze_lookup_name to False if you want to allow the user choose a
    different lookup name. The user will not be allowed to choose the lookup
    name of an existing column. The provided lookup_name and column_heading
    either must not exist or generate_unused_lookup_name must be True,
    regardless of the value of freeze_lookup_name.

    The 'display' parameter is used to pass item- and type-specific information
    for the column. It is a dict. The easiest way to see the current values for
    'display' for a particular column is to create a column like you want then
    look for the lookup name in the file metadata_db_prefs_backup.json. You must
    restart calibre twice after creating a new column before its information
    will appear in that file.

    The key:value pairs for each type are as follows. Note that this
    list might be incorrect. As said above, the best way to get current values
    is to create a similar column and look at the values in 'display'.
      all types:
        'default_value': a string representation of the default value for the
                         column. Permitted values are type specific
        'description': a string containing the column's description
      comments columns:
        'heading_position': a string specifying where a comment heading goes:
                            hide, above, side
        'interpret_as': a string specifying the comment's purpose:
                        html, short-text, long-text, markdown
      composite columns:
        'composite_template': a string containing the template for the composite column
        'composite_sort': a string specifying how the composite is to be sorted
        'make_category': True or False -- whether the column is shown in the tag browser
        'contains_html': True or False -- whether the column is interpreted as HTML
        'use_decorations': True or False -- should check marks be displayed
        'search_template': a template used to construct a search URL for book details
      datetime columns:
        'date_format': a string specifying the display format
      enumerated columns
        'enum_values': a string containing comma-separated valid values for an enumeration
        'enum_colors': a string containing comma-separated colors for an enumeration
        'use_decorations': True or False -- should check marks be displayed
        'search_template': a template used to construct a search URL for book details
      float columns:
        'decimals': the number of decimal digits to allow when editing (int). Range: 1 - 9
      float and int columns:
        'number_format': the format to apply when displaying the column
      rating columns:
        'allow_half_stars': True or False -- are half-stars allowed
      series columns:
        'search_template': a template used to construct a search URL for book details
      text columns:
        'is_names': True or False -- whether the items are comma or ampersand separated
        'use_decorations': True or False -- should check marks be displayed
        'search_template': a template used to construct a search URL for book details

    This method returns a tuple (Result.enum_value, message). If tuple[0] is
    Result.COLUMN_ADDED then the message is the lookup name including the '#'.
    Otherwise it is a potentially localized error message.

    You or the user must restart calibre for the column(s) to be actually added.

    Result.EXCEPTION_RAISED is returned if the create dialog raises an exception.
    This can happen if the display contains illegal values, for example a string
    where a boolean is required. The string is the exception text. Run calibre
    in debug mode to see the entire traceback.

    The method returns Result.MUST_RESTART if further calibre configuration has
    been blocked. You can check for this situation in advance by calling
    must_restart().
    '''

    class Result(Enum):
        COLUMN_ADDED = 0
        CANCELED = 1
        INVALID_KEY = 2
        DUPLICATE_KEY = 3
        DUPLICATE_HEADING = 4
        INVALID_TYPE = 5
        INVALID_IS_MULTIPLE = 6
        INVALID_DISPLAY = 7
        EXCEPTION_RAISED = 8
        MUST_RESTART = 9
        COLUMN_EDITED = 11

    def __init__(self, gui):
        self.gui = gui
        self.restart_required = gui.must_restart_before_config
        self.db = db = self.gui.library_view.model().db
        self.custcols = copy.deepcopy(db.field_metadata.custom_field_metadata())
        # Get the largest internal column number so we can be sure that we can
        # detect duplicates.
        self.created_count = max((x['colnum'] for x in self.custcols.values()),
                                         default=0) + 1

    def create_column(self, lookup_name, column_heading, datatype, is_multiple,
                      display={}, generate_unused_lookup_name=False, freeze_lookup_name=True):
        ''' See the class documentation for more information.'''
        if self.restart_required:
            return (self.Result.MUST_RESTART, _('You must restart calibre before making any more changes'))
        if not lookup_name.startswith('#'):
            return (self.Result.INVALID_KEY, _("The lookup name must begin with a '#'"))
        suffix_number = 1
        if lookup_name in self.custcols:
            if not generate_unused_lookup_name:
                return (self.Result.DUPLICATE_KEY, _('The custom column %s already exists') % lookup_name)
            for suffix_number in range(suffix_number, 100000):
                nk = f'{lookup_name}_{suffix_number}'
                if nk not in self.custcols:
                    lookup_name = nk
                    break
        if column_heading:
            headings = {v['name'] for v in self.custcols.values()}
            if column_heading in headings:
                if not generate_unused_lookup_name:
                    return (self.Result.DUPLICATE_HEADING,
                           _('The column heading %s already exists') % column_heading)
                for i in range(suffix_number, 100000):
                    nh = f'{column_heading}_{i}'
                    if nh not in headings:
                        column_heading = nh
                        break
        else:
            column_heading = lookup_name
        if datatype not in CreateCustomColumn.column_types_map:
            return (self.Result.INVALID_TYPE,
                   _("The custom column type %s doesn't exist") % datatype)
        if is_multiple and '*' + datatype not in CreateCustomColumn.column_types_map:
            return (self.Result.INVALID_IS_MULTIPLE,
                   _('You cannot specify is_multiple for the datatype %s') % datatype)
        if not isinstance(display, dict):
            return (self.Result.INVALID_DISPLAY,
                   _('The display parameter must be a Python dictionary'))
        self.created_count += 1
        self.custcols[lookup_name] = {
                'label': lookup_name,
                'name': column_heading,
                'datatype': datatype,
                'display': display,
                'normalized': None,
                'colnum': self.created_count,
                'is_multiple': is_multiple,
            }

        return self._create_or_edit_column(lookup_name, freeze_lookup_name=freeze_lookup_name,
                                           operation='create')

    def edit_existing_column(self, lookup_name):
        if lookup_name not in self.custcols:
            return self.Result.INVALID_KEY
        return self._create_or_edit_column(lookup_name, freeze_lookup_name=False, operation='edit')

    def _create_or_edit_column(self, lookup_name, freeze_lookup_name, operation=None):
        try:
            dialog = CreateCustomColumn(self.gui, self, lookup_name,
                                        self.gui.library_view.model().orig_headers,
                                        freeze_lookup_name=freeze_lookup_name)
            if dialog.result() == QDialog.DialogCode.Accepted and self.cc_column_key is not None:
                cc = self.custcols[lookup_name]
                if operation == 'create':
                    self.db.create_custom_column(
                                    label=cc['label'],
                                    name=cc['name'],
                                    datatype=cc['datatype'],
                                    is_multiple=bool(cc['is_multiple']),
                                    display=cc['display'])
                    self.gui.must_restart_before_config = True
                    return (self.Result.COLUMN_ADDED, self.cc_column_key)
                # editing/viewing
                if operation == 'edit':
                    self.db.set_custom_column_metadata(cc['colnum'], name=cc['name'],
                                                  label=cc['label'], display=cc['display'],
                                                  notify=False)
                    if '*must_restart' in cc:
                        self.gui.must_restart_before_config = True
                    return (self.Result.COLUMN_EDITED, self.cc_column_key)
                return (self.Result.CANCELED, self.cc_column_key)
        except Exception as e:
            import traceback
            traceback.print_exc()
            self.custcols.pop(lookup_name, None)
            return (self.Result.EXCEPTION_RAISED, str(e))
        self.custcols.pop(lookup_name, None)
        return (self.Result.CANCELED, _('Canceled'))

    def current_columns(self):
        '''
        Return the currently defined custom columns

        Return the currently defined custom columns including the ones that haven't
        yet been created. It is a dict of dicts defined as follows:
            custcols[lookup_name] = {
                    'label': lookup_name,
                    'name': column_heading,
                    'datatype': datatype,
                    'display': display,
                    'normalized': None,
                    'colnum': an integer used internally,
                    'is_multiple': is_multiple,
                }
        Columns that already exist will have additional attributes that this class
        doesn't use. See calibre.library.field_metadata.add_custom_field() for the
        complete list.
        '''
        # deepcopy to prevent users from changing it. The new MappingProxyType
        # isn't enough because only the top-level dict is immutable, not the
        # items in the dict.
        return copy.deepcopy(self.custcols)

    def current_headings(self):
        '''
        Return the currently defined column headings

        Return the column headings including the ones that haven't yet been
        created. It is a dict. The key is the heading, the value is the lookup
        name having that heading.
        '''
        return {v['name']:('#' + v['label']) for v in self.custcols.values()}

    def must_restart(self):
        '''Return true if calibre must be restarted before new columns can be added.'''
        return self.restart_required
