diff --git a/src/calibre/ebooks/conversion/cli.py b/src/calibre/ebooks/conversion/cli.py index 49ddffde15..0b43300c2e 100644 --- a/src/calibre/ebooks/conversion/cli.py +++ b/src/calibre/ebooks/conversion/cli.py @@ -95,7 +95,7 @@ def option_recommendation_to_cli_option(add_option, rec): else: if isinstance(rec.recommended_value, numbers.Integral): attrs['type'] = 'int' - if isinstance(rec.recommended_value, numbers.Real): + elif isinstance(rec.recommended_value, numbers.Real): attrs['type'] = 'float' if opt.long_switch == 'verbose': @@ -121,6 +121,14 @@ def option_recommendation_to_cli_option(add_option, rec): ' dialog. Once you create the rules, you can use the "Export" button' ' to save them to a file.' ) + elif opt.name == 'recipe_specific_option': + attrs['action'] = 'append' + attrs['help'] = _( + 'Recipe specific options. Syntax is option_name:value. For example:' + ' {example}. Can be specified multiple' + ' times to set different options. To see a list of all available options' + ' for a recipe, use {list}.' + ).format(example='--recipe-specific-option=date:2030-11-31', list='--recipe-specific-option=list') if opt.name in DEFAULT_TRUE_OPTIONS and rec.recommended_value is True: switches = ['--disable-'+opt.long_switch] add_option(Option(*switches, **attrs)) diff --git a/src/calibre/ebooks/conversion/plugins/recipe_input.py b/src/calibre/ebooks/conversion/plugins/recipe_input.py index 9adbe045ab..af78daf3ec 100644 --- a/src/calibre/ebooks/conversion/plugins/recipe_input.py +++ b/src/calibre/ebooks/conversion/plugins/recipe_input.py @@ -47,6 +47,8 @@ class RecipeInput(InputFormatPlugin): OptionRecommendation(name='password', recommended_value=None, help=_('Password for sites that require a login to access ' 'content.')), + OptionRecommendation(name='recipe_specific_option', + help=_('Recipe specific options.')), OptionRecommendation(name='dont_download_recipe', recommended_value=False, help=_('Do not download latest version of builtin recipes from the calibre server')), @@ -56,6 +58,7 @@ class RecipeInput(InputFormatPlugin): def convert(self, recipe_or_file, opts, file_ext, log, accelerators): + listing_recipe_specific_options = 'list' in (opts.recipe_specific_option or ()) from calibre.web.feeds.recipes import compile_recipe opts.output_profile.flow_size = 0 orig_no_inline_navbars = opts.no_inline_navbars @@ -80,7 +83,7 @@ class RecipeInput(InputFormatPlugin): if rtype == 'custom': self.recipe_source = get_custom_recipe(recipe_id) else: - self.recipe_source = get_builtin_recipe_by_id(urn, log=log, download_recipe=True) + self.recipe_source = get_builtin_recipe_by_id(urn, log=log, download_recipe=not listing_recipe_specific_options) if not self.recipe_source: raise ValueError('Could not find recipe with urn: ' + urn) if not isinstance(self.recipe_source, bytes): @@ -101,7 +104,7 @@ class RecipeInput(InputFormatPlugin): title = title.rpartition('.')[0] raw = get_builtin_recipe_by_title(title, log=log, - download_recipe=not opts.dont_download_recipe) + download_recipe=not opts.dont_download_recipe and not listing_recipe_specific_options) builtin = False try: recipe = compile_recipe(raw) @@ -133,6 +136,20 @@ class RecipeInput(InputFormatPlugin): disabled = getattr(recipe, 'recipe_disabled', None) if disabled is not None: raise RecipeDisabled(disabled) + if listing_recipe_specific_options: + rso = (getattr(recipe, 'recipe_specific_options', None) or {}) + if rso: + log(recipe.title, _('specific options:')) + name_maxlen = max(map(len, rso)) + for name, meta in rso.items(): + log(' ', name.ljust(name_maxlen), '-', meta.get('short')) + if 'long' in meta: + from textwrap import wrap + for line in wrap(meta['long'], 70 - name_maxlen + 5): + log(' '*(name_maxlen + 4), line) + else: + log(recipe.title, _('has no recipe specific options')) + raise SystemExit(0) try: ro = recipe(opts, log, self.report_progress) ro.download() diff --git a/src/calibre/gui2/dialogs/scheduler.py b/src/calibre/gui2/dialogs/scheduler.py index 517e25ef4b..49eac21e21 100644 --- a/src/calibre/gui2/dialogs/scheduler.py +++ b/src/calibre/gui2/dialogs/scheduler.py @@ -18,6 +18,7 @@ from qt.core import ( QDialog, QDialogButtonBox, QDoubleSpinBox, + QFormLayout, QFrame, QGridLayout, QGroupBox, @@ -297,7 +298,6 @@ class SchedulerDialog(QDialog): self.tab = QWidget() self.detail_box.addTab(self.tab, _("&Schedule")) self.tab.v = vt = QVBoxLayout(self.tab) - vt.setContentsMargins(0, 0, 0, 0) self.blurb = la = QLabel('blurb') la.setWordWrap(True), la.setOpenExternalLinks(True) vt.addWidget(la) @@ -351,19 +351,15 @@ class SchedulerDialog(QDialog): # Second tab (advanced settings) self.tab2 = t2 = QWidget() self.detail_box.addTab(self.tab2, _("&Advanced")) - self.tab2.g = g = QGridLayout(t2) - g.setContentsMargins(0, 0, 0, 0) + self.tab2.g = g = QFormLayout(t2) + g.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.add_title_tag = tt = QCheckBox(_("Add &title as tag"), t2) - g.addWidget(tt, 0, 0, 1, 2) - t2.la = la = QLabel(_("&Extra tags:")) + g.addRow(tt) self.custom_tags = ct = QLineEdit(self) - la.setBuddy(ct) - g.addWidget(la), g.addWidget(ct, 1, 1) - t2.la2 = la = QLabel(_("&Keep at most:")) - la.setToolTip(_("Maximum number of copies (issues) of this recipe to keep. Set to 0 to keep all (disable).")) + g.addRow(_("&Extra tags:"), ct) self.keep_issues = ki = QSpinBox(t2) tt.toggled['bool'].connect(self.keep_issues.setEnabled) - ki.setMaximum(100000), la.setBuddy(ki) + ki.setMaximum(100000) ki.setToolTip(_( "
When set, this option will cause calibre to keep, at most, the specified number of issues" " of this periodical. Every time a new issue is downloaded, the oldest one is deleted, if the" @@ -371,9 +367,8 @@ class SchedulerDialog(QDialog): " option to add the title as tag checked, above.\n
Also, the setting for deleting periodicals"
" older than a number of days, below, takes priority over this setting."))
ki.setSpecialValueText(_("all issues")), ki.setSuffix(_(" issues"))
- g.addWidget(la), g.addWidget(ki, 2, 1)
- si = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
- g.addItem(si, 3, 1, 1, 1)
+ g.addRow(_("&Keep at most:"), ki)
+ self.recipe_specific_widgets = {}
# Bottom area
self.hb = h = QHBoxLayout()
@@ -506,7 +501,11 @@ class SchedulerDialog(QDialog):
keep_issues = str(self.keep_issues.value())
custom_tags = str(self.custom_tags.text()).strip()
custom_tags = [x.strip() for x in custom_tags.split(',')]
- self.recipe_model.customize_recipe(urn, add_title_tag, custom_tags, keep_issues)
+ from calibre.web.feeds.recipes.collection import RecipeCustomization
+ recipe_specific_options = None
+ if self.recipe_specific_widgets:
+ recipe_specific_options = {name: w.text().strip() for name, w in self.recipe_specific_widgets.items() if w.text().strip()}
+ self.recipe_model.customize_recipe(urn, RecipeCustomization(add_title_tag, custom_tags, keep_issues, recipe_specific_options))
return True
def initialize_detail_box(self, urn):
@@ -578,16 +577,30 @@ class SchedulerDialog(QDialog):
rb.setChecked(True)
self.schedule_stack.setCurrentIndex(sch_widget)
self.schedule_stack.currentWidget().initialize(typ, sch)
- add_title_tag, custom_tags, keep_issues = customize_info
- self.add_title_tag.setChecked(add_title_tag)
- self.custom_tags.setText(', '.join(custom_tags))
+ self.add_title_tag.setChecked(customize_info.add_title_tag)
+ self.custom_tags.setText(', '.join(customize_info.custom_tags))
self.last_downloaded.setText(_('Last downloaded:') + ' ' + ld_text)
- try:
- keep_issues = int(keep_issues)
- except:
- keep_issues = 0
- self.keep_issues.setValue(keep_issues)
+ self.keep_issues.setValue(customize_info.keep_issues)
self.keep_issues.setEnabled(self.add_title_tag.isChecked())
+ g = self.tab2.layout()
+ for x in self.recipe_specific_widgets.values():
+ g.removeRow(x)
+ self.recipe_specific_widgets = {}
+ raw = recipe.get('options')
+ if raw:
+ import json
+ rsom = json.loads(raw)
+ rso = customize_info.recipe_specific_options
+ for name, metadata in rsom.items():
+ w = QLineEdit(self)
+ if 'default' in metadata:
+ w.setPlaceholderText(_('Default if unspecified: {}').format(metadata['default']))
+ w.setClearButtonEnabled(True)
+ w.setText(str(rso.get(name, '')).strip())
+ w.setToolTip(str(metadata.get('long', '')))
+ title = '&' + str(metadata.get('short') or name).replace('&', '&&') + ':'
+ g.addRow(title, w)
+ self.recipe_specific_widgets[name] = w
class Scheduler(QObject):
@@ -687,15 +700,15 @@ class Scheduler(QObject):
un = pw = None
if account_info is not None:
un, pw = account_info
- add_title_tag, custom_tags, keep_issues = customize_info
arg = {
'username': un,
'password': pw,
- 'add_title_tag':add_title_tag,
- 'custom_tags':custom_tags,
+ 'add_title_tag':customize_info.add_title_tag,
+ 'custom_tags':customize_info.custom_tags,
'title':recipe.get('title',''),
'urn':urn,
- 'keep_issues':keep_issues
+ 'keep_issues':str(customize_info.keep_issues),
+ 'recipe_specific_options': customize_info.recipe_specific_options,
}
self.download_queue.add(urn)
self.start_recipe_fetch.emit(arg)
diff --git a/src/calibre/gui2/tools.py b/src/calibre/gui2/tools.py
index 994766415a..2cdc626af1 100644
--- a/src/calibre/gui2/tools.py
+++ b/src/calibre/gui2/tools.py
@@ -320,6 +320,11 @@ def fetch_scheduled_recipe(arg): # {{{
recs.append(('username', arg['username'], OptionRecommendation.HIGH))
if arg['password'] is not None:
recs.append(('password', arg['password'], OptionRecommendation.HIGH))
+ if arg.get('recipe_specific_options', None):
+ serialized = []
+ for name, val in arg['recipe_specific_options'].items():
+ serialized.append(f'{name}:{val}')
+ recs.append(('recipe_specific_option', serialized, OptionRecommendation.HIGH))
return 'gui_convert_recipe', args, _('Fetch news from %s')%arg['title'], fmt.upper(), [pt]
diff --git a/src/calibre/web/feeds/news.py b/src/calibre/web/feeds/news.py
index cb5c2a7669..126752b52b 100644
--- a/src/calibre/web/feeds/news.py
+++ b/src/calibre/web/feeds/news.py
@@ -410,6 +410,23 @@ class BasicNewsRecipe(Recipe):
#: with the URL scheme of your particular website.
resolve_internal_links = False
+ #: Specify options specific to this recipe. These will be available for the user to customize
+ #: in the Advanced tab of the Fetch News dialog or at the ebook-convert command line. The options
+ #: are specified as a dictionary mapping option name to metadata about the option. For example::
+ #:
+ #: recipe_specific_options = {
+ #: 'edition_date': {
+ #: 'short': 'The issue date to download',
+ #: 'long': 'Specify a date in the format YYYY-mm-dd to download the issue corresponding to that date',
+ #: 'default': 'current',
+ #: }
+ #: }
+ #:
+ #: When the recipe is run, self.recipe_specific_options will be a dict mapping option name to the option value
+ #: specified by the user. When the option is unspecified by the user, it will have the value specified by 'default'.
+ #: If no default is specified, the option will not be in the dict at all, when unspecified by the user.
+ recipe_specific_options = None
+
#: Set to False if you do not want to use gzipped transfers. Note that some old servers flake out with gzip
handle_gzip = True
@@ -988,6 +1005,19 @@ class BasicNewsRecipe(Recipe):
self.failed_downloads = []
self.partial_failures = []
self.aborted_articles = []
+ self.recipe_specific_options_metadata = rso = self.recipe_specific_options or {}
+ self.recipe_specific_options = {k: rso[k]['default'] for k in rso if 'default' in rso[k]}
+ for x in options.recipe_specific_option:
+ k, sep, v = x.partition(':')
+ if not sep:
+ raise ValueError(f'{x} is not a valid recipe specific option')
+ if k not in rso:
+ raise KeyError(f'{k} is not an option supported by: {self.title}')
+ self.recipe_specific_options[k] = v
+ if self.recipe_specific_options:
+ log('Recipe specific options:')
+ for k, v in self.recipe_specific_options.items():
+ log(' ', f'{k} = {v}')
def _postprocess_html(self, soup, first_fetch, job_info):
if self.no_stylesheets:
diff --git a/src/calibre/web/feeds/recipes/collection.py b/src/calibre/web/feeds/recipes/collection.py
index db0f84f98a..cb5ae36749 100644
--- a/src/calibre/web/feeds/recipes/collection.py
+++ b/src/calibre/web/feeds/recipes/collection.py
@@ -6,10 +6,12 @@ __copyright__ = '2009, Kovid Goyal