From 25ad31270cc4309be33393af4f08c5ea658a4b7a Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 21 Oct 2026 07:39:21 +0200 Subject: [PATCH 1/2] fix(hydra-gates): vendor manifest schema 2.138.0 and name schema drift in gates 22 and 53 The vendored app-manifest-v2 schema was at 2.96.0 while @conduction/nextcloud-vue 2.118.0 (npm latest) ships 2.138.0. dossiq, portaliq and shillinq failed gates 22 and 53 on keys the library accepts: entitySource "endpoint", rowIndicators[].after and statusPill.values. - Re-vendor the schema from the published 2.118.0 tarball. - Add scripts/lib/vendored_schema_freshness.js: compares the vendored copy with npm latest, a schema file, or an app's installed library. - check_manifest.js logs VENDORED SCHEMA BEHIND when the app's installed library ships a newer schema; gates 22 and 53 append it to their reason. - The sync workflow runs daily and closes older sync PRs it supersedes. Assisted-by: ClaudeCode:claude-opus-5-5 --- .github/workflows/hydra-gates-schema-sync.yml | 25 +- hydra-gates/README.md | 12 +- hydra-gates/scripts/lib/check_manifest.js | 8 + .../lib/test_vendored_schema_freshness.js | 84 + .../scripts/lib/vendored_schema_freshness.js | 145 ++ hydra-gates/scripts/run-hydra-gates.sh | 6 +- .../schemas/app-manifest-v2.schema.json | 1582 +++++++++++++++-- 7 files changed, 1677 insertions(+), 185 deletions(-) create mode 100644 hydra-gates/scripts/lib/test_vendored_schema_freshness.js create mode 100644 hydra-gates/scripts/lib/vendored_schema_freshness.js diff --git a/.github/workflows/hydra-gates-schema-sync.yml b/.github/workflows/hydra-gates-schema-sync.yml index cb37ac2ab..d0fc9f0ad 100644 --- a/.github/workflows/hydra-gates-schema-sync.yml +++ b/.github/workflows/hydra-gates-schema-sync.yml @@ -51,13 +51,15 @@ name: Vendored manifest schema sync on: schedule: - # Mondays 04:00 UTC, an hour BEFORE fleet-shared-dep-bump. An app that is - # about to be moved onto a newer nextcloud-vue should find the gate's - # schema already able to read what that release added. + # Daily 04:00 UTC. Weekly was too slow: the library publishes several + # schema versions a week, and between two Monday runs apps adopted keys + # the gate still refused (2.96.0 vendored against 2.138.0 published, + # 2026-10-21). Monday's run still lands an hour BEFORE + # fleet-shared-dep-bump. # # CRON IS UTC AND DOES NOT KNOW ABOUT SUMMER TIME. Nothing here needs a # precise local hour. - - cron: "0 4 * * 1" + - cron: "0 4 * * *" workflow_dispatch: inputs: dry_run: @@ -187,9 +189,20 @@ jobs: echo "Opened by .github/workflows/hydra-gates-schema-sync.yml. See #785 for why the vendored copy needs a keeper." } > pr-body.md - gh pr create --base main --head "${BRANCH}" \ + NEW_PR=$(gh pr create --base main --head "${BRANCH}" \ --title "chore(hydra-gates): vendor manifest schema ${WANT}" \ - --body-file pr-body.md + --body-file pr-body.md) + echo "${NEW_PR}" + + # An older sync PR left open is how the copy stayed behind: each run + # opened a new branch and the previous one sat unmerged. Close the + # ones this PR supersedes, so exactly one sync PR is open at a time. + gh pr list --state open --base main --json number,headRefName \ + --jq ".[] | select(.headRefName | startswith(\"chore/vendored-manifest-schema-\")) | select(.headRefName != \"${BRANCH}\") | .number" \ + | while read -r OLD; do + gh pr close "${OLD}" --delete-branch \ + --comment "Superseded by ${NEW_PR}, which vendors ${WANT}." + done - name: Report if: always() diff --git a/hydra-gates/README.md b/hydra-gates/README.md index 725484df0..2dca81946 100644 --- a/hydra-gates/README.md +++ b/hydra-gates/README.md @@ -48,7 +48,17 @@ library had published. No app could fix that, because the schema is not in any app's repo (#785). `.github/workflows/hydra-gates-schema-sync.yml` now watches the registry every -Monday and opens a pull request when the copy falls behind. It opens a pull +day and opens a pull request when the copy falls behind, closing any older sync +pull request it supersedes. Weekly was not enough: on 2026-10-21 the copy was at +2.96.0 against a published 2.138.0, with two sync pull requests sitting open, +and dossiq, portaliq and shillinq failed gates 22 and 53 on keys the library +already accepted. + +Drift is also named where it bites. When the app running the gate has a newer +`@conduction/nextcloud-vue` installed than the vendored copy, gates 22 and 53 +append `VENDORED SCHEMA BEHIND: …` to their failure reason. To check by hand, +`node scripts/lib/vendored_schema_freshness.js` compares the copy with npm +latest (`--strict` exits 1 when behind). It opens a pull request rather than pushing, because CI resolves these gates at `@main`: a bump reaches all 21 swept apps the minute it lands. The pull request carries the output of `scripts/lib/diff_schema_strictness.js`, which names every diff --git a/hydra-gates/scripts/lib/check_manifest.js b/hydra-gates/scripts/lib/check_manifest.js index 8a48ed32a..d99696dc2 100644 --- a/hydra-gates/scripts/lib/check_manifest.js +++ b/hydra-gates/scripts/lib/check_manifest.js @@ -77,6 +77,7 @@ const MANIFEST_PATH = _positional[0] : path.resolve(process.cwd(), 'src', 'manifest.json') const scopeFilter = require('./manifest_scope_filter.js') +const freshness = require('./vendored_schema_freshness.js') // Base-schema candidates, in priority order. The CANONICAL hydra-vendored // schema wins (2026-07-06 manifest audit, item 4): pinned-first meant "pass" @@ -503,6 +504,13 @@ function main() { const basePath = findFirst(BASE_SCHEMA_CANDIDATES) const base = basePath ? loadJson(basePath) : canonical console.error(`[check_manifest] base schema: ${basePath || CANONICAL_SCHEMA_PATH}`) + const installed = freshness.installedSchemaPath(process.cwd()) + const behind = installed && freshness.behindNote( + canonical.version, + freshness.schemaVersion(installed), + 'the @conduction/nextcloud-vue installed in this app', + ) + if (behind) console.error(`[check_manifest] ${behind}`) console.error('[check_manifest] AppHost blocks (observability, deepLinks) validated against the hydra-vendored canonical ADR-040 schema') const schema = mergeAppHostBlocks(base, canonical) diff --git a/hydra-gates/scripts/lib/test_vendored_schema_freshness.js b/hydra-gates/scripts/lib/test_vendored_schema_freshness.js new file mode 100644 index 000000000..fd469ec08 --- /dev/null +++ b/hydra-gates/scripts/lib/test_vendored_schema_freshness.js @@ -0,0 +1,84 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: EUPL-1.2 +// +// test_vendored_schema_freshness.js: the drift between the vendored manifest +// schema and the one @conduction/nextcloud-vue ships is named, never silent. +// Offline: every comparison runs against schema files written here. +// +// Run: node scripts/lib/test_vendored_schema_freshness.js + +'use strict' + +const fs = require('fs') +const os = require('os') +const path = require('path') +const { spawnSync } = require('child_process') +const f = require('./vendored_schema_freshness.js') + +let fails = 0 +function check(name, cond) { + console.log(` ${cond ? 'PASS' : 'FAIL'}: ${name}`) + if (!cond) fails++ +} + +const work = fs.mkdtempSync(path.join(os.tmpdir(), 'hg-fresh-')) +const vendored = f.schemaVersion(f.VENDORED_PATH) +function schemaFile(name, version) { + const p = path.join(work, name) + fs.writeFileSync(p, JSON.stringify({ version })) + return p +} +function run(args, cwd) { + return spawnSync(process.execPath, [path.join(__dirname, 'vendored_schema_freshness.js'), ...args], { encoding: 'utf8', cwd }) +} + +try { + console.log('== compareVersions ==') + check('2.138.0 > 2.96.0 (numeric, not lexical)', f.compareVersions('2.138.0', '2.96.0') > 0) + check('2.96.0 < 2.138.0', f.compareVersions('2.96.0', '2.138.0') < 0) + check('equal versions compare 0', f.compareVersions('2.96.0', '2.96.0') === 0) + check('pre-release suffix ignored', f.compareVersions('3.0.0-beta.4', '3.0.0') === 0) + + console.log('== behindNote ==') + check('newer source names the drift', /VENDORED SCHEMA BEHIND/.test(f.behindNote('2.96.0', '2.138.0', 'x') || '')) + check('same version is silent', f.behindNote('2.96.0', '2.96.0', 'x') === null) + check('older source is silent', f.behindNote('2.138.0', '2.96.0', 'x') === null) + check('missing version is silent, not a false alarm', f.behindNote('2.96.0', null, 'x') === null) + + console.log('== the vendored copy declares a version ==') + check(`vendored schema has a version (${vendored})`, typeof vendored === 'string') + + console.log('== CLI ==') + const newer = schemaFile('newer.json', '999.0.0') + let r = run(['--published', newer]) + check('behind: WARN and exit 0 by default', r.status === 0 && /^WARN VENDORED SCHEMA BEHIND/.test(r.stdout)) + r = run(['--published', newer, '--strict']) + check('behind with --strict: FAIL and exit 1', r.status === 1 && /^FAIL VENDORED SCHEMA BEHIND/.test(r.stdout)) + r = run(['--published', schemaFile('same.json', vendored), '--strict']) + check('level: OK and exit 0 under --strict', r.status === 0 && /^OK /.test(r.stdout)) + r = run(['--published', schemaFile('nover.json', '')]) + check('unreadable version: exit 2, not a pass', r.status === 2) + + const app = path.join(work, 'app') + const lib = path.join(app, 'node_modules', '@conduction', 'nextcloud-vue', 'src', 'schemas') + fs.mkdirSync(lib, { recursive: true }) + fs.writeFileSync(path.join(lib, 'app-manifest-v2.schema.json'), JSON.stringify({ version: '999.0.0' })) + r = run(['--app-dir', app]) + check('--app-dir reads the installed library schema', r.status === 0 && /installed under/.test(r.stdout)) + r = run(['--app-dir', path.join(work, 'no-such-app')]) + check('--app-dir without the library: exit 2', r.status === 2) + + console.log('== check_manifest.js names the drift in a gate log ==') + fs.mkdirSync(path.join(app, 'src'), { recursive: true }) + fs.writeFileSync(path.join(app, 'src', 'manifest.json'), '{}') + r = spawnSync(process.execPath, [path.join(__dirname, 'check_manifest.js'), 'src/manifest.json'], { encoding: 'utf8', cwd: app }) + check('installed library newer than vendored copy: note on stderr', /VENDORED SCHEMA BEHIND/.test(r.stderr)) + fs.writeFileSync(path.join(lib, 'app-manifest-v2.schema.json'), JSON.stringify({ version: vendored })) + r = spawnSync(process.execPath, [path.join(__dirname, 'check_manifest.js'), 'src/manifest.json'], { encoding: 'utf8', cwd: app }) + check('installed library level with vendored copy: no note', !/VENDORED SCHEMA BEHIND/.test(r.stderr)) +} finally { + fs.rmSync(work, { recursive: true, force: true }) +} + +console.log(fails === 0 ? '\n== all passed ==' : `\n== ${fails} failed ==`) +process.exit(fails === 0 ? 0 : 1) diff --git a/hydra-gates/scripts/lib/vendored_schema_freshness.js b/hydra-gates/scripts/lib/vendored_schema_freshness.js new file mode 100644 index 000000000..c125399cf --- /dev/null +++ b/hydra-gates/scripts/lib/vendored_schema_freshness.js @@ -0,0 +1,145 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: EUPL-1.2 +// +// vendored_schema_freshness.js: is scripts/schemas/app-manifest-v2.schema.json +// behind the schema @conduction/nextcloud-vue ships? +// +// Gates 22 and 53 validate against the vendored copy. When it falls behind, +// a manifest that uses a key the library already accepts is rejected by the +// gate while the app's own `npm run check:manifest` passes. The weekly sync +// workflow opens a pull request for that, but an unmerged sync PR left the +// copy 42 schema versions behind (2.96.0 against 2.138.0) and the gate's +// findings never said why. This helper names the drift. +// +// Two sources to compare against: +// - offline: the schema in an app's installed node_modules (used by +// check_manifest.js, so a gate log says when the app's library is newer); +// - online: the latest published package on npm (the CLI default). +// +// CLI: +// node vendored_schema_freshness.js compare against npm latest +// node vendored_schema_freshness.js --published F compare against schema file F +// node vendored_schema_freshness.js --app-dir D compare against D/node_modules +// --strict exit 1 when the vendored copy is behind (default: warn, exit 0) +// +// Exit: 0 current (or behind without --strict), 1 behind with --strict, +// 2 the comparison could not be made. + +'use strict' + +const fs = require('fs') +const os = require('os') +const path = require('path') +const { execFileSync } = require('child_process') + +const PACKAGE = '@conduction/nextcloud-vue' +const SCHEMA_IN_PACKAGE = path.join('src', 'schemas', 'app-manifest-v2.schema.json') +const VENDORED_PATH = path.resolve(__dirname, '..', 'schemas', 'app-manifest-v2.schema.json') + +// Numeric x.y.z comparison; a pre-release suffix is ignored. Returns <0, 0, >0. +function compareVersions(a, b) { + const pa = String(a).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0) + const pb = String(b).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0) + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] || 0) - (pb[i] || 0) + if (d !== 0) return d + } + return 0 +} + +function schemaVersion(file) { + try { + const v = JSON.parse(fs.readFileSync(file, 'utf8')).version + return typeof v === 'string' && v !== '' ? v : null + } catch (_) { + return null + } +} + +// The schema shipped by the library installed under appDir, or null. +function installedSchemaPath(appDir) { + const p = path.join(appDir, 'node_modules', '@conduction', 'nextcloud-vue', SCHEMA_IN_PACKAGE) + return fs.existsSync(p) ? p : null +} + +// One line naming the drift when `otherVersion` is newer than the vendored +// copy, otherwise null. +function behindNote(vendored, other, source) { + if (!vendored || !other || compareVersions(other, vendored) <= 0) return null + return `VENDORED SCHEMA BEHIND: gates 22/53 validate against vendored app-manifest-v2 ${vendored}, ` + + `but ${source} ships ${other}. A key added since ${vendored} is rejected here while the library accepts it; ` + + 're-vendor hydra-gates/scripts/schemas/app-manifest-v2.schema.json (workflow hydra-gates-schema-sync).' +} + +// Downloads the latest published package into a temp dir and returns +// { lib, file }. Throws when npm cannot answer. +function fetchPublished() { + const lib = execFileSync('npm', ['view', PACKAGE, 'version'], { encoding: 'utf8', timeout: 60000 }).trim() + if (!lib) throw new Error(`npm view ${PACKAGE} version returned nothing`) + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hg-schema-')) + const tgz = execFileSync('npm', ['pack', `${PACKAGE}@${lib}`, '--silent'], { cwd: dir, encoding: 'utf8', timeout: 120000 }).trim().split('\n').pop() + execFileSync('tar', ['xzf', tgz, 'package/' + SCHEMA_IN_PACKAGE.split(path.sep).join('/')], { cwd: dir, timeout: 60000 }) + return { lib, file: path.join(dir, 'package', SCHEMA_IN_PACKAGE) } +} + +function main(argv) { + let published = null + let appDir = null + let strict = false + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--published') published = argv[++i] + else if (argv[i] === '--app-dir') appDir = argv[++i] + else if (argv[i] === '--strict') strict = true + else { + console.error(`unknown argument: ${argv[i]}`) + return 2 + } + } + + const vendored = schemaVersion(VENDORED_PATH) + if (!vendored) { + console.error(`[schema-freshness] cannot read a version from ${VENDORED_PATH}`) + return 2 + } + + let source + if (published) { + source = published + } else if (appDir) { + published = installedSchemaPath(appDir) + if (!published) { + console.error(`[schema-freshness] no ${PACKAGE} installed under ${appDir}`) + return 2 + } + source = `${PACKAGE} installed under ${appDir}` + } else { + try { + const got = fetchPublished() + published = got.file + source = `${PACKAGE} ${got.lib} (npm latest)` + } catch (e) { + console.error(`[schema-freshness] could not fetch ${PACKAGE} from npm: ${e.message.split('\n')[0]}`) + return 2 + } + } + + const other = schemaVersion(published) + if (!other) { + console.error(`[schema-freshness] cannot read a version from ${published}`) + return 2 + } + + const note = behindNote(vendored, other, source) + if (note) { + console.log(`${strict ? 'FAIL' : 'WARN'} ${note}`) + return strict ? 1 : 0 + } + console.log(`OK vendored app-manifest-v2 ${vendored} is level with or ahead of ${source} (${other})`) + return 0 +} + +if (require.main === module) { + process.exit(main(process.argv.slice(2))) +} + +module.exports = { compareVersions, schemaVersion, installedSchemaPath, behindNote, VENDORED_PATH } diff --git a/hydra-gates/scripts/run-hydra-gates.sh b/hydra-gates/scripts/run-hydra-gates.sh index 1dfb8bf3c..1087fe2e3 100755 --- a/hydra-gates/scripts/run-hydra-gates.sh +++ b/hydra-gates/scripts/run-hydra-gates.sh @@ -5102,7 +5102,8 @@ if [ -f src/manifest.json ]; then _mv_why=$(head -3 "${_mv_log}" 2>/dev/null | tr '\n' ' ' | cut -c1-200) _skip 22 "manifest-validation" wiring "the vendored canonical validator (${_mv_validator}) exited ${_mv_rc} WITHOUT reporting a single \`at : \` diagnostic, so src/manifest.json was NOT schema-validated and NO violation was measured. That is a crashed validator, not a finding about the manifest — the manifest is UNVERIFIED by this run. Validator output: ${_mv_why:-}. See ${_mv_log}${_mv_app_note}" else - _fail 22 "manifest-validation" "${_mv_fail} schema violation(s) in src/manifest.json — see ${_mv_log}${_mv_app_note}" + _mv_stale=$(grep -m1 -o 'VENDORED SCHEMA BEHIND:.*' "${_mv_log}" 2>/dev/null || true) + _fail 22 "manifest-validation" "${_mv_fail} schema violation(s) in src/manifest.json — see ${_mv_log}${_mv_app_note}${_mv_stale:+ [${_mv_stale}]}" fi ;; esac fi @@ -9920,7 +9921,8 @@ if [ -f src/manifest.json ]; then # by the prefix, so the count is what this PR must actually fix. _em_n=$(grep -E '^at ' "${_em_log}" 2>/dev/null | grep -cvE ': (WARN|PRE-EXISTING) ' || true) { [ -z "${_em_n}" ] || [ "${_em_n}" -eq 0 ]; } && _em_n=1 - _em_reason="${_em_n} structural violation(s) in the ASSEMBLED manifest (base+fragments+menu-layout) — see ${_em_log}" + _em_stale=$(grep -m1 -o 'VENDORED SCHEMA BEHIND:.*' "${_em_log}" 2>/dev/null || true) + _em_reason="${_em_n} structural violation(s) in the ASSEMBLED manifest (base+fragments+menu-layout) — see ${_em_log}${_em_stale:+ [${_em_stale}]}" else set +e node "${_em_crossref}" --app-dir . --manifest "${_em_tmp}" ${_em_scope_flag} ${_em_scope_val} >> "${_em_log}" 2>&1 diff --git a/hydra-gates/scripts/schemas/app-manifest-v2.schema.json b/hydra-gates/scripts/schemas/app-manifest-v2.schema.json index 28a931589..da3af5b40 100644 --- a/hydra-gates/scripts/schemas/app-manifest-v2.schema.json +++ b/hydra-gates/scripts/schemas/app-manifest-v2.schema.json @@ -3,7 +3,7 @@ "$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json", "title": "Conduction App Manifest v2", "description": "v2 schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Introduces a uniform widgets[] array on every page type with a per-slot grid coordinate system, a typed actions[] discriminator, and the required $schema field for version detection. v1 manifests continue to validate against app-manifest.schema.json (unchanged).", - "version": "2.96.0", + "version": "2.138.0", "type": "object", "required": ["$schema", "version"], "allOf": [ @@ -97,6 +97,9 @@ "type": { "type": "string", "enum": ["info", "config-fields", "choice", "run-action", "summary", "component"], "description": "Built-in step renderer." }, "title": { "type": "string" }, "body": { "type": "string", "description": "info: explanatory copy." }, + "note": { "type": "string", "minLength": 1, "description": "choice: a footnote under the options, translated like body." }, + "visibleWhen": { "description": "Offer the step only when the setup status says so: a dotted path into the setup status document whose value must be truthy, or { path, equals } for an exact value. Hidden while the status loads.", "oneOf": [ { "type": "string", "minLength": 1 }, { "type": "object", "required": ["path"], "additionalProperties": false, "properties": { "path": { "type": "string", "minLength": 1 }, "equals": {} } } ] }, + "footnote": { "type": "string", "minLength": 1, "description": "choice: the same footnote under another name; note wins when both are set." }, "required": { "type": "boolean", "default": false, "description": "When true the step gates the app until done." }, "schema": { "description": "config-fields: a JSON Schema ref (string) or inline object rendered via fieldsFromSchema." }, "configKey": { "type": "string", "description": "choice: app-config key the selection is written to." }, @@ -112,10 +115,12 @@ } }, "optionsSource": { "type": "string", "description": "choice: where to read the option list from at runtime, instead of declaring it here. Names a key in the app's own GET /api/setup/status document (dots walk into nested objects, e.g. `profiles`). Entries accept `id` for `value` and `name` for `label`, and a numeric `objectCount` becomes the stat a card shows. Use it when the SERVER owns what is on offer, so the manifest cannot drift from it." }, - "display": { "type": "string", "enum": ["select", "cards"], "default": "select", "description": "choice: how the options are rendered. `select` is a dropdown; `cards` is a grid of CnChoiceCards, which shows each option's description and stats. Pick cards when the choice needs explaining." }, + "display": { "type": "string", "enum": ["select", "cards", "rows"], "default": "select", "description": "choice: how the options are rendered. `select` is a dropdown; `cards` is a grid of CnChoiceCards, which shows each option's description and stats; `rows` is CnChoiceCards' rows layout, one radio row per option with its description below the label. Pick cards or rows when the choice needs explaining." }, "dependsOn": { "type": "string", "description": "choice: the configKey of a previous choice step this one cascades from. Disabled until the parent is set; its option list is taken from optionsByParent[parentValue] (e.g. organisation-type depends on country)." }, "optionsByParent": { "type": "object", "description": "choice: map of parent-choice value → options[] for a dependent (dependsOn) choice. Same option item shape as `options`.", "additionalProperties": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["value", "label"], "properties": { "value": {}, "label": { "type": "string" } } } } }, "suggestFrom": { "type": "string", "description": "choice: the configKey of an earlier choice this step pre-fills a suggestion from. When that value is set and the user has not yet picked here, the option matching suggestMap[parentValue] is auto-selected (still user-overridable)." }, + "default": { "description": "choice: the option value chosen when the step opens (an array of values with `multiple`). Not applied to a step the server already completed." }, + "noteStyle": { "type": "string", "enum": ["line", "card"], "default": "line", "description": "choice: how `note` is drawn: a muted line under the options (`line`) or an info note card (`card`)." }, "suggestMap": { "type": "object", "description": "choice: map of suggestFrom value → suggested option value. Used to auto-suggest a default (e.g. RGS template suggested from organisation type).", "additionalProperties": { "type": "string" } }, "multiple": { "type": "boolean", "default": false, "description": "choice: allow multi-select." }, "loadAction": { "type": "string", "minLength": 1, "description": "choice with display cards: an actionId each option card loads itself with. Every card except the one whose value is `none` gets its own Load button, which POSTs { dataset: } to /api/setup/action/{loadAction}, spins while it runs and shows the result on that card. A successful load also selects the card. With loadAction the separate run-action step that loaded the picked dataset is no longer needed; manifests that keep it still work." }, @@ -335,6 +340,11 @@ } } }, + "searchPlaceholder": { + "type": "string", + "minLength": 1, + "description": "i18n key / text for the line on the search field in Nextcloud's header (screens-board-top-bar-search, 2.118.0), naming what the app's search finds, for example \"Search cases, documents or contacts\". Applied by CnAppRoot under `look: \"board\"` on a page with `#header`; it replaces only the text of the resting field. Omitted, the field keeps Nextcloud's own line." + }, "featureRequestRepo": { "type": "string", "description": "`/` slug on the forge that the in-product feature-request deep-link targets (provided to descendants as `cnFeatureRequestRepo`). Falls back to `Conduction/` when omitted." @@ -533,6 +543,97 @@ } }, "$defs": { + "fieldOverride": { + "type": "object", + "additionalProperties": true, + "description": "One field's overrides over its schema-derived descriptor (label, order, hidden, readOnly, widget, enumLabels and more stay free-form). A `widget` that is not one of the form's own widget names mounts the registered component of that name (the v2 registry, then customComponents). 2.126.0 documents the search look of a single select.", + "properties": { + "widget": { "type": "string", "minLength": 1 }, + "look": { "type": "string", "enum": ["search"], "description": "`search` draws a single select as CnSearchSelect: a magnifier instead of the chevron, and rows with initials or an icon, the name, a secondary line and a check on the chosen one (DqOverdragen \"Nieuw team\")." }, + "optionSecondary": { "type": "string", "description": "look 'search': the row's secondary line, a template filled from each option (\"{members} leden · {focus}\")." }, + "optionAvatar": { "type": "boolean", "description": "look 'search': draw each option's initials." }, + "optionIcon": { "type": "string", "minLength": 1, "description": "look 'search': one icon for every row." }, + "itemOverrides": { "type": "object", "additionalProperties": { "$ref": "#/$defs/fieldOverride" }, "description": "widget 'sub-objects': field overrides for the row form (sub-objects-editor)." }, + "newItem": { "type": "object", "description": "widget 'sub-objects': the values a new row starts with." }, + "itemLabel": { "type": "string", "minLength": 1, "description": "widget 'sub-objects': what one row is called (\"Checklist item\"), for its accessible name and dialog title." }, + "emptyText": { "type": "string", "description": "widget 'sub-objects': shown while there are no rows." }, + "pruneWhen": { "type": "object", "additionalProperties": false, "description": "widget 'sub-objects': tidy the rows on save: trim strings, omit empty optional keys, drop rows with a `blank` key, and with `invalid: true` rows missing a required key.", "properties": { "blank": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "invalid": { "type": "boolean" } } }, + "placeholderFrom": { "type": "string", "minLength": 1, "description": "A text field's placeholder is this other field's value (form-small-features)." }, + "requiredWhen": { "type": "object", "description": "Required while this condition holds: a `visibleWhen` predicate, or `{ all }` / `{ any }` of them (form-small-features)." }, + "warnWhen": { "description": "A note under the field that does not block the save, shown while `when` holds (form-small-features).", "anyOf": [{ "$ref": "#/$defs/fieldWarning" }, { "type": "array", "items": { "$ref": "#/$defs/fieldWarning" } }] }, + "clearWhenHidden": { "type": "boolean", "description": "Write the field empty when its visibleWhen hides it (form-small-features)." }, + "stored": { "type": "boolean", "description": "`false`: the field steers the form only and is not saved; it may be a key the schema does not have, with `type` and `label` (form-small-features)." }, + "initialWhen": { "type": "object", "description": "A field that is not stored starts true when this condition holds on the loaded values (form-small-features)." }, + "sectionCollapsed": { "type": "boolean", "description": "Makes the field's section foldable from its heading; true opens it folded (form-small-features)." }, + "optionSwatches": { "type": "object", "additionalProperties": { "type": "string" }, "description": "A select option's colour by value, drawn before its label (form-small-features)." }, + "optionIcons": { "type": "object", "additionalProperties": { "type": "string" }, "description": "A select option's icon by value, drawn before its label (form-small-features)." }, + "unique": { "description": "Refuse a value another stored object of this schema already has, checked before the save: `true`, or `{ filter }` to compare only among the objects the filter matches (`{ \"caseType\": \"@objectId\" }`) (list-reorder-order-field).", "anyOf": [{ "type": "boolean", "const": true }, { "type": "object", "additionalProperties": false, "properties": { "filter": { "type": "object" } } }] }, + "defaults": { "type": "object", "description": "widget 'object-switch': the object the switch fills in when turned on; off writes null. Its keys are edited as fields at dotted paths." }, + "optionsFrom": { "$ref": "#/$defs/optionsFrom", "description": "Draw the field as a select (a multiselect on an array field) over options read from a source; the option's value is stored and its label shown. A register schema property can say the same with `x-options-from` (form-field-options-from, 2.133.0)." } + } + }, + "fieldWarning": { + "type": "object", + "additionalProperties": false, + "required": ["when", "text"], + "properties": { + "when": { "type": "object", "description": "The condition, as visibleWhen takes it, or `{ all }` / `{ any }`." }, + "text": { "type": "string", "minLength": 1, "description": "The note, through the app's translate." } + } + }, + "optionsFrom": { + "description": "Select options read from a source: rows from an app endpoint (`url`, `params`, `responsePath`) or OpenRegister objects (`register`, `schema`, `filter`; tokens resolve). `valueField` and `labelField` are dot-paths into a row: by default the value is `id` (else the object's uuid) and the label `title` (else `name`, else the value).", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["url"], + "properties": { + "url": { "type": "string", "minLength": 1, "description": "An app path, resolved with generateUrl." }, + "params": { "type": "object", "description": "Query parameters sent with the request." }, + "responsePath": { "type": "string", "minLength": 1, "description": "Where the rows sit in the answer." }, + "valueField": { "type": "string", "minLength": 1 }, + "labelField": { "type": "string", "minLength": 1 }, + "groupField": { "type": "string", "minLength": 1, "description": "Groups the options by this field, groups in the order they first appear (form-small-features)." }, + "where": { "type": "object", "description": "Keep the rows whose fields equal these values; a value may be `@answer.`, another field of the form (object-list-edit-in-place)." }, + "itemsPath": { "type": "string", "minLength": 1, "description": "Read the options from the list at this path in each row (object-list-edit-in-place)." }, + "hideWhenEmpty": { "type": "boolean", "description": "Leave the field out while the source gives no options (object-list-edit-in-place)." } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["register", "schema"], + "properties": { + "register": { "type": "string", "minLength": 1 }, + "schema": { "type": "string", "minLength": 1 }, + "filter": { "type": "object", "description": "Filters on the objects, as a list filter takes them." }, + "valueField": { "type": "string", "minLength": 1 }, + "labelField": { "type": "string", "minLength": 1 }, + "groupField": { "type": "string", "minLength": 1, "description": "Groups the options by this field, groups in the order they first appear (form-small-features)." }, + "where": { "type": "object", "description": "Keep the rows whose fields equal these values; a value may be `@answer.`, another field of the form (object-list-edit-in-place)." }, + "itemsPath": { "type": "string", "minLength": 1, "description": "Read the options from the list at this path in each row (object-list-edit-in-place)." }, + "hideWhenEmpty": { "type": "boolean", "description": "Leave the field out while the source gives no options (object-list-edit-in-place)." } + } + } + ] + }, + "breadcrumbCrumb": { + "type": "object", + "additionalProperties": false, + "required": ["label"], + "description": "One breadcrumb crumb (config.breadcrumb).", + "properties": { + "label": { "type": "string", "minLength": 1 }, + "route": { "type": "string" }, + "params": { "type": "object", "additionalProperties": true }, + "query": { "type": "object", "additionalProperties": true, "description": "Query of the `route` link, such as `{ savedView: 'default:' }` to land on a view (saved-view-overrides)." }, + "href": { "type": "string" }, + "icon": { "type": "string", "minLength": 1, "description": "Optional MDI icon name (e.g. `Home`) drawn as the first crumb instead of the label, which then becomes its accessible name (zuiddrecht-pixel-gaps-2). Without it the label shows as text." }, + "currentField": { "type": "string", "minLength": 1, "description": "A field of the record (dotted path, e.g. `identifier`) whose value is the current crumb instead of the display name; an empty value falls back to the display name (zuiddrecht-pixel-gaps-3)." }, + "separator": { "type": "string", "minLength": 1, "maxLength": 3, "description": "Text drawn between the crumbs (e.g. `/`) instead of the chevron (zuiddrecht-pixel-gaps-3)." } + } + }, "rowIndicator": { "type": "object", "additionalProperties": false, @@ -559,6 +660,16 @@ "type": "array", "description": "The indicator applies when the field's value is one of these." }, + "after": { + "type": "string", + "minLength": 1, + "description": "The indicator applies while the field holds a readable date after this moment: `now` or an ISO date (\"held until a later date\")." + }, + "before": { + "type": "string", + "minLength": 1, + "description": "The indicator applies while the field holds a readable date before this moment: `now` or an ISO date." + }, "icon": { "type": "string", "description": "CnIcon name (PascalCase). Defaults to InformationOutline." @@ -577,7 +688,7 @@ "folderSidebarScope": { "type": "object", "additionalProperties": true, - "description": "One folder in `config.folderSidebar.folders[]`, which doubles as a SCOPE: the three optional list-layout keys below let one index page show each folder the way that folder needs. Every other key (id, name, icon, ...) stays free-form, so an existing folder list validates unchanged. The page's `config.columns` still decides which columns this page HAS; a scope only decides which of them are shown, in what order, sorted by what and searched over what. A scope naming a column the page does not declare is refused by validateManifestV2, not silently rendered.", + "description": "One folder in `config.folderSidebar.folders[]`, which doubles as a SCOPE: the three optional list-layout keys below let one index page show each folder the way that folder needs. Every other key (id, name, icon, ...) stays free-form, so an existing folder list validates unchanged. A scope (or the `x-index` block of the row a register-derived folder comes from) may also carry the presentation keys a query preset sets, under the same names: title, quickFilters, cardFields, countText, searchPlaceholder, footerNote, viewSwitch, addLabel, headerButtons, breadcrumb, headerMeta and afterList (index-scope-presentation); a scope's value wins over a preset's. The page's `config.columns` still decides which columns this page HAS; a scope only decides which of them are shown, in what order, sorted by what and searched over what. A scope naming a column the page does not declare is refused by validateManifestV2, not silently rendered.", "properties": { "columns": { "type": "array", @@ -1023,7 +1134,8 @@ }, "seeded": { "type": "array", - "description": "Views that ship with the app. They render as their own group above the user's own, and a user may copy one but not delete it: a view the product promises is not a view one person can remove from everybody's install.", + "deprecated": true, + "description": "DEPRECATED since 2.129.0: declare default views with the index page's `config.defaultSavedViews`, which carries the criteria this key never had and is what the page actually lists. Nothing ever read this key, so moving off it loses nothing. Views that ship with the app. They render as their own group above the user's own, and a user may copy one but not delete it: a view the product promises is not a view one person can remove from everybody's install.", "items": { "type": "object", "additionalProperties": false, @@ -1110,13 +1222,19 @@ ] }, "pill": { "type": "string", "description": "The field shown as a status pill." }, + "resolve": { "type": "object", "additionalProperties": false, "description": "pill-color-field: the `pill` field holds the id of a record; the pill takes the tone that record's colour maps onto and shows its icon. Register and schema default to the field's reference.", "properties": { "register": { "type": "string", "minLength": 1 }, "schema": { "type": "string", "minLength": 1 }, "colorField": { "type": "string", "minLength": 1 }, "iconField": { "type": "string", "minLength": 1 } } }, "pillColors": { "type": "object", "description": "Pill value to badge variant. A value without an entry takes the default variant.", "additionalProperties": { "type": "string", "enum": ["default", "primary", "success", "warning", "error", "info", "purple", "teal"] } }, "due": { "type": "string", "description": "The date field of the footer's due text, marked by `dueRule`." }, - "owner": { "type": "string", "description": "The field of the owner shown as an avatar (initials) at the footer's end." } + "owner": { "type": "string", "description": "The field of the owner shown as an avatar (initials) at the footer's end." }, + "formatters": { + "type": "object", + "description": "screens-board-reference-columns (2.97.0): a card field (one of the role fields above) to the id of a formatter in the app's `cnFormatters` registry, the one table columns name in `formatter`. The card shows the formatted text, so a reference reads as its name (`{ \"caseType\": \"caseTypeName\" }`).", + "additionalProperties": { "type": "string", "minLength": 1 } + } } }, "colorField": { @@ -1145,6 +1263,98 @@ "dueRule": { "$ref": "#/$defs/dueRule", "description": "Marks late cards. A card whose date is before today gets an error edge and the label 'Overdue'; one due within `soonDays` gets 'Due soon'. Omitted, no card is marked." + }, + "columnFields": { + "type": "array", + "minItems": 1, + "description": "screens-board-reference-columns (2.97.0): the fields the columns can come from, offered in a \"Columns:\" switch after the search when there are two or more (\"status | type | assignee\"). The entry on `statusField` is the default. An entry with a `source` takes its columns from the rows the field REFERENCES (a case's status pointing at statusType rows), fetched, not from the values on the cards; `kind: \"values\"` (a user id, which references no schema) takes the values present plus a column for no value. Without a `source` or `kind` the field's enum or lifecycle states are read as before. A drop writes the reference: through `moveHandler` when named, else a PATCH of the field on the row.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["field"], + "properties": { + "field": { "type": "string", "minLength": 1, "description": "The row field the columns come from." }, + "label": { "type": "string", "description": "Its name in the column switch (an i18n key). Defaults to the field." }, + "kind": { "type": "string", "enum": ["values"], "description": "`values`: the field references no schema (a Nextcloud user id); the columns are the values present on the rows, by `valueLabels` or the value itself, then one column for no value (`emptyLabel`)." }, + "valueLabels": { "type": "object", "additionalProperties": { "type": "string" }, "description": "`kind: values`: raw value to the column label." }, + "emptyLabel": { "type": "string", "description": "`kind: values`: the label of the column for no value (an i18n key). Defaults to \"Nobody\"." }, + "source": { + "type": "object", + "additionalProperties": false, + "description": "The referenced rows that are the columns: one list request to `register`/`schema` (defaults: the page's register and the property's `$ref`).", + "properties": { + "register": { "type": "string", "minLength": 1 }, + "schema": { "type": "string", "minLength": 1 }, + "labelField": { "type": "string", "description": "The referenced row's field that names a column. Defaults to `name`, then `title`." }, + "orderField": { "type": "string", "description": "The referenced row's numeric field the columns are ordered by." }, + "colorField": { "type": "string", "description": "The referenced row's field that holds the column's dot colour (a badge variant or a CSS colour)." }, + "filter": { "type": "object", "description": "Equality filters on the referenced rows, e.g. `{ \"isFinal\": false }` to leave out end states.", "additionalProperties": { "type": ["string", "number", "boolean"] } }, + "capacityField": { "type": "string", "minLength": 1, "description": "board-column-capacity (2.100.0): the referenced row's numeric field that holds the column's limit. The header reads \"count / capacity\" and turns full at the limit, and a move into a full row is refused before it is written (the server still guards). A column merged from several rows shows no limit, but each row's own limit still refuses a move into it. A row without a number above 0 has no limit." }, + "neutralColor": { "type": "string", "minLength": 1, "description": "board-neutral-column-colour (2.101.0): the colour a referenced row holds when nobody chose one (the schema default, e.g. \"grey\"). A column merged from several rows (`mergeBy`) then takes the first row colour that is not this one, and this one only when every row has it. Compared as written: \"Grey\" is not \"grey\", and a hex value is not a name. Unset, a merged column takes its first row's colour." }, + "colorMap": { "type": "object", "description": "board-column-colour-map (2.102.0): a stored row colour to the CSS colour its column is drawn in, e.g. `{ \"orange\": \"var(--nl-color-orange, #e17000)\" }`, so a palette name draws a theme token. Applied after the merge, so `neutralColor` still compares the stored values. A colour it does not map draws as before.", "additionalProperties": { "type": "string", "minLength": 1 } }, + "mergeBy": { "type": "string", "description": "Merge referenced rows with the same value of this field into one column (statuses of every case type by `name`). A drop then writes the row of the card's own parent (`narrowBy`), and is refused when that parent has none." }, + "narrowBy": { + "type": "object", + "additionalProperties": false, + "required": ["field", "rowField"], + "description": "The parent link: the referenced row's `field` equals the card's `rowField` (statusType.caseType = case.caseType). While the page filters on `rowField` with one value, the columns are that parent's rows only.", + "properties": { + "field": { "type": "string", "minLength": 1 }, + "rowField": { "type": "string", "minLength": 1 } + } + } + } + }, + "readOnly": { "type": "boolean", "default": false, "description": "No drag and no Move to while the columns come from this field." }, + "moveHandler": { "type": "string", "minLength": 1, "description": "A handler in the app's registry that makes the move: it gets `{ card, toKey, field, value, target }` (value: the referenced id to write; target: its row) and returns a promise; a rejection shows its message as the refusal. Absent, the field is written with a PATCH." }, + "moveTargetsHandler": { "type": "string", "minLength": 1, "description": "A handler in the app's registry that answers where one card may go: it gets `{ card, field }` and returns the values (referenced ids) it can reach. Columns out of reach fade during a drag, read \"not allowed\" in the card menu, and refuse a drop." } + } + } + }, + "moveHandler": { + "type": "string", + "minLength": 1, + "description": "screens-board-reference-columns (2.97.0): a handler in the app's registry that moves a card on `statusField` (the app's transition), with the `columnFields[].moveHandler` call shape. Lets a manifest-only page run the host's transition, which the `runTransition` prop could not." + }, + "moveTargetsHandler": { + "type": "string", + "minLength": 1, + "description": "screens-board-reference-columns (2.97.0): where a card on `statusField` may go, with the `columnFields[].moveTargetsHandler` call shape." + }, + "narrow": { + "type": "object", + "additionalProperties": false, + "required": ["field"], + "description": "screens-board-reference-columns (2.97.0): a select after the search (\"Case type: All case types | Woo-verzoek | ...\") that sets the page filter on `field`. With a reference column's `narrowBy.rowField` on the same field, the columns narrow to that parent's rows.", + "properties": { + "field": { "type": "string", "minLength": 1, "description": "The row field the select filters on." }, + "label": { "type": "string", "description": "The select's label (an i18n key), shown with a colon. Defaults to the property's title." }, + "allLabel": { "type": "string", "description": "The option for no narrowing (an i18n key). Defaults to \"All\"." }, + "source": { + "type": "object", + "additionalProperties": false, + "description": "Where the options come from (defaults: the page's register and the property's `$ref`).", + "properties": { + "register": { "type": "string", "minLength": 1 }, + "schema": { "type": "string", "minLength": 1 }, + "labelField": { "type": "string", "description": "The option row's label field. Defaults to `title`." }, + "filter": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } } + } + } + } + }, + "columnSwitchLabel": { + "type": "string", + "description": "screens-board-reference-columns (2.97.0): the column switch's label (an i18n key), shown with a colon. Defaults to \"Columns\"." + }, + "hint": { + "type": "string", + "description": "screens-board-reference-columns (2.97.0): one sentence after the board's controls, in the secondary colour (an i18n key), e.g. how a move works." + }, + "wrap": { + "type": "boolean", + "default": false, + "description": "screens-board-reference-columns (2.97.0): wrap the columns onto more rows, as many to a row as fit at 240px, instead of one row that scrolls sideways. For a board with more stages than fit." } } }, @@ -1163,6 +1373,11 @@ "minLength": 1, "description": "Dot-path to the value on the record." }, + "values": { + "type": "array", + "items": { "type": "string" }, + "description": "With `field`: draw the pill only when the raw value is one of these (a priority pill for high and urgent only)." + }, "text": { "type": "string", "minLength": 1, @@ -1179,6 +1394,7 @@ "filter": { "type": "object", "additionalProperties": true } } }, + "resolve": { "type": "object", "additionalProperties": false, "required": ["register", "schema"], "description": "detail-pill-resolve: the field holds the id of a record of this register and schema; the pill shows that record's `labelField` (else its title or name) and keys `colorMap` by it. The raw value shows while the lookup runs and when it finds nothing.", "properties": { "register": { "type": "string", "minLength": 1 }, "schema": { "type": "string", "minLength": 1 }, "labelField": { "type": "string", "minLength": 1 }, "colorField": { "type": "string", "minLength": 1, "description": "pill-color-field: the referenced record's property holding a colour name (an NL Design System hue such as `red` or `blue-light`, or a badge role); the pill takes the tone it maps onto." }, "iconField": { "type": "string", "minLength": 1, "description": "pill-color-field: the referenced record's property holding a registered icon name, shown before the label." } } }, "countLabel": { "type": "object", "additionalProperties": false, @@ -1554,6 +1770,59 @@ } } }, + "linkModule": { + "type": "object", + "required": ["id", "label"], + "additionalProperties": false, + "description": "One module on a type='links' page. The app decides whether it is in the menu (`enabled`, or `enabledIf` against runtime). Every card opens; a module that is off links to the admin settings (`settings`).", + "properties": { + "id": { "type": "string", "minLength": 1, "description": "Unique within the page's modules." }, + "label": { "type": "string", "minLength": 1, "description": "i18n key / the module's title." }, + "description": { "type": "string", "description": "i18n key / one or two lines under the title." }, + "icon": { "type": "string", "description": "MDI icon name." }, + "enabled": { "type": "boolean", "description": "Whether the module is in the menu. Wins over enabledIf." }, + "enabledIf": { "$ref": "#/$defs/visibleIfCondition", "description": "Condition, evaluated like visibleIf, that says the module is in the menu." }, + "route": { "type": "string", "minLength": 1, "description": "Where Open goes: a route name or a path starting with '/'. Defaults to the first page." }, + "params": { "type": "object", "additionalProperties": { "type": ["string", "number"] } }, + "query": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } }, + "pages": { + "type": "array", + "description": "The module's pages, drawn as links and named in the 'N pages' line.", + "items": { + "type": "object", + "required": ["label"], + "additionalProperties": false, + "anyOf": [{ "required": ["route"] }, { "required": ["href"] }], + "properties": { + "label": { "type": "string", "minLength": 1 }, + "route": { "type": "string", "minLength": 1 }, + "params": { "type": "object", "additionalProperties": { "type": ["string", "number"] } }, + "query": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } }, + "href": { "type": "string", "minLength": 1 }, + "highlight": { "type": "boolean", "description": "Named in the line of a module with more than four pages ('15 pages, including ...')." } + } + } + }, + "settings": { + "type": "object", + "additionalProperties": false, + "anyOf": [{ "required": ["route"] }, { "required": ["href"] }], + "description": "Where an admin puts a module that is off in the menu (the admin settings), shown on its card as '