import i18n from '@/i18n' import store from '@/store' import evaluate from 'simple-evaluate' import * as validators from '@/helpers/validators' import { isObjectLiteral, isEmptyValue, flattenObjectLiteral, getFileContent, } from '@/helpers/commons' const NO_VALUE_FIELDS = [ 'ReadOnlyField', 'ReadOnlyAlertItem', 'MarkdownItem', 'DisplayTextItem', 'ButtonItem', ] export const DEFAULT_STATUS_ICON = { [null]: null, danger: 'times', error: 'times', info: 'info', success: 'check', warning: 'warning', } /** * Tries to find a translation corresponding to the user's locale/fallback locale in a * Yunohost argument or simply return the string if it's not an object literal. * * @param {(Object|String|undefined)} field - A field value containing a translation object or string * @return {String} */ export function formatI18nField(field) { if (typeof field === 'string') return field const { locale, fallbackLocale } = store.state return field ? field[locale] || field[fallbackLocale] || field.en : '' } /** * Returns a string size declaration to a M value. * * @param {String} sizeStr - A size declared like '500M' or '56k' * @return {Number} */ export function sizeToM(sizeStr) { const unit = sizeStr.slice(-1) const value = sizeStr.slice(0, -1) if (unit === 'M') return parseInt(value) if (unit === 'b') return Math.ceil(value / (1024 * 1024)) if (unit === 'k') return Math.ceil(value / 1024) if (unit === 'G') return Math.ceil(value * 1024) if (unit === 'T') return Math.ceil(value * 1024 * 1024) } /** * Returns a formatted address element to be used by AdressInputSelect component. * * @param {String} address - A string representing an adress (subdomain or email) * @return {Object} - `{ localPart, separator, domain }`. */ export function adressToFormValue(address) { const separator = address.includes('@') ? '@' : '.' const [localPart, domain] = address.split(separator) return { localPart, separator, domain } } /** * Evaluate config panel string expression that can contain regular expressions. * Expression are evaluated with the config panel form as context. * * @param {String} expression - A String to evaluate. * @param {Object} forms - A nested form used in config panels. * @return {Boolean} - expression evaluation result. */ export function evaluateExpression(expression, form, nested = true) { if (!expression) return true if (expression === '"false"') return false const context = nested ? Object.values(form).reduce((merged, next) => ({ ...merged, ...next })) : form for (const key in context) { if (isObjectLiteral(context[key]) && 'file' in context[key]) { context[key] = context[key].content } } // Allow to use match(var,regexp) function const matchRe = /match(\s*(\w+)\s*,\s*"([^"]+)"\s*)/g for (const matched of expression.matchAll(matchRe)) { const [fullMatch, varMatch, regExpMatch] = matched const varName = varMatch + '__re' + matched.index context[varName] = new RegExp(regExpMatch, 'm').test(context[varMatch]) expression = expression.replace(fullMatch, varName) } try { return !!evaluate(context, expression) } catch { return false } } // Adds a property to an Object that will dynamically returns a expression evaluation result. function addEvaluationGetter(prop, obj, expr, ctx, nested) { Object.defineProperty(obj, prop, { get: () => evaluateExpression(expr, ctx, nested), }) } /** * Format app install, actions and config panel argument into a data structure that * will be automaticly transformed into a component on screen. * * @param {Object} arg - a yunohost arg options written by a packager. * @return {Object} an formated argument containing formItem props, validation and base value. */ export function formatYunoHostArgument(arg) { let value = arg.value !== undefined ? arg.value : null const validation = {} const error = { message: null } arg.ask = formatI18nField(arg.ask) const field = { is: arg.readonly ? 'ReadOnlyField' : 'FormField', visible: arg.visible, props: { label: arg.ask, component: undefined, props: {}, }, } const defaultProps = ['id', 'placeholder:example'] const components = [ { types: ['string', 'path'], name: 'InputItem', props: defaultProps.concat(['autocomplete', 'trim', 'choices']), }, { types: ['email', 'url', 'date', 'time', 'color'], name: 'InputItem', props: defaultProps.concat(['type', 'trim']), }, { types: ['password'], name: 'InputItem', props: defaultProps.concat(['type', 'autocomplete', 'trim']), callback: function () { if (!arg.help) { arg.help = i18n.t('good_practices_about_admin_password') } arg.example = '••••••••••••' validation.passwordLenght = validators.minLength(8) }, }, { types: ['number', 'range'], name: 'InputItem', props: defaultProps.concat(['type', 'min', 'max', 'step']), callback: function () { if (arg.min !== undefined) { validation.minValue = validators.minValue(arg.min) } if (arg.max !== undefined) { validation.maxValue = validators.maxValue(arg.max) } validation.numValue = validators.integer }, }, { types: ['select', 'user', 'domain', 'app', 'group'], name: 'SelectItem', props: ['id', 'choices'], callback: function () { if (arg.type !== 'select') { field.props.link = { name: arg.type + '-list', text: i18n.t(`manage_${arg.type}s`), } } }, }, { types: ['file'], name: 'FileItem', props: defaultProps.concat(['accept']), callback: function () { value = { // in case of already defined file, we receive only the file path (not the actual file) file: value ? new File([''], value) : null, content: '', current: !!value, removed: false, } }, }, { types: ['text'], name: 'TextAreaItem', props: defaultProps, }, { types: ['tags'], name: 'TagsItem', props: defaultProps.concat([ 'limit', 'placeholder', 'options:choices', 'tagIcon:icon', ]), callback: function () { if (arg.choices && arg.choices.length) { this.name = 'TagsSelectizeItem' Object.assign(field.props.props, { auto: true, itemsName: '', label: arg.placeholder, }) } if (typeof value === 'string') { value = value.split(',') } else if (!value) { value = [] } }, }, { types: ['boolean'], name: 'CheckboxItem', props: ['id', 'choices'], callback: function () { if (value !== null && value !== undefined) { value = ['1', 'yes', 'y', 'true'].includes( String(value).toLowerCase(), ) } else if (arg.default !== null && arg.default !== undefined) { value = ['1', 'yes', 'y', 'true'].includes( String(arg.default).toLowerCase(), ) } }, }, { types: ['alert'], name: 'ReadOnlyAlertItem', props: ['type:style', 'label:ask', 'icon'], renderSelf: true, }, { types: ['markdown'], name: 'MarkdownItem', props: ['label:ask'], renderSelf: true, }, { types: ['display_text'], name: 'DisplayTextItem', props: ['label:ask'], renderSelf: true, }, { types: ['button'], name: 'ButtonItem', props: ['type:style', 'label:ask', 'icon', 'enabled'], renderSelf: true, }, ] // Default type management if no one is filled if (arg.type === undefined) { if (arg.choices && arg.choices.length) { arg.type = 'select' } else { arg.type = 'string' } } // Search the component bind to the type const component = components.find((element) => element.types.includes(arg.type), ) if (component === undefined) throw new TypeError('Unknown type: ' + arg.type) // Callback use for specific behaviour if (component.callback) component.callback() field.props.component = component.name // Affect properties to the field Item for (let prop of component.props) { prop = prop.split(':') const propName = prop[0] const argName = prop.slice(-1)[0] if (argName in arg) { field.props.props[propName] = arg[argName] } } // Required (no need for checkbox its value can't be null) if ( !component.renderSelf && arg.type !== 'boolean' && arg.optional !== true ) { validation.required = validators.required } if (arg.pattern && arg.type !== 'tags') { validation.pattern = validators.helpers.regex( formatI18nField(arg.pattern.error), new RegExp(arg.pattern.regexp), ) } if (!component.renderSelf && !arg.readonly) { // Bind a validation with what the server may respond validation.remote = validators.helpers.withParams(error, (v) => { const result = !error.message error.message = null return result }) } // Default value if still `null` if (value === null && arg.default) { value = arg.default } // Help message if (arg.help) { field.props.description = formatI18nField(arg.help) } // Help message if (arg.helpLink) { field.props.link = { href: arg.helpLink.href, text: i18n.t(arg.helpLink.text), } } if (component.renderSelf) { field.is = field.props.component field.props = field.props.props } return { value, field, // Return null instead of empty object if there's no validation validation: Object.keys(validation).length === 0 ? null : validation, error, } } /** * Format app install, actions and config panel manifest args into a form that can be used * as v-model values, fields that can be passed to a FormField component and validations. * * @param {Array} args - a yunohost arg array written by a packager. * @param {Object|null} forms - nested form used as the expression evualuations context. * @return {Object} an object containing all parsed values to be used in vue views. */ export function formatYunoHostArguments(args, forms) { const form = {} const fields = {} const validations = {} const errors = {} for (const arg of args) { const { value, field, validation, error } = formatYunoHostArgument(arg) fields[arg.id] = field form[arg.id] = value if (validation) validations[arg.id] = validation errors[arg.id] = error if ('visible' in arg && typeof arg.visible === 'string') { addEvaluationGetter( 'visible', field, arg.visible, forms || form, forms !== undefined, ) } if ('enabled' in arg && typeof arg.enabled === 'string') { addEvaluationGetter( 'enabled', field.props, arg.enabled, forms || form, forms !== undefined, ) } } return { form, fields, validations, errors } } export function formatYunoHostConfigPanels(data) { const result = { panels: [], forms: {}, validations: {}, errors: {}, } for (const { id: panelId, name, help, sections } of data.panels) { const panel = { id: panelId, sections: [], serverError: '', hasApplyButton: false, } result.forms[panelId] = {} result.validations[panelId] = {} result.errors[panelId] = {} if (name) panel.name = formatI18nField(name) if (help) panel.help = formatI18nField(help) for (const _section of sections) { const section = { id: _section.id, isActionSection: _section.is_action_section, visible: _section.visible, } if (_section.help) section.help = formatI18nField(_section.help) if (_section.name) section.name = formatI18nField(_section.name) if (typeof _section.visible === 'string') { addEvaluationGetter('visible', section, section.visible, result.forms) } const { form, fields, validations, errors } = formatYunoHostArguments( _section.options, result.forms, ) // Merge all sections forms to the panel to get a unique form Object.assign(result.forms[panelId], form) Object.assign(result.validations[panelId], validations) Object.assign(result.errors[panelId], errors) section.fields = fields panel.sections.push(section) if ( !section.isActionSection && Object.values(fields).some( (field) => !NO_VALUE_FIELDS.includes(field.is), ) ) { panel.hasApplyButton = true } } result.panels.push(panel) } return result } /** * Parse a front-end value to its API equivalent. This function returns a Promise or an * Object `{ key: Promise }` if `key` is supplied. When parsing a form, all those * objects must be merged to define the final sent form. * * Convert Boolean to '1' (true) or '0' (false), * Concatenate two parts adresses (subdomain or email for example) into a single string, * Convert File to its Base64 representation or set its value to '' to ask for a removal. * * @param {*} value * @return {*} */ export function formatFormDataValue(value, key = null) { if (Array.isArray(value)) { return Promise.all(value.map((value_) => formatFormDataValue(value_))).then( (resolvedValues) => ({ [key]: resolvedValues }), ) } let result = value if (typeof value === 'boolean') result = value ? 1 : 0 if (isObjectLiteral(value) && 'file' in value) { // File has to be deleted if (value.removed) result = '' // File has not changed (will not be sent) else if (value.current || value.file === null) result = null else { return getFileContent(value.file, { base64: true }).then((content) => { return { [key]: content.replace(/data:[^;]*;base64,/, ''), [key + '[name]']: value.file.name, } }) } } else if (isObjectLiteral(value) && 'separator' in value) { result = Object.values(value).join('') } // Returns a resolved Promise for non async values return Promise.resolve(key ? { [key]: result } : result) } /** * Convinient helper to properly parse a front-end form to its API equivalent. * This parse each values asynchronously, allow to inject keys into the final form and * make sure every async values resolves before resolving itself. * * @param {Object} formData * @return {Object} */ function formatFormDataValues(formData) { const promisedValues = Object.entries(formData).map(([key, value]) => { return formatFormDataValue(value, key) }) return Promise.all(promisedValues).then((resolvedValues) => { return resolvedValues.reduce((form, obj) => ({ ...form, ...obj }), {}) }) } /** * Format a form produced by a vue view to be sent to the server. * * @param {Object} formData - An object literal containing form values. * @param {Object} [extraParams] - Optionnal params * @param {Array} [extraParams.extract] - An array of keys that should be extracted from the form. * @param {Boolean} [extraParams.flatten=false] - Flattens or not the passed formData. * @param {Boolean} [extraParams.removeEmpty=true] - Removes "empty" values from the object. * @return {Object} the parsed data to be sent to the server, with extracted values if specified. */ export async function formatFormData( formData, { extract = null, flatten = false, removeEmpty = true, removeNull = false, } = {}, ) { const output = { data: {}, extracted: {}, } const values = await formatFormDataValues(formData) for (const key in values) { const type = extract && extract.includes(key) ? 'extracted' : 'data' const value = values[key] if (removeEmpty && isEmptyValue(value)) { continue } else if (removeNull && [null, undefined].includes(value)) { continue } else if (flatten && isObjectLiteral(value)) { flattenObjectLiteral(value, output[type]) } else { output[type][key] = value } } const { data, extracted } = output return extract ? { data, ...extracted } : data }