diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index d9e3a50d0..28b713b03 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -838,8 +838,8 @@ app: arguments: app: help: App name - panel: - help: Select a specific panel + key: + help: Select a specific panel, section or a question nargs: '?' -f: full: --full diff --git a/data/helpers.d/configpanel b/data/helpers.d/configpanel index e1a96b866..0c3469c14 100644 --- a/data/helpers.d/configpanel +++ b/data/helpers.d/configpanel @@ -96,51 +96,72 @@ ynh_value_set() { _ynh_panel_get() { # From settings - local params_sources - params_sources=`python3 << EOL + local lines + lines=`python3 << EOL import toml from collections import OrderedDict with open("../config_panel.toml", "r") as f: file_content = f.read() loaded_toml = toml.loads(file_content, _dict=OrderedDict) -for panel_name,panel in loaded_toml.items(): - if isinstance(panel, dict): - for section_name, section in panel.items(): - if isinstance(section, dict): - for name, param in section.items(): - if isinstance(param, dict) and param.get('type', 'string') not in ['success', 'info', 'warning', 'danger', 'display_text', 'markdown']: - print("%s=%s" % (name, param.get('source', 'settings'))) +for panel_name, panel in loaded_toml.items(): + if not isinstance(panel, dict): continue + for section_name, section in panel.items(): + if not isinstance(section, dict): continue + for name, param in section.items(): + if not isinstance(param, dict): + continue + print(';'.join([ + name, + param.get('type', 'string'), + param.get('source', 'settings' if param.get('type', 'string') != 'file' else '') + ])) EOL ` - for param_source in $params_sources + for line in $lines do - local short_setting="$(echo $param_source | cut -d= -f1)" + IFS=';' read short_setting type source <<< "$line" local getter="get__${short_setting}" - local source="$(echo $param_source | cut -d= -f2)" sources[${short_setting}]="$source" + types[${short_setting}]="$type" file_hash[${short_setting}]="" - + formats[${short_setting}]="" # Get value from getter if exists if type -t $getter 2>/dev/null | grep -q '^function$' 2>/dev/null; then old[$short_setting]="$($getter)" + formats[${short_setting}]="yaml" + elif [[ "$source" == "" ]] ; then + old[$short_setting]="YNH_NULL" + # Get value from app settings or from another file - elif [[ "$source" == "settings" ]] || [[ "$source" == *":"* ]] ; then + elif [[ "$type" == "file" ]] ; then + if [[ "$source" == "settings" ]] ; then + ynh_die "File '${short_setting}' can't be stored in settings" + fi + old[$short_setting]="$(ls $(echo $source | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/) 2> /dev/null || echo YNH_NULL)" + file_hash[$short_setting]="true" + + # Get multiline text from settings or from a full file + elif [[ "$type" == "text" ]] ; then + if [[ "$source" == "settings" ]] ; then + old[$short_setting]="$(ynh_app_setting_get $app $short_setting)" + elif [[ "$source" == *":"* ]] ; then + ynh_die "For technical reasons, multiline text '${short_setting}' can't be stored automatically in a variable file, you have to create custom getter/setter" + else + old[$short_setting]="$(cat $(echo $source | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/) 2> /dev/null || echo YNH_NULL)" + fi + + # Get value from a kind of key/value file + else if [[ "$source" == "settings" ]] ; then source=":/etc/yunohost/apps/$app/settings.yml" fi - local source_key="$(echo "$source" | cut -d: -f1)" source_key=${source_key:-$short_setting} local source_file="$(echo "$source" | cut -d: -f2 | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)" old[$short_setting]="$(ynh_value_get --file="${source_file}" --key="${source_key}")" - # Specific case for files (all content of the file is the source) - else - - old[$short_setting]="$(ls $(echo $source | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/) 2> /dev/null || echo YNH_NULL)" - file_hash[$short_setting]="true" fi done @@ -152,27 +173,42 @@ _ynh_panel_apply() { do local setter="set__${short_setting}" local source="${sources[$short_setting]}" + local type="${types[$short_setting]}" if [ "${changed[$short_setting]}" == "true" ] ; then # Apply setter if exists if type -t $setter 2>/dev/null | grep -q '^function$' 2>/dev/null; then $setter - # Copy file in right place + elif [[ "$source" == "" ]] ; then + continue + + # Save in a file + elif [[ "$type" == "file" ]] ; then + if [[ "$source" == "settings" ]] ; then + ynh_die "File '${short_setting}' can't be stored in settings" + fi + local source_file="$(echo "$source" | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)" + cp "${!short_setting}" "$source_file" + + # Save value in app settings elif [[ "$source" == "settings" ]] ; then ynh_app_setting_set $app $short_setting "${!short_setting}" - # Get value from a kind of key/value file - elif [[ "$source" == *":"* ]] - then + # Save multiline text in a file + elif [[ "$type" == "text" ]] ; then + if [[ "$source" == *":"* ]] ; then + ynh_die "For technical reasons, multiline text '${short_setting}' can't be stored automatically in a variable file, you have to create custom getter/setter" + fi + local source_file="$(echo "$source" | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)" + echo "${!short_setting}" > "$source_file" + + # Set value into a kind of key/value file + else local source_key="$(echo "$source" | cut -d: -f1)" source_key=${source_key:-$short_setting} local source_file="$(echo "$source" | cut -d: -f2 | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)" ynh_value_set --file="${source_file}" --key="${source_key}" --value="${!short_setting}" - # Specific case for files (all content of the file is the source) - else - local source_file="$(echo "$source" | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)" - cp "${!short_setting}" "$source_file" fi fi done @@ -182,7 +218,13 @@ _ynh_panel_show() { for short_setting in "${!old[@]}" do if [[ "${old[$short_setting]}" != YNH_NULL ]] ; then - ynh_return "${short_setting}: \"${old[$short_setting]}\"" + if [[ "${formats[$short_setting]}" == "yaml" ]] ; then + ynh_return "${short_setting}:" + ynh_return "$(echo "${old[$short_setting]}" | sed 's/^/ /g')" + else + ynh_return "${short_setting}: \"$(echo "${old[$short_setting]}" | sed ':a;N;$!ba;s/\n/\n\n/g')\"" + + fi fi done } @@ -225,7 +267,8 @@ _ynh_panel_validate() { done if [[ "$is_error" == "true" ]] then - ynh_die "Nothing has changed" + ynh_print_info "Nothing has changed" + exit 0 fi # Run validation if something is changed @@ -241,7 +284,7 @@ _ynh_panel_validate() { if [ -n "$result" ] then local key="YNH_ERROR_${short_setting}" - ynh_return "$key: $result" + ynh_return "$key: \"$result\"" is_error=true fi done @@ -274,6 +317,9 @@ ynh_panel_run() { declare -Ag changed=() declare -Ag file_hash=() declare -Ag sources=() + declare -Ag types=() + declare -Ag formats=() + case $1 in show) ynh_panel_get @@ -281,12 +327,12 @@ ynh_panel_run() { ;; apply) max_progression=4 - ynh_script_progression --message="Reading config panel description and current configuration..." --weight=1 + ynh_script_progression --message="Reading config panel description and current configuration..." ynh_panel_get ynh_panel_validate - ynh_script_progression --message="Applying the new configuration..." --weight=1 + ynh_script_progression --message="Applying the new configuration..." ynh_panel_apply ynh_script_progression --message="Configuration of $app completed" --last ;; diff --git a/src/yunohost/app.py b/src/yunohost/app.py index 4e20c6950..2acdcb679 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -35,6 +35,7 @@ import glob import urllib.parse import base64 import tempfile +import readline from collections import OrderedDict from moulinette import msignals, m18n, msettings @@ -1754,36 +1755,21 @@ def app_action_run(operation_logger, app, action, args=None): # * docstrings # * merge translations on the json once the workflow is in place @is_unit_operation() -def app_config_show(operation_logger, app, panel='', full=False): +def app_config_show(operation_logger, app, key='', full=False): # logger.warning(m18n.n("experimental_feature")) # Check app is installed _assert_is_installed(app) - panel = panel if panel else '' - operation_logger.start() + key = key if key else '' # Read config panel toml - config_panel = _get_app_config_panel(app, filter_key=panel) + config_panel = _get_app_hydrated_config_panel(operation_logger, + app, filter_key=key) if not config_panel: return None - # Call config script to extract current values - parsed_values = _call_config_script(operation_logger, app, 'show') - - # # Check and transform values if needed - # options = [option for _, _, option in _get_options_iterator(config_panel)] - # args_dict = _parse_args_in_yunohost_format( - # parsed_values, options, False - # ) - - # Hydrate - logger.debug("Hydrating config with current value") - for _, _, option in _get_options_iterator(config_panel): - if option['name'] in parsed_values: - option["value"] = parsed_values[option['name']] #args_dict[option["name"]][0] - # Format result in full or reduce mode if full: operation_logger.success() @@ -1800,8 +1786,8 @@ def app_config_show(operation_logger, app, panel='', full=False): } if not option.get('optional', False): r_option['ask'] += ' *' - if option.get('value', None) is not None: - r_option['value'] = option['value'] + if option.get('current_value', None) is not None: + r_option['value'] = option['current_value'] operation_logger.success() return result @@ -1812,7 +1798,6 @@ def app_config_get(operation_logger, app, key): # Check app is installed _assert_is_installed(app) - operation_logger.start() # Read config panel toml config_panel = _get_app_config_panel(app, filter_key=key) @@ -1820,6 +1805,8 @@ def app_config_get(operation_logger, app, key): if not config_panel: raise YunohostError("app_config_no_panel") + operation_logger.start() + # Call config script to extract current values parsed_values = _call_config_script(operation_logger, app, 'show') @@ -1851,7 +1838,8 @@ def app_config_set(operation_logger, app, key=None, value=None, args=None): filter_key = key if key else '' # Read config panel toml - config_panel = _get_app_config_panel(app, filter_key=filter_key) + config_panel = _get_app_hydrated_config_panel(operation_logger, + app, filter_key=filter_key) if not config_panel: raise YunohostError("app_config_no_panel") @@ -1862,12 +1850,16 @@ def app_config_set(operation_logger, app, key=None, value=None, args=None): operation_logger.start() # Prepare pre answered questions - args = dict(urllib.parse.parse_qsl(args, keep_blank_values=True)) if args else {} + if args: + args = { key: ','.join(value) for key, value in urllib.parse.parse_qs(args, keep_blank_values=True).items() } + else: + args = {} if value is not None: args = {filter_key.split('.')[-1]: value} try: logger.debug("Asking unanswered question and prevalidating...") + args_dict = {} for panel in config_panel.get("panel", []): if msettings.get('interface') == 'cli' and len(filter_key.split('.')) < 3: msignals.display(colorize("\n" + "=" * 40, 'purple')) @@ -1878,13 +1870,13 @@ def app_config_set(operation_logger, app, key=None, value=None, args=None): msignals.display(colorize(f"\n# {section['name']}", 'purple')) # Check and ask unanswered questions - args_dict = _parse_args_in_yunohost_format( + args_dict.update(_parse_args_in_yunohost_format( args, section['options'] - ) + )) # Call config script to extract current values logger.info("Running config script...") - env = {key: str(value[0]) for key, value in args_dict.items()} + env = {key: str(value[0]) for key, value in args_dict.items() if not value[0] is None} errors = _call_config_script(operation_logger, app, 'apply', env=env) # Script got manually interrupted ... N.B. : KeyboardInterrupt does not inherit from Exception @@ -2246,6 +2238,37 @@ def _get_app_config_panel(app_id, filter_key=''): return None +def _get_app_hydrated_config_panel(operation_logger, app, filter_key=''): + + # Read config panel toml + config_panel = _get_app_config_panel(app, filter_key=filter_key) + + if not config_panel: + return None + + operation_logger.start() + + # Call config script to extract current values + parsed_values = _call_config_script(operation_logger, app, 'show') + + # # Check and transform values if needed + # options = [option for _, _, option in _get_options_iterator(config_panel)] + # args_dict = _parse_args_in_yunohost_format( + # parsed_values, options, False + # ) + + # Hydrate + logger.debug("Hydrating config with current value") + for _, _, option in _get_options_iterator(config_panel): + if option['name'] in parsed_values: + value = parsed_values[option['name']] + if isinstance(value, dict): + option.update(value) + else: + option["current_value"] = value #args_dict[option["name"]][0] + + return config_panel + def _get_app_settings(app_id): """ @@ -2808,6 +2831,7 @@ class YunoHostArgumentFormatParser(object): parsed_question.name = question["name"] parsed_question.type = question.get("type", 'string') parsed_question.default = question.get("default", None) + parsed_question.current_value = question.get("current_value") parsed_question.optional = question.get("optional", False) parsed_question.choices = question.get("choices", []) parsed_question.pattern = question.get("pattern") @@ -2835,11 +2859,20 @@ class YunoHostArgumentFormatParser(object): msignals.display(text_for_user_input_in_cli) elif question.value is None: - question.value = msignals.prompt( - message=text_for_user_input_in_cli, - is_password=self.hide_user_input_in_prompt, - confirm=self.hide_user_input_in_prompt - ) + prefill = None + if question.current_value is not None: + prefill = question.current_value + elif question.default is not None: + prefill = question.default + readline.set_startup_hook(lambda: readline.insert_text(prefill)) + try: + question.value = msignals.prompt( + message=text_for_user_input_in_cli, + is_password=self.hide_user_input_in_prompt, + confirm=self.hide_user_input_in_prompt + ) + finally: + readline.set_startup_hook() # Apply default value @@ -2897,8 +2930,6 @@ class YunoHostArgumentFormatParser(object): if question.choices: text_for_user_input_in_cli += " [{0}]".format(" | ".join(question.choices)) - if question.default is not None: - text_for_user_input_in_cli += " (default: {0})".format(question.default) if question.help or question.helpLink: text_for_user_input_in_cli += ":\033[m" if question.help: @@ -2919,6 +2950,18 @@ class StringArgumentParser(YunoHostArgumentFormatParser): default_value = "" +class TagsArgumentParser(YunoHostArgumentFormatParser): + argument_type = "tags" + + def _prevalidate(self, question): + values = question.value + for value in values.split(','): + question.value = value + super()._prevalidate(question) + question.value = values + + + class PasswordArgumentParser(YunoHostArgumentFormatParser): hide_user_input_in_prompt = True argument_type = "password" @@ -2938,13 +2981,15 @@ class PasswordArgumentParser(YunoHostArgumentFormatParser): return question def _prevalidate(self, question): - if any(char in question.value for char in self.forbidden_chars): - raise YunohostValidationError( - "pattern_password_app", forbidden_chars=self.forbidden_chars - ) + super()._prevalidate(question) - # If it's an optional argument the value should be empty or strong enough - if not question.optional or question.value: + if question.value is not None: + if any(char in question.value for char in self.forbidden_chars): + raise YunohostValidationError( + "pattern_password_app", forbidden_chars=self.forbidden_chars + ) + + # If it's an optional argument the value should be empty or strong enough from yunohost.utils.password import assert_password_is_strong_enough assert_password_is_strong_enough("user", question.value) @@ -3098,23 +3143,26 @@ class DisplayTextArgumentParser(YunoHostArgumentFormatParser): readonly = True def parse_question(self, question, user_answers): - question = super(DisplayTextArgumentParser, self).parse_question( + question_parsed = super().parse_question( question, user_answers ) - question.optional = True + question_parsed.optional = True + question_parsed.style = question.get('style', 'info') - return question + return question_parsed def _format_text_for_user_input_in_cli(self, question): text = question.ask['en'] - if question.type in ['info', 'warning', 'danger']: + + if question.style in ['success', 'info', 'warning', 'danger']: color = { + 'success': 'green', 'info': 'cyan', 'warning': 'yellow', 'danger': 'red' } - return colorize(m18n.g(question.type), color[question.type]) + f" {text}" + return colorize(m18n.g(question.style), color[question.style]) + f" {text}" else: return text @@ -3137,7 +3185,7 @@ class FileArgumentParser(YunoHostArgumentFormatParser): if question.get('accept'): question_parsed.accept = question.get('accept').replace(' ', '').split(',') else: - question.accept = [] + question_parsed.accept = [] if msettings.get('interface') == 'api': if user_answers.get(question_parsed.name): question_parsed.value = { @@ -3200,7 +3248,7 @@ ARGUMENTS_TYPE_PARSERS = { "string": StringArgumentParser, "text": StringArgumentParser, "select": StringArgumentParser, - "tags": StringArgumentParser, + "tags": TagsArgumentParser, "email": StringArgumentParser, "url": StringArgumentParser, "date": StringArgumentParser, @@ -3214,10 +3262,7 @@ ARGUMENTS_TYPE_PARSERS = { "number": NumberArgumentParser, "range": NumberArgumentParser, "display_text": DisplayTextArgumentParser, - "success": DisplayTextArgumentParser, - "danger": DisplayTextArgumentParser, - "warning": DisplayTextArgumentParser, - "info": DisplayTextArgumentParser, + "alert": DisplayTextArgumentParser, "markdown": DisplayTextArgumentParser, "file": FileArgumentParser, }