1#!/usr/local/bin/python3.8
2# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
3# License: GPLv3 Copyright: 2010, Kovid Goyal <kovid at kovidgoyal.net>
4
5import errno
6import json
7import numbers
8import os
9import sys
10import textwrap
11import time
12
13from qt.core import (
14    QCheckBox, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout,
15    QFrame, QHBoxLayout, QIcon, QLabel, QLineEdit, QListWidget, QPlainTextEdit, QLayout,
16    QPushButton, QScrollArea, QSize, QSizePolicy, QSpinBox, Qt, QTabWidget, QTimer,
17    QToolButton, QUrl, QVBoxLayout, QWidget, pyqtSignal, sip
18)
19
20from calibre import as_unicode
21from calibre.constants import isportable, iswindows
22from calibre.gui2 import (
23    choose_files, choose_save_file, config, error_dialog, gprefs, info_dialog,
24    open_url, warning_dialog
25)
26from calibre.gui2.preferences import AbortCommit, ConfigWidgetBase, test_widget
27from calibre.gui2.widgets import HistoryLineEdit
28from calibre.srv.code import custom_list_template as default_custom_list_template
29from calibre.srv.embedded import custom_list_template, search_the_net_urls
30from calibre.srv.loop import parse_trusted_ips
31from calibre.srv.library_broker import load_gui_libraries
32from calibre.srv.opts import change_settings, options, server_config
33from calibre.srv.users import (
34    UserManager, create_user_data, validate_password, validate_username
35)
36from calibre.utils.icu import primary_sort_key
37from calibre.utils.shared_file import share_open
38from polyglot.builtins import as_bytes
39
40
41if iswindows and not isportable:
42    from calibre_extensions import winutil
43
44    def get_exe():
45        exe_base = os.path.abspath(os.path.dirname(sys.executable))
46        exe = os.path.join(exe_base, 'calibre.exe')
47        if isinstance(exe, bytes):
48            exe = os.fsdecode(exe)
49        return exe
50
51    def startup_shortcut_path():
52        startup_path = winutil.special_folder_path(winutil.CSIDL_STARTUP)
53        return os.path.join(startup_path, "calibre.lnk")
54
55    def create_shortcut(shortcut_path, target, description, *args):
56        quoted_args = None
57        if args:
58            quoted_args = []
59            for arg in args:
60                quoted_args.append('"{}"'.format(arg))
61            quoted_args = ' '.join(quoted_args)
62        winutil.manage_shortcut(shortcut_path, target, description, quoted_args)
63
64    def shortcut_exists_at(shortcut_path, target):
65        if not os.access(shortcut_path, os.R_OK):
66            return False
67        name = winutil.manage_shortcut(shortcut_path, None, None, None)
68        if name is None:
69            return False
70        return os.path.normcase(os.path.abspath(name)) == os.path.normcase(os.path.abspath(target))
71
72    def set_run_at_startup(run_at_startup=True):
73        if run_at_startup:
74            create_shortcut(startup_shortcut_path(), get_exe(), 'calibre - E-book management', '--start-in-tray')
75        else:
76            shortcut_path = startup_shortcut_path()
77            if os.path.exists(shortcut_path):
78                os.remove(shortcut_path)
79
80    def is_set_to_run_at_startup():
81        try:
82            return shortcut_exists_at(startup_shortcut_path(), get_exe())
83        except Exception:
84            import traceback
85            traceback.print_exc()
86
87else:
88    set_run_at_startup = is_set_to_run_at_startup = None
89
90
91# Advanced {{{
92
93
94def init_opt(widget, opt, layout):
95    widget.name, widget.default_val = opt.name, opt.default
96    if opt.longdoc:
97        widget.setWhatsThis(opt.longdoc)
98        widget.setStatusTip(opt.longdoc)
99        widget.setToolTip(textwrap.fill(opt.longdoc))
100    layout.addRow(opt.shortdoc + ':', widget)
101
102
103class Bool(QCheckBox):
104
105    changed_signal = pyqtSignal()
106
107    def __init__(self, name, layout):
108        opt = options[name]
109        QCheckBox.__init__(self)
110        self.stateChanged.connect(self.changed_signal.emit)
111        init_opt(self, opt, layout)
112
113    def get(self):
114        return self.isChecked()
115
116    def set(self, val):
117        self.setChecked(bool(val))
118
119
120class Int(QSpinBox):
121
122    changed_signal = pyqtSignal()
123
124    def __init__(self, name, layout):
125        QSpinBox.__init__(self)
126        self.setRange(0, 20000)
127        opt = options[name]
128        self.valueChanged.connect(self.changed_signal.emit)
129        init_opt(self, opt, layout)
130
131    def get(self):
132        return self.value()
133
134    def set(self, val):
135        self.setValue(int(val))
136
137
138class Float(QDoubleSpinBox):
139
140    changed_signal = pyqtSignal()
141
142    def __init__(self, name, layout):
143        QDoubleSpinBox.__init__(self)
144        self.setRange(0, 20000)
145        self.setDecimals(1)
146        opt = options[name]
147        self.valueChanged.connect(self.changed_signal.emit)
148        init_opt(self, opt, layout)
149
150    def get(self):
151        return self.value()
152
153    def set(self, val):
154        self.setValue(float(val))
155
156
157class Text(QLineEdit):
158
159    changed_signal = pyqtSignal()
160
161    def __init__(self, name, layout):
162        QLineEdit.__init__(self)
163        self.setClearButtonEnabled(True)
164        opt = options[name]
165        self.textChanged.connect(self.changed_signal.emit)
166        init_opt(self, opt, layout)
167
168    def get(self):
169        return self.text().strip() or None
170
171    def set(self, val):
172        self.setText(str(val or ''))
173
174
175class Path(QWidget):
176
177    changed_signal = pyqtSignal()
178
179    def __init__(self, name, layout):
180        QWidget.__init__(self)
181        self.dname = name
182        opt = options[name]
183        self.l = l = QHBoxLayout(self)
184        l.setContentsMargins(0, 0, 0, 0)
185        self.text = t = HistoryLineEdit(self)
186        t.initialize('server-opts-{}'.format(name))
187        t.setClearButtonEnabled(True)
188        t.currentTextChanged.connect(self.changed_signal.emit)
189        l.addWidget(t)
190
191        self.b = b = QToolButton(self)
192        l.addWidget(b)
193        b.setIcon(QIcon(I('document_open.png')))
194        b.setToolTip(_("Browse for the file"))
195        b.clicked.connect(self.choose)
196        init_opt(self, opt, layout)
197
198    def get(self):
199        return self.text.text().strip() or None
200
201    def set(self, val):
202        self.text.setText(str(val or ''))
203
204    def choose(self):
205        ans = choose_files(self, 'choose_path_srv_opts_' + self.dname, _('Choose a file'), select_only_single_file=True)
206        if ans:
207            self.set(ans[0])
208            self.text.save_history()
209
210
211class Choices(QComboBox):
212
213    changed_signal = pyqtSignal()
214
215    def __init__(self, name, layout):
216        QComboBox.__init__(self)
217        self.setEditable(False)
218        opt = options[name]
219        self.choices = opt.choices
220        self.addItems(opt.choices)
221        self.currentIndexChanged.connect(self.changed_signal.emit)
222        init_opt(self, opt, layout)
223
224    def get(self):
225        return self.currentText()
226
227    def set(self, val):
228        if val in self.choices:
229            self.setCurrentText(val)
230        else:
231            self.setCurrentIndex(0)
232
233
234class AdvancedTab(QWidget):
235
236    changed_signal = pyqtSignal()
237
238    def __init__(self, parent=None):
239        QWidget.__init__(self, parent)
240        self.l = l = QFormLayout(self)
241        l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
242        self.widgets = []
243        self.widget_map = {}
244        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
245        for name in sorted(options, key=lambda n: options[n].shortdoc.lower()):
246            if name in ('auth', 'port', 'allow_socket_preallocation', 'userdb'):
247                continue
248            opt = options[name]
249            if opt.choices:
250                w = Choices
251            elif isinstance(opt.default, bool):
252                w = Bool
253            elif isinstance(opt.default, numbers.Integral):
254                w = Int
255            elif isinstance(opt.default, numbers.Real):
256                w = Float
257            else:
258                w = Text
259                if name in ('ssl_certfile', 'ssl_keyfile'):
260                    w = Path
261            w = w(name, l)
262            setattr(self, 'opt_' + name, w)
263            self.widgets.append(w)
264            self.widget_map[name] = w
265
266    def genesis(self):
267        opts = server_config()
268        for w in self.widgets:
269            w.set(getattr(opts, w.name))
270            w.changed_signal.connect(self.changed_signal.emit)
271
272    def restore_defaults(self):
273        for w in self.widgets:
274            w.set(w.default_val)
275
276    def get(self, name):
277        return self.widget_map[name].get()
278
279    @property
280    def settings(self):
281        return {w.name: w.get() for w in self.widgets}
282
283    @property
284    def has_ssl(self):
285        return bool(self.get('ssl_certfile')) and bool(self.get('ssl_keyfile'))
286
287# }}}
288
289
290class MainTab(QWidget):  # {{{
291
292    changed_signal = pyqtSignal()
293    start_server = pyqtSignal()
294    stop_server = pyqtSignal()
295    test_server = pyqtSignal()
296    show_logs = pyqtSignal()
297
298    def __init__(self, parent=None):
299        QWidget.__init__(self, parent)
300        self.l = l = QVBoxLayout(self)
301        self.la = la = QLabel(
302            _(
303                'calibre contains an internet server that allows you to'
304                ' access your book collection using a browser from anywhere'
305                ' in the world. Any changes to the settings will only take'
306                ' effect after a server restart.'
307            )
308        )
309        la.setWordWrap(True)
310        l.addWidget(la)
311        l.addSpacing(10)
312        self.fl = fl = QFormLayout()
313        l.addLayout(fl)
314        self.opt_port = sb = QSpinBox(self)
315        if options['port'].longdoc:
316            sb.setToolTip(options['port'].longdoc)
317        sb.setRange(1, 65535)
318        sb.valueChanged.connect(self.changed_signal.emit)
319        fl.addRow(options['port'].shortdoc + ':', sb)
320        l.addSpacing(25)
321        self.opt_auth = cb = QCheckBox(
322            _('Require &username and password to access the Content server')
323        )
324        l.addWidget(cb)
325        self.auth_desc = la = QLabel(self)
326        la.setStyleSheet('QLabel { font-size: small; font-style: italic }')
327        la.setWordWrap(True)
328        l.addWidget(la)
329        l.addSpacing(25)
330        self.opt_autolaunch_server = al = QCheckBox(
331            _('Run server &automatically when calibre starts')
332        )
333        l.addWidget(al)
334        l.addSpacing(25)
335        self.h = h = QHBoxLayout()
336        l.addLayout(h)
337        for text, name in [(_('&Start server'),
338                            'start_server'), (_('St&op server'), 'stop_server'),
339                           (_('&Test server'),
340                            'test_server'), (_('Show server &logs'), 'show_logs')]:
341            b = QPushButton(text)
342            b.clicked.connect(getattr(self, name).emit)
343            setattr(self, name + '_button', b)
344            if name == 'show_logs':
345                h.addStretch(10)
346            h.addWidget(b)
347        self.ip_info = QLabel(self)
348        self.update_ip_info()
349        from calibre.gui2.ui import get_gui
350        gui = get_gui()
351        if gui is not None:
352            gui.iactions['Connect Share'].share_conn_menu.server_state_changed_signal.connect(self.update_ip_info)
353        l.addSpacing(10)
354        l.addWidget(self.ip_info)
355        if set_run_at_startup is not None:
356            self.run_at_start_button = b = QPushButton('', self)
357            self.set_run_at_start_text()
358            b.clicked.connect(self.toggle_run_at_startup)
359            l.addSpacing(10)
360            l.addWidget(b)
361        l.addSpacing(10)
362
363        l.addStretch(10)
364
365    def set_run_at_start_text(self):
366        is_autostarted = is_set_to_run_at_startup()
367        self.run_at_start_button.setText(
368            _('Do not start calibre automatically when computer is started') if is_autostarted else
369            _('Start calibre when the computer is started')
370        )
371        self.run_at_start_button.setToolTip('<p>' + (
372            _('''Currently calibre is set to run automatically when the
373            computer starts.  Use this button to disable that.''') if is_autostarted else
374            _('''Start calibre in the system tray automatically when the computer starts''')))
375
376    def toggle_run_at_startup(self):
377        set_run_at_startup(not is_set_to_run_at_startup())
378        self.set_run_at_start_text()
379
380    def update_ip_info(self):
381        from calibre.gui2.ui import get_gui
382        gui = get_gui()
383        if gui is not None:
384            t = get_gui().iactions['Connect Share'].share_conn_menu.ip_text
385            t = t.strip().strip('[]')
386            self.ip_info.setText(_('Content server listening at: %s') % t)
387
388    def genesis(self):
389        opts = server_config()
390        self.opt_auth.setChecked(opts.auth)
391        self.opt_auth.stateChanged.connect(self.auth_changed)
392        self.opt_port.setValue(opts.port)
393        self.change_auth_desc()
394        self.update_button_state()
395
396    def change_auth_desc(self):
397        self.auth_desc.setText(
398            _('Remember to create at least one user account in the "User accounts" tab')
399            if self.opt_auth.isChecked() else _(
400                'Requiring a username/password prevents unauthorized people from'
401                ' accessing your calibre library. It is also needed for some features'
402                ' such as making any changes to the library as well as'
403                ' last read position/annotation syncing.'
404            )
405        )
406
407    def auth_changed(self):
408        self.changed_signal.emit()
409        self.change_auth_desc()
410
411    def restore_defaults(self):
412        self.opt_auth.setChecked(options['auth'].default)
413        self.opt_port.setValue(options['port'].default)
414
415    def update_button_state(self):
416        from calibre.gui2.ui import get_gui
417        gui = get_gui()
418        if gui is not None:
419            is_running = gui.content_server is not None and gui.content_server.is_running
420            self.ip_info.setVisible(is_running)
421            self.update_ip_info()
422            self.start_server_button.setEnabled(not is_running)
423            self.stop_server_button.setEnabled(is_running)
424            self.test_server_button.setEnabled(is_running)
425
426    @property
427    def settings(self):
428        return {'auth': self.opt_auth.isChecked(), 'port': self.opt_port.value()}
429
430
431# }}}
432
433# Users {{{
434
435
436class NewUser(QDialog):
437
438    def __init__(self, user_data, parent=None, username=None):
439        QDialog.__init__(self, parent)
440        self.user_data = user_data
441        self.setWindowTitle(
442            _('Change password for {}').format(username)
443            if username else _('Add new user')
444        )
445        self.l = l = QFormLayout(self)
446        l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
447        self.uw = u = QLineEdit(self)
448        l.addRow(_('&Username:'), u)
449        if username:
450            u.setText(username)
451            u.setReadOnly(True)
452        l.addRow(QLabel(_('Set the password for this user')))
453        self.p1, self.p2 = p1, p2 = QLineEdit(self), QLineEdit(self)
454        l.addRow(_('&Password:'), p1), l.addRow(_('&Repeat password:'), p2)
455        for p in p1, p2:
456            p.setEchoMode(QLineEdit.EchoMode.PasswordEchoOnEdit)
457            p.setMinimumWidth(300)
458            if username:
459                p.setText(user_data[username]['pw'])
460        self.showp = sp = QCheckBox(_('&Show password'))
461        sp.stateChanged.connect(self.show_password)
462        l.addRow(sp)
463        self.bb = bb = QDialogButtonBox(
464            QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
465        )
466        l.addRow(bb)
467        bb.accepted.connect(self.accept), bb.rejected.connect(self.reject)
468        (self.uw if not username else self.p1).setFocus(Qt.FocusReason.OtherFocusReason)
469
470    def show_password(self):
471        for p in self.p1, self.p2:
472            p.setEchoMode(
473                QLineEdit.EchoMode.Normal
474                if self.showp.isChecked() else QLineEdit.EchoMode.PasswordEchoOnEdit
475            )
476
477    @property
478    def username(self):
479        return self.uw.text().strip()
480
481    @property
482    def password(self):
483        return self.p1.text()
484
485    def accept(self):
486        if not self.uw.isReadOnly():
487            un = self.username
488            if not un:
489                return error_dialog(
490                    self,
491                    _('Empty username'),
492                    _('You must enter a username'),
493                    show=True
494                )
495            if un in self.user_data:
496                return error_dialog(
497                    self,
498                    _('Username already exists'),
499                    _(
500                        'A user with the username {} already exists. Please choose a different username.'
501                    ).format(un),
502                    show=True
503                )
504            err = validate_username(un)
505            if err:
506                return error_dialog(self, _('Username is not valid'), err, show=True)
507        p1, p2 = self.password, self.p2.text()
508        if p1 != p2:
509            return error_dialog(
510                self,
511                _('Password do not match'),
512                _('The two passwords you entered do not match!'),
513                show=True
514            )
515        if not p1:
516            return error_dialog(
517                self,
518                _('Empty password'),
519                _('You must enter a password for this user'),
520                show=True
521            )
522        err = validate_password(p1)
523        if err:
524            return error_dialog(self, _('Invalid password'), err, show=True)
525        return QDialog.accept(self)
526
527
528class Library(QWidget):
529
530    restriction_changed = pyqtSignal(object, object)
531
532    def __init__(self, name, is_checked=False, path='', restriction='', parent=None, is_first=False, enable_on_checked=True):
533        QWidget.__init__(self, parent)
534        self.name = name
535        self.enable_on_checked = enable_on_checked
536        self.l = l = QVBoxLayout(self)
537        l.setSizeConstraint(QLayout.SizeConstraint.SetMinAndMaxSize)
538        if not is_first:
539            self.border = b = QFrame(self)
540            b.setFrameStyle(QFrame.Shape.HLine)
541            l.addWidget(b)
542        self.cw = cw = QCheckBox(name.replace('&', '&&'))
543        cw.setStyleSheet('QCheckBox { font-weight: bold }')
544        cw.setChecked(is_checked)
545        cw.stateChanged.connect(self.state_changed)
546        if path:
547            cw.setToolTip(path)
548        l.addWidget(cw)
549        self.la = la = QLabel(_('Further &restrict access to books in this library that match:'))
550        l.addWidget(la)
551        self.rw = rw = QLineEdit(self)
552        rw.setPlaceholderText(_('A search expression'))
553        rw.setToolTip(textwrap.fill(_(
554            'A search expression. If specified, access will be further restricted'
555            ' to only those books that match this expression. For example:'
556            ' tags:"=Share"')))
557        rw.setText(restriction or '')
558        rw.textChanged.connect(self.on_rchange)
559        la.setBuddy(rw)
560        l.addWidget(rw)
561        self.state_changed()
562
563    def state_changed(self):
564        c = self.cw.isChecked()
565        w = (self.enable_on_checked and c) or (not self.enable_on_checked and not c)
566        for x in (self.la, self.rw):
567            x.setEnabled(bool(w))
568
569    def on_rchange(self):
570        self.restriction_changed.emit(self.name, self.restriction)
571
572    @property
573    def is_checked(self):
574        return self.cw.isChecked()
575
576    @property
577    def restriction(self):
578        return self.rw.text().strip()
579
580
581class ChangeRestriction(QDialog):
582
583    def __init__(self, username, restriction, parent=None):
584        QDialog.__init__(self, parent)
585        self.setWindowTitle(_('Change library access permissions for {}').format(username))
586        self.username = username
587        self._items = []
588        self.l = l = QFormLayout(self)
589        l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
590
591        self.libraries = t = QWidget(self)
592        t.setObjectName('libraries')
593        t.l = QVBoxLayout(self.libraries)
594        self.atype = a = QComboBox(self)
595        a.addItems([_('All libraries'), _('Only the specified libraries'), _('All except the specified libraries')])
596        self.library_restrictions = restriction['library_restrictions'].copy()
597        if restriction['allowed_library_names']:
598            a.setCurrentIndex(1)
599            self.items = restriction['allowed_library_names']
600        elif restriction['blocked_library_names']:
601            a.setCurrentIndex(2)
602            self.items = restriction['blocked_library_names']
603        else:
604            a.setCurrentIndex(0)
605        a.currentIndexChanged.connect(self.atype_changed)
606        l.addRow(_('Allow access to:'), a)
607
608        self.msg = la = QLabel(self)
609        la.setWordWrap(True)
610        l.addRow(la)
611        self.la = la = QLabel(_('Specify the libraries below:'))
612        la.setWordWrap(True)
613        self.sa = sa = QScrollArea(self)
614        sa.setWidget(t), sa.setWidgetResizable(True)
615        l.addRow(la), l.addRow(sa)
616        self.atype_changed()
617
618        self.bb = bb = QDialogButtonBox(
619            QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
620        )
621        bb.accepted.connect(self.accept), bb.rejected.connect(self.reject)
622        l.addWidget(bb)
623        self.items = self.items
624
625    def sizeHint(self):
626        return QSize(800, 600)
627
628    def __iter__(self):
629        return iter(self._items)
630
631    @property
632    def items(self):
633        return frozenset(item.name for item in self if item.is_checked)
634
635    def clear(self):
636        for c in self:
637            self.libraries.l.removeWidget(c)
638            c.setParent(None)
639            c.restriction_changed.disconnect()
640            sip.delete(c)
641        self._items = []
642
643    @items.setter
644    def items(self, val):
645        self.clear()
646        checked_libraries = frozenset(val)
647        library_paths = load_gui_libraries(gprefs)
648        gui_libraries = {os.path.basename(l):l for l in library_paths}
649        lchecked_libraries = {l.lower() for l in checked_libraries}
650        seen = set()
651        items = []
652        for x in checked_libraries | set(gui_libraries):
653            xl = x.lower()
654            if xl not in seen:
655                seen.add(xl)
656                items.append((x, xl in lchecked_libraries))
657        items.sort(key=lambda x: primary_sort_key(x[0]))
658        enable_on_checked = self.atype.currentIndex() == 1
659        for i, (l, checked) in enumerate(items):
660            l = Library(
661                l, checked, path=gui_libraries.get(l, ''),
662                restriction=self.library_restrictions.get(l.lower(), ''),
663                parent=self.libraries, is_first=i == 0,
664                enable_on_checked=enable_on_checked
665            )
666            l.restriction_changed.connect(self.restriction_changed)
667            self.libraries.l.addWidget(l)
668            self._items.append(l)
669
670    def restriction_changed(self, name, val):
671        name = name.lower()
672        self.library_restrictions[name] = val
673
674    @property
675    def restriction(self):
676        ans = {'allowed_library_names': frozenset(), 'blocked_library_names': frozenset(), 'library_restrictions': {}}
677        if self.atype.currentIndex() != 0:
678            k = ['allowed_library_names', 'blocked_library_names'][self.atype.currentIndex() - 1]
679            ans[k] = self.items
680            ans['library_restrictions'] = self.library_restrictions
681        return ans
682
683    def accept(self):
684        if self.atype.currentIndex() != 0 and not self.items:
685            return error_dialog(self, _('No libraries specified'), _(
686                'You have not specified any libraries'), show=True)
687        return QDialog.accept(self)
688
689    def atype_changed(self):
690        ci = self.atype.currentIndex()
691        sheet = ''
692        if ci == 0:
693            m = _('<b>{} is allowed access to all libraries')
694            self.libraries.setEnabled(False), self.la.setEnabled(False)
695        else:
696            if ci == 1:
697                m = _('{} is allowed access only to the libraries whose names'
698                      ' <b>match</b> one of the names specified below.')
699            else:
700                m = _('{} is allowed access to all libraries, <b>except</b> those'
701                      ' whose names match one of the names specified below.')
702                sheet += 'QWidget#libraries { background-color: #FAE7B5}'
703            self.libraries.setEnabled(True), self.la.setEnabled(True)
704            self.items = self.items
705        self.msg.setText(m.format(self.username))
706        self.libraries.setStyleSheet(sheet)
707
708
709class User(QWidget):
710
711    changed_signal = pyqtSignal()
712
713    def __init__(self, parent=None):
714        QWidget.__init__(self, parent)
715        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
716        self.l = l = QFormLayout(self)
717        l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
718        self.username_label = la = QLabel('')
719        l.addWidget(la)
720        self.ro_text = _('Allow {} to make &changes (i.e. grant write access)')
721        self.rw = rw = QCheckBox(self)
722        rw.setToolTip(
723            _(
724                'If enabled, allows the user to make changes to the library.'
725                ' Adding books/deleting books/editing metadata, etc.'
726            )
727        )
728        rw.stateChanged.connect(self.readonly_changed)
729        l.addWidget(rw)
730        self.access_label = la = QLabel(self)
731        l.addWidget(la), la.setWordWrap(True)
732        self.cpb = b = QPushButton(_('Change &password'))
733        l.addWidget(b)
734        b.clicked.connect(self.change_password)
735        self.restrict_button = b = QPushButton(self)
736        b.clicked.connect(self.change_restriction)
737        l.addWidget(b)
738
739        self.show_user()
740
741    def change_password(self):
742        d = NewUser(self.user_data, self, self.username)
743        if d.exec() == QDialog.DialogCode.Accepted:
744            self.user_data[self.username]['pw'] = d.password
745            self.changed_signal.emit()
746
747    def readonly_changed(self):
748        self.user_data[self.username]['readonly'] = not self.rw.isChecked()
749        self.changed_signal.emit()
750
751    def update_restriction(self):
752        username, user_data = self.username, self.user_data
753        r = user_data[username]['restriction']
754        if r['allowed_library_names']:
755            libs = r['allowed_library_names']
756            m = ngettext(
757                '{} is currently only allowed to access the library named: {}',
758                '{} is currently only allowed to access the libraries named: {}',
759                len(libs)
760            ).format(username, ', '.join(libs))
761            b = _('Change the allowed libraries')
762        elif r['blocked_library_names']:
763            libs = r['blocked_library_names']
764            m = ngettext(
765                '{} is currently not allowed to access the library named: {}',
766                '{} is currently not allowed to access the libraries named: {}',
767                len(libs)
768            ).format(username, ', '.join(libs))
769            b = _('Change the blocked libraries')
770        else:
771            m = _('{} is currently allowed access to all libraries')
772            b = _('Restrict the &libraries {} can access').format(self.username)
773        self.restrict_button.setText(b),
774        self.access_label.setText(m.format(username))
775
776    def show_user(self, username=None, user_data=None):
777        self.username, self.user_data = username, user_data
778        self.cpb.setVisible(username is not None)
779        self.username_label.setText(('<h2>' + username) if username else '')
780        if username:
781            self.rw.setText(self.ro_text.format(username))
782            self.rw.setVisible(True)
783            self.rw.blockSignals(True), self.rw.setChecked(
784                not user_data[username]['readonly']
785            ), self.rw.blockSignals(False)
786            self.access_label.setVisible(True)
787            self.restrict_button.setVisible(True)
788            self.update_restriction()
789        else:
790            self.rw.setVisible(False)
791            self.access_label.setVisible(False)
792            self.restrict_button.setVisible(False)
793
794    def change_restriction(self):
795        d = ChangeRestriction(
796            self.username,
797            self.user_data[self.username]['restriction'].copy(),
798            parent=self
799        )
800        if d.exec() == QDialog.DialogCode.Accepted:
801            self.user_data[self.username]['restriction'] = d.restriction
802            self.update_restriction()
803            self.changed_signal.emit()
804
805    def sizeHint(self):
806        ans = QWidget.sizeHint(self)
807        ans.setWidth(400)
808        return ans
809
810
811class Users(QWidget):
812
813    changed_signal = pyqtSignal()
814
815    def __init__(self, parent=None):
816        QWidget.__init__(self, parent)
817        self.l = l = QHBoxLayout(self)
818        self.lp = lp = QVBoxLayout()
819        l.addLayout(lp)
820
821        self.h = h = QHBoxLayout()
822        lp.addLayout(h)
823        self.add_button = b = QPushButton(QIcon(I('plus.png')), _('&Add user'), self)
824        b.clicked.connect(self.add_user)
825        h.addWidget(b)
826        self.remove_button = b = QPushButton(
827            QIcon(I('minus.png')), _('&Remove user'), self
828        )
829        b.clicked.connect(self.remove_user)
830        h.addStretch(2), h.addWidget(b)
831
832        self.user_list = w = QListWidget(self)
833        w.setSpacing(1)
834        w.doubleClicked.connect(self.current_user_activated)
835        w.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
836        lp.addWidget(w)
837
838        self.user_display = u = User(self)
839        u.changed_signal.connect(self.changed_signal.emit)
840        l.addWidget(u)
841
842    def genesis(self):
843        self.user_data = UserManager().user_data
844        self.user_list.addItems(sorted(self.user_data, key=primary_sort_key))
845        self.user_list.setCurrentRow(0)
846        self.user_list.currentItemChanged.connect(self.current_item_changed)
847        self.current_item_changed()
848
849    def current_user_activated(self):
850        self.user_display.change_password()
851
852    def current_item_changed(self):
853        item = self.user_list.currentItem()
854        if item is None:
855            username = None
856        else:
857            username = item.text()
858        if username not in self.user_data:
859            username = None
860        self.display_user_data(username)
861
862    def add_user(self):
863        d = NewUser(self.user_data, parent=self)
864        if d.exec() == QDialog.DialogCode.Accepted:
865            un, pw = d.username, d.password
866            self.user_data[un] = create_user_data(pw)
867            self.user_list.insertItem(0, un)
868            self.user_list.setCurrentRow(0)
869            self.display_user_data(un)
870            self.changed_signal.emit()
871
872    def remove_user(self):
873        u = self.user_list.currentItem()
874        if u is not None:
875            self.user_list.takeItem(self.user_list.row(u))
876            un = u.text()
877            self.user_data.pop(un, None)
878            self.changed_signal.emit()
879            self.current_item_changed()
880
881    def display_user_data(self, username=None):
882        self.user_display.show_user(username, self.user_data)
883
884
885# }}}
886
887
888class CustomList(QWidget):  # {{{
889
890    changed_signal = pyqtSignal()
891
892    def __init__(self, parent):
893        QWidget.__init__(self, parent)
894        self.default_template = default_custom_list_template()
895        self.l = l = QFormLayout(self)
896        l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
897        self.la = la = QLabel('<p>' + _(
898            'Here you can create a template to control what data is shown when'
899            ' using the <i>Custom list</i> mode for the book list'))
900        la.setWordWrap(True)
901        l.addRow(la)
902        self.thumbnail = t = QCheckBox(_('Show a cover &thumbnail'))
903        self.thumbnail_height = th = QSpinBox(self)
904        th.setSuffix(' px'), th.setRange(60, 600)
905        self.entry_height = eh = QLineEdit(self)
906        l.addRow(t), l.addRow(_('Thumbnail &height:'), th)
907        l.addRow(_('Entry &height:'), eh)
908        t.stateChanged.connect(self.changed_signal)
909        th.valueChanged.connect(self.changed_signal)
910        eh.textChanged.connect(self.changed_signal)
911        eh.setToolTip(textwrap.fill(_(
912            'The height for each entry. The special value "auto" causes a height to be calculated'
913            ' based on the number of lines in the template. Otherwise, use a CSS length, such as'
914            ' 100px or 15ex')))
915        t.stateChanged.connect(self.thumbnail_state_changed)
916        th.setVisible(False)
917
918        self.comments_fields = cf = QLineEdit(self)
919        l.addRow(_('&Long text fields:'), cf)
920        cf.setToolTip(textwrap.fill(_(
921            'A comma separated list of fields that will be added at the bottom of every entry.'
922            ' These fields are interpreted as containing HTML, not plain text.')))
923        cf.textChanged.connect(self.changed_signal)
924
925        self.la1 = la = QLabel('<p>' + _(
926            'The template below will be interpreted as HTML and all {{fields}} will be replaced'
927            ' by the actual metadata, if available. For custom columns use the column lookup'
928            ' name, for example: #mytags. You can use {0} as a separator'
929            ' to split a line into multiple columns.').format('|||'))
930        la.setWordWrap(True)
931        l.addRow(la)
932        self.template = t = QPlainTextEdit(self)
933        l.addRow(t)
934        t.textChanged.connect(self.changed_signal)
935        self.imex = bb = QDialogButtonBox(self)
936        b = bb.addButton(_('&Import template'), QDialogButtonBox.ButtonRole.ActionRole)
937        b.clicked.connect(self.import_template)
938        b = bb.addButton(_('E&xport template'), QDialogButtonBox.ButtonRole.ActionRole)
939        b.clicked.connect(self.export_template)
940        l.addRow(bb)
941
942    def import_template(self):
943        paths = choose_files(self, 'custom-list-template', _('Choose template file'),
944            filters=[(_('Template files'), ['json'])], all_files=False, select_only_single_file=True)
945        if paths:
946            with lopen(paths[0], 'rb') as f:
947                raw = f.read()
948            self.current_template = self.deserialize(raw)
949
950    def export_template(self):
951        path = choose_save_file(
952            self, 'custom-list-template', _('Choose template file'),
953            filters=[(_('Template files'), ['json'])], initial_filename='custom-list-template.json')
954        if path:
955            raw = self.serialize(self.current_template)
956            with lopen(path, 'wb') as f:
957                f.write(as_bytes(raw))
958
959    def thumbnail_state_changed(self):
960        is_enabled = bool(self.thumbnail.isChecked())
961        for w, x in [(self.thumbnail_height, True), (self.entry_height, False)]:
962            w.setVisible(is_enabled is x)
963            self.layout().labelForField(w).setVisible(is_enabled is x)
964
965    def genesis(self):
966        self.current_template = custom_list_template() or self.default_template
967
968    @property
969    def current_template(self):
970        return {
971            'thumbnail': self.thumbnail.isChecked(),
972            'thumbnail_height': self.thumbnail_height.value(),
973            'height': self.entry_height.text().strip() or 'auto',
974            'comments_fields': [x.strip() for x in self.comments_fields.text().split(',') if x.strip()],
975            'lines': [x.strip() for x in self.template.toPlainText().splitlines()]
976        }
977
978    @current_template.setter
979    def current_template(self, template):
980        self.thumbnail.setChecked(bool(template.get('thumbnail')))
981        try:
982            th = int(template['thumbnail_height'])
983        except Exception:
984            th = self.default_template['thumbnail_height']
985        self.thumbnail_height.setValue(th)
986        self.entry_height.setText(template.get('height') or 'auto')
987        self.comments_fields.setText(', '.join(template.get('comments_fields') or ()))
988        self.template.setPlainText('\n'.join(template.get('lines') or ()))
989
990    def serialize(self, template):
991        return json.dumps(template, sort_keys=True, indent=4, separators=(',', ': '), ensure_ascii=True)
992
993    def deserialize(self, raw):
994        return json.loads(raw)
995
996    def restore_defaults(self):
997        self.current_template = self.default_template
998
999    def commit(self):
1000        template = self.current_template
1001        if template == self.default_template:
1002            try:
1003                os.remove(custom_list_template.path)
1004            except OSError as err:
1005                if err.errno != errno.ENOENT:
1006                    raise
1007        else:
1008            raw = self.serialize(template)
1009            with lopen(custom_list_template.path, 'wb') as f:
1010                f.write(as_bytes(raw))
1011        return True
1012
1013# }}}
1014
1015
1016# Search the internet {{{
1017
1018class URLItem(QWidget):
1019
1020    changed_signal = pyqtSignal()
1021
1022    def __init__(self, as_dict, parent=None):
1023        QWidget.__init__(self, parent)
1024        self.changed_signal.connect(parent.changed_signal)
1025        self.l = l = QFormLayout(self)
1026        self.type_widget = t = QComboBox(self)
1027        l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
1028        t.addItems([_('Book'), _('Author')])
1029        l.addRow(_('URL type:'), t)
1030        self.name_widget = n = QLineEdit(self)
1031        n.setClearButtonEnabled(True)
1032        l.addRow(_('Name:'), n)
1033        self.url_widget = w = QLineEdit(self)
1034        w.setClearButtonEnabled(True)
1035        l.addRow(_('URL:'), w)
1036        if as_dict:
1037            self.name = as_dict['name']
1038            self.url = as_dict['url']
1039            self.url_type = as_dict['type']
1040        self.type_widget.currentIndexChanged.connect(self.changed_signal)
1041        self.name_widget.textChanged.connect(self.changed_signal)
1042        self.url_widget.textChanged.connect(self.changed_signal)
1043
1044    @property
1045    def is_empty(self):
1046        return not self.name or not self.url
1047
1048    @property
1049    def url_type(self):
1050        return 'book' if self.type_widget.currentIndex() == 0 else 'author'
1051
1052    @url_type.setter
1053    def url_type(self, val):
1054        self.type_widget.setCurrentIndex(1 if val == 'author' else 0)
1055
1056    @property
1057    def name(self):
1058        return self.name_widget.text().strip()
1059
1060    @name.setter
1061    def name(self, val):
1062        self.name_widget.setText((val or '').strip())
1063
1064    @property
1065    def url(self):
1066        return self.url_widget.text().strip()
1067
1068    @url.setter
1069    def url(self, val):
1070        self.url_widget.setText((val or '').strip())
1071
1072    @property
1073    def as_dict(self):
1074        return {'name': self.name, 'url': self.url, 'type': self.url_type}
1075
1076    def validate(self):
1077        if self.is_empty:
1078            return True
1079        if '{author}' not in self.url:
1080            error_dialog(self.parent(), _('Missing author placeholder'), _(
1081                'The URL {0} does not contain the {1} placeholder').format(self.url, '{author}'), show=True)
1082            return False
1083        if self.url_type == 'book' and '{title}' not in self.url:
1084            error_dialog(self.parent(), _('Missing title placeholder'), _(
1085                'The URL {0} does not contain the {1} placeholder').format(self.url, '{title}'), show=True)
1086            return False
1087        return True
1088
1089
1090class SearchTheInternet(QWidget):
1091
1092    changed_signal = pyqtSignal()
1093
1094    def __init__(self, parent):
1095        QWidget.__init__(self, parent)
1096        self.sa = QScrollArea(self)
1097        self.lw = QWidget(self)
1098        self.l = QVBoxLayout(self.lw)
1099        self.sa.setWidget(self.lw), self.sa.setWidgetResizable(True)
1100        self.gl = gl = QVBoxLayout(self)
1101        self.la = QLabel(_(
1102            'Add new locations to search for books or authors using the "Search the internet" feature'
1103            ' of the Content server. The URLs should contain {author} which will be'
1104            ' replaced by the author name and, for book URLs, {title} which will'
1105            ' be replaced by the book title.'))
1106        self.la.setWordWrap(True)
1107        gl.addWidget(self.la)
1108
1109        self.h = QHBoxLayout()
1110        gl.addLayout(self.h)
1111        self.add_url_button = b = QPushButton(QIcon(I('plus.png')), _('&Add URL'))
1112        b.clicked.connect(self.add_url)
1113        self.h.addWidget(b)
1114        self.export_button = b = QPushButton(_('Export URLs'))
1115        b.clicked.connect(self.export_urls)
1116        self.h.addWidget(b)
1117        self.import_button = b = QPushButton(_('Import URLs'))
1118        b.clicked.connect(self.import_urls)
1119        self.h.addWidget(b)
1120        self.clear_button = b = QPushButton(_('Clear'))
1121        b.clicked.connect(self.clear)
1122        self.h.addWidget(b)
1123
1124        self.h.addStretch(10)
1125        gl.addWidget(self.sa, stretch=10)
1126        self.items = []
1127
1128    def genesis(self):
1129        self.current_urls = search_the_net_urls() or []
1130
1131    @property
1132    def current_urls(self):
1133        return [item.as_dict for item in self.items if not item.is_empty]
1134
1135    def append_item(self, item_as_dict):
1136        self.items.append(URLItem(item_as_dict, self))
1137        self.l.addWidget(self.items[-1])
1138
1139    def clear(self):
1140        [(self.l.removeWidget(w), w.setParent(None), w.deleteLater()) for w in self.items]
1141        self.items = []
1142        self.changed_signal.emit()
1143
1144    @current_urls.setter
1145    def current_urls(self, val):
1146        self.clear()
1147        for entry in val:
1148            self.append_item(entry)
1149
1150    def add_url(self):
1151        self.items.append(URLItem(None, self))
1152        self.l.addWidget(self.items[-1])
1153        QTimer.singleShot(100, self.scroll_to_bottom)
1154
1155    def scroll_to_bottom(self):
1156        sb = self.sa.verticalScrollBar()
1157        if sb:
1158            sb.setValue(sb.maximum())
1159        self.items[-1].name_widget.setFocus(Qt.FocusReason.OtherFocusReason)
1160
1161    @property
1162    def serialized_urls(self):
1163        return json.dumps(self.current_urls, indent=2)
1164
1165    def commit(self):
1166        for item in self.items:
1167            if not item.validate():
1168                return False
1169        cu = self.current_urls
1170        if cu:
1171            with lopen(search_the_net_urls.path, 'wb') as f:
1172                f.write(self.serialized_urls.encode('utf-8'))
1173        else:
1174            try:
1175                os.remove(search_the_net_urls.path)
1176            except OSError as err:
1177                if err.errno != errno.ENOENT:
1178                    raise
1179        return True
1180
1181    def export_urls(self):
1182        path = choose_save_file(
1183            self, 'search-net-urls', _('Choose URLs file'),
1184            filters=[(_('URL files'), ['json'])], initial_filename='search-urls.json')
1185        if path:
1186            with lopen(path, 'wb') as f:
1187                f.write(self.serialized_urls.encode('utf-8'))
1188
1189    def import_urls(self):
1190        paths = choose_files(self, 'search-net-urls', _('Choose URLs file'),
1191            filters=[(_('URL files'), ['json'])], all_files=False, select_only_single_file=True)
1192        if paths:
1193            with lopen(paths[0], 'rb') as f:
1194                items = json.loads(f.read())
1195                [self.append_item(x) for x in items]
1196                self.changed_signal.emit()
1197
1198# }}}
1199
1200
1201class ConfigWidget(ConfigWidgetBase):
1202
1203    def __init__(self, *args, **kw):
1204        ConfigWidgetBase.__init__(self, *args, **kw)
1205        self.l = l = QVBoxLayout(self)
1206        l.setContentsMargins(0, 0, 0, 0)
1207        self.tabs_widget = t = QTabWidget(self)
1208        l.addWidget(t)
1209        self.main_tab = m = MainTab(self)
1210        t.addTab(m, _('&Main'))
1211        m.start_server.connect(self.start_server)
1212        m.stop_server.connect(self.stop_server)
1213        m.test_server.connect(self.test_server)
1214        m.show_logs.connect(self.view_server_logs)
1215        self.opt_autolaunch_server = m.opt_autolaunch_server
1216        self.users_tab = ua = Users(self)
1217        t.addTab(ua, _('&User accounts'))
1218        self.advanced_tab = a = AdvancedTab(self)
1219        sa = QScrollArea(self)
1220        sa.setWidget(a), sa.setWidgetResizable(True)
1221        t.addTab(sa, _('&Advanced'))
1222        self.custom_list_tab = clt = CustomList(self)
1223        sa = QScrollArea(self)
1224        sa.setWidget(clt), sa.setWidgetResizable(True)
1225        t.addTab(sa, _('Book &list template'))
1226        self.search_net_tab = SearchTheInternet(self)
1227        t.addTab(self.search_net_tab, _('&Search the internet'))
1228
1229        for tab in self.tabs:
1230            if hasattr(tab, 'changed_signal'):
1231                tab.changed_signal.connect(self.changed_signal.emit)
1232
1233    @property
1234    def tabs(self):
1235
1236        def w(x):
1237            if isinstance(x, QScrollArea):
1238                x = x.widget()
1239            return x
1240
1241        return (
1242            w(self.tabs_widget.widget(i)) for i in range(self.tabs_widget.count())
1243        )
1244
1245    @property
1246    def server(self):
1247        return self.gui.content_server
1248
1249    def restore_defaults(self):
1250        ConfigWidgetBase.restore_defaults(self)
1251        for tab in self.tabs:
1252            if hasattr(tab, 'restore_defaults'):
1253                tab.restore_defaults()
1254
1255    def genesis(self, gui):
1256        self.gui = gui
1257        for tab in self.tabs:
1258            tab.genesis()
1259
1260        r = self.register
1261        r('autolaunch_server', config)
1262
1263    def start_server(self):
1264        if not self.save_changes():
1265            return
1266        self.setCursor(Qt.CursorShape.BusyCursor)
1267        try:
1268            self.gui.start_content_server(check_started=False)
1269            while (not self.server.is_running and self.server.exception is None):
1270                time.sleep(0.1)
1271            if self.server.exception is not None:
1272                error_dialog(
1273                    self,
1274                    _('Failed to start Content server'),
1275                    as_unicode(self.gui.content_server.exception)
1276                ).exec()
1277                self.gui.content_server = None
1278                return
1279            self.main_tab.update_button_state()
1280        finally:
1281            self.unsetCursor()
1282
1283    def stop_server(self):
1284        self.server.stop()
1285        self.stopping_msg = info_dialog(
1286            self,
1287            _('Stopping'),
1288            _('Stopping server, this could take up to a minute, please wait...'),
1289            show_copy_button=False
1290        )
1291        QTimer.singleShot(500, self.check_exited)
1292        self.stopping_msg.exec()
1293
1294    def check_exited(self):
1295        if getattr(self.server, 'is_running', False):
1296            QTimer.singleShot(20, self.check_exited)
1297            return
1298
1299        self.gui.content_server = None
1300        self.main_tab.update_button_state()
1301        self.stopping_msg.accept()
1302
1303    def test_server(self):
1304        prefix = self.advanced_tab.get('url_prefix') or ''
1305        protocol = 'https' if self.advanced_tab.has_ssl else 'http'
1306        lo = self.advanced_tab.get('listen_on') or '0.0.0.0'
1307        lo = {'0.0.0.0': '127.0.0.1', '::':'::1'}.get(lo)
1308        url = '{protocol}://{interface}:{port}{prefix}'.format(
1309            protocol=protocol, interface=lo,
1310            port=self.main_tab.opt_port.value(), prefix=prefix)
1311        open_url(QUrl(url))
1312
1313    def view_server_logs(self):
1314        from calibre.srv.embedded import log_paths
1315        log_error_file, log_access_file = log_paths()
1316        d = QDialog(self)
1317        d.resize(QSize(800, 600))
1318        layout = QVBoxLayout()
1319        d.setLayout(layout)
1320        layout.addWidget(QLabel(_('Error log:')))
1321        el = QPlainTextEdit(d)
1322        layout.addWidget(el)
1323        try:
1324            el.setPlainText(
1325                share_open(log_error_file, 'rb').read().decode('utf8', 'replace')
1326            )
1327        except OSError:
1328            el.setPlainText(_('No error log found'))
1329        layout.addWidget(QLabel(_('Access log:')))
1330        al = QPlainTextEdit(d)
1331        layout.addWidget(al)
1332        try:
1333            al.setPlainText(
1334                share_open(log_access_file, 'rb').read().decode('utf8', 'replace')
1335            )
1336        except OSError:
1337            al.setPlainText(_('No access log found'))
1338        loc = QLabel(_('The server log files are in: {}').format(os.path.dirname(log_error_file)))
1339        loc.setWordWrap(True)
1340        layout.addWidget(loc)
1341        bx = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
1342        layout.addWidget(bx)
1343        bx.accepted.connect(d.accept)
1344        b = bx.addButton(_('&Clear logs'), QDialogButtonBox.ButtonRole.ActionRole)
1345
1346        def clear_logs():
1347            if getattr(self.server, 'is_running', False):
1348                return error_dialog(d, _('Server running'), _(
1349                    'Cannot clear logs while the server is running. First stop the server.'), show=True)
1350            if self.server:
1351                self.server.access_log.clear()
1352                self.server.log.clear()
1353            else:
1354                for x in (log_error_file, log_access_file):
1355                    try:
1356                        os.remove(x)
1357                    except OSError as err:
1358                        if err.errno != errno.ENOENT:
1359                            raise
1360            el.setPlainText(''), al.setPlainText('')
1361
1362        b.clicked.connect(clear_logs)
1363        d.show()
1364
1365    def save_changes(self):
1366        settings = {}
1367        for tab in self.tabs:
1368            settings.update(getattr(tab, 'settings', {}))
1369        users = self.users_tab.user_data
1370        if settings['auth']:
1371            if not users:
1372                error_dialog(
1373                    self,
1374                    _('No users specified'),
1375                    _(
1376                        'You have turned on the setting to require passwords to access'
1377                        ' the Content server, but you have not created any user accounts.'
1378                        ' Create at least one user account in the "User accounts" tab to proceed.'
1379                    ),
1380                    show=True
1381                )
1382                self.tabs_widget.setCurrentWidget(self.users_tab)
1383                return False
1384        if settings['trusted_ips']:
1385            try:
1386                tuple(parse_trusted_ips(settings['trusted_ips']))
1387            except Exception as e:
1388                error_dialog(
1389                    self, _('Invalid trusted IPs'), str(e), show=True)
1390                return False
1391
1392        if not self.custom_list_tab.commit():
1393            return False
1394        if not self.search_net_tab.commit():
1395            return False
1396        ConfigWidgetBase.commit(self)
1397        change_settings(**settings)
1398        UserManager().user_data = users
1399        return True
1400
1401    def commit(self):
1402        if not self.save_changes():
1403            raise AbortCommit()
1404        warning_dialog(
1405            self,
1406            _('Restart needed'),
1407            _('You need to restart the server for changes to'
1408              ' take effect'),
1409            show=True
1410        )
1411        return False
1412
1413    def refresh_gui(self, gui):
1414        if self.server:
1415            self.server.user_manager.refresh()
1416            self.server.ctx.custom_list_template = custom_list_template()
1417            self.server.ctx.search_the_net_urls = search_the_net_urls()
1418
1419
1420if __name__ == '__main__':
1421    from calibre.gui2 import Application
1422    app = Application([])
1423    test_widget('Sharing', 'Server')
1424