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