diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index ef6b2a88..9598335d 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -337,7 +337,27 @@ "copy-clipboard-tooltip": "Copy all app config to system clipboard, in YAML format", "download-file-btn": "Download as File", "download-file-tooltip": "Download all app config to your device, in a YAML file", - "view-title": "View Config" + "current-config-title": "Current Config", + "preview-toggle": "YAML preview", + "issues-title": "Schema issues ({n})", + "config-list-title": "Config List", + "col-title": "Title", + "col-path": "Path", + "col-content": "Content", + "col-status": "Status", + "col-actions": "Actions", + "content-summary": "{sections} sections, {items} items, {widgets} widgets", + "status-loading": "loading", + "status-valid": "valid", + "status-warnings": "{n} warnings", + "status-error": "error", + "status-unknown": "unknown", + "edit-current-btn": "Edit Config", + "edit-current-tooltip": "Open the current config in the JSON editor", + "copy-fail-msg": "Unable to copy to clipboard, see log", + "download-row-tooltip": "Download YAML", + "preview-row-tooltip": "Toggle YAML Preview", + "apply-row-tooltip": "Apply config" } }, "critical-error": { diff --git a/src/components/InteractiveEditor/ExportConfigMenu.vue b/src/components/InteractiveEditor/ExportConfigMenu.vue index 5d25b13e..339e154e 100644 --- a/src/components/InteractiveEditor/ExportConfigMenu.vue +++ b/src/components/InteractiveEditor/ExportConfigMenu.vue @@ -1,40 +1,113 @@ @@ -46,8 +119,27 @@ import { modalNames } from '@/utils/config/defaults'; import AccessError from '@/components/Configuration/AccessError'; import DownloadConfigIcon from '@/assets/interface-icons/config-download-file.svg'; import CopyConfigIcon from '@/assets/interface-icons/interactive-editor-copy-clipboard.svg'; +import EditIcon from '@/assets/interface-icons/config-edit-json.svg'; +import PreviewIcon from '@/assets/interface-icons/config-preview.svg'; +import request from '@/utils/request'; +import { formatConfigPath, makePageName, makeRoutePath } from '@/utils/config/ConfigHelpers'; +import { validateConfig, formatIssue } from '@/utils/config/validateConfig'; import { ErrorHandler, InfoHandler, InfoKeys } from '@/utils/logging/ErrorHandler'; +/* Counts num of sections, items and widgets for a given config */ +const sumBy = (arr, key) => { + return (arr || []) + .reduce( + (n, item) => n + (Array.isArray(item?.[key]) ? item[key].length : 0), 0, + ); +} + +const basename = (path) => { + if (!path) return null; + const last = path.split('/').filter(Boolean).pop(); + return last && /\.ya?ml$/i.test(last) ? last : null; +}; + export default { name: 'ExportConfigMenu', components: { @@ -55,60 +147,161 @@ export default { AccessError, CopyConfigIcon, DownloadConfigIcon, + PreviewIcon, + EditIcon, }, data() { return { modalName: modalNames.EXPORT_CONFIG_MENU, + previewOpen: false, + expandedRow: null, + rows: [], }; }, - props: {}, computed: { config() { return this.$store.state.configSource; }, + rootConfig() { + return this.$store.state.rootConfig; + }, allowViewConfig() { return this.$store.getters.permissions.allowViewConfig; }, - configPath() { + currentConfigYaml() { + return JsYaml.dump(this.config); + }, + currentConfigPath() { return this.$store.state.currentConfigInfo?.confPath - || import.meta.env.VITE_APP_CONFIG_PATH - || '/conf.yml'; + || import.meta.env.VITE_APP_CONFIG_PATH + || '/conf.yml'; + }, + currentConfigHref() { + return formatConfigPath(this.currentConfigPath); + }, + currentIssues() { + return validateConfig(this.config).errors.map((e) => formatIssue(e)); }, }, methods: { - convertJsonToYaml() { - return JsYaml.dump(this.config); + buildRows() { + this.expandedRow = null; + const root = this.rootConfig || {}; + const rootRow = this.makeRow({ + id: 'root', + title: root.pageInfo?.title || this.currentConfigPath, + path: this.currentConfigPath, + isRoot: true, + }); + rootRow.yamlText = JsYaml.dump(root); + this.applyValidation(rootRow, root); + const pageRows = (root.pages || []).map((page) => this.makeRow({ + id: makePageName(page.name), + title: page.name, + path: page.path, + isRoot: false, + })); + this.rows = [rootRow, ...pageRows]; + // Iterate this.rows (not pageRows) so loadRow mutates the reactive proxies + this.rows.forEach((row) => { if (!row.isRoot) this.loadRow(row); }); }, - downloadConfig() { - const filename = 'dashy_conf.yml'; - const config = this.convertJsonToYaml(); + makeRow(base) { + return { + status: 'loading', summary: '', yamlText: '', errorCount: 0, ...base, + }; + }, + async loadRow(row) { + try { + const res = await request.get(formatConfigPath(row.path)); + let parsed; + try { + parsed = JsYaml.load(res.data) || {}; + } catch (parseErr) { + row.status = 'error'; + ErrorHandler(`Sub-config parse failed: ${row.path}`, parseErr); + return; + } + row.yamlText = typeof res.data === 'string' ? res.data : JsYaml.dump(parsed); + row.title = parsed.pageInfo?.title || row.title || row.path; + this.applyValidation(row, parsed); + } catch (fetchErr) { + row.status = 'unknown'; + ErrorHandler(`Sub-config fetch failed: ${row.path}`, fetchErr); + } + }, + applyValidation(row, cfg) { + const { errors } = validateConfig(cfg); + row.summary = this.summarize(cfg); + row.errorCount = errors.length; + row.status = errors.length ? 'warnings' : 'valid'; + }, + summarize(cfg) { + const sections = Array.isArray(cfg?.sections) ? cfg.sections.length : 0; + const items = sumBy(cfg?.sections, 'items'); + const widgets = sumBy(cfg?.sections, 'widgets'); + return this.$t('interactive-editor.export.content-summary', { sections, items, widgets }); + }, + statusLabel(row) { + if (row.status === 'warnings') { + return this.$t('interactive-editor.export.status-warnings', { n: row.errorCount }); + } + return this.$t(`interactive-editor.export.status-${row.status}`); + }, + downloadYaml(text, filename) { const element = document.createElement('a'); - element.setAttribute('href', `data:text/plain;charset=utf-8,${encodeURIComponent(config)}`); + element.setAttribute('href', `data:text/plain;charset=utf-8,${encodeURIComponent(text)}`); element.setAttribute('download', filename); element.style.display = 'none'; document.body.appendChild(element); element.click(); document.body.removeChild(element); + }, + downloadCurrent() { + this.downloadYaml(this.currentConfigYaml, 'dashy_conf.yml'); InfoHandler('Config downloaded as YAML file', InfoKeys.EDITOR); }, - copyConfigToClipboard() { - const config = this.convertJsonToYaml(); - if (navigator.clipboard) { - navigator.clipboard.writeText(config); - this.$toast(this.$t('config.data-copied-msg')); - } else { + downloadRow(row) { + const filename = basename(row.path) || `${row.id}.yml`; + this.downloadYaml(row.yamlText, filename); + InfoHandler(`Config '${row.id}' downloaded as YAML file`, InfoKeys.EDITOR); + }, + async copyToClipboard() { + if (!navigator.clipboard) { ErrorHandler('Clipboard access requires HTTPS. See: https://bit.ly/3N5WuAA'); - this.$toast.error('Unable to copy, see log'); + this.$toast.error(this.$t('interactive-editor.export.copy-fail-msg')); + return; } - InfoHandler('Config copied to clipboard', InfoKeys.EDITOR); + try { + await navigator.clipboard.writeText(this.currentConfigYaml); + this.$toast(this.$t('config.data-copied-msg')); + InfoHandler('Config copied to clipboard', InfoKeys.EDITOR); + } catch (e) { + ErrorHandler('Clipboard write failed', e); + this.$toast.error(this.$t('interactive-editor.export.copy-fail-msg')); + } + }, + toggleRow(row) { + this.expandedRow = this.expandedRow === row.id ? null : row.id; + }, + openConfig(row) { + const target = makeRoutePath('home', row.isRoot ? null : row.id); + // this.$modal.hide(this.modalName); + if (this.$route.path !== target) this.$router.push(target); + }, + editCurrent() { + this.$modal.hide(this.modalName); + this.$store.commit(StoreKeys.CONF_MENU_INDEX, 2); + this.$modal.show(modalNames.CONF_EDITOR); + this.$store.commit(StoreKeys.SET_MODAL_OPEN, true); + }, + rawPathHref(row) { + return formatConfigPath(row.path); }, modalClosed() { this.$store.commit(StoreKeys.SET_MODAL_OPEN, false); }, tooltip(content) { - return { - content, popperClass: 'in-modal-tt', - }; + return { content, popperClass: 'in-modal-tt' }; }, }, }; @@ -117,45 +310,168 @@ export default { diff --git a/src/components/Settings/ConfigLauncher.vue b/src/components/Settings/ConfigLauncher.vue index d681dd2f..2a482a4e 100644 --- a/src/components/Settings/ConfigLauncher.vue +++ b/src/components/Settings/ConfigLauncher.vue @@ -17,7 +17,7 @@ - diff --git a/src/components/Settings/SettingsContainer.vue b/src/components/Settings/SettingsContainer.vue index c7bdb635..fe791a6a 100644 --- a/src/components/Settings/SettingsContainer.vue +++ b/src/components/Settings/SettingsContainer.vue @@ -26,7 +26,7 @@ Math.max(lo, Math.min(hi, n)); -// Turn Ajv's raw errors into user readable strings -const formatError = (err) => { - const { keyword, params, message } = err; - const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`; - switch (keyword) { - case 'enum': - return `must be one of: ${(params.allowedValues || []).join(', ')}`; - case 'const': - return `must be: ${JSON.stringify(params.allowedValue)}`; - case 'required': - return `missing required field: ${params.missingProperty}`; - case 'additionalProperties': - return `unknown property: ${params.additionalProperty}`; - case 'propertyNames': - return `invalid property name: ${params.propertyName}`; - case 'dependencies': - case 'dependentRequired': - return `missing required field: ${params.missingProperty} (needed when ${params.property} is set)`; - case 'type': - return `must be ${Array.isArray(params.type) ? params.type.join(' or ') : params.type}`; - case 'minLength': - return `must be at least ${plural(params.limit, 'character')}`; - case 'maxLength': - return `must be at most ${plural(params.limit, 'character')}`; - case 'minimum': - case 'maximum': - return `must be ${keyword === 'minimum' ? '≥' : '≤'} ${params.limit}`; - case 'exclusiveMinimum': - case 'exclusiveMaximum': - return `must be ${keyword === 'exclusiveMinimum' ? '>' : '<'} ${params.limit}`; - case 'multipleOf': - return `must be a multiple of ${params.multipleOf}`; - case 'minItems': - return `must have at least ${plural(params.limit, 'item')}`; - case 'maxItems': - return `must have at most ${plural(params.limit, 'item')}`; - case 'uniqueItems': - return 'must not contain duplicate items'; - case 'minProperties': - return `must have at least ${plural(params.limit, 'property')}`; - case 'maxProperties': - return `must have at most ${plural(params.limit, 'property')}`; - case 'pattern': - return `must match pattern ${params.pattern}`; - case 'format': - return `must be a valid ${params.format}`; - case 'anyOf': - case 'oneOf': - return 'must match one of the allowed shapes for this field'; - case 'not': - return 'must not match the disallowed shape for this field'; - case 'if': - return 'does not match the conditional schema for this field'; - default: - return message; - } -}; - -const prefix = (instancePath) => (instancePath ? `${instancePath} ` : ''); - export function schemaLinter(view) { const text = view.state.doc.toString(); if (!text.trim()) return []; @@ -112,7 +46,7 @@ export function schemaLinter(view) { to, severity: 'warning', source: 'schema', - message: `${prefix(err.instancePath)}${formatError(err)}`, + message: formatIssue(err), }; }; diff --git a/src/utils/config/validateConfig.js b/src/utils/config/validateConfig.js new file mode 100644 index 00000000..d4882bf1 --- /dev/null +++ b/src/utils/config/validateConfig.js @@ -0,0 +1,62 @@ +/** + * Single shared AJV validator for Dashy's ConfigSchema.json. + * Used by: + * - schemaLinter.js (CodeMirror diagnostics in the JSON editor) + * - ExportConfigMenu (status badges per config in the export dialog) + * Note: SchemaForm.vue keeps its own AJV instance because JSONForms manages it. + */ +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; +import schema from './ConfigSchema.json'; + +const ajv = new Ajv({ allErrors: true, strict: false }); +addFormats(ajv); + +/* Raw compiled validator: call as a fn, then read .errors. Prefer validateConfig() unless you need positions. */ +export const compiledValidator = ajv.compile(schema); + +/* Convenience wrapper returning { valid, errors }. */ +export function validateConfig(data) { + const valid = compiledValidator(data); + return { valid, errors: valid ? [] : (compiledValidator.errors || []) }; +} + +/* Turn a raw Ajv error into a user-readable message (no path). */ +export function formatError(err) { + const { keyword, params, message } = err; + const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`; + switch (keyword) { + case 'enum': return `must be one of: ${(params.allowedValues || []).join(', ')}`; + case 'const': return `must be: ${JSON.stringify(params.allowedValue)}`; + case 'required': return `missing required field: ${params.missingProperty}`; + case 'additionalProperties': return `unknown property: ${params.additionalProperty}`; + case 'propertyNames': return `invalid property name: ${params.propertyName}`; + case 'dependencies': + case 'dependentRequired': return `missing required field: ${params.missingProperty} (needed when ${params.property} is set)`; + case 'type': return `must be ${Array.isArray(params.type) ? params.type.join(' or ') : params.type}`; + case 'minLength': return `must be at least ${plural(params.limit, 'character')}`; + case 'maxLength': return `must be at most ${plural(params.limit, 'character')}`; + case 'minimum': + case 'maximum': return `must be ${keyword === 'minimum' ? '≥' : '≤'} ${params.limit}`; + case 'exclusiveMinimum': + case 'exclusiveMaximum': return `must be ${keyword === 'exclusiveMinimum' ? '>' : '<'} ${params.limit}`; + case 'multipleOf': return `must be a multiple of ${params.multipleOf}`; + case 'minItems': return `must have at least ${plural(params.limit, 'item')}`; + case 'maxItems': return `must have at most ${plural(params.limit, 'item')}`; + case 'uniqueItems': return 'must not contain duplicate items'; + case 'minProperties': return `must have at least ${plural(params.limit, 'property')}`; + case 'maxProperties': return `must have at most ${plural(params.limit, 'property')}`; + case 'pattern': return `must match pattern ${params.pattern}`; + case 'format': return `must be a valid ${params.format}`; + case 'anyOf': + case 'oneOf': return 'must match one of the allowed shapes for this field'; + case 'not': return 'must not match the disallowed shape for this field'; + case 'if': return 'does not match the conditional schema for this field'; + default: return message; + } +} + +/* Combined " " with optional path prefix. */ +export function formatIssue(err) { + return `${err.instancePath ? `${err.instancePath} ` : ''}${formatError(err)}`; +}