%PDF- %PDF-
Direktori : /lib/calibre/calibre/gui2/preferences/ |
Current File : //lib/calibre/calibre/gui2/preferences/server.py |
#!/usr/bin/env python3 # License: GPLv3 Copyright: 2010, Kovid Goyal <kovid at kovidgoyal.net> import errno import json import numbers import os import sys import textwrap import time from qt.core import ( QCheckBox, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout, QFrame, QHBoxLayout, QIcon, QLabel, QLineEdit, QListWidget, QPlainTextEdit, QLayout, QPushButton, QScrollArea, QSize, QSizePolicy, QSpinBox, Qt, QTabWidget, QTimer, QToolButton, QUrl, QVBoxLayout, QWidget, pyqtSignal, sip ) from calibre import as_unicode from calibre.constants import isportable, iswindows from calibre.gui2 import ( choose_files, choose_save_file, config, error_dialog, gprefs, info_dialog, open_url, warning_dialog ) from calibre.gui2.preferences import AbortCommit, ConfigWidgetBase, test_widget from calibre.gui2.widgets import HistoryLineEdit from calibre.srv.code import custom_list_template as default_custom_list_template from calibre.srv.embedded import custom_list_template, search_the_net_urls from calibre.srv.loop import parse_trusted_ips from calibre.srv.library_broker import load_gui_libraries from calibre.srv.opts import change_settings, options, server_config from calibre.srv.users import ( UserManager, create_user_data, validate_password, validate_username ) from calibre.utils.icu import primary_sort_key from calibre.utils.shared_file import share_open from polyglot.builtins import as_bytes if iswindows and not isportable: from calibre_extensions import winutil def get_exe(): exe_base = os.path.abspath(os.path.dirname(sys.executable)) exe = os.path.join(exe_base, 'calibre.exe') if isinstance(exe, bytes): exe = os.fsdecode(exe) return exe def startup_shortcut_path(): startup_path = winutil.special_folder_path(winutil.CSIDL_STARTUP) return os.path.join(startup_path, "calibre.lnk") def create_shortcut(shortcut_path, target, description, *args): quoted_args = None if args: quoted_args = [] for arg in args: quoted_args.append(f'"{arg}"') quoted_args = ' '.join(quoted_args) winutil.manage_shortcut(shortcut_path, target, description, quoted_args) def shortcut_exists_at(shortcut_path, target): if not os.access(shortcut_path, os.R_OK): return False name = winutil.manage_shortcut(shortcut_path, None, None, None) if name is None: return False return os.path.normcase(os.path.abspath(name)) == os.path.normcase(os.path.abspath(target)) def set_run_at_startup(run_at_startup=True): if run_at_startup: create_shortcut(startup_shortcut_path(), get_exe(), 'calibre - E-book management', '--start-in-tray') else: shortcut_path = startup_shortcut_path() if os.path.exists(shortcut_path): os.remove(shortcut_path) def is_set_to_run_at_startup(): try: return shortcut_exists_at(startup_shortcut_path(), get_exe()) except Exception: import traceback traceback.print_exc() else: set_run_at_startup = is_set_to_run_at_startup = None # Advanced {{{ def init_opt(widget, opt, layout): widget.name, widget.default_val = opt.name, opt.default if opt.longdoc: widget.setWhatsThis(opt.longdoc) widget.setStatusTip(opt.longdoc) widget.setToolTip(textwrap.fill(opt.longdoc)) layout.addRow(opt.shortdoc + ':', widget) class Bool(QCheckBox): changed_signal = pyqtSignal() def __init__(self, name, layout): opt = options[name] QCheckBox.__init__(self) self.stateChanged.connect(self.changed_signal.emit) init_opt(self, opt, layout) def get(self): return self.isChecked() def set(self, val): self.setChecked(bool(val)) class Int(QSpinBox): changed_signal = pyqtSignal() def __init__(self, name, layout): QSpinBox.__init__(self) self.setRange(0, 20000) opt = options[name] self.valueChanged.connect(self.changed_signal.emit) init_opt(self, opt, layout) def get(self): return self.value() def set(self, val): self.setValue(int(val)) class Float(QDoubleSpinBox): changed_signal = pyqtSignal() def __init__(self, name, layout): QDoubleSpinBox.__init__(self) self.setRange(0, 20000) self.setDecimals(1) opt = options[name] self.valueChanged.connect(self.changed_signal.emit) init_opt(self, opt, layout) def get(self): return self.value() def set(self, val): self.setValue(float(val)) class Text(QLineEdit): changed_signal = pyqtSignal() def __init__(self, name, layout): QLineEdit.__init__(self) self.setClearButtonEnabled(True) opt = options[name] self.textChanged.connect(self.changed_signal.emit) init_opt(self, opt, layout) def get(self): return self.text().strip() or None def set(self, val): self.setText(str(val or '')) class Path(QWidget): changed_signal = pyqtSignal() def __init__(self, name, layout): QWidget.__init__(self) self.dname = name opt = options[name] self.l = l = QHBoxLayout(self) l.setContentsMargins(0, 0, 0, 0) self.text = t = HistoryLineEdit(self) t.initialize(f'server-opts-{name}') t.setClearButtonEnabled(True) t.currentTextChanged.connect(self.changed_signal.emit) l.addWidget(t) self.b = b = QToolButton(self) l.addWidget(b) b.setIcon(QIcon(I('document_open.png'))) b.setToolTip(_("Browse for the file")) b.clicked.connect(self.choose) init_opt(self, opt, layout) def get(self): return self.text.text().strip() or None def set(self, val): self.text.setText(str(val or '')) def choose(self): ans = choose_files(self, 'choose_path_srv_opts_' + self.dname, _('Choose a file'), select_only_single_file=True) if ans: self.set(ans[0]) self.text.save_history() class Choices(QComboBox): changed_signal = pyqtSignal() def __init__(self, name, layout): QComboBox.__init__(self) self.setEditable(False) opt = options[name] self.choices = opt.choices self.addItems(opt.choices) self.currentIndexChanged.connect(self.changed_signal.emit) init_opt(self, opt, layout) def get(self): return self.currentText() def set(self, val): if val in self.choices: self.setCurrentText(val) else: self.setCurrentIndex(0) class AdvancedTab(QWidget): changed_signal = pyqtSignal() def __init__(self, parent=None): QWidget.__init__(self, parent) self.l = l = QFormLayout(self) l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.widgets = [] self.widget_map = {} self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) for name in sorted(options, key=lambda n: options[n].shortdoc.lower()): if name in ('auth', 'port', 'allow_socket_preallocation', 'userdb'): continue opt = options[name] if opt.choices: w = Choices elif isinstance(opt.default, bool): w = Bool elif isinstance(opt.default, numbers.Integral): w = Int elif isinstance(opt.default, numbers.Real): w = Float else: w = Text if name in ('ssl_certfile', 'ssl_keyfile'): w = Path w = w(name, l) setattr(self, 'opt_' + name, w) self.widgets.append(w) self.widget_map[name] = w def genesis(self): opts = server_config() for w in self.widgets: w.set(getattr(opts, w.name)) w.changed_signal.connect(self.changed_signal.emit) def restore_defaults(self): for w in self.widgets: w.set(w.default_val) def get(self, name): return self.widget_map[name].get() @property def settings(self): return {w.name: w.get() for w in self.widgets} @property def has_ssl(self): return bool(self.get('ssl_certfile')) and bool(self.get('ssl_keyfile')) # }}} class MainTab(QWidget): # {{{ changed_signal = pyqtSignal() start_server = pyqtSignal() stop_server = pyqtSignal() test_server = pyqtSignal() show_logs = pyqtSignal() def __init__(self, parent=None): QWidget.__init__(self, parent) self.l = l = QVBoxLayout(self) self.la = la = QLabel( _( 'calibre contains an internet server that allows you to' ' access your book collection using a browser from anywhere' ' in the world. Any changes to the settings will only take' ' effect after a server restart.' ) ) la.setWordWrap(True) l.addWidget(la) l.addSpacing(10) self.fl = fl = QFormLayout() l.addLayout(fl) self.opt_port = sb = QSpinBox(self) if options['port'].longdoc: sb.setToolTip(options['port'].longdoc) sb.setRange(1, 65535) sb.valueChanged.connect(self.changed_signal.emit) fl.addRow(options['port'].shortdoc + ':', sb) l.addSpacing(25) self.opt_auth = cb = QCheckBox( _('Require &username and password to access the Content server') ) l.addWidget(cb) self.auth_desc = la = QLabel(self) la.setStyleSheet('QLabel { font-size: small; font-style: italic }') la.setWordWrap(True) l.addWidget(la) l.addSpacing(25) self.opt_autolaunch_server = al = QCheckBox( _('Run server &automatically when calibre starts') ) l.addWidget(al) l.addSpacing(25) self.h = h = QHBoxLayout() l.addLayout(h) for text, name in [(_('&Start server'), 'start_server'), (_('St&op server'), 'stop_server'), (_('&Test server'), 'test_server'), (_('Show server &logs'), 'show_logs')]: b = QPushButton(text) b.clicked.connect(getattr(self, name).emit) setattr(self, name + '_button', b) if name == 'show_logs': h.addStretch(10) h.addWidget(b) self.ip_info = QLabel(self) self.update_ip_info() from calibre.gui2.ui import get_gui gui = get_gui() if gui is not None: gui.iactions['Connect Share'].share_conn_menu.server_state_changed_signal.connect(self.update_ip_info) l.addSpacing(10) l.addWidget(self.ip_info) if set_run_at_startup is not None: self.run_at_start_button = b = QPushButton('', self) self.set_run_at_start_text() b.clicked.connect(self.toggle_run_at_startup) l.addSpacing(10) l.addWidget(b) l.addSpacing(10) l.addStretch(10) def set_run_at_start_text(self): is_autostarted = is_set_to_run_at_startup() self.run_at_start_button.setText( _('Do not start calibre automatically when computer is started') if is_autostarted else _('Start calibre when the computer is started') ) self.run_at_start_button.setToolTip('<p>' + ( _('''Currently calibre is set to run automatically when the computer starts. Use this button to disable that.''') if is_autostarted else _('''Start calibre in the system tray automatically when the computer starts'''))) def toggle_run_at_startup(self): set_run_at_startup(not is_set_to_run_at_startup()) self.set_run_at_start_text() def update_ip_info(self): from calibre.gui2.ui import get_gui gui = get_gui() if gui is not None: t = get_gui().iactions['Connect Share'].share_conn_menu.ip_text t = t.strip().strip('[]') self.ip_info.setText(_('Content server listening at: %s') % t) def genesis(self): opts = server_config() self.opt_auth.setChecked(opts.auth) self.opt_auth.stateChanged.connect(self.auth_changed) self.opt_port.setValue(opts.port) self.change_auth_desc() self.update_button_state() def change_auth_desc(self): self.auth_desc.setText( _('Remember to create at least one user account in the "User accounts" tab') if self.opt_auth.isChecked() else _( 'Requiring a username/password prevents unauthorized people from' ' accessing your calibre library. It is also needed for some features' ' such as making any changes to the library as well as' ' last read position/annotation syncing.' ) ) def auth_changed(self): self.changed_signal.emit() self.change_auth_desc() def restore_defaults(self): self.opt_auth.setChecked(options['auth'].default) self.opt_port.setValue(options['port'].default) def update_button_state(self): from calibre.gui2.ui import get_gui gui = get_gui() if gui is not None: is_running = gui.content_server is not None and gui.content_server.is_running self.ip_info.setVisible(is_running) self.update_ip_info() self.start_server_button.setEnabled(not is_running) self.stop_server_button.setEnabled(is_running) self.test_server_button.setEnabled(is_running) @property def settings(self): return {'auth': self.opt_auth.isChecked(), 'port': self.opt_port.value()} # }}} # Users {{{ class NewUser(QDialog): def __init__(self, user_data, parent=None, username=None): QDialog.__init__(self, parent) self.user_data = user_data self.setWindowTitle( _('Change password for {}').format(username) if username else _('Add new user') ) self.l = l = QFormLayout(self) l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.uw = u = QLineEdit(self) l.addRow(_('&Username:'), u) if username: u.setText(username) u.setReadOnly(True) l.addRow(QLabel(_('Set the password for this user'))) self.p1, self.p2 = p1, p2 = QLineEdit(self), QLineEdit(self) l.addRow(_('&Password:'), p1), l.addRow(_('&Repeat password:'), p2) for p in p1, p2: p.setEchoMode(QLineEdit.EchoMode.PasswordEchoOnEdit) p.setMinimumWidth(300) if username: p.setText(user_data[username]['pw']) self.showp = sp = QCheckBox(_('&Show password')) sp.stateChanged.connect(self.show_password) l.addRow(sp) self.bb = bb = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) l.addRow(bb) bb.accepted.connect(self.accept), bb.rejected.connect(self.reject) (self.uw if not username else self.p1).setFocus(Qt.FocusReason.OtherFocusReason) def show_password(self): for p in self.p1, self.p2: p.setEchoMode( QLineEdit.EchoMode.Normal if self.showp.isChecked() else QLineEdit.EchoMode.PasswordEchoOnEdit ) @property def username(self): return self.uw.text().strip() @property def password(self): return self.p1.text() def accept(self): if not self.uw.isReadOnly(): un = self.username if not un: return error_dialog( self, _('Empty username'), _('You must enter a username'), show=True ) if un in self.user_data: return error_dialog( self, _('Username already exists'), _( 'A user with the username {} already exists. Please choose a different username.' ).format(un), show=True ) err = validate_username(un) if err: return error_dialog(self, _('Username is not valid'), err, show=True) p1, p2 = self.password, self.p2.text() if p1 != p2: return error_dialog( self, _('Password do not match'), _('The two passwords you entered do not match!'), show=True ) if not p1: return error_dialog( self, _('Empty password'), _('You must enter a password for this user'), show=True ) err = validate_password(p1) if err: return error_dialog(self, _('Invalid password'), err, show=True) return QDialog.accept(self) class Library(QWidget): restriction_changed = pyqtSignal(object, object) def __init__(self, name, is_checked=False, path='', restriction='', parent=None, is_first=False, enable_on_checked=True): QWidget.__init__(self, parent) self.name = name self.enable_on_checked = enable_on_checked self.l = l = QVBoxLayout(self) l.setSizeConstraint(QLayout.SizeConstraint.SetMinAndMaxSize) if not is_first: self.border = b = QFrame(self) b.setFrameStyle(QFrame.Shape.HLine) l.addWidget(b) self.cw = cw = QCheckBox(name.replace('&', '&&')) cw.setStyleSheet('QCheckBox { font-weight: bold }') cw.setChecked(is_checked) cw.stateChanged.connect(self.state_changed) if path: cw.setToolTip(path) l.addWidget(cw) self.la = la = QLabel(_('Further &restrict access to books in this library that match:')) l.addWidget(la) self.rw = rw = QLineEdit(self) rw.setPlaceholderText(_('A search expression')) rw.setToolTip(textwrap.fill(_( 'A search expression. If specified, access will be further restricted' ' to only those books that match this expression. For example:' ' tags:"=Share"'))) rw.setText(restriction or '') rw.textChanged.connect(self.on_rchange) la.setBuddy(rw) l.addWidget(rw) self.state_changed() def state_changed(self): c = self.cw.isChecked() w = (self.enable_on_checked and c) or (not self.enable_on_checked and not c) for x in (self.la, self.rw): x.setEnabled(bool(w)) def on_rchange(self): self.restriction_changed.emit(self.name, self.restriction) @property def is_checked(self): return self.cw.isChecked() @property def restriction(self): return self.rw.text().strip() class ChangeRestriction(QDialog): def __init__(self, username, restriction, parent=None): QDialog.__init__(self, parent) self.setWindowTitle(_('Change library access permissions for {}').format(username)) self.username = username self._items = [] self.l = l = QFormLayout(self) l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.libraries = t = QWidget(self) t.setObjectName('libraries') t.l = QVBoxLayout(self.libraries) self.atype = a = QComboBox(self) a.addItems([_('All libraries'), _('Only the specified libraries'), _('All except the specified libraries')]) self.library_restrictions = restriction['library_restrictions'].copy() if restriction['allowed_library_names']: a.setCurrentIndex(1) self.items = restriction['allowed_library_names'] elif restriction['blocked_library_names']: a.setCurrentIndex(2) self.items = restriction['blocked_library_names'] else: a.setCurrentIndex(0) a.currentIndexChanged.connect(self.atype_changed) l.addRow(_('Allow access to:'), a) self.msg = la = QLabel(self) la.setWordWrap(True) l.addRow(la) self.la = la = QLabel(_('Specify the libraries below:')) la.setWordWrap(True) self.sa = sa = QScrollArea(self) sa.setWidget(t), sa.setWidgetResizable(True) l.addRow(la), l.addRow(sa) self.atype_changed() self.bb = bb = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) bb.accepted.connect(self.accept), bb.rejected.connect(self.reject) l.addWidget(bb) self.items = self.items def sizeHint(self): return QSize(800, 600) def __iter__(self): return iter(self._items) @property def items(self): return frozenset(item.name for item in self if item.is_checked) def clear(self): for c in self: self.libraries.l.removeWidget(c) c.setParent(None) c.restriction_changed.disconnect() sip.delete(c) self._items = [] @items.setter def items(self, val): self.clear() checked_libraries = frozenset(val) library_paths = load_gui_libraries(gprefs) gui_libraries = {os.path.basename(l):l for l in library_paths} lchecked_libraries = {l.lower() for l in checked_libraries} seen = set() items = [] for x in checked_libraries | set(gui_libraries): xl = x.lower() if xl not in seen: seen.add(xl) items.append((x, xl in lchecked_libraries)) items.sort(key=lambda x: primary_sort_key(x[0])) enable_on_checked = self.atype.currentIndex() == 1 for i, (l, checked) in enumerate(items): l = Library( l, checked, path=gui_libraries.get(l, ''), restriction=self.library_restrictions.get(l.lower(), ''), parent=self.libraries, is_first=i == 0, enable_on_checked=enable_on_checked ) l.restriction_changed.connect(self.restriction_changed) self.libraries.l.addWidget(l) self._items.append(l) def restriction_changed(self, name, val): name = name.lower() self.library_restrictions[name] = val @property def restriction(self): ans = {'allowed_library_names': frozenset(), 'blocked_library_names': frozenset(), 'library_restrictions': {}} if self.atype.currentIndex() != 0: k = ['allowed_library_names', 'blocked_library_names'][self.atype.currentIndex() - 1] ans[k] = self.items ans['library_restrictions'] = self.library_restrictions return ans def accept(self): if self.atype.currentIndex() != 0 and not self.items: return error_dialog(self, _('No libraries specified'), _( 'You have not specified any libraries'), show=True) return QDialog.accept(self) def atype_changed(self): ci = self.atype.currentIndex() sheet = '' if ci == 0: m = _('<b>{} is allowed access to all libraries') self.libraries.setEnabled(False), self.la.setEnabled(False) else: if ci == 1: m = _('{} is allowed access only to the libraries whose names' ' <b>match</b> one of the names specified below.') else: m = _('{} is allowed access to all libraries, <b>except</b> those' ' whose names match one of the names specified below.') sheet += 'QWidget#libraries { background-color: #FAE7B5}' self.libraries.setEnabled(True), self.la.setEnabled(True) self.items = self.items self.msg.setText(m.format(self.username)) self.libraries.setStyleSheet(sheet) class User(QWidget): changed_signal = pyqtSignal() def __init__(self, parent=None): QWidget.__init__(self, parent) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.l = l = QFormLayout(self) l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.username_label = la = QLabel('') l.addWidget(la) self.ro_text = _('Allow {} to make &changes (i.e. grant write access)') self.rw = rw = QCheckBox(self) rw.setToolTip( _( 'If enabled, allows the user to make changes to the library.' ' Adding books/deleting books/editing metadata, etc.' ) ) rw.stateChanged.connect(self.readonly_changed) l.addWidget(rw) self.access_label = la = QLabel(self) l.addWidget(la), la.setWordWrap(True) self.cpb = b = QPushButton(_('Change &password')) l.addWidget(b) b.clicked.connect(self.change_password) self.restrict_button = b = QPushButton(self) b.clicked.connect(self.change_restriction) l.addWidget(b) self.show_user() def change_password(self): d = NewUser(self.user_data, self, self.username) if d.exec() == QDialog.DialogCode.Accepted: self.user_data[self.username]['pw'] = d.password self.changed_signal.emit() def readonly_changed(self): self.user_data[self.username]['readonly'] = not self.rw.isChecked() self.changed_signal.emit() def update_restriction(self): username, user_data = self.username, self.user_data r = user_data[username]['restriction'] if r['allowed_library_names']: libs = r['allowed_library_names'] m = ngettext( '{} is currently only allowed to access the library named: {}', '{} is currently only allowed to access the libraries named: {}', len(libs) ).format(username, ', '.join(libs)) b = _('Change the allowed libraries') elif r['blocked_library_names']: libs = r['blocked_library_names'] m = ngettext( '{} is currently not allowed to access the library named: {}', '{} is currently not allowed to access the libraries named: {}', len(libs) ).format(username, ', '.join(libs)) b = _('Change the blocked libraries') else: m = _('{} is currently allowed access to all libraries') b = _('Restrict the &libraries {} can access').format(self.username) self.restrict_button.setText(b), self.access_label.setText(m.format(username)) def show_user(self, username=None, user_data=None): self.username, self.user_data = username, user_data self.cpb.setVisible(username is not None) self.username_label.setText(('<h2>' + username) if username else '') if username: self.rw.setText(self.ro_text.format(username)) self.rw.setVisible(True) self.rw.blockSignals(True), self.rw.setChecked( not user_data[username]['readonly'] ), self.rw.blockSignals(False) self.access_label.setVisible(True) self.restrict_button.setVisible(True) self.update_restriction() else: self.rw.setVisible(False) self.access_label.setVisible(False) self.restrict_button.setVisible(False) def change_restriction(self): d = ChangeRestriction( self.username, self.user_data[self.username]['restriction'].copy(), parent=self ) if d.exec() == QDialog.DialogCode.Accepted: self.user_data[self.username]['restriction'] = d.restriction self.update_restriction() self.changed_signal.emit() def sizeHint(self): ans = QWidget.sizeHint(self) ans.setWidth(400) return ans class Users(QWidget): changed_signal = pyqtSignal() def __init__(self, parent=None): QWidget.__init__(self, parent) self.l = l = QHBoxLayout(self) self.lp = lp = QVBoxLayout() l.addLayout(lp) self.h = h = QHBoxLayout() lp.addLayout(h) self.add_button = b = QPushButton(QIcon(I('plus.png')), _('&Add user'), self) b.clicked.connect(self.add_user) h.addWidget(b) self.remove_button = b = QPushButton( QIcon(I('minus.png')), _('&Remove user'), self ) b.clicked.connect(self.remove_user) h.addStretch(2), h.addWidget(b) self.user_list = w = QListWidget(self) w.setSpacing(1) w.doubleClicked.connect(self.current_user_activated) w.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding) lp.addWidget(w) self.user_display = u = User(self) u.changed_signal.connect(self.changed_signal.emit) l.addWidget(u) def genesis(self): self.user_data = UserManager().user_data self.user_list.addItems(sorted(self.user_data, key=primary_sort_key)) self.user_list.setCurrentRow(0) self.user_list.currentItemChanged.connect(self.current_item_changed) self.current_item_changed() def current_user_activated(self): self.user_display.change_password() def current_item_changed(self): item = self.user_list.currentItem() if item is None: username = None else: username = item.text() if username not in self.user_data: username = None self.display_user_data(username) def add_user(self): d = NewUser(self.user_data, parent=self) if d.exec() == QDialog.DialogCode.Accepted: un, pw = d.username, d.password self.user_data[un] = create_user_data(pw) self.user_list.insertItem(0, un) self.user_list.setCurrentRow(0) self.display_user_data(un) self.changed_signal.emit() def remove_user(self): u = self.user_list.currentItem() if u is not None: self.user_list.takeItem(self.user_list.row(u)) un = u.text() self.user_data.pop(un, None) self.changed_signal.emit() self.current_item_changed() def display_user_data(self, username=None): self.user_display.show_user(username, self.user_data) # }}} class CustomList(QWidget): # {{{ changed_signal = pyqtSignal() def __init__(self, parent): QWidget.__init__(self, parent) self.default_template = default_custom_list_template() self.l = l = QFormLayout(self) l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.la = la = QLabel('<p>' + _( 'Here you can create a template to control what data is shown when' ' using the <i>Custom list</i> mode for the book list')) la.setWordWrap(True) l.addRow(la) self.thumbnail = t = QCheckBox(_('Show a cover &thumbnail')) self.thumbnail_height = th = QSpinBox(self) th.setSuffix(' px'), th.setRange(60, 600) self.entry_height = eh = QLineEdit(self) l.addRow(t), l.addRow(_('Thumbnail &height:'), th) l.addRow(_('Entry &height:'), eh) t.stateChanged.connect(self.changed_signal) th.valueChanged.connect(self.changed_signal) eh.textChanged.connect(self.changed_signal) eh.setToolTip(textwrap.fill(_( 'The height for each entry. The special value "auto" causes a height to be calculated' ' based on the number of lines in the template. Otherwise, use a CSS length, such as' ' 100px or 15ex'))) t.stateChanged.connect(self.thumbnail_state_changed) th.setVisible(False) self.comments_fields = cf = QLineEdit(self) l.addRow(_('&Long text fields:'), cf) cf.setToolTip(textwrap.fill(_( 'A comma separated list of fields that will be added at the bottom of every entry.' ' These fields are interpreted as containing HTML, not plain text.'))) cf.textChanged.connect(self.changed_signal) self.la1 = la = QLabel('<p>' + _( 'The template below will be interpreted as HTML and all {{fields}} will be replaced' ' by the actual metadata, if available. For custom columns use the column lookup' ' name, for example: #mytags. You can use {0} as a separator' ' to split a line into multiple columns.').format('|||')) la.setWordWrap(True) l.addRow(la) self.template = t = QPlainTextEdit(self) l.addRow(t) t.textChanged.connect(self.changed_signal) self.imex = bb = QDialogButtonBox(self) b = bb.addButton(_('&Import template'), QDialogButtonBox.ButtonRole.ActionRole) b.clicked.connect(self.import_template) b = bb.addButton(_('E&xport template'), QDialogButtonBox.ButtonRole.ActionRole) b.clicked.connect(self.export_template) l.addRow(bb) def import_template(self): paths = choose_files(self, 'custom-list-template', _('Choose template file'), filters=[(_('Template files'), ['json'])], all_files=False, select_only_single_file=True) if paths: with lopen(paths[0], 'rb') as f: raw = f.read() self.current_template = self.deserialize(raw) def export_template(self): path = choose_save_file( self, 'custom-list-template', _('Choose template file'), filters=[(_('Template files'), ['json'])], initial_filename='custom-list-template.json') if path: raw = self.serialize(self.current_template) with lopen(path, 'wb') as f: f.write(as_bytes(raw)) def thumbnail_state_changed(self): is_enabled = bool(self.thumbnail.isChecked()) for w, x in [(self.thumbnail_height, True), (self.entry_height, False)]: w.setVisible(is_enabled is x) self.layout().labelForField(w).setVisible(is_enabled is x) def genesis(self): self.current_template = custom_list_template() or self.default_template @property def current_template(self): return { 'thumbnail': self.thumbnail.isChecked(), 'thumbnail_height': self.thumbnail_height.value(), 'height': self.entry_height.text().strip() or 'auto', 'comments_fields': [x.strip() for x in self.comments_fields.text().split(',') if x.strip()], 'lines': [x.strip() for x in self.template.toPlainText().splitlines()] } @current_template.setter def current_template(self, template): self.thumbnail.setChecked(bool(template.get('thumbnail'))) try: th = int(template['thumbnail_height']) except Exception: th = self.default_template['thumbnail_height'] self.thumbnail_height.setValue(th) self.entry_height.setText(template.get('height') or 'auto') self.comments_fields.setText(', '.join(template.get('comments_fields') or ())) self.template.setPlainText('\n'.join(template.get('lines') or ())) def serialize(self, template): return json.dumps(template, sort_keys=True, indent=4, separators=(',', ': '), ensure_ascii=True) def deserialize(self, raw): return json.loads(raw) def restore_defaults(self): self.current_template = self.default_template def commit(self): template = self.current_template if template == self.default_template: try: os.remove(custom_list_template.path) except OSError as err: if err.errno != errno.ENOENT: raise else: raw = self.serialize(template) with lopen(custom_list_template.path, 'wb') as f: f.write(as_bytes(raw)) return True # }}} # Search the internet {{{ class URLItem(QWidget): changed_signal = pyqtSignal() def __init__(self, as_dict, parent=None): QWidget.__init__(self, parent) self.changed_signal.connect(parent.changed_signal) self.l = l = QFormLayout(self) self.type_widget = t = QComboBox(self) l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) t.addItems([_('Book'), _('Author')]) l.addRow(_('URL type:'), t) self.name_widget = n = QLineEdit(self) n.setClearButtonEnabled(True) l.addRow(_('Name:'), n) self.url_widget = w = QLineEdit(self) w.setClearButtonEnabled(True) l.addRow(_('URL:'), w) if as_dict: self.name = as_dict['name'] self.url = as_dict['url'] self.url_type = as_dict['type'] self.type_widget.currentIndexChanged.connect(self.changed_signal) self.name_widget.textChanged.connect(self.changed_signal) self.url_widget.textChanged.connect(self.changed_signal) @property def is_empty(self): return not self.name or not self.url @property def url_type(self): return 'book' if self.type_widget.currentIndex() == 0 else 'author' @url_type.setter def url_type(self, val): self.type_widget.setCurrentIndex(1 if val == 'author' else 0) @property def name(self): return self.name_widget.text().strip() @name.setter def name(self, val): self.name_widget.setText((val or '').strip()) @property def url(self): return self.url_widget.text().strip() @url.setter def url(self, val): self.url_widget.setText((val or '').strip()) @property def as_dict(self): return {'name': self.name, 'url': self.url, 'type': self.url_type} def validate(self): if self.is_empty: return True if '{author}' not in self.url: error_dialog(self.parent(), _('Missing author placeholder'), _( 'The URL {0} does not contain the {1} placeholder').format(self.url, '{author}'), show=True) return False if self.url_type == 'book' and '{title}' not in self.url: error_dialog(self.parent(), _('Missing title placeholder'), _( 'The URL {0} does not contain the {1} placeholder').format(self.url, '{title}'), show=True) return False return True class SearchTheInternet(QWidget): changed_signal = pyqtSignal() def __init__(self, parent): QWidget.__init__(self, parent) self.sa = QScrollArea(self) self.lw = QWidget(self) self.l = QVBoxLayout(self.lw) self.sa.setWidget(self.lw), self.sa.setWidgetResizable(True) self.gl = gl = QVBoxLayout(self) self.la = QLabel(_( 'Add new locations to search for books or authors using the "Search the internet" feature' ' of the Content server. The URLs should contain {author} which will be' ' replaced by the author name and, for book URLs, {title} which will' ' be replaced by the book title.')) self.la.setWordWrap(True) gl.addWidget(self.la) self.h = QHBoxLayout() gl.addLayout(self.h) self.add_url_button = b = QPushButton(QIcon(I('plus.png')), _('&Add URL')) b.clicked.connect(self.add_url) self.h.addWidget(b) self.export_button = b = QPushButton(_('Export URLs')) b.clicked.connect(self.export_urls) self.h.addWidget(b) self.import_button = b = QPushButton(_('Import URLs')) b.clicked.connect(self.import_urls) self.h.addWidget(b) self.clear_button = b = QPushButton(_('Clear')) b.clicked.connect(self.clear) self.h.addWidget(b) self.h.addStretch(10) gl.addWidget(self.sa, stretch=10) self.items = [] def genesis(self): self.current_urls = search_the_net_urls() or [] @property def current_urls(self): return [item.as_dict for item in self.items if not item.is_empty] def append_item(self, item_as_dict): self.items.append(URLItem(item_as_dict, self)) self.l.addWidget(self.items[-1]) def clear(self): [(self.l.removeWidget(w), w.setParent(None), w.deleteLater()) for w in self.items] self.items = [] self.changed_signal.emit() @current_urls.setter def current_urls(self, val): self.clear() for entry in val: self.append_item(entry) def add_url(self): self.items.append(URLItem(None, self)) self.l.addWidget(self.items[-1]) QTimer.singleShot(100, self.scroll_to_bottom) def scroll_to_bottom(self): sb = self.sa.verticalScrollBar() if sb: sb.setValue(sb.maximum()) self.items[-1].name_widget.setFocus(Qt.FocusReason.OtherFocusReason) @property def serialized_urls(self): return json.dumps(self.current_urls, indent=2) def commit(self): for item in self.items: if not item.validate(): return False cu = self.current_urls if cu: with lopen(search_the_net_urls.path, 'wb') as f: f.write(self.serialized_urls.encode('utf-8')) else: try: os.remove(search_the_net_urls.path) except OSError as err: if err.errno != errno.ENOENT: raise return True def export_urls(self): path = choose_save_file( self, 'search-net-urls', _('Choose URLs file'), filters=[(_('URL files'), ['json'])], initial_filename='search-urls.json') if path: with lopen(path, 'wb') as f: f.write(self.serialized_urls.encode('utf-8')) def import_urls(self): paths = choose_files(self, 'search-net-urls', _('Choose URLs file'), filters=[(_('URL files'), ['json'])], all_files=False, select_only_single_file=True) if paths: with lopen(paths[0], 'rb') as f: items = json.loads(f.read()) [self.append_item(x) for x in items] self.changed_signal.emit() # }}} class ConfigWidget(ConfigWidgetBase): def __init__(self, *args, **kw): ConfigWidgetBase.__init__(self, *args, **kw) self.l = l = QVBoxLayout(self) l.setContentsMargins(0, 0, 0, 0) self.tabs_widget = t = QTabWidget(self) l.addWidget(t) self.main_tab = m = MainTab(self) t.addTab(m, _('&Main')) m.start_server.connect(self.start_server) m.stop_server.connect(self.stop_server) m.test_server.connect(self.test_server) m.show_logs.connect(self.view_server_logs) self.opt_autolaunch_server = m.opt_autolaunch_server self.users_tab = ua = Users(self) t.addTab(ua, _('&User accounts')) self.advanced_tab = a = AdvancedTab(self) sa = QScrollArea(self) sa.setWidget(a), sa.setWidgetResizable(True) t.addTab(sa, _('&Advanced')) self.custom_list_tab = clt = CustomList(self) sa = QScrollArea(self) sa.setWidget(clt), sa.setWidgetResizable(True) t.addTab(sa, _('Book &list template')) self.search_net_tab = SearchTheInternet(self) t.addTab(self.search_net_tab, _('&Search the internet')) for tab in self.tabs: if hasattr(tab, 'changed_signal'): tab.changed_signal.connect(self.changed_signal.emit) @property def tabs(self): def w(x): if isinstance(x, QScrollArea): x = x.widget() return x return ( w(self.tabs_widget.widget(i)) for i in range(self.tabs_widget.count()) ) @property def server(self): return self.gui.content_server def restore_defaults(self): ConfigWidgetBase.restore_defaults(self) for tab in self.tabs: if hasattr(tab, 'restore_defaults'): tab.restore_defaults() def genesis(self, gui): self.gui = gui for tab in self.tabs: tab.genesis() r = self.register r('autolaunch_server', config) def start_server(self): if not self.save_changes(): return self.setCursor(Qt.CursorShape.BusyCursor) try: self.gui.start_content_server(check_started=False) while (not self.server.is_running and self.server.exception is None): time.sleep(0.1) if self.server.exception is not None: error_dialog( self, _('Failed to start Content server'), as_unicode(self.gui.content_server.exception) ).exec() self.gui.content_server = None return self.main_tab.update_button_state() finally: self.unsetCursor() def stop_server(self): self.server.stop() self.stopping_msg = info_dialog( self, _('Stopping'), _('Stopping server, this could take up to a minute, please wait...'), show_copy_button=False ) QTimer.singleShot(500, self.check_exited) self.stopping_msg.exec() def check_exited(self): if getattr(self.server, 'is_running', False): QTimer.singleShot(20, self.check_exited) return self.gui.content_server = None self.main_tab.update_button_state() self.stopping_msg.accept() def test_server(self): prefix = self.advanced_tab.get('url_prefix') or '' protocol = 'https' if self.advanced_tab.has_ssl else 'http' lo = self.advanced_tab.get('listen_on') or '0.0.0.0' lo = {'0.0.0.0': '127.0.0.1', '::':'::1'}.get(lo) url = '{protocol}://{interface}:{port}{prefix}'.format( protocol=protocol, interface=lo, port=self.main_tab.opt_port.value(), prefix=prefix) open_url(QUrl(url)) def view_server_logs(self): from calibre.srv.embedded import log_paths log_error_file, log_access_file = log_paths() d = QDialog(self) d.resize(QSize(800, 600)) layout = QVBoxLayout() d.setLayout(layout) layout.addWidget(QLabel(_('Error log:'))) el = QPlainTextEdit(d) layout.addWidget(el) try: el.setPlainText( share_open(log_error_file, 'rb').read().decode('utf8', 'replace') ) except OSError: el.setPlainText(_('No error log found')) layout.addWidget(QLabel(_('Access log:'))) al = QPlainTextEdit(d) layout.addWidget(al) try: al.setPlainText( share_open(log_access_file, 'rb').read().decode('utf8', 'replace') ) except OSError: al.setPlainText(_('No access log found')) loc = QLabel(_('The server log files are in: {}').format(os.path.dirname(log_error_file))) loc.setWordWrap(True) layout.addWidget(loc) bx = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) layout.addWidget(bx) bx.accepted.connect(d.accept) b = bx.addButton(_('&Clear logs'), QDialogButtonBox.ButtonRole.ActionRole) def clear_logs(): if getattr(self.server, 'is_running', False): return error_dialog(d, _('Server running'), _( 'Cannot clear logs while the server is running. First stop the server.'), show=True) if self.server: self.server.access_log.clear() self.server.log.clear() else: for x in (log_error_file, log_access_file): try: os.remove(x) except OSError as err: if err.errno != errno.ENOENT: raise el.setPlainText(''), al.setPlainText('') b.clicked.connect(clear_logs) d.show() def save_changes(self): settings = {} for tab in self.tabs: settings.update(getattr(tab, 'settings', {})) users = self.users_tab.user_data if settings['auth']: if not users: error_dialog( self, _('No users specified'), _( 'You have turned on the setting to require passwords to access' ' the Content server, but you have not created any user accounts.' ' Create at least one user account in the "User accounts" tab to proceed.' ), show=True ) self.tabs_widget.setCurrentWidget(self.users_tab) return False if settings['trusted_ips']: try: tuple(parse_trusted_ips(settings['trusted_ips'])) except Exception as e: error_dialog( self, _('Invalid trusted IPs'), str(e), show=True) return False if not self.custom_list_tab.commit(): return False if not self.search_net_tab.commit(): return False ConfigWidgetBase.commit(self) change_settings(**settings) UserManager().user_data = users return True def commit(self): if not self.save_changes(): raise AbortCommit() warning_dialog( self, _('Restart needed'), _('You need to restart the server for changes to' ' take effect'), show=True ) return False def refresh_gui(self, gui): if self.server: self.server.user_manager.refresh() self.server.ctx.custom_list_template = custom_list_template() self.server.ctx.search_the_net_urls = search_the_net_urls() if __name__ == '__main__': from calibre.gui2 import Application app = Application([]) test_widget('Sharing', 'Server')