From 0600012c2ce4284d80c0033e95f1d6e94a1b3b53 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 8 Oct 2026 12:07:06 +0200
Subject: [PATCH 001/124] fix(dashboard): re-point dashboards from the renamed
widget id and fix 47 @spec paths
Two findings from Thijn's re-review of the beta promotion (#1026), after
#1261 restored the frozen widget id:
Restoring `softwarecatalog_concept_organisaties_widget` keeps the widget for
users from before the rename, but drops it for everyone who added it while
stable 0.1.148 to 0.2.0 and beta registered it as
`stackiq_concept_organisaties_widget`. The Dashboard app keeps that choice in
`oc_preferences` under its own app id `dashboard`, key `layout`, as a
comma-separated list of widget ids, which IConfig reaches like this app's
own rows. The new post-migration repair step RepointDashboardWidgetId rewrites
the renamed id to the frozen one for every seen user, in place and without
listing the frozen id twice; a layout without the renamed id is left alone.
The widget comment and the app-id-rename spec no longer claim the rows are
out of reach.
The archive pass (1aa23464, 7339b355) moved four changes without updating
their @spec tags: wizard-dataset-card-load (4), portal-contribution (8),
publication-field-rules (20) and rename-app-id-to-stackiq (14), 47 tags in
16 files now point at openspec/specs//spec.md. The anchors were
checked against the synced specs.
Co-Authored-By: Claude Fable 5.1
---
appinfo/info.xml | 9 +
.../ModuleVersionPublicationJob.php | 6 +-
lib/Controller/SetupController.php | 8 +-
lib/Dashboard/ConceptOrganisatiesWidget.php | 11 +-
.../ModuleVersionPublicationListener.php | 6 +-
lib/Portal/PortalContributionProvider.php | 14 +-
.../BackfillModuleVersionPublication.php | 6 +-
lib/Repair/MigrateAppConfigKeys.php | 10 +-
lib/Repair/MigrateBackgroundJobClasses.php | 8 +-
lib/Repair/MigrateUserPreferences.php | 10 +-
lib/Repair/RepointDashboardWidgetId.php | 222 ++++++++++++++
.../ModuleVersionPublicationService.php | 16 +-
lib/Service/SettingsService.php | 2 +-
openspec/specs/app-id-rename/spec.md | 9 +-
.../Portal/PortalContributionProviderTest.php | 2 +-
.../BackfillModuleVersionPublicationTest.php | 2 +-
.../Repair/RepointDashboardWidgetIdTest.php | 270 ++++++++++++++++++
.../ModuleVersionPublicationServiceTest.php | 2 +-
.../Settings/PublicationFieldRulesTest.php | 2 +-
19 files changed, 563 insertions(+), 52 deletions(-)
create mode 100644 lib/Repair/RepointDashboardWidgetId.php
create mode 100644 tests/Unit/Repair/RepointDashboardWidgetIdTest.php
diff --git a/appinfo/info.xml b/appinfo/info.xml
index aa70380b6..da795bd7a 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -122,6 +122,15 @@ Vrij en open source onder de EUPL-licentie.
-->
OCA\Stackiq\Repair\MigrateAppConfigKeysOCA\Stackiq\Repair\MigrateUserPreferences
+
+ OCA\Stackiq\Repair\RepointDashboardWidgetId
+ {{
+ t(
+ 'stackiq',
+ 'The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.',
+ )
+ }}
+
+
+
+
+
+
diff --git a/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts b/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
new file mode 100644
index 000000000..df1164397
--- /dev/null
+++ b/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
@@ -0,0 +1,231 @@
+// SPDX-License-Identifier: EUPL-1.2
+// SPDX-FileCopyrightText: 2026 Conduction B.V.
+/**
+ * E2e coverage for openspec/changes/cmdb-import-mapping-view (the read-only
+ * "Mapping (read-only)" block of the "CMDB import" settings section).
+ *
+ * Every scenario the delta spec tags
+ * `@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts` is driven here:
+ * the endpoint as an admin, the block on the real settings page, the sheet
+ * names in the help text (also with the endpoint answering 503, through a
+ * route intercept, so no shipped file is broken on a shared instance), and
+ * the refusal for a signed-in user who is not a Nextcloud admin.
+ *
+ * The shipped profile and packs are the expected values; nothing is written.
+ */
+
+import type { Page } from '@playwright/test'
+
+import { expect, request as playwrightRequest, test } from '@playwright/test'
+import { BASE_URL, newApiContext, RUN_ID } from '../workflows/_fixtures.ts'
+
+const MAPPING_PATH = '/index.php/apps/stackiq/api/settings/cmdb-import/mapping'
+// The page builds its URL with generateUrl(), which drops `/index.php` on an
+// instance with pretty URLs, so the browser-side matcher uses the path tail.
+const MAPPING_ROUTE = '**/apps/stackiq/api/settings/cmdb-import/mapping'
+const TARGETS = ['module', 'manufacturer', 'municipality', 'usage', 'businessOwner']
+const SHIPPED_SHEETS = ['Onbeh Applicaties CMDB', 'Beheerde Applicaties CMDB']
+
+/**
+ * Close Nextcloud's first-run wizard when it shows, the way
+ * cmdb-import.spec.ts does: mark it as seen and load the page again.
+ *
+ * @param page The page
+ * @return True when the page was reloaded
+ */
+async function dismissFirstRunWizard(page: Page): Promise {
+ const wizard = page.locator('.first-run-wizard[role="dialog"]')
+ try {
+ await wizard.waitFor({ state: 'visible', timeout: 3000 })
+ } catch {
+ return false
+ }
+ await page.evaluate(async () => {
+ const oc = (
+ window as unknown as {
+ OC: { requestToken: string; generateUrl: (u: string) => string }
+ }
+ ).OC
+ await fetch(oc.generateUrl('/apps/firstrunwizard/wizard'), {
+ method: 'DELETE',
+ headers: { requesttoken: oc.requestToken },
+ })
+ })
+ await page.reload({ waitUntil: 'domcontentloaded' })
+ return true
+}
+
+/**
+ * Open stackiq's admin settings and return the CMDB import section.
+ *
+ * @param page The page
+ */
+async function gotoCmdbSection(page: Page) {
+ await page.goto('/settings/admin/stackiq', { waitUntil: 'domcontentloaded' })
+ const section = page.locator('[data-testid="cmdb-import"]')
+ await expect(section).toBeVisible({ timeout: 30000 })
+ if (await dismissFirstRunWizard(page)) {
+ await expect(section).toBeVisible({ timeout: 30000 })
+ }
+ await section.scrollIntoViewIfNeeded()
+ return section
+}
+
+test.describe('CMDB import mapping (read-only)', () => {
+ // @e2e cmdb-export-import::an-admin-reads-the-mapping-the-import-uses
+ test('an admin reads the mapping the import uses', async () => {
+ const ctx = await newApiContext()
+ try {
+ const res = await ctx.get(MAPPING_PATH)
+ expect(res.status(), await res.text()).toBe(200)
+ const body = await res.json()
+
+ expect(Object.keys(body).sort()).toEqual(['packs', 'profile'])
+ expect(
+ body.profile.sheets.map((sheet: { name: string }) => sheet.name),
+ ).toEqual(SHIPPED_SHEETS)
+ expect(body.profile.keyColumn).toBe('APPID')
+ expect(body.profile.requiredColumns).toEqual([
+ 'APPID',
+ 'Applicatie Naam',
+ ])
+
+ expect(
+ body.packs.map((pack: { target: string }) => pack.target),
+ ).toEqual(TARGETS)
+ for (const pack of body.packs) {
+ expect(pack.id, pack.target).not.toBe('')
+ expect(pack.name, pack.target).not.toBe('')
+ expect(pack.version, pack.target).not.toBe('')
+ }
+
+ const usage = body.packs.find(
+ (pack: { target: string }) => pack.target === 'usage',
+ )
+ const status = usage.fieldMappings.find(
+ (mapping: { source: string }) =>
+ mapping.source === 'Applicatie Status',
+ )
+ expect(status.target).toBe('status')
+ expect(status.transform.type).toBe('lookup')
+ expect(status.transform.map['In productie']).toBe('In production')
+ } finally {
+ await ctx.dispose()
+ }
+ })
+
+ // @e2e cmdb-export-import::the-section-shows-one-table-per-pack
+ test('the section shows one table per pack, without editing', async ({
+ page,
+ }) => {
+ const section = await gotoCmdbSection(page)
+ const block = section.locator('[data-testid="cmdb-import-mapping"]')
+ const toggle = block.locator('[data-testid="cmdb-import-mapping-toggle"]')
+
+ await expect(toggle).toHaveAttribute('aria-expanded', 'false')
+ await toggle.click()
+ await expect(toggle).toHaveAttribute('aria-expanded', 'true')
+
+ for (const target of TARGETS) {
+ await expect(
+ block.locator(`[data-testid="cmdb-import-mapping-${target}"]`),
+ target,
+ ).toBeVisible({ timeout: 30000 })
+ }
+
+ const statusRow = block
+ .locator('[data-testid="cmdb-import-mapping-usage"]')
+ .getByRole('row')
+ .filter({ hasText: 'Applicatie Status' })
+ await expect(statusRow).toContainText('status')
+ await expect(statusRow).toContainText(/Lookup|Opzoektabel/)
+ await expect(statusRow).toContainText('In productie → In production')
+
+ // Read-only: the toggle is the block's only control.
+ await expect(block.getByRole('button')).toHaveCount(1)
+ await expect(block.locator('input, textarea, select')).toHaveCount(0)
+ })
+
+ // @e2e cmdb-export-import::the-sheet-names-in-the-help-come-from-the-server
+ test('the help text names the sheets from the server, and the shipped ones when it fails', async ({
+ page,
+ }) => {
+ // The endpoint answers other names: the help must follow it.
+ await page.route(MAPPING_ROUTE, async (route) => {
+ const response = await route.fetch()
+ const body = await response.json()
+ body.profile.sheets = [
+ { name: `Sheet one ${RUN_ID}`, constants: {}, absentColumns: [] },
+ { name: `Sheet two ${RUN_ID}`, constants: {}, absentColumns: [] },
+ ]
+ await route.fulfill({ response, json: body })
+ })
+ await gotoCmdbSection(page)
+ const help = page.locator('#cmdb-import-file-help')
+ await expect(help).toContainText(`Sheet one ${RUN_ID}`)
+ await expect(help).toContainText(`Sheet two ${RUN_ID}`)
+
+ // The endpoint fails: the shipped names, and the block shows the error.
+ await page.unroute(MAPPING_ROUTE)
+ await page.route(MAPPING_ROUTE, (route) =>
+ route.fulfill({
+ status: 503,
+ json: {
+ success: false,
+ error: 'MAPPING_UNAVAILABLE',
+ message: 'The import mapping cannot be shown.',
+ details: { reason: 'topdesk-usage.json: refused in this test' },
+ },
+ }),
+ )
+ const section = await gotoCmdbSection(page)
+ for (const sheet of SHIPPED_SHEETS) {
+ await expect(help).toContainText(sheet)
+ }
+ await section.locator('[data-testid="cmdb-import-mapping-toggle"]').click()
+ const error = section.locator('[data-testid="cmdb-import-mapping-error"]')
+ await expect(error).toBeVisible()
+ await expect(error).toContainText('MAPPING_UNAVAILABLE')
+ await expect(error).toContainText('topdesk-usage.json: refused in this test')
+ await expect(
+ section.locator('[data-testid="cmdb-import-mapping-usage"]'),
+ ).toHaveCount(0)
+ })
+
+ // @e2e cmdb-export-import::a-user-who-is-not-a-nextcloud-admin-cannot-read-the-mapping
+ test('a signed-in user who is not a Nextcloud admin is refused', async () => {
+ const ctx = await newApiContext()
+ const userId = `cmdbmap-${RUN_ID}`
+ const password = `Cmdbmap-${RUN_ID}-Pw!9`
+ const created = await ctx.post('/ocs/v2.php/cloud/users?format=json', {
+ form: { userid: userId, password },
+ })
+ expect(created.ok(), await created.text()).toBe(true)
+ const user = await playwrightRequest.newContext({
+ baseURL: BASE_URL,
+ storageState: { cookies: [], origins: [] },
+ httpCredentials: { username: userId, password, send: 'always' },
+ extraHTTPHeaders: { 'OCS-APIREQUEST': 'true' },
+ })
+ try {
+ // Prove the context is that user, so a 403 is the rule and not a failed login.
+ const whoami = await user.get('/ocs/v2.php/cloud/user?format=json')
+ expect(whoami.status()).toBe(200)
+ expect((await whoami.json()).ocs.data.id).toBe(userId)
+
+ const res = await user.get(MAPPING_PATH)
+ expect(res.status()).toBe(403)
+
+ // The admin settings page that holds the section is not theirs either:
+ // Nextcloud refuses or redirects it, it never renders it (2xx).
+ const settings = await user.get('/index.php/settings/admin/stackiq', {
+ maxRedirects: 0,
+ })
+ expect(settings.ok(), `status ${settings.status()}`).toBe(false)
+ } finally {
+ await user.dispose()
+ await ctx.delete(`/ocs/v2.php/cloud/users/${userId}?format=json`)
+ await ctx.dispose()
+ }
+ })
+})
diff --git a/tests/vitest/cmdbImportMapping.spec.js b/tests/vitest/cmdbImportMapping.spec.js
new file mode 100644
index 000000000..b7ad8715f
--- /dev/null
+++ b/tests/vitest/cmdbImportMapping.spec.js
@@ -0,0 +1,281 @@
+// @vitest-environment jsdom
+
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * The read-only "Mapping (read-only)" block of the CMDB import section, and
+ * the helpers that shape the mapping endpoint's answer into its tables.
+ *
+ * The block is mounted against a mocked endpoint answer: one table per pack,
+ * the lookup pairs of a row, the `loaded` event the section takes its sheet
+ * names from, and the error state of a 503.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { defineComponent, h } from 'vue'
+
+const http = vi.hoisted(() => ({ get: vi.fn() }))
+
+vi.mock('@nextcloud/axios', () => ({ default: http }))
+
+// `@conduction/nextcloud-vue` is not resolvable in this offline suite. The
+// stub renders the rows through the same `column-` slots the real table
+// offers, so the block's own cell markup is what is asserted.
+vi.mock('@conduction/nextcloud-vue', () => ({
+ CnDataTable: defineComponent({
+ name: 'CnDataTable',
+ props: {
+ rows: { type: Array, default: () => [] },
+ columns: { type: Array, default: () => [] },
+ rowKey: { type: String, default: 'id' },
+ emptyText: { type: String, default: '' },
+ },
+ setup(props, { slots }) {
+ return () =>
+ h('table', [
+ h(
+ 'thead',
+ h(
+ 'tr',
+ props.columns.map((column) => h('th', column.label)),
+ ),
+ ),
+ h(
+ 'tbody',
+ props.rows.map((row) =>
+ h(
+ 'tr',
+ { key: row[props.rowKey] },
+ props.columns.map((column) => {
+ const slot = slots['column-' + column.key]
+ return h(
+ 'td',
+ slot
+ ? slot({ row })
+ : String(row[column.key]),
+ )
+ }),
+ ),
+ ),
+ ),
+ ])
+ },
+ }),
+}))
+
+const {
+ mappingRows,
+ mappingSheetNames,
+ packTargetLabel,
+ PROFILE_DEFAULTS,
+ transformDetails,
+ transformLabel,
+} = await import('../../src/utils/cmdbImport.js')
+const CmdbImportMapping = (
+ await import('../../src/views/settings/sections/CmdbImportMapping.vue')
+).default
+
+/**
+ * A small answer of the mapping endpoint, shaped like the shipped files.
+ *
+ * @return {{profile: object, packs: Array}} The answer
+ */
+function answer() {
+ const pack = (target, fieldMappings = []) => ({
+ target,
+ file: `topdesk-${target}.json`,
+ id: `stackiq-topdesk-${target}`,
+ name: `TOPdesk ${target}`,
+ version: '1.0.0',
+ description: '',
+ fieldMappings,
+ })
+ return {
+ profile: {
+ id: 'topdesk-cmdb',
+ sheets: [
+ { name: 'Sheet A', constants: {}, absentColumns: [] },
+ { name: 'Sheet B', constants: {}, absentColumns: [] },
+ ],
+ keyColumn: 'APPID',
+ nameColumn: 'Applicatie Naam',
+ requiredColumns: ['APPID', 'Applicatie Naam'],
+ dateColumns: ['Datum'],
+ missingRecords: ['keep'],
+ },
+ packs: [
+ pack('module', [
+ {
+ source: 'Applicatie Naam',
+ target: 'name',
+ required: true,
+ transform: { type: 'trim' },
+ },
+ ]),
+ pack('manufacturer'),
+ pack('municipality'),
+ pack('usage', [
+ {
+ source: 'Applicatie Status',
+ target: 'status',
+ required: false,
+ transform: {
+ type: 'lookup',
+ map: { 'In productie': 'In production' },
+ default: null,
+ },
+ },
+ ]),
+ pack('businessOwner'),
+ ],
+ }
+}
+
+describe('CmdbImportMapping', () => {
+ beforeEach(() => {
+ http.get.mockReset()
+ })
+
+ it('renders one table per pack from the endpoint and emits the answer', async () => {
+ http.get.mockResolvedValue({ data: answer() })
+
+ const wrapper = mount(CmdbImportMapping)
+ await flushPromises()
+
+ expect(http.get).toHaveBeenCalledWith(
+ expect.stringContaining(
+ '/apps/stackiq/api/settings/cmdb-import/mapping',
+ ),
+ )
+ expect(wrapper.emitted('loaded')[0][0].profile.keyColumn).toBe('APPID')
+
+ const toggle = wrapper.find('[data-testid="cmdb-import-mapping-toggle"]')
+ expect(toggle.attributes('aria-expanded')).toBe('false')
+ await toggle.trigger('click')
+ expect(toggle.attributes('aria-expanded')).toBe('true')
+
+ for (const target of [
+ 'module',
+ 'manufacturer',
+ 'municipality',
+ 'usage',
+ 'businessOwner',
+ ]) {
+ expect(
+ wrapper
+ .find(`[data-testid="cmdb-import-mapping-${target}"]`)
+ .exists(),
+ target,
+ ).toBe(true)
+ }
+
+ const usage = wrapper.find('[data-testid="cmdb-import-mapping-usage"]')
+ expect(usage.text()).toContain('Applicatie Status')
+ expect(usage.text()).toContain('status')
+ expect(usage.text()).toContain('Lookup')
+ expect(usage.text()).toContain('In productie → In production')
+ expect(
+ wrapper.find('[data-testid="cmdb-import-mapping-profile"]').text(),
+ ).toContain('Sheet A, Sheet B')
+ expect(
+ wrapper.find('[data-testid="cmdb-import-mapping-error"]').exists(),
+ ).toBe(false)
+ // Read-only: the only button is the toggle.
+ expect(wrapper.findAll('button')).toHaveLength(1)
+ })
+
+ it('shows the error, its reason and its code on a 503, and no table', async () => {
+ http.get.mockRejectedValue({
+ response: {
+ status: 503,
+ data: {
+ success: false,
+ error: 'MAPPING_UNAVAILABLE',
+ message: 'The import mapping cannot be shown.',
+ details: { reason: 'topdesk-usage.json: unknown transform' },
+ },
+ },
+ })
+
+ const wrapper = mount(CmdbImportMapping)
+ await flushPromises()
+ await wrapper
+ .find('[data-testid="cmdb-import-mapping-toggle"]')
+ .trigger('click')
+
+ const error = wrapper.find('[data-testid="cmdb-import-mapping-error"]')
+ expect(error.exists()).toBe(true)
+ expect(error.text()).toContain('topdesk-usage.json: unknown transform')
+ expect(error.text()).toContain('MAPPING_UNAVAILABLE')
+ expect(
+ wrapper.find('[data-testid="cmdb-import-mapping-usage"]').exists(),
+ ).toBe(false)
+ expect(wrapper.emitted('loaded')).toBeUndefined()
+ })
+})
+
+describe('cmdbImport mapping helpers', () => {
+ it('takes the sheet names from the answer, and the shipped ones without it', () => {
+ expect(mappingSheetNames(answer())).toEqual(['Sheet A', 'Sheet B'])
+ expect(mappingSheetNames(null)).toEqual(PROFILE_DEFAULTS.sheets)
+ expect(mappingSheetNames({ profile: { sheets: [] } })).toEqual(
+ PROFILE_DEFAULTS.sheets,
+ )
+ })
+
+ it('keeps the shipped sheet names when the answer names only one sheet', () => {
+ expect(
+ mappingSheetNames({ profile: { sheets: [{ name: 'Sheet A' }] } }),
+ ).toEqual(PROFILE_DEFAULTS.sheets)
+ })
+
+ it('describes every transformation the packs use', () => {
+ expect(transformLabel(undefined)).toBe('As is')
+ expect(transformLabel('bool-map')).toBe('Yes/no lookup')
+ expect(transformLabel('something-new')).toBe('something-new')
+ expect(transformDetails({ type: 'trim' })).toEqual([])
+ expect(
+ transformDetails({
+ type: 'concat',
+ fields: ['Cluster', 'Afdeling'],
+ separator: ' / ',
+ }),
+ ).toEqual(['Joined with the columns Cluster, Afdeling, separated by " / "'])
+ expect(transformDetails({ type: 'const', value: 'Gemeente' })).toEqual([
+ 'Value: Gemeente',
+ ])
+ expect(
+ transformDetails({
+ type: 'date',
+ sourceFormat: 'd-m-Y',
+ targetFormat: 'Y-m-d',
+ }),
+ ).toEqual(['Read as d-m-Y, stored as Y-m-d'])
+ expect(
+ transformDetails({ type: 'lookup', map: { Ja: true }, extra: 1 }),
+ ).toEqual(['Ja → true', 'extra: 1'])
+ })
+
+ it('builds one row per field mapping and names each target', () => {
+ const rows = mappingRows(answer().packs[3])
+ expect(rows).toHaveLength(1)
+ expect(rows[0]).toMatchObject({
+ source: 'Applicatie Status',
+ target: 'status',
+ required: false,
+ transform: 'Lookup',
+ })
+ expect(rows[0].details).toEqual([
+ 'In productie → In production',
+ 'Any other value: —',
+ ])
+ expect(mappingRows({})).toEqual([])
+ expect(packTargetLabel('businessOwner')).toBe(
+ 'Business owner (contact person)',
+ )
+ })
+})
From b9460014d8a308c03e87be584ae3ab75dff7f77e Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 13:37:20 +0200
Subject: [PATCH 017/124] fix(ui): search the Applications and Services lists
server side and give them the Search and Columns sidebar
A free-text search on the faceted Applications page narrowed the list by
every id the facets matched, sent as _ids[] in the query string. From
about 170 matches the request line was too long, the server answered
HTTP 414 and the list showed an error on every search after that.
The page now has the Search and Columns sidebar the other index pages
have. Its search is the list's own _search, which OpenRegister pages
server side; the facet counts follow the same term. Only a GEMMA facet
selection still narrows the list by the matched ids. The Services page
uses the same view and gets the same sidebar, as its toolbar search box
is gone.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
src/manifest.json | 4 +-
src/utils/facetNarrowing.js | 30 +++++++
src/utils/facetNarrowing.spec.js | 26 ++++++
src/views/FacetedCatalogIndexView.vue | 122 +++++++++++++-------------
4 files changed, 121 insertions(+), 61 deletions(-)
diff --git a/src/manifest.json b/src/manifest.json
index 22b321684..825de7293 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -616,12 +616,13 @@
"type": "custom",
"title": "Applications",
"component": "FacetedCatalogIndexView",
- "_note": "Faceted index page (gemma-faceted-search): CnFacetSidebar (live GEMMA-dimension counts from FacetService) + a standalone CnIndexPage narrowed via { _ids: matchedObjectIds } (OpenRegister's list parameter; the objects endpoint ignores a plain id filter). Columns are objects: `provider` is a reference, so it renders through the fkResolve cell widget (uuid → organisation name) and carries its own label — object-typed properties get no schema-generated column, so a bare key showed the raw uuid under the header 'provider'. Stays custom rather than type:index — see customComponents.js's FacetedCatalogIndexView entry for why CnIndexPage's own embedded facet sidebar cannot drive this narrowing. bio-compliance-assessment: bbnLevel/dpiaStatus columns + BBN/DPIA quickFilters reconciled onto this page after gemma-faceted-search (#382) merged — see FacetedCatalogIndexView.vue's quickFilters prop docblock for how the quick filter's field constraints compose with the facet { _ids: ... } narrowing (disjoint keys, additive AND, no clobber).",
+ "_note": "Faceted index page (gemma-faceted-search): CnFacetSidebar (live GEMMA-dimension counts from FacetService) + a standalone CnIndexPage narrowed via { _ids: matchedObjectIds } (OpenRegister's list parameter; the objects endpoint ignores a plain id filter). Columns are objects: `provider` is a reference, so it renders through the fkResolve cell widget (uuid → organisation name) and carries its own label — object-typed properties get no schema-generated column, so a bare key showed the raw uuid under the header 'provider'. Stays custom rather than type:index — see customComponents.js's FacetedCatalogIndexView entry for why CnIndexPage's own embedded facet sidebar cannot drive this narrowing. bio-compliance-assessment: bbnLevel/dpiaStatus columns + BBN/DPIA quickFilters reconciled onto this page after gemma-faceted-search (#382) merged — see FacetedCatalogIndexView.vue's quickFilters prop docblock for how the quick filter's field constraints compose with the facet { _ids: ... } narrowing (disjoint keys, additive AND, no clobber). config.sidebar gives the page the Search and Columns sidebar of the other index pages; its search is the page's free-text search, sent by the list itself as _search, so only a GEMMA facet selection narrows by _ids (a search narrowed by _ids put every match in the URL and failed with HTTP 414 from about 170 matches).",
"config": {
"register": "@resolve:voorzieningen_register",
"schema": "module",
"detailRoute": "ModuleDetail",
"description": "Browse the application (module) catalogue, filtered by GEMMA architecture dimension.",
+ "sidebar": { "enabled": true, "showMetadata": true },
"columns": [
{ "key": "name", "label": "Name" },
{ "key": "provider", "label": "Supplier", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "organization", "labelField": "name" } },
@@ -677,6 +678,7 @@
"register": "@resolve:voorzieningen_register",
"schema": "catalogService",
"description": "Browse the service (dienst) catalogue, filtered by GEMMA architecture dimension.",
+ "sidebar": { "enabled": true, "showMetadata": true },
"columns": [
"name",
"provider",
diff --git a/src/utils/facetNarrowing.js b/src/utils/facetNarrowing.js
index 58b6a9047..f37b4678a 100644
--- a/src/utils/facetNarrowing.js
+++ b/src/utils/facetNarrowing.js
@@ -32,3 +32,33 @@ export function facetNarrowingFilter(ids) {
: []
return { _ids: list.length > 0 ? list : [FACET_NO_MATCH_SENTINEL] }
}
+
+/**
+ * Whether any GEMMA facet dimension has a selected value.
+ *
+ * @param {object} activeFilters The facet store's `{ dimension: [values] }` map.
+ * @return {boolean} True when at least one dimension has a value.
+ * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facets-combine-with-free-text-search
+ */
+export function hasFacetSelection(activeFilters) {
+ return Object.values(activeFilters ?? {}).some(
+ (values) => Array.isArray(values) && values.length > 0,
+ )
+}
+
+/**
+ * The `CnIndexPage` `filter` prop of a faceted list.
+ *
+ * Only a GEMMA facet selection narrows by id. A free-text search alone goes
+ * through the list's own `_search`, which OpenRegister pages server side:
+ * narrowing it by the matched ids put every match in the URL, and from about
+ * 170 matches the request line was too long (HTTP 414) and the list failed.
+ *
+ * @param {object} activeFilters The facet store's `{ dimension: [values] }` map.
+ * @param {Array} ids The `_meta.matchedObjectIds` of the last facets response.
+ * @return {object} `{}` without a facet selection, else `{ _ids: [...] }`.
+ * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facets-combine-with-free-text-search
+ */
+export function listNarrowingFilter(activeFilters, ids) {
+ return hasFacetSelection(activeFilters) ? facetNarrowingFilter(ids) : {}
+}
diff --git a/src/utils/facetNarrowing.spec.js b/src/utils/facetNarrowing.spec.js
index 06f101c92..92775598b 100644
--- a/src/utils/facetNarrowing.spec.js
+++ b/src/utils/facetNarrowing.spec.js
@@ -7,6 +7,8 @@
import {
FACET_NO_MATCH_SENTINEL,
facetNarrowingFilter,
+ hasFacetSelection,
+ listNarrowingFilter,
} from '../../src/utils/facetNarrowing.js'
describe('facetNarrowingFilter', () => {
@@ -30,3 +32,27 @@ describe('facetNarrowingFilter', () => {
})
})
})
+
+describe('listNarrowingFilter', () => {
+ it("does not narrow by id without a facet selection, so a search stays on the list's own _search", () => {
+ const many = Array.from({ length: 1200 }, (_, i) => `id-${i}`)
+ expect(listNarrowingFilter({}, many)).toEqual({})
+ expect(listNarrowingFilter({ domain: [] }, many)).toEqual({})
+ expect(listNarrowingFilter(undefined, many)).toEqual({})
+ })
+
+ it('narrows by the matched ids once a facet dimension has a value', () => {
+ expect(listNarrowingFilter({ domain: ['Burgerzaken'] }, ['a1'])).toEqual({
+ _ids: ['a1'],
+ })
+ expect(listNarrowingFilter({ standard: ['StUF'] }, [])).toEqual({
+ _ids: [FACET_NO_MATCH_SENTINEL],
+ })
+ })
+
+ it('tells a selection from an empty dimension', () => {
+ expect(hasFacetSelection({ domain: [], standard: ['StUF'] })).toBe(true)
+ expect(hasFacetSelection({ domain: [] })).toBe(false)
+ expect(hasFacetSelection(null)).toBe(false)
+ })
+})
diff --git a/src/views/FacetedCatalogIndexView.vue b/src/views/FacetedCatalogIndexView.vue
index f80deda08..97694580c 100644
--- a/src/views/FacetedCatalogIndexView.vue
+++ b/src/views/FacetedCatalogIndexView.vue
@@ -49,22 +49,6 @@ generic route-query-to-filter passthrough never sees it (see the
-
-
-
-
-
-
-
@@ -115,7 +99,9 @@ generic route-query-to-filter passthrough never sees it (see the
:quickFilters="quickFilters"
:quickFilterMode="quickFilterMode"
:quickFilterMultiple="quickFilterMultiple"
+ :sidebar="sidebar"
:rowClickToView="Boolean(detailRoute)"
+ @search="onListSearch"
@rowClick="openDetail"
@view="openDetail" />
@@ -133,21 +119,14 @@ generic route-query-to-filter passthrough never sees it (see the
import { CnFacetSidebar, CnIndexPage } from '@conduction/nextcloud-vue'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
-import {
- NcActionButton,
- NcActionCaption,
- NcActions,
- NcButton,
- NcTextField,
-} from '@nextcloud/vue'
-import CloseIcon from 'vue-material-design-icons/Close.vue'
+import { NcActionButton, NcActionCaption, NcActions } from '@nextcloud/vue'
import ContentSaveOutline from 'vue-material-design-icons/ContentSaveOutline.vue'
import FolderOutline from 'vue-material-design-icons/FolderOutline.vue'
import FolderStarOutline from 'vue-material-design-icons/FolderStarOutline.vue'
import SaveFacetViewModal from '../modals/SaveFacetViewModal.vue'
import { useFacetStore } from '../store/modules/facets.js'
import { rowDetailLocation } from '../utils/applicationContracts.js'
-import { facetNarrowingFilter } from '../utils/facetNarrowing.js'
+import { listNarrowingFilter } from '../utils/facetNarrowing.js'
import { buildFacetDimensionSchema } from '../utils/facetSchema.js'
/** Dimension key -> translated label, matching `FacetController`'s query params. */
@@ -158,22 +137,29 @@ const DIMENSION_LABELS = {
domain: () => t('stackiq', 'Domain'),
}
-/** Debounce delay (ms) between search keystrokes and the facets/list refetch. */
+/** Debounce delay (ms) between search keystrokes and the facets refetch. */
const SEARCH_DEBOUNCE_MS = 400
+/**
+ * The list's search term in a route query (`CnIndexPage` keeps it in `_search`).
+ *
+ * @param {object} query `$route.query`.
+ * @return {string} The term, or ''.
+ */
+function routeSearch(query) {
+ return typeof query?._search === 'string' ? query._search : ''
+}
+
export default {
name: 'FacetedCatalogIndexView',
components: {
- NcTextField,
- NcButton,
NcActions,
NcActionButton,
NcActionCaption,
CnFacetSidebar,
CnIndexPage,
SaveFacetViewModal,
- CloseIcon,
ContentSaveOutline,
FolderOutline,
FolderStarOutline,
@@ -250,6 +236,17 @@ export default {
type: Boolean,
default: false,
},
+
+ /**
+ * Search and Columns sidebar, forwarded to `CnIndexPage` (manifest
+ * `config.sidebar`), as on the other index pages. Its search is the
+ * page's free-text search: the list sends it as `_search`, and
+ * `onListSearch` hands it to the facet counts.
+ */
+ sidebar: {
+ type: Object,
+ default: null,
+ },
},
/**
@@ -268,6 +265,7 @@ export default {
showSaveViewModal: false,
savingView: false,
searchDebounceTimer: null,
+ listRemount: 0,
}
},
@@ -299,30 +297,19 @@ export default {
return buildFacetDimensionSchema(DIMENSION_LABELS)
},
- /**
- * @return {string} The current free-text search term for this schema.
- * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facets-combine-with-free-text-search
- */
- searchValue() {
- return this.facetStore[this.schema].search
- },
-
/**
* Bounded `{ _ids: [...] }` narrowing filter for `CnIndexPage`'s
* self-fetch object list, sourced from the last facets response's
- * `_meta.matchedObjectIds`. Empty object (no narrowing) when no facet
- * filter or search term is active. `facetNarrowingFilter` says why the
- * key is `_ids` and what is sent when nothing matched.
+ * `_meta.matchedObjectIds`, while a GEMMA facet has a value. A search
+ * alone does not narrow by id: the list searches itself (`_search`).
+ * `listNarrowingFilter` says why.
*
* @return {object} The `CnIndexPage` `filter` prop value.
* @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-counts-reflect-the-currently-filtered-set-not-the-unfiltered-universe
*/
listFilter() {
- if (!this.facetStore.hasActiveFilterOrSearchFor(this.schema)) {
- return {}
- }
-
- return facetNarrowingFilter(
+ return listNarrowingFilter(
+ this.facetStore[this.schema].activeFilters,
this.facetStore.matchedObjectIdsFor(this.schema),
)
},
@@ -332,18 +319,22 @@ export default {
* watcher on the `filter` prop (only route/quick-filter/tab changes
* trigger an internal refetch), so this view forces a clean remount —
* and therefore a fresh `onMounted()` fetch with the current
- * `listFilter` — whenever the narrowing id set changes.
+ * `listFilter` — whenever the narrowing id set changes, and when a
+ * saved view put its search term in the route (`listRemount`).
*
- * @return {string} A key that changes exactly when `listFilter` does.
+ * @return {string} A key that changes when `listFilter` or `listRemount` does.
* @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-sidebar-ui-on-the-module-and-dienst-index-pages
*/
indexPageKey() {
- return `${this.schema}-${JSON.stringify(this.listFilter)}`
+ return `${this.schema}-${this.listRemount}-${JSON.stringify(this.listFilter)}`
},
},
created() {
this.facetStore.setFiltersFromQuery(this.schema, this.$route.query)
+ // The list restores its search from `_search` in the route; the facet
+ // counts follow the same term.
+ this.facetStore.setSearch(this.schema, routeSearch(this.$route.query))
},
/**
@@ -403,11 +394,15 @@ export default {
}
})
+ // The search term lives in the list's own `_search`, not in `_gf_search`.
+ const facetQuery = this.facetStore.filtersToQuery(this.schema)
+ delete facetQuery._gf_search
+
const nextQuery = {
...currentQuery,
- ...this.facetStore.filtersToQuery(this.schema),
+ ...facetQuery,
}
- this.$router.replace({ query: nextQuery }).catch(() => {
+ return this.$router.replace({ query: nextQuery }).catch(() => {
// NavigationDuplicated — same query, no-op.
})
},
@@ -438,18 +433,18 @@ export default {
},
/**
- * Debounced search-box handler.
+ * The list's search changed (sidebar Search tab): the list has
+ * already searched itself; the facet counts follow, debounced.
*
* @param {string} value The new search term.
* @return {void}
* @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facets-combine-with-free-text-search
*/
- onSearchInput(value) {
+ onListSearch(value) {
this.facetStore.setSearch(this.schema, value)
clearTimeout(this.searchDebounceTimer)
this.searchDebounceTimer = setTimeout(
() => {
- this.syncUrl()
this.refetch()
},
value === '' ? 0 : SEARCH_DEBOUNCE_MS,
@@ -464,9 +459,21 @@ export default {
*
* @spec openspec/specs/gemma-faceted-search/spec.md#requirement-a-facet-selection-can-be-saved-as-a-view
*/
- applySavedView(view) {
+ async applySavedView(view) {
this.facetStore.applyView(this.schema, view)
- this.syncUrl()
+ const search = this.facetStore[this.schema].search.trim()
+ const query = { ...this.$route.query }
+ if (search === '') {
+ delete query._search
+ } else {
+ query._search = search
+ }
+ await this.$router.replace({ query }).catch(() => {
+ // NavigationDuplicated — same query, no-op.
+ })
+ await this.syncUrl()
+ // Remount the list so it reads the view's search from the route.
+ this.listRemount++
this.refetch()
},
@@ -520,11 +527,6 @@ export default {
padding: calc(var(--default-grid-baseline, 8px) * 2);
}
-.faceted-catalog-index__search {
- max-width: 400px;
- flex: 1;
-}
-
.faceted-catalog-index__body {
display: flex;
align-items: flex-start;
From 388181a349b11f4e308890342c37fdd62fd82af8 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 13:41:30 +0200
Subject: [PATCH 018/124] docs(openspec): name the renamed module-version test
in the cmdb-export-import spec
The fragment test was renamed to testTheMergedModuleIsVersion039WithTheExternalIds
when the module schema moved to 0.3.9; the live spec still named the 038 test.
The archived change keeps its historical name.
Co-Authored-By: Claude Opus 5.5
---
openspec/specs/cmdb-export-import/spec.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/openspec/specs/cmdb-export-import/spec.md b/openspec/specs/cmdb-export-import/spec.md
index 927da9cfb..c521964eb 100644
--- a/openspec/specs/cmdb-export-import/spec.md
+++ b/openspec/specs/cmdb-export-import/spec.md
@@ -295,7 +295,7 @@ For each row the service SHALL compute `externalKey` = `topdesk:
Date: Mon, 12 Oct 2026 14:26:04 +0200
Subject: [PATCH 019/124] fix(cmdb-import): do not cache a trash listing that
failed to load
trash() set the cached trash to empty before it read the listing, so a
listing that threw failed its row and left every later row of the run
matching against an empty trash: a soft-deleted module that returned was
created a second time. The cache is now set only once the listing was
read; the next row reads it again. The missing-archive-services path
keeps caching the empty trash, since a retry cannot succeed there.
Mutation-checked: testAFailedTrashReadIsNotCachedForTheNextRow fails on
the previous trash() (outcomes and read count) and passes on this one.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/CmdbExportImportService.php | 7 +++-
.../Service/CmdbExportImportServiceTest.php | 41 +++++++++++++++++++
2 files changed, 46 insertions(+), 2 deletions(-)
diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php
index 3c98a3f1e..5c55e2a65 100644
--- a/lib/Service/CmdbExportImportService.php
+++ b/lib/Service/CmdbExportImportService.php
@@ -2110,7 +2110,8 @@ private function restore(string $uuid): mixed {
* modules and usages are kept. Its objects carry no archive or trash
* marker; everything in it is deleted. Without the archive services (a
* `keep` import on an OpenRegister without them) the trash cannot be read:
- * nothing in it is matched, and the log says so.
+ * nothing in it is matched, and the log says so. A listing that throws is
+ * not cached, so the next row reads it again.
*
* @return array{module: array, usage: array} Schema key => uuid => entity.
*
@@ -2121,14 +2122,16 @@ private function trash(): array {
return $this->trash;
}
- $this->trash = ['module' => [], 'usage' => []];
try {
$services = $this->archiveServices();
} catch (CmdbImportException $e) {
$this->logger->warning('CmdbExportImportService: OpenRegister trash is not readable; soft-deleted modules and usages are not matched');
+ $this->trash = ['module' => [], 'usage' => []];
return $this->trash;
}
+ // Cached only once the listing was read: a listing that fails fails its row, and the
+ // next row reads it again instead of matching against an empty trash and duplicating.
$coordinates = $this->coordinates();
$deleted = $services['mapper']->findDeletedAcrossAllMagicTables(limit: null, offset: null, _rbac: false, _multitenancy: false);
$this->trash = self::registerTrash(deleted: $deleted, coordinates: $coordinates);
diff --git a/tests/Unit/Service/CmdbExportImportServiceTest.php b/tests/Unit/Service/CmdbExportImportServiceTest.php
index 7ac56ddc6..0a5d66b94 100644
--- a/tests/Unit/Service/CmdbExportImportServiceTest.php
+++ b/tests/Unit/Service/CmdbExportImportServiceTest.php
@@ -119,6 +119,13 @@ class CmdbExportImportServiceTest extends TestCase {
*/
private int $trashReads = 0;
+ /**
+ * How many of the next trash reads throw.
+ *
+ * @var int
+ */
+ private int $trashFailures = 0;
+
/**
* Whether the container knows OpenRegister's archive handler and object mapper.
*
@@ -254,6 +261,7 @@ protected function setUp(): void {
$this->transitions = [];
$this->beforeTransition = null;
$this->trashReads = 0;
+ $this->trashFailures = 0;
$this->archiveServicesAvailable = true;
$this->contacts = [];
$this->addressBooks = [];
@@ -719,6 +727,11 @@ public function storedEntity(string $uuid): ObjectEntityInterface {
public function trashListing(bool $rbac, bool $multitenancy): array {
$this->noteScope(method: 'findDeletedAcrossAllMagicTables', rbac: $rbac, multitenancy: $multitenancy);
$this->trashReads++;
+ if ($this->trashFailures > 0) {
+ $this->trashFailures--;
+ throw new RuntimeException('trash listing failed');
+ }
+
$listed = [];
foreach ($this->states as $schema => $states) {
foreach ($states as $uuid => $state) {
@@ -2227,6 +2240,34 @@ public function testADeletedApplicationOnTheArchiveSheetIsRestoredAndArchived():
$this->assertSame(1, $this->trashReads, 'the trash is read once per import, not per row');
}//end testADeletedApplicationOnTheArchiveSheetIsRestoredAndArchived()
+ /**
+ * A trash listing that throws fails its row and is read again by the next row, which restores instead of duplicating.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
+ */
+ public function testAFailedTrashReadIsNotCachedForTheNextRow(): void {
+ $this->importOneSevenAndEight();
+ $deleted = ['deleted' => ['deletedBy' => 'admin', 'deletedAt' => '2026-10-02T00:00:00+00:00']];
+ foreach (['7', '8'] as $appId) {
+ $module = $this->moduleOf(appId: $appId);
+ $this->states[self::MODULE][$module] = $deleted;
+ $this->states[self::USAGE][$this->usageOf(moduleUuid: $module)] = $deleted;
+ }
+
+ $counts = [count($this->store[self::MODULE]), count($this->store[self::USAGE])];
+ $this->trashReads = 0;
+ $this->trashFailures = 1;
+
+ $rows = [$this->row(appId: '8', row: 2), $this->row(appId: '7', row: 3, sheet: 'Onbeh Applicaties CMDB')];
+ $report = $this->service(reader: $this->rowsReader(rows: $rows))->import(path: '', options: ['municipalityUuid' => 'muni-1', 'missingRecords' => 'keep']);
+
+ $this->assertSame(['failed', 'restored'], array_column($report['rows'], 'outcome'));
+ $this->assertSame(2, $this->trashReads, 'the failed read is not cached');
+ $this->assertSame($counts, [count($this->store[self::MODULE]), count($this->store[self::USAGE])], 'no second module or usage');
+ }//end testAFailedTrashReadIsNotCachedForTheNextRow()
+
/**
* With updateExisting false a returning application is skipped as `exists` and stays archived.
*
From fd49e6159cae709e1b0e5500a955a6489917b905 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 14:26:06 +0200
Subject: [PATCH 020/124] feat(usage): show the business owner by name in the
Applications in use list
The business owner column showed the contact person's uuid. A contact
person in OpenRegister carries only a contactsUid; the name is in
Nextcloud Contacts (the CMDB import puts its owners in the importing
admin's "Stackiq CMDB owners" address book).
GET /api/contact-names?ids[]= gives the names for a signed-in
user: it reads the contact persons with the caller's own OpenRegister
rights and looks each contactsUid up in the address books Nextcloud
gives the caller. A name is only returned to someone who can already
read both the contact person and the contact. There is no public route
and nothing is stored, so the owner stays unreadable for anonymous
visitors, OpenCatalogi and portaliq.
The new contactName cell widget batches the cells of one page into one
request and keeps the uuid when the caller has no name for it.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
appinfo/routes.php | 2 +
l10n/en.js | 4 +-
l10n/en.json | 4 +-
l10n/nl.js | 4 +-
l10n/nl.json | 4 +-
lib/Controller/ContactNamesController.php | 249 ++++++++++++++++++
src/App.vue | 4 +
src/cellWidgets.js | 14 +
src/components/cells/ContactNameCell.vue | 69 +++++
src/manifest.d/usages.json | 2 +-
src/utils/contactNames.js | 106 ++++++++
.../Controller/ContactNamesControllerTest.php | 219 +++++++++++++++
tests/vitest/contactNames.spec.js | 111 ++++++++
13 files changed, 787 insertions(+), 5 deletions(-)
create mode 100644 lib/Controller/ContactNamesController.php
create mode 100644 src/cellWidgets.js
create mode 100644 src/components/cells/ContactNameCell.vue
create mode 100644 src/utils/contactNames.js
create mode 100644 tests/Unit/Controller/ContactNamesControllerTest.php
create mode 100644 tests/vitest/contactNames.spec.js
diff --git a/appinfo/routes.php b/appinfo/routes.php
index 9fc761db5..0eab59877 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -168,6 +168,8 @@
// User Profile endpoint
['name' => 'contactpersonen#getMe', 'url' => '/api/me', 'verb' => 'GET'],
+ // Display names of contact persons from the caller's own address books (owner columns).
+ ['name' => 'contactNames#index', 'url' => '/api/contact-names', 'verb' => 'GET'],
// Contactpersonen Management endpoints
['name' => 'contactpersonen#getContactpersonen', 'url' => '/api/contactpersonen/organisation/{organisationId}', 'verb' => 'GET'],
diff --git a/l10n/en.js b/l10n/en.js
index 8dd84a2f4..a02d4aabf 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1132,7 +1132,9 @@ OC.L10N.register(
"The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.",
"In development since": "In development since",
- "In use since": "In use since"
+ "In use since": "In use since",
+ "Not signed in": "Not signed in",
+ "At most %s contact persons per request": "At most %s contact persons per request"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index 313f968ea..02c4b18b2 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1131,6 +1131,8 @@
"The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.",
"In development since": "In development since",
- "In use since": "In use since"
+ "In use since": "In use since",
+ "Not signed in": "Not signed in",
+ "At most %s contact persons per request": "At most %s contact persons per request"
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index 3151813d6..927c85f6a 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1200,7 +1200,9 @@ OC.L10N.register(
"The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.",
"In development since": "In ontwikkeling sinds",
- "In use since": "In gebruik sinds"
+ "In use since": "In gebruik sinds",
+ "Not signed in": "Niet aangemeld",
+ "At most %s contact persons per request": "Ten hoogste %s contactpersonen per verzoek"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index 680f6f853..d97f06ce7 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1199,6 +1199,8 @@
"The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.",
"In development since": "In ontwikkeling sinds",
- "In use since": "In gebruik sinds"
+ "In use since": "In gebruik sinds",
+ "Not signed in": "Niet aangemeld",
+ "At most %s contact persons per request": "Ten hoogste %s contactpersonen per verzoek"
}
}
diff --git a/lib/Controller/ContactNamesController.php b/lib/Controller/ContactNamesController.php
new file mode 100644
index 000000000..086a6d400
--- /dev/null
+++ b/lib/Controller/ContactNamesController.php
@@ -0,0 +1,249 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Controller;
+
+use OCA\OpenRegister\Contract\ObjectServiceInterface;
+use OCA\Stackiq\Service\SettingsService;
+use OCA\Stackiq\Service\StackiqContactSyncService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IL10N;
+use OCP\IRequest;
+use OCP\IUserSession;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * The display names of contact persons, for a signed-in user.
+ *
+ * A contact person in OpenRegister carries only a `contactsUid`; the name
+ * lives in Nextcloud Contacts (the CMDB import puts its owners in the
+ * importing admin's "Stackiq CMDB owners" address book). This endpoint reads
+ * the contact persons with the caller's own OpenRegister rights, then looks
+ * each `contactsUid` up in the address books Nextcloud gives the caller. A
+ * name is therefore returned only to someone who can already read both the
+ * contact person and the contact. Nothing is stored, and there is no public
+ * route.
+ *
+ * @category Controller
+ * @package OCA\Stackiq\Controller
+ *
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ */
+class ContactNamesController extends Controller {
+
+ /**
+ * The most contact persons one request may name; a list page shows at most this many rows.
+ */
+ public const MAX_IDS = 100;
+
+ /**
+ * Constructor for ContactNamesController.
+ *
+ * @param string $appName The app name.
+ * @param IRequest $request The request object.
+ * @param IUserSession $userSession The user session.
+ * @param IL10N $l10n Translations for the error messages.
+ * @param SettingsService $settingsService Gives the register and contact person schema.
+ * @param StackiqContactSyncService $contactSync Reads Nextcloud Contacts as the signed-in user.
+ * @param ObjectServiceInterface $objectService OpenRegister object access, with the caller's rights.
+ * @param LoggerInterface $logger The logger.
+ */
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private readonly IUserSession $userSession,
+ private readonly IL10N $l10n,
+ private readonly SettingsService $settingsService,
+ private readonly StackiqContactSyncService $contactSync,
+ private readonly ObjectServiceInterface $objectService,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(appName: $appName, request: $request);
+ }//end __construct()
+
+ /**
+ * The display names of the given contact persons.
+ *
+ * API Endpoint: GET /api/contact-names?ids[]=
+ *
+ * Answers `{"names": {"": ""}}`. The per-object
+ * check is OpenRegister's: the contact persons are read with the caller's
+ * RBAC and organisation scope, so one the caller may not read is left out,
+ * as is one whose contact is not in the caller's address books.
+ *
+ * @return JSONResponse 200 with the names, 400 for more than MAX_IDS ids, 401 when not signed in.
+ *
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function index(): JSONResponse {
+ if ($this->userSession->getUser() === null) {
+ return new JSONResponse(
+ ['message' => $this->l10n->t('Not signed in'), 'error' => 'not-authenticated'],
+ Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ $ids = $this->requestedIds();
+ if (count($ids) > self::MAX_IDS) {
+ return new JSONResponse(
+ ['message' => $this->l10n->t('At most %s contact persons per request', [self::MAX_IDS]), 'error' => 'too-many-ids'],
+ Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ if ($ids === [] || $this->contactSync->isAvailable() === false) {
+ return new JSONResponse(['names' => (object)[]], Http::STATUS_OK);
+ }
+
+ $names = [];
+ foreach ($this->contactsUidsOf(ids: $ids) as $uuid => $contactsUid) {
+ $contact = $this->contactSync->findContactByUid(uid: $contactsUid);
+ $name = self::firstValue(value: ($contact['FN'] ?? ''));
+ if ($name !== '') {
+ $names[$uuid] = $name;
+ }
+ }
+
+ return new JSONResponse(['names' => (object)$names], Http::STATUS_OK);
+ }//end index()
+
+ /**
+ * The distinct, well-formed uuids in the `ids` query parameter, lower case.
+ *
+ * @return array
+ */
+ private function requestedIds(): array {
+ $raw = $this->request->getParam('ids', []);
+ if (is_string($raw) === true) {
+ $raw = [$raw];
+ }
+
+ if (is_array($raw) === false) {
+ return [];
+ }
+
+ $ids = [];
+ foreach ($raw as $id) {
+ if (is_string($id) === true && preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $id) === 1) {
+ $ids[strtolower($id)] = true;
+ }
+ }
+
+ return array_keys($ids);
+ }//end requestedIds()
+
+ /**
+ * The `contactsUid` of each requested contact person the caller may read.
+ *
+ * Fails closed: without a configured register and contact person schema,
+ * or when OpenRegister cannot be read, no contact person is resolved.
+ *
+ * @param array $ids Contact person uuids, lower case.
+ *
+ * @return array uuid => contactsUid.
+ */
+ private function contactsUidsOf(array $ids): array {
+ $config = $this->settingsService->getVoorzieningenConfig();
+ $register = ($config['register'] ?? null);
+ $schema = ($config[SettingsService::LEGACY_SCHEMA_KEY['contactPerson']] ?? null);
+ if (empty($register) === true || empty($schema) === true) {
+ $this->logger->warning('[ContactNames] The register or the contact person schema is not configured; no names are given');
+ return [];
+ }
+
+ try {
+ // RBAC and multitenancy stay at their defaults: the caller's own rights.
+ $result = $this->objectService->searchObjectsPaginated(
+ query: [
+ '@self' => ['register' => (int)$register, 'schema' => (int)$schema],
+ '_limit' => self::MAX_IDS,
+ ],
+ ids: $ids
+ );
+ } catch (Throwable $e) {
+ $this->logger->warning('[ContactNames] The contact persons could not be read', ['exception' => $e]);
+ return [];
+ }
+
+ $uids = [];
+ foreach (($result['results'] ?? []) as $entry) {
+ $data = self::objectData(entry: $entry);
+ $uuid = strtolower((string)($data['id'] ?? ($data['@self']['id'] ?? '')));
+ $contactsUid = trim((string)($data['contactsUid'] ?? ''));
+ // The ids filter is OpenRegister's; a record it did not ask for is never named.
+ if ($contactsUid !== '' && in_array($uuid, $ids, true) === true) {
+ $uids[$uuid] = $contactsUid;
+ }
+ }
+
+ return $uids;
+ }//end contactsUidsOf()
+
+ /**
+ * The data of one search result, an entity or an array.
+ *
+ * @param mixed $entry The search result.
+ *
+ * @return array
+ */
+ private static function objectData(mixed $entry): array {
+ if (is_array($entry) === true) {
+ return $entry;
+ }
+
+ if (is_object($entry) === true && method_exists($entry, 'jsonSerialize') === true) {
+ $data = $entry->jsonSerialize();
+ if (is_array($data) === true) {
+ return $data;
+ }
+ }
+
+ return [];
+ }//end objectData()
+
+ /**
+ * The first value of a vCard property, which the contacts manager may give as a list.
+ *
+ * @param mixed $value The property value.
+ *
+ * @return string
+ */
+ private static function firstValue(mixed $value): string {
+ if (is_array($value) === true) {
+ $value = (reset($value) ?? '');
+ }
+
+ if (is_scalar($value) === false) {
+ return '';
+ }
+
+ return trim((string)$value);
+ }//end firstValue()
+}//end class
diff --git a/src/App.vue b/src/App.vue
index 89ee4fb9e..c7db8a038 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -21,6 +21,7 @@
:manifest="manifest"
:customComponents="customComponents"
:formatters="formatters"
+ :cellWidgets="cellWidgets"
:registry="registry"
:pageTypes="pageTypes"
appId="stackiq"
@@ -75,6 +76,7 @@ import { reactive } from 'vue'
import OrganisationSwitcher from './components/organisations/OrganisationSwitcher.vue'
import Dialogs from './dialogs/Dialogs.vue'
import Modals from './modals/Modals.vue'
+import appCellWidgets from './cellWidgets.js'
import { setActiveOrganisationUuid } from './composables/orClient.js'
import appFormatters from './formatters.js'
import { settingsStore } from './store/store.js'
@@ -152,6 +154,8 @@ export default {
return {
// App cell formatters for manifest columns (`columns[].formatter`).
formatters: appFormatters,
+ // App cell widgets for manifest columns (`columns[].widget`).
+ cellWidgets: appCellWidgets,
objectSidebarState: reactive({
active: false,
open: true,
diff --git a/src/cellWidgets.js b/src/cellWidgets.js
new file mode 100644
index 000000000..de491599a
--- /dev/null
+++ b/src/cellWidgets.js
@@ -0,0 +1,14 @@
+// SPDX-License-Identifier: EUPL-1.2
+// Copyright (C) 2026 Conduction B.V.
+//
+// App cell widgets, handed to CnAppRoot as `cellWidgets` and resolved by id
+// from a manifest column's `widget`. The library's own widgets (badge,
+// fkResolve, link, avatar, date) are handled before this registry, so an id
+// here must not equal one of theirs.
+
+import ContactNameCell from './components/cells/ContactNameCell.vue'
+
+export default {
+ // The owner column of the usage list (landscape-usage-registration).
+ contactName: ContactNameCell,
+}
diff --git a/src/components/cells/ContactNameCell.vue b/src/components/cells/ContactNameCell.vue
new file mode 100644
index 000000000..06a7ec2f4
--- /dev/null
+++ b/src/components/cells/ContactNameCell.vue
@@ -0,0 +1,69 @@
+
+
+
+ {{
+ name || id
+ }}
+
+
+
diff --git a/src/manifest.d/usages.json b/src/manifest.d/usages.json
index 66990ad7d..df2b3d22e 100644
--- a/src/manifest.d/usages.json
+++ b/src/manifest.d/usages.json
@@ -23,7 +23,7 @@
{ "key": "module", "label": "Application", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } },
{ "key": "moduleVersion", "label": "Version", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "moduleVersion", "labelField": "version" } },
"status",
- "businessOwner",
+ { "key": "businessOwner", "label": "Business owner", "widget": "contactName" },
"technicalOwner",
"timeClassification",
"serviceDeskRecordId"
diff --git a/src/utils/contactNames.js b/src/utils/contactNames.js
new file mode 100644
index 000000000..6051639f4
--- /dev/null
+++ b/src/utils/contactNames.js
@@ -0,0 +1,106 @@
+// SPDX-License-Identifier: EUPL-1.2
+// Copyright (C) 2026 Conduction B.V.
+
+/**
+ * Display names of contact persons, for the owner column of the usage list.
+ *
+ * A contact person in OpenRegister carries only a `contactsUid`; its name is
+ * in Nextcloud Contacts. `GET /api/contact-names` gives the names the
+ * signed-in user can read in their own address books. The cells of one page
+ * ask in the same tick, so they are batched into one request, and a name is
+ * asked for once per page load.
+ *
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ */
+
+import axios from '@nextcloud/axios'
+import { generateUrl } from '@nextcloud/router'
+
+/** The endpoint's own limit (`ContactNamesController::MAX_IDS`). */
+export const MAX_IDS_PER_REQUEST = 100
+
+/** uuid -> Promise; null when no name could be read. */
+const cache = new Map()
+
+/** uuid -> { resolve } for the ids of the batch being collected. */
+let pending = new Map()
+
+/**
+ * The contact person uuid a cell value refers to.
+ *
+ * @param {string|object|null} value A uuid, or a related object with `id` or `uuid`.
+ * @return {string} The uuid, or ''.
+ */
+export function contactPersonId(value) {
+ if (typeof value === 'string') {
+ return value.trim()
+ }
+ if (value && typeof value === 'object') {
+ return String(value.id ?? value.uuid ?? '').trim()
+ }
+ return ''
+}
+
+/**
+ * Ask the endpoint for the names of the collected ids, in chunks it accepts.
+ *
+ * @return {Promise}
+ */
+async function flush() {
+ const batch = pending
+ pending = new Map()
+ const ids = [...batch.keys()]
+ for (let start = 0; start < ids.length; start += MAX_IDS_PER_REQUEST) {
+ const chunk = ids.slice(start, start + MAX_IDS_PER_REQUEST)
+ let names = {}
+ try {
+ const { data } = await axios.get(
+ generateUrl('/apps/stackiq/api/contact-names'),
+ { params: { ids: chunk } },
+ )
+ names = data?.names ?? {}
+ } catch {
+ names = {}
+ }
+ chunk.forEach((id) => {
+ const name = typeof names[id] === 'string' ? names[id] : ''
+ batch.get(id).resolve(name !== '' ? name : null)
+ })
+ }
+}
+
+/**
+ * The display name of a contact person, or null when the signed-in user
+ * cannot read one.
+ *
+ * @param {string} id The contact person uuid.
+ * @return {Promise} The name.
+ */
+export function contactName(id) {
+ const key = String(id ?? '').toLowerCase()
+ if (key === '') {
+ return Promise.resolve(null)
+ }
+ if (!cache.has(key)) {
+ cache.set(
+ key,
+ new Promise((resolve) => {
+ if (pending.size === 0) {
+ Promise.resolve().then(flush)
+ }
+ pending.set(key, { resolve })
+ }),
+ )
+ }
+ return cache.get(key)
+}
+
+/**
+ * Forget every name read so far (tests).
+ *
+ * @return {void}
+ */
+export function clearContactNames() {
+ cache.clear()
+ pending = new Map()
+}
diff --git a/tests/Unit/Controller/ContactNamesControllerTest.php b/tests/Unit/Controller/ContactNamesControllerTest.php
new file mode 100644
index 000000000..0860873de
--- /dev/null
+++ b/tests/Unit/Controller/ContactNamesControllerTest.php
@@ -0,0 +1,219 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Tests\Unit\Controller;
+
+use OCA\OpenRegister\Contract\ObjectServiceInterface;
+use OCA\Stackiq\Controller\ContactNamesController;
+use OCA\Stackiq\Service\SettingsService;
+use OCA\Stackiq\Service\StackiqContactSyncService;
+use OCP\IL10N;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+/**
+ * The names endpoint answers only a signed-in user, reads the contact persons
+ * with that user's rights, and names only what it was asked for.
+ *
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ */
+class ContactNamesControllerTest extends TestCase {
+
+ private const A = '00000000-0000-0000-0000-00000000000a';
+ private const B = '00000000-0000-0000-0000-00000000000b';
+ private const C = '00000000-0000-0000-0000-00000000000c';
+
+ /** @var ObjectServiceInterface&MockObject */
+ private ObjectServiceInterface&MockObject $objectService;
+
+ /** @var StackiqContactSyncService&MockObject */
+ private StackiqContactSyncService&MockObject $contactSync;
+
+ /** @var IUserSession&MockObject */
+ private IUserSession&MockObject $userSession;
+
+ /** @var SettingsService&MockObject */
+ private SettingsService&MockObject $settingsService;
+
+ /**
+ * Signed in, Contacts available, register and schema configured.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ $this->objectService = $this->createMock(ObjectServiceInterface::class);
+ $this->contactSync = $this->createMock(StackiqContactSyncService::class);
+ $this->contactSync->method('isAvailable')->willReturn(true);
+ $this->userSession = $this->createMock(IUserSession::class);
+ $this->userSession->method('getUser')->willReturn($this->createMock(IUser::class));
+ $this->settingsService = $this->createMock(SettingsService::class);
+ $this->settingsService->method('getVoorzieningenConfig')->willReturn(
+ ['register' => '20', 'contactpersoon_schema' => '7']
+ );
+ }//end setUp()
+
+ /**
+ * A controller for a request with these ids.
+ *
+ * @param mixed $ids The `ids` query parameter.
+ *
+ * @return ContactNamesController
+ */
+ private function controller(mixed $ids): ContactNamesController {
+ $request = $this->createMock(IRequest::class);
+ $request->method('getParam')->willReturnCallback(
+ static fn (string $key, mixed $default = null): mixed => ($key === 'ids' ? $ids : $default)
+ );
+ $l10n = $this->createMock(IL10N::class);
+ $l10n->method('t')->willReturnArgument(0);
+
+ return new ContactNamesController(
+ appName: 'stackiq',
+ request: $request,
+ userSession: $this->userSession,
+ l10n: $l10n,
+ settingsService: $this->settingsService,
+ contactSync: $this->contactSync,
+ objectService: $this->objectService,
+ logger: $this->createMock(LoggerInterface::class)
+ );
+ }//end controller()
+
+ /**
+ * A contact person's name comes from its contact, read with the caller's rights.
+ *
+ * @return void
+ */
+ public function testNamesTheReadableContactPersons(): void {
+ $this->objectService->expects($this->once())
+ ->method('searchObjectsPaginated')
+ ->with(
+ $this->callback(static fn (array $query): bool => $query['@self'] === ['register' => 20, 'schema' => 7]),
+ // The caller's own RBAC and organisation scope.
+ true,
+ true,
+ false,
+ [self::A, self::B]
+ )
+ ->willReturn(
+ [
+ 'results' => [
+ ['id' => self::A, 'contactsUid' => 'uid-a'],
+ ['id' => self::B, 'contactsUid' => 'uid-b'],
+ ],
+ ]
+ );
+ $this->contactSync->method('findContactByUid')->willReturnMap(
+ [
+ ['uid-a', ['UID' => 'uid-a', 'FN' => 'Teamleider Applicatiebeheer']],
+ ['uid-b', null],
+ ]
+ );
+
+ $response = $this->controller(ids: [self::A, strtoupper(self::B), 'not-a-uuid', self::A])->index();
+
+ $this->assertSame(200, $response->getStatus());
+ $this->assertEquals(['names' => (object)[self::A => 'Teamleider Applicatiebeheer']], $response->getData());
+ }//end testNamesTheReadableContactPersons()
+
+ /**
+ * A record OpenRegister returns but that was not asked for is never named.
+ *
+ * @return void
+ */
+ public function testNamesOnlyTheRequestedIds(): void {
+ $this->objectService->method('searchObjectsPaginated')->willReturn(
+ ['results' => [['id' => self::C, 'contactsUid' => 'uid-c']]]
+ );
+ $this->contactSync->expects($this->never())->method('findContactByUid');
+
+ $response = $this->controller(ids: [self::A])->index();
+
+ $this->assertEquals(['names' => (object)[]], $response->getData());
+ }//end testNamesOnlyTheRequestedIds()
+
+ /**
+ * Without a signed-in user nothing is read.
+ *
+ * @return void
+ */
+ public function testRefusesAnonymousCallers(): void {
+ $this->userSession = $this->createMock(IUserSession::class);
+ $this->userSession->method('getUser')->willReturn(null);
+ $this->objectService->expects($this->never())->method('searchObjectsPaginated');
+
+ $response = $this->controller(ids: [self::A])->index();
+
+ $this->assertSame(401, $response->getStatus());
+ $this->assertSame('not-authenticated', $response->getData()['error']);
+ }//end testRefusesAnonymousCallers()
+
+ /**
+ * More ids than one list page shows are refused.
+ *
+ * @return void
+ */
+ public function testRefusesMoreThanOnePageOfIds(): void {
+ $ids = [];
+ for ($i = 0; $i <= ContactNamesController::MAX_IDS; $i++) {
+ $ids[] = sprintf('00000000-0000-0000-0000-%012d', $i);
+ }
+
+ $this->objectService->expects($this->never())->method('searchObjectsPaginated');
+
+ $response = $this->controller(ids: $ids)->index();
+
+ $this->assertSame(400, $response->getStatus());
+ }//end testRefusesMoreThanOnePageOfIds()
+
+ /**
+ * Without a configured contact person schema no contact person is resolved.
+ *
+ * @return void
+ */
+ public function testFailsClosedWithoutTheContactPersonSchema(): void {
+ $this->settingsService = $this->createMock(SettingsService::class);
+ $this->settingsService->method('getVoorzieningenConfig')->willReturn(['register' => '20']);
+ $this->objectService->expects($this->never())->method('searchObjectsPaginated');
+
+ $response = $this->controller(ids: [self::A])->index();
+
+ $this->assertSame(200, $response->getStatus());
+ $this->assertEquals(['names' => (object)[]], $response->getData());
+ }//end testFailsClosedWithoutTheContactPersonSchema()
+
+ /**
+ * An OpenRegister failure gives no names, not a 500.
+ *
+ * @return void
+ */
+ public function testAnUnreadableRegisterGivesNoNames(): void {
+ $this->objectService->method('searchObjectsPaginated')->willThrowException(new \RuntimeException('down'));
+
+ $response = $this->controller(ids: self::A)->index();
+
+ $this->assertSame(200, $response->getStatus());
+ $this->assertEquals(['names' => (object)[]], $response->getData());
+ }//end testAnUnreadableRegisterGivesNoNames()
+}//end class
diff --git a/tests/vitest/contactNames.spec.js b/tests/vitest/contactNames.spec.js
new file mode 100644
index 000000000..f301c045f
--- /dev/null
+++ b/tests/vitest/contactNames.spec.js
@@ -0,0 +1,111 @@
+// @vitest-environment jsdom
+
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * The owner column of the usage list: contact persons shown by the name of
+ * their Nextcloud contact, read in one batched request, with the uuid kept
+ * when the signed-in user has no name for it.
+ *
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ */
+
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const http = vi.hoisted(() => ({ get: vi.fn() }))
+
+vi.mock('@nextcloud/axios', () => ({ default: http }))
+vi.mock('@nextcloud/router', () => ({ generateUrl: (url) => url }))
+
+const { clearContactNames, contactName, contactPersonId, MAX_IDS_PER_REQUEST } =
+ await import('../../src/utils/contactNames.js')
+const ContactNameCell = (
+ await import('../../src/components/cells/ContactNameCell.vue')
+).default
+const cellWidgets = (await import('../../src/cellWidgets.js')).default
+
+const A = '00000000-0000-0000-0000-00000000000a'
+const B = '00000000-0000-0000-0000-00000000000b'
+
+describe('contactNames', () => {
+ beforeEach(() => {
+ clearContactNames()
+ http.get.mockReset()
+ })
+
+ it('asks once for every id of the same tick and answers each', async () => {
+ http.get.mockResolvedValue({ data: { names: { [A]: 'Teamleider' } } })
+
+ const [a, b, again] = await Promise.all([
+ contactName(A),
+ contactName(B),
+ contactName(A),
+ ])
+
+ expect(http.get).toHaveBeenCalledTimes(1)
+ expect(http.get).toHaveBeenCalledWith('/apps/stackiq/api/contact-names', {
+ params: { ids: [A, B] },
+ })
+ expect(a).toBe('Teamleider')
+ expect(again).toBe('Teamleider')
+ expect(b).toBeNull()
+
+ await contactName(A)
+ expect(http.get).toHaveBeenCalledTimes(1)
+ })
+
+ it('splits a batch at the endpoint limit', async () => {
+ http.get.mockResolvedValue({ data: { names: {} } })
+ const ids = Array.from(
+ { length: MAX_IDS_PER_REQUEST + 1 },
+ (_, i) => `00000000-0000-0000-0000-${String(i).padStart(12, '0')}`,
+ )
+
+ await Promise.all(ids.map((id) => contactName(id)))
+
+ expect(http.get).toHaveBeenCalledTimes(2)
+ expect(http.get.mock.calls[0][1].params.ids).toHaveLength(
+ MAX_IDS_PER_REQUEST,
+ )
+ expect(http.get.mock.calls[1][1].params.ids).toHaveLength(1)
+ })
+
+ it('gives no name when the request fails', async () => {
+ http.get.mockRejectedValue(new Error('offline'))
+ expect(await contactName(A)).toBeNull()
+ })
+
+ it('reads the uuid from a string or a related object', () => {
+ expect(contactPersonId(` ${A} `)).toBe(A)
+ expect(contactPersonId({ id: A })).toBe(A)
+ expect(contactPersonId({ uuid: B })).toBe(B)
+ expect(contactPersonId(null)).toBe('')
+ })
+})
+
+describe('ContactNameCell', () => {
+ beforeEach(() => {
+ clearContactNames()
+ http.get.mockReset()
+ })
+
+ it('is registered as the contactName cell widget', () => {
+ expect(cellWidgets.contactName).toBe(ContactNameCell)
+ })
+
+ it('shows the contact name instead of the uuid', async () => {
+ http.get.mockResolvedValue({ data: { names: { [A]: 'Teamleider' } } })
+ const wrapper = mount(ContactNameCell, { props: { value: A } })
+ await flushPromises()
+ expect(wrapper.text()).toBe('Teamleider')
+ })
+
+ it('keeps the uuid when the signed-in user has no name for it', async () => {
+ http.get.mockResolvedValue({ data: { names: {} } })
+ const wrapper = mount(ContactNameCell, { props: { value: B } })
+ await flushPromises()
+ expect(wrapper.text()).toBe(B)
+ })
+})
From 981b2a43b282f73424660716f4a1ff5418dbc361 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 14:29:01 +0200
Subject: [PATCH 021/124] chore(deps): bump @conduction/nextcloud-vue from
2.65.0 to 2.73.1
The release build runs npm ci, so it bundles the version in the lock.
2.73.1 carries nextcloud-vue#1390: the page-size selector of an index
page works again, and the Related panel of a detail page opens the
related object and shows names instead of uuids. The floor in
package.json moves with it, so an install can never resolve below the
version that has those fixes.
The lock also drops "dev": true from five optional platform binaries;
that is the newer npm writing the lock, not a dependency change.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
package-lock.json | 20 ++++----------------
package.json | 2 +-
2 files changed, 5 insertions(+), 17 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 1e1acc619..a7915df86 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,7 +11,7 @@
"dependencies": {
"@babel/core": "^7.22.9",
"@codemirror/lang-json": "^6.0.0",
- "@conduction/nextcloud-vue": "^2.65.0",
+ "@conduction/nextcloud-vue": "^2.73.1",
"@nextcloud/auth": "^2.6.0",
"@nextcloud/axios": "^2.5.0",
"@nextcloud/capabilities": "^1.2.1",
@@ -2165,9 +2165,9 @@
}
},
"node_modules/@conduction/nextcloud-vue": {
- "version": "2.65.0",
- "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.65.0.tgz",
- "integrity": "sha512-TsI4q/VFOXvsPnquc+QEVUgW8sitMY49L1oMB+/sJt2OJUs9eWhywJ7ANmPpw3+OA313G/L3wd5r5ukXFJmikw==",
+ "version": "2.73.1",
+ "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.73.1.tgz",
+ "integrity": "sha512-kD2H0ud6u49Ws6UMH6jgFDsLq7YgHlZysnxmP7FxB2soA6fpXg9Gdii1bt/4m3D1k7KtH4/LC2F14q4pa3KEYA==",
"license": "EUPL-1.2",
"dependencies": {
"@ckpack/vue-color": "^1.6.0",
@@ -5895,7 +5895,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5916,7 +5915,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5937,7 +5935,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5958,7 +5955,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5979,7 +5975,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6000,7 +5995,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6021,7 +6015,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6042,7 +6035,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6063,7 +6055,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6084,7 +6075,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6105,7 +6095,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6126,7 +6115,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
diff --git a/package.json b/package.json
index eacd7d0ed..2c6a1daf5 100644
--- a/package.json
+++ b/package.json
@@ -42,7 +42,7 @@
"dependencies": {
"@babel/core": "^7.22.9",
"@codemirror/lang-json": "^6.0.0",
- "@conduction/nextcloud-vue": "^2.65.0",
+ "@conduction/nextcloud-vue": "^2.73.1",
"@nextcloud/auth": "^2.6.0",
"@nextcloud/axios": "^2.5.0",
"@nextcloud/capabilities": "^1.2.1",
From f196420b8fb87b64d978ce949cbd72375e304124 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 14:38:09 +0200
Subject: [PATCH 022/124] docs(cmdb-import): point the two new @spec tags at
the archived cmdb-export-import change
openspec/changes/cmdb-export-import was archived to
openspec/changes/archive/2026-10-05-cmdb-export-import, so the tags this
change added on addRunWarnings() and the module write path named a file
that no longer exists. The older tags in lib/ share the stale path and are
left for a separate sweep.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/CmdbExportImportService.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php
index 5c55e2a65..89a51526b 100644
--- a/lib/Service/CmdbExportImportService.php
+++ b/lib/Service/CmdbExportImportService.php
@@ -620,7 +620,7 @@ private function runImport(string $path, array $options, string $startedAt): arr
*
* @return void
*
- * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ * @spec openspec/changes/archive/2026-10-05-cmdb-export-import/tasks.md#task-7
* @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
*/
private function addRunWarnings(CmdbImportReport $report, array $municipality, array $archive, string $missingRecords): void {
@@ -1865,7 +1865,7 @@ private function search(string $schemaKey, array $filters, array $lens): ?object
*
* @return bool
*
- * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ * @spec openspec/changes/archive/2026-10-05-cmdb-export-import/tasks.md#task-5
*/
private static function matchesFilters(object $entity, array $filters): bool {
$data = $entity->getObject();
From f6ae270882b12ceb6f1c03f70376dd99f19770b8 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 14:41:15 +0200
Subject: [PATCH 023/124] fix(cmdb-import): stop telling the admin missing
applications stay and to split the export
With missingRecords archive as the default, two texts were now wrong:
- The section's notes said applications missing from a newer export "are
left as they are"; they are archived or deleted unless the admin keeps
them.
- The TOO_MANY_ROWS, FILE_TOO_LARGE and WORKBOOK_TOO_LARGE hints, in the
section and in the feature docs, advised splitting the export and
importing the parts one after the other. An archiving import of one part
treats the applications of the other parts as missing, so each part
archived or deleted the others. The hints now say to import every part
with "Keep them as they are".
Dutch follows in nl.js and nl.json. Checked with jest (cmdbImport.spec.js,
CmdbImport.spec.js: 86 passed), test:l10n and check:l10n-js; eslint could
not run locally (Node lacks module.findPackageJSON).
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 6 +++---
l10n/en.js | 10 +++++-----
l10n/en.json | 10 +++++-----
l10n/nl.js | 10 +++++-----
l10n/nl.json | 10 +++++-----
src/utils/cmdbImport.js | 8 ++++----
src/utils/cmdbImport.spec.js | 2 +-
src/views/settings/sections/CmdbImport.vue | 2 +-
8 files changed, 29 insertions(+), 29 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index d4077f959..184fc6932 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -344,14 +344,14 @@ and the section shows the reason and the error code.
| Error code | What it means | What to do |
|---|---|---|
| `NOT_XLSX` | The file is not an Excel workbook: wrong extension, or the content is not an `.xlsx` package. | Save the export as Excel workbook (`.xlsx`). |
-| `FILE_TOO_LARGE` | The file is larger than the upload limit (10 MB by default). The message names the limit in force. | Remove sheets the import does not read, or split the export. |
+| `FILE_TOO_LARGE` | The file is larger than the upload limit (10 MB by default). The message names the limit in force. | Remove sheets the import does not read. Or split the export and import every part with **Keep them as they are**: an import that archives treats the applications of the other parts as missing. |
| `NO_FILE_UPLOADED` | No file arrived. | Choose the file again. |
| `MUNICIPALITY_REQUIRED` | No municipality was chosen. | Pick or type a municipality. |
| `MUNICIPALITY_INVALID` | The chosen organisation does not exist or is not of type Municipality. | Pick an organisation of type Municipality, or type a new name. |
| `NO_SOURCE_SHEET` | Neither `Onbeh Applicaties CMDB` nor `Beheerde Applicaties CMDB` is in the workbook. | Check the sheet names; they must match exactly. |
| `MISSING_COLUMN` | A present CMDB sheet has no `APPID` or `Applicatie Naam` column. The message names the sheet and the column. | Add the column to that sheet. |
-| `TOO_MANY_ROWS` | A CMDB sheet has more rows with data than the row limit (10,000 by default). The message names the sheet and the limit. | Split the export and import the parts one after the other. |
-| `WORKBOOK_TOO_LARGE` | Unpacked, the workbook is larger than the import reads: 50 MB in all, 10 MB for one sheet the import reads or for the table of texts the sheets share, or more than 200,000 different texts, by default. Sheets the import does not read may be larger than 10 MB. An `.xlsx` is a compressed package, so a file of a few MB can unpack to far more. The message names the limit, and for a sheet the sheet and its unpacked size. | For a sheet that is too large: split its rows over two copies of the export and import them one after the other. For a workbook that is too large in all: delete the sheets the import does not read from a copy and import that copy. |
+| `TOO_MANY_ROWS` | A CMDB sheet has more rows with data than the row limit (10,000 by default). The message names the sheet and the limit. | Split the export and import the parts one after the other with **Keep them as they are**: an import that archives treats the applications of the other parts as missing. |
+| `WORKBOOK_TOO_LARGE` | Unpacked, the workbook is larger than the import reads: 50 MB in all, 10 MB for one sheet the import reads or for the table of texts the sheets share, or more than 200,000 different texts, by default. Sheets the import does not read may be larger than 10 MB. An `.xlsx` is a compressed package, so a file of a few MB can unpack to far more. The message names the limit, and for a sheet the sheet and its unpacked size. | For a sheet that is too large: split its rows over two copies of the export and import both with **Keep them as they are**. For a workbook that is too large in all: delete the sheets the import does not read from a copy and import that copy. |
| `MUNICIPALITY_AMBIGUOUS` | More than one municipality has the typed name. The import does not guess which one. | Pick the municipality from the list instead of typing its name. |
| `IMPORT_IN_PROGRESS` | Another CMDB import is running. Only one import runs at a time. | Wait until it has finished and try again. |
| `FIELD_INVALID` | A form field of the request has a value the import does not accept, for example an `updateExisting` or `publish` that is neither `true` nor `false`. The message names the field. | Not reachable from the section; reported for API callers. |
diff --git a/l10n/en.js b/l10n/en.js
index 8e95ef029..b424aa790 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -982,7 +982,7 @@ OC.L10N.register(
"Import for {name} cancelled after {read} rows.": "Import for {name} cancelled after {read} rows.",
"Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.",
"Import progress": "Import progress",
- "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.",
+ "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.",
"Importing the export…": "Importing the export…",
"Importing…": "Importing…",
"APPID": "APPID",
@@ -999,7 +999,7 @@ OC.L10N.register(
"Reasons and warnings": "Reasons and warnings",
"Records missing from the export can only be kept.": "Records missing from the export can only be kept.",
"Reload the page and try again.": "Reload the page and try again.",
- "Remove sheets the import does not read, or split the export, and try again.": "Remove sheets the import does not read, or split the export, and try again.",
+ "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".": "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".",
"Row": "Row",
"Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.",
"Rows": "Rows",
@@ -1100,8 +1100,8 @@ OC.L10N.register(
"The server could not store the uploaded file. The details are in the Nextcloud log.": "The server could not store the uploaded file. The details are in the Nextcloud log.",
"The file is larger than {size}, the most the import accepts.": "The file is larger than {size}, the most the import accepts.",
"The file is larger than the server accepts.": "The file is larger than the server accepts.",
- "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.",
- "Split the export and import the parts one after the other.": "Split the export and import the parts one after the other.",
+ "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".",
+ "Split the export and import the parts one after the other with \"Keep them as they are\".": "Split the export and import the parts one after the other with \"Keep them as they are\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.",
"The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.",
"The page got no answer from the import.": "The page got no answer from the import.",
@@ -1139,7 +1139,7 @@ OC.L10N.register(
"conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: the application with this import key is used by another organisation, so it is not changed",
"When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.",
"An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.",
- "Split the rows over two copies of the export and import them one after the other.": "Split the rows over two copies of the export and import them one after the other.",
+ "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.",
"Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.",
diff --git a/l10n/en.json b/l10n/en.json
index 0d85cfbaf..c4e679026 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -981,7 +981,7 @@
"Import for {name} cancelled after {read} rows.": "Import for {name} cancelled after {read} rows.",
"Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.",
"Import progress": "Import progress",
- "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.",
+ "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.",
"Importing the export…": "Importing the export…",
"Importing…": "Importing…",
"APPID": "APPID",
@@ -998,7 +998,7 @@
"Reasons and warnings": "Reasons and warnings",
"Records missing from the export can only be kept.": "Records missing from the export can only be kept.",
"Reload the page and try again.": "Reload the page and try again.",
- "Remove sheets the import does not read, or split the export, and try again.": "Remove sheets the import does not read, or split the export, and try again.",
+ "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".": "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".",
"Row": "Row",
"Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.",
"Rows": "Rows",
@@ -1099,8 +1099,8 @@
"The server could not store the uploaded file. The details are in the Nextcloud log.": "The server could not store the uploaded file. The details are in the Nextcloud log.",
"The file is larger than {size}, the most the import accepts.": "The file is larger than {size}, the most the import accepts.",
"The file is larger than the server accepts.": "The file is larger than the server accepts.",
- "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.",
- "Split the export and import the parts one after the other.": "Split the export and import the parts one after the other.",
+ "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".",
+ "Split the export and import the parts one after the other with \"Keep them as they are\".": "Split the export and import the parts one after the other with \"Keep them as they are\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.",
"The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.",
"The page got no answer from the import.": "The page got no answer from the import.",
@@ -1138,7 +1138,7 @@
"conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: the application with this import key is used by another organisation, so it is not changed",
"When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.",
"An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.",
- "Split the rows over two copies of the export and import them one after the other.": "Split the rows over two copies of the export and import them one after the other.",
+ "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.",
"Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.",
diff --git a/l10n/nl.js b/l10n/nl.js
index b03118333..b97e21dd2 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1050,7 +1050,7 @@ OC.L10N.register(
"Import for {name} cancelled after {read} rows.": "Import voor {name} geannuleerd na {read} rijen.",
"Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import voor {name} voltooid. {read} rijen gelezen: {created} aangemaakt, {updated} bijgewerkt, {unchanged} ongewijzigd.",
"Import progress": "Voortgang van de import",
- "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, blijven zoals ze zijn.",
+ "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, worden gearchiveerd of verwijderd, tenzij je kiest ze te laten zoals ze zijn.",
"Importing the export…": "De export wordt geïmporteerd…",
"Importing…": "Importeren…",
"APPID": "APPID",
@@ -1067,7 +1067,7 @@ OC.L10N.register(
"Reasons and warnings": "Redenen en waarschuwingen",
"Records missing from the export can only be kept.": "Records die in de export ontbreken, kunnen alleen worden behouden.",
"Reload the page and try again.": "Laad de pagina opnieuw en probeer het nog eens.",
- "Remove sheets the import does not read, or split the export, and try again.": "Verwijder tabbladen die de import niet leest, of splits de export, en probeer het opnieuw.",
+ "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".": "Verwijder tabbladen die de import niet leest en probeer het opnieuw. Of splits de export en importeer elk deel met \"Laat ze zoals ze zijn\".",
"Row": "Rij",
"Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Rij 1 bevat de kolomnamen. \"APPID\" en \"Applicatie Naam\" zijn verplicht; de volgorde van de kolommen maakt niet uit.",
"Rows": "Rijen",
@@ -1168,8 +1168,8 @@ OC.L10N.register(
"The server could not store the uploaded file. The details are in the Nextcloud log.": "De server kon het geüploade bestand niet opslaan. De details staan in het Nextcloud-logboek.",
"The file is larger than {size}, the most the import accepts.": "Het bestand is groter dan {size}, het maximum dat de import accepteert.",
"The file is larger than the server accepts.": "Het bestand is groter dan de server accepteert.",
- "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar.",
- "Split the export and import the parts one after the other.": "Splits de export en importeer de delen na elkaar.",
+ "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
+ "Split the export and import the parts one after the other with \"Keep them as they are\".": "Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad \"{first}\" of \"{second}\". Standaard mag het bestand hoogstens {size} zijn.",
"The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
"The page got no answer from the import.": "De pagina kreeg geen antwoord van de import.",
@@ -1207,7 +1207,7 @@ OC.L10N.register(
"conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: de applicatie met deze importsleutel wordt door een andere organisatie gebruikt en wordt daarom niet gewijzigd",
"When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "Staat dit aan, dan overschrijft een herhaalde import de naam, de omschrijvingen, het applicatietype, het hostingmodel, het BBN-niveau, de bronvelden en de leverancier van de applicatie, en de status, de uitfaseerdatum en de functioneel eigenaar van het gebruik, met de waarden uit de export. De TIME-classificatie en de interne notitie van het gebruik worden alleen gezet wanneer het gebruik wordt aangemaakt of het veld leeg is, zodat wijzigingen in stackiq blijven staan.",
"An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "Een .xlsx-bestand is gecomprimeerd: een bestand van een paar MB kan uitgepakt veel groter zijn. Er is niets geïmporteerd.",
- "Split the rows over two copies of the export and import them one after the other.": "Verdeel de rijen over twee kopieën van de export en importeer die na elkaar.",
+ "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Verdeel de rijen over twee kopieën van de export en importeer beide met \"Laat ze zoals ze zijn\": een import die archiveert, ziet de applicaties van de andere kopie als ontbrekend.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "Het tabblad \"{sheet}\" is te groot om te importeren: uitgepakt is het {size}, en de import leest van één tabblad hooguit {limit}.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "Het onderdeel {part} van de werkmap is te groot om te importeren: uitgepakt is het {size}, en de import leest van één onderdeel hooguit {limit}.",
"Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen \"{first}\" en \"{second}\"), en importeer die kopie.",
diff --git a/l10n/nl.json b/l10n/nl.json
index 92a16d73e..b5b334fce 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1049,7 +1049,7 @@
"Import for {name} cancelled after {read} rows.": "Import voor {name} geannuleerd na {read} rijen.",
"Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import voor {name} voltooid. {read} rijen gelezen: {created} aangemaakt, {updated} bijgewerkt, {unchanged} ongewijzigd.",
"Import progress": "Voortgang van de import",
- "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, blijven zoals ze zijn.",
+ "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, worden gearchiveerd of verwijderd, tenzij je kiest ze te laten zoals ze zijn.",
"Importing the export…": "De export wordt geïmporteerd…",
"Importing…": "Importeren…",
"APPID": "APPID",
@@ -1066,7 +1066,7 @@
"Reasons and warnings": "Redenen en waarschuwingen",
"Records missing from the export can only be kept.": "Records die in de export ontbreken, kunnen alleen worden behouden.",
"Reload the page and try again.": "Laad de pagina opnieuw en probeer het nog eens.",
- "Remove sheets the import does not read, or split the export, and try again.": "Verwijder tabbladen die de import niet leest, of splits de export, en probeer het opnieuw.",
+ "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".": "Verwijder tabbladen die de import niet leest en probeer het opnieuw. Of splits de export en importeer elk deel met \"Laat ze zoals ze zijn\".",
"Row": "Rij",
"Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Rij 1 bevat de kolomnamen. \"APPID\" en \"Applicatie Naam\" zijn verplicht; de volgorde van de kolommen maakt niet uit.",
"Rows": "Rijen",
@@ -1167,8 +1167,8 @@
"The server could not store the uploaded file. The details are in the Nextcloud log.": "De server kon het geüploade bestand niet opslaan. De details staan in het Nextcloud-logboek.",
"The file is larger than {size}, the most the import accepts.": "Het bestand is groter dan {size}, het maximum dat de import accepteert.",
"The file is larger than the server accepts.": "Het bestand is groter dan de server accepteert.",
- "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar.",
- "Split the export and import the parts one after the other.": "Splits de export en importeer de delen na elkaar.",
+ "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
+ "Split the export and import the parts one after the other with \"Keep them as they are\".": "Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad \"{first}\" of \"{second}\". Standaard mag het bestand hoogstens {size} zijn.",
"The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
"The page got no answer from the import.": "De pagina kreeg geen antwoord van de import.",
@@ -1206,7 +1206,7 @@
"conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: de applicatie met deze importsleutel wordt door een andere organisatie gebruikt en wordt daarom niet gewijzigd",
"When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "Staat dit aan, dan overschrijft een herhaalde import de naam, de omschrijvingen, het applicatietype, het hostingmodel, het BBN-niveau, de bronvelden en de leverancier van de applicatie, en de status, de uitfaseerdatum en de functioneel eigenaar van het gebruik, met de waarden uit de export. De TIME-classificatie en de interne notitie van het gebruik worden alleen gezet wanneer het gebruik wordt aangemaakt of het veld leeg is, zodat wijzigingen in stackiq blijven staan.",
"An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "Een .xlsx-bestand is gecomprimeerd: een bestand van een paar MB kan uitgepakt veel groter zijn. Er is niets geïmporteerd.",
- "Split the rows over two copies of the export and import them one after the other.": "Verdeel de rijen over twee kopieën van de export en importeer die na elkaar.",
+ "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Verdeel de rijen over twee kopieën van de export en importeer beide met \"Laat ze zoals ze zijn\": een import die archiveert, ziet de applicaties van de andere kopie als ontbrekend.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "Het tabblad \"{sheet}\" is te groot om te importeren: uitgepakt is het {size}, en de import leest van één tabblad hooguit {limit}.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "Het onderdeel {part} van de werkmap is te groot om te importeren: uitgepakt is het {size}, en de import leest van één onderdeel hooguit {limit}.",
"Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen \"{first}\" en \"{second}\"), en importeer die kopie.",
diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js
index 64d7df36d..da602f3a4 100644
--- a/src/utils/cmdbImport.js
+++ b/src/utils/cmdbImport.js
@@ -535,7 +535,7 @@ function workbookTooLarge(details) {
+ ' '
+ t(
'stackiq',
- 'Split the rows over two copies of the export and import them one after the other.',
+ 'Split the rows over two copies of the export and import both with "Keep them as they are": an import that archives treats the applications of the other copy as missing.',
)
if (Number(details.maxPartBytes) > 0) {
const sizes = {
@@ -651,7 +651,7 @@ export function errorText(error) {
),
hint: t(
'stackiq',
- 'Remove sheets the import does not read, or split the export, and try again.',
+ 'Remove sheets the import does not read and try again. Or split the export and import every part with "Keep them as they are".',
),
}
case 'MISSING_RECORDS_UNSUPPORTED':
@@ -765,13 +765,13 @@ export function errorText(error) {
Number(details.limit) > 0
? t(
'stackiq',
- 'A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.',
+ 'A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with "Keep them as they are".',
{ limit: Number(details.limit).toLocaleString() },
AS_TEXT,
)
: t(
'stackiq',
- 'Split the export and import the parts one after the other.',
+ 'Split the export and import the parts one after the other with "Keep them as they are".',
),
}
case 'MAPPING_UNAVAILABLE':
diff --git a/src/utils/cmdbImport.spec.js b/src/utils/cmdbImport.spec.js
index 9377f3b59..24126d167 100644
--- a/src/utils/cmdbImport.spec.js
+++ b/src/utils/cmdbImport.spec.js
@@ -413,7 +413,7 @@ describe('errorText', () => {
'The sheet "Beheerde Applicaties CMDB" is too large to import: unpacked it is 14.3 MB, and the import reads at most 10 MB of one sheet.',
)
expect(words.hint).toBe(
- 'An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported. Split the rows over two copies of the export and import them one after the other.',
+ 'An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported. Split the rows over two copies of the export and import both with "Keep them as they are": an import that archives treats the applications of the other copy as missing.',
)
})
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index 00af68892..6d728fec7 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -443,7 +443,7 @@
{{
t(
'stackiq',
- 'Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.',
+ 'Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.',
)
}}
From 0108ef07886802fe6de3325398356b9e675e163b Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 14:43:09 +0200
Subject: [PATCH 024/124] fix(cmdb-import): answer the mapping's failure reason
as one bounded line
The mapping endpoint returned the loader's exception message as
details.reason unbounded, and its docblock and design D2 said that message
named files and validator rules only. The validator's messages also quote
the configuration value they refused, and nothing limited their length. The
reason is now the message's first line, cut to 500 characters, and both
texts say what it carries: the file, the rule and the refused value, never
a workbook cell or a person.
Mutation-checked: testTheReasonIsOneBoundedLine fails when the endpoint
returns the raw message and passes with the bound. phpcs, phpmd and
phpstan report 0; the CMDB and SettingsController suites pass (306 tests).
Co-Authored-By: Claude Opus 5.5
---
lib/Controller/SettingsController.php | 33 +++++++++++++++++--
.../cmdb-import-mapping-view/design.md | 2 +-
...ettingsControllerCmdbImportMappingTest.php | 32 ++++++++++++++++++
3 files changed, 63 insertions(+), 4 deletions(-)
diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php
index e0c47c5be..13c9fc0ac 100644
--- a/lib/Controller/SettingsController.php
+++ b/lib/Controller/SettingsController.php
@@ -67,6 +67,13 @@
*/
class SettingsController extends Controller {
+ /**
+ * The longest loader reason the CMDB mapping endpoint answers, in characters.
+ *
+ * @var int
+ */
+ private const REASON_MAX_LENGTH = 500;
+
/**
* The OpenRegister object service.
*
@@ -3900,8 +3907,10 @@ public function getEolSyncStatus(): JSONResponse {
* Success is the flat `{profile, packs}`; a failure uses the import's
* error envelope, so the section shows it like an import error, with the
* loader's reason in `details.reason` because the admin who edited a file
- * needs to know which file and why. That reason names files and validator
- * rules only, never a cell value or a person.
+ * needs to know which file and why. That reason is the loader's message cut
+ * to its first line and REASON_MAX_LENGTH characters: the file, the
+ * validator's rule and the configuration value it refused, never a
+ * workbook cell or a person.
*
* @return JSONResponse `{profile, packs}` (200), 503 MAPPING_UNAVAILABLE, or 500 IMPORT_FAILED.
*
@@ -3924,7 +3933,7 @@ public function getCmdbImportMapping(): JSONResponse {
'success' => false,
'error' => $e->getErrorCode(),
'message' => $this->translate(text: 'The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.'),
- 'details' => (object)['reason' => $e->getMessage()],
+ 'details' => (object)['reason' => self::boundedReason(message: $e->getMessage())],
],
$e->getHttpStatus()
);
@@ -3944,6 +3953,24 @@ public function getCmdbImportMapping(): JSONResponse {
return new JSONResponse($overview, Http::STATUS_OK);
}//end getCmdbImportMapping()
+ /**
+ * A loader message as the mapping endpoint answers it: its first line, at most REASON_MAX_LENGTH characters.
+ *
+ * @param string $message The CmdbImportException message.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+ private static function boundedReason(string $message): string {
+ $firstLine = strtok($message, "\r\n");
+ if ($firstLine === false) {
+ return '';
+ }
+
+ return mb_substr($firstLine, 0, self::REASON_MAX_LENGTH);
+ }//end boundedReason()
+
/**
* Translate a message, or hand it back as it is when no translator was injected.
*
diff --git a/openspec/changes/cmdb-import-mapping-view/design.md b/openspec/changes/cmdb-import-mapping-view/design.md
index ece7a5082..d8927e2f6 100644
--- a/openspec/changes/cmdb-import-mapping-view/design.md
+++ b/openspec/changes/cmdb-import-mapping-view/design.md
@@ -40,7 +40,7 @@ The route sits with the other `/api/settings/*` reads, and `SettingsController`
### D2. Admin-only, CSRF, the import's error envelope
-The method carries no auth attribute (Nextcloud's default: admins with a CSRF token), with `@auth admin-only` and its reason, as `CmdbImportController` does. The settings page is admin-only anyway, so a delegated group never sees the section; the endpoint follows the import's posture so an API caller gets the same answer from both. Success is the flat `{profile, packs}` (ADR-050). Failure is the import's envelope `{success: false, error, message, details}`, so `cmdbImport.js`'s `normaliseError()` and `errorText()` handle it unchanged; `details.reason` carries the loader's message, because an administrator who edited a file needs to know which file and why, and that message names files and validator rules only.
+The method carries no auth attribute (Nextcloud's default: admins with a CSRF token), with `@auth admin-only` and its reason, as `CmdbImportController` does. The settings page is admin-only anyway, so a delegated group never sees the section; the endpoint follows the import's posture so an API caller gets the same answer from both. Success is the flat `{profile, packs}` (ADR-050). Failure is the import's envelope `{success: false, error, message, details}`, so `cmdbImport.js`'s `normaliseError()` and `errorText()` handle it unchanged; `details.reason` carries the loader's message, because an administrator who edited a file needs to know which file and why. The message is cut to its first line and 500 characters; it names the file, the validator's rule and the configuration value it refused, never a workbook cell or a person.
### D3. The transform is shown as stored
diff --git a/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php b/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
index 9bfe2106d..d02b54498 100644
--- a/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
+++ b/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
@@ -25,6 +25,7 @@
require_once __DIR__ . '/../Support/CmdbTestSupport.php';
use OCA\Stackiq\Controller\SettingsController;
+use OCA\Stackiq\Exception\CmdbImportException;
use OCA\Stackiq\Service\ArchiMateService;
use OCA\Stackiq\Service\Cmdb\CmdbImportProfile;
use OCA\Stackiq\Service\EolSyncService;
@@ -294,6 +295,37 @@ public function testAMissingValidatorIs503(): void {
$this->assertSame('OpenRegister PackDefinitionValidator is not available', $data['details']->reason);
}//end testAMissingValidatorIs503()
+ /**
+ * The loader's reason is answered as its first line, cut to 500 characters.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+ public function testTheReasonIsOneBoundedLine(): void {
+ $profile = new class(container: $this->emptyContainer()) extends CmdbImportProfile {
+ /**
+ * A loader failure with a long first line and a second line.
+ *
+ * @return array
+ *
+ * @throws CmdbImportException Always.
+ */
+ public function mappingOverview(): array {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'topdesk-usage.json: ' . str_repeat('x', 600) . "\nsecond line"
+ );
+ }
+ };
+
+ $reason = $this->controller(profile: $profile)->getCmdbImportMapping()->getData()['details']->reason;
+
+ $this->assertStringStartsWith('topdesk-usage.json: ', $reason);
+ $this->assertStringNotContainsString('second line', $reason);
+ $this->assertSame(500, mb_strlen($reason));
+ }//end testTheReasonIsOneBoundedLine()
+
/**
* An unexpected error is 500 IMPORT_FAILED with a static message, and the exception is logged.
*
From 97c4e9992970f06fca91a36f36a419b588924093 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 14:47:17 +0200
Subject: [PATCH 025/124] docs(cmdb-import): say what a split import with Keep
leaves undone
A split export imported with Keep them as they are archives and deletes
nothing, so the FILE_TOO_LARGE and TOO_MANY_ROWS rows now say that
applications which left the export are then not archived or deleted.
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index 184fc6932..ae9b2c240 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -344,13 +344,13 @@ and the section shows the reason and the error code.
| Error code | What it means | What to do |
|---|---|---|
| `NOT_XLSX` | The file is not an Excel workbook: wrong extension, or the content is not an `.xlsx` package. | Save the export as Excel workbook (`.xlsx`). |
-| `FILE_TOO_LARGE` | The file is larger than the upload limit (10 MB by default). The message names the limit in force. | Remove sheets the import does not read. Or split the export and import every part with **Keep them as they are**: an import that archives treats the applications of the other parts as missing. |
+| `FILE_TOO_LARGE` | The file is larger than the upload limit (10 MB by default). The message names the limit in force. | Remove sheets the import does not read. Or split the export and import every part with **Keep them as they are**: an import that archives treats the applications of the other parts as missing. Applications that left the export are then not archived or deleted. |
| `NO_FILE_UPLOADED` | No file arrived. | Choose the file again. |
| `MUNICIPALITY_REQUIRED` | No municipality was chosen. | Pick or type a municipality. |
| `MUNICIPALITY_INVALID` | The chosen organisation does not exist or is not of type Municipality. | Pick an organisation of type Municipality, or type a new name. |
| `NO_SOURCE_SHEET` | Neither `Onbeh Applicaties CMDB` nor `Beheerde Applicaties CMDB` is in the workbook. | Check the sheet names; they must match exactly. |
| `MISSING_COLUMN` | A present CMDB sheet has no `APPID` or `Applicatie Naam` column. The message names the sheet and the column. | Add the column to that sheet. |
-| `TOO_MANY_ROWS` | A CMDB sheet has more rows with data than the row limit (10,000 by default). The message names the sheet and the limit. | Split the export and import the parts one after the other with **Keep them as they are**: an import that archives treats the applications of the other parts as missing. |
+| `TOO_MANY_ROWS` | A CMDB sheet has more rows with data than the row limit (10,000 by default). The message names the sheet and the limit. | Split the export and import the parts one after the other with **Keep them as they are**: an import that archives treats the applications of the other parts as missing. Applications that left the export are then not archived or deleted. |
| `WORKBOOK_TOO_LARGE` | Unpacked, the workbook is larger than the import reads: 50 MB in all, 10 MB for one sheet the import reads or for the table of texts the sheets share, or more than 200,000 different texts, by default. Sheets the import does not read may be larger than 10 MB. An `.xlsx` is a compressed package, so a file of a few MB can unpack to far more. The message names the limit, and for a sheet the sheet and its unpacked size. | For a sheet that is too large: split its rows over two copies of the export and import both with **Keep them as they are**. For a workbook that is too large in all: delete the sheets the import does not read from a copy and import that copy. |
| `MUNICIPALITY_AMBIGUOUS` | More than one municipality has the typed name. The import does not guess which one. | Pick the municipality from the list instead of typing its name. |
| `IMPORT_IN_PROGRESS` | Another CMDB import is running. Only one import runs at a time. | Wait until it has finished and try again. |
From 80dc55178021573a0b8ee5fa7456886bce05bbdb Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 14:47:33 +0200
Subject: [PATCH 026/124] docs(openspec): state the bound on the mapping's
failure reason
The cmdb-import-mapping-view delta now says details.reason is one line of at
most 500 characters and may quote the refused configuration value, as the
endpoint and design D2 do since the reason is bounded.
Co-Authored-By: Claude Opus 5.5
---
.../cmdb-import-mapping-view/specs/cmdb-export-import/spec.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
index 39afdb9e7..1c516174e 100644
--- a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
@@ -42,7 +42,7 @@ Nextcloud OCP interfaces used: `OCP\IL10N` (messages). OpenRegister: `Service\Mi
- **GIVEN** a pack file on the server that OpenRegister's validator refuses
- **WHEN** a Nextcloud admin requests the mapping
- **THEN** the endpoint SHALL answer 503 with error `MAPPING_UNAVAILABLE`
-- **AND** `details.reason` SHALL name the pack file and the validator's reason
+- **AND** `details.reason` SHALL name the pack file and the validator's reason, on one line of at most 500 characters
- **AND** the section SHALL show the error and the code `MAPPING_UNAVAILABLE` instead of the tables
#### Scenario: A user who is not a Nextcloud admin cannot read the mapping
@@ -81,7 +81,7 @@ Stackiq's admin settings page SHALL show a section "CMDB import", rendered by th
## Non-Functional Requirements
- **Performance:** the endpoint reads six small JSON files and validates five packs; it SHALL answer within the time an admin settings page loads and SHALL be called once per page view.
-- **Security:** admin-only with CSRF, as the import routes. The answer holds column names, field names and lookup tables from files that ship with the app; no object data and no person data. The loader's reason in `details.reason` names a file and a validator message, never a cell value.
+- **Security:** admin-only with CSRF, as the import routes. The answer holds column names, field names and lookup tables from files that ship with the app; no object data and no person data. The loader's reason in `details.reason` is its message's first line, at most 500 characters: the file, the validator's rule and the configuration value it refused, never a cell value.
- **Accessibility:** Target WCAG 2.2 AA. The block's toggle is an `NcButton` with a visible label and `aria-expanded` (SC 4.1.2; gate `button-name`), the content it controls is referenced with `aria-controls`, and each table has header cells (SC 1.3.1; gate `table-headers`). New in 2.2: 2.4.11 Focus Not Obscured applies (the tables are in the page flow, nothing sticky covers them); 2.5.7 Dragging Movements does not apply; 2.5.8 Target Size applies to the toggle (Nextcloud default); 3.2.6 Consistent Help does not apply (no help mechanism added); 3.3.7 Redundant Entry does not apply (nothing is entered); 3.3.8 Accessible Authentication does not apply.
- **Internationalization:** Dutch and English MUST be supported (ADR-005) for the block, the table headers, the target and transformation names and the error.
From 9aaacca22189c11e3f47b89f5131a1bb87d0d6e6 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 15:43:17 +0200
Subject: [PATCH 027/124] fix(usage): look owner names up only in the caller's
own address books, and by the requested ids
Review of #1292:
- The name lookup used IManager::search with its defaults, which also
searches the system address book (one card per local user, UID = user
id). A contactsUid is free text on a contactPerson, so whoever may
write one could have the endpoint name any local user. The new
StackiqContactSyncService::findOwnContactByUid() passes enumeration
and fullmatch false (the contacts manager then skips the system
address book) and strict_search (exact UID, no substring scan).
- OpenRegister's single register+schema search path ignores the ids
argument and reads `_ids` from the query, so only the first 100
readable contact persons could ever be named. The ids now go in the
query as `_ids`.
- #[UserRateLimit(limit: 120, period: 60)] on the endpoint.
- A failed request no longer pins "no name" for the page: its ids are
dropped from the cache, so the next cell asks again.
- @spec tags on the five frontend methods gate 16 named, and a
scenario under REQ-UAP-003 for who sees a name.
PHPUnit ContactNamesControllerTest 6 and StackiqContactSyncServiceTest
5 pass, vitest contactNames.spec.js 7 pass; removing the `_ids` key
or the cache.delete turns a test red.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
lib/Controller/ContactNamesController.php | 22 ++++++++-----
lib/Service/StackiqContactSyncService.php | 32 +++++++++++++++++++
.../specs/application-usage-pages/spec.md | 9 ++++++
src/components/cells/ContactNameCell.vue | 2 ++
src/utils/contactNames.js | 12 +++++++
.../Controller/ContactNamesControllerTest.php | 13 +++++---
.../Service/StackiqContactSyncServiceTest.php | 31 ++++++++++++++++++
tests/vitest/contactNames.spec.js | 8 +++--
8 files changed, 115 insertions(+), 14 deletions(-)
diff --git a/lib/Controller/ContactNamesController.php b/lib/Controller/ContactNamesController.php
index 086a6d400..51bb9524a 100644
--- a/lib/Controller/ContactNamesController.php
+++ b/lib/Controller/ContactNamesController.php
@@ -30,6 +30,7 @@
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\UserRateLimit;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IL10N;
use OCP\IRequest;
@@ -44,10 +45,11 @@
* lives in Nextcloud Contacts (the CMDB import puts its owners in the
* importing admin's "Stackiq CMDB owners" address book). This endpoint reads
* the contact persons with the caller's own OpenRegister rights, then looks
- * each `contactsUid` up in the address books Nextcloud gives the caller. A
- * name is therefore returned only to someone who can already read both the
- * contact person and the contact. Nothing is stored, and there is no public
- * route.
+ * each `contactsUid` up, by exact UID, in the caller's own and shared address
+ * books; the system address book is never searched, so a contactsUid set to a
+ * user id names nobody. A name is therefore returned only to someone who can
+ * already read both the contact person and the contact. Nothing is stored,
+ * and there is no public route.
*
* @category Controller
* @package OCA\Stackiq\Controller
@@ -94,7 +96,8 @@ public function __construct(
* Answers `{"names": {"": ""}}`. The per-object
* check is OpenRegister's: the contact persons are read with the caller's
* RBAC and organisation scope, so one the caller may not read is left out,
- * as is one whose contact is not in the caller's address books.
+ * as is one whose contact is not in the caller's own or shared address
+ * books. At most 120 requests a minute per user (one per list page).
*
* @return JSONResponse 200 with the names, 400 for more than MAX_IDS ids, 401 when not signed in.
*
@@ -102,6 +105,7 @@ public function __construct(
*/
#[NoAdminRequired]
#[NoCSRFRequired]
+ #[UserRateLimit(limit: 120, period: 60)]
public function index(): JSONResponse {
if ($this->userSession->getUser() === null) {
return new JSONResponse(
@@ -124,7 +128,7 @@ public function index(): JSONResponse {
$names = [];
foreach ($this->contactsUidsOf(ids: $ids) as $uuid => $contactsUid) {
- $contact = $this->contactSync->findContactByUid(uid: $contactsUid);
+ $contact = $this->contactSync->findOwnContactByUid(uid: $contactsUid);
$name = self::firstValue(value: ($contact['FN'] ?? ''));
if ($name !== '') {
$names[$uuid] = $name;
@@ -180,12 +184,14 @@ private function contactsUidsOf(array $ids): array {
try {
// RBAC and multitenancy stay at their defaults: the caller's own rights.
+ // The ids go in the query as `_ids`: OpenRegister's single
+ // register+schema path reads that key and ignores the `ids` argument.
$result = $this->objectService->searchObjectsPaginated(
query: [
'@self' => ['register' => (int)$register, 'schema' => (int)$schema],
+ '_ids' => $ids,
'_limit' => self::MAX_IDS,
- ],
- ids: $ids
+ ]
);
} catch (Throwable $e) {
$this->logger->warning('[ContactNames] The contact persons could not be read', ['exception' => $e]);
diff --git a/lib/Service/StackiqContactSyncService.php b/lib/Service/StackiqContactSyncService.php
index 2c09cd6fb..dc100c01f 100644
--- a/lib/Service/StackiqContactSyncService.php
+++ b/lib/Service/StackiqContactSyncService.php
@@ -470,6 +470,38 @@ public function findContactByUid(string $uid): ?array {
return null;
}//end findContactByUid()
+ /**
+ * Find a contact by its exact UID in the signed-in user's own and shared address books only.
+ *
+ * The system address book (one card per local user) is skipped, and the
+ * UID is matched exactly, not as a substring. This is the lookup for a
+ * name shown to a user: a contactsUid is free text on a contactPerson,
+ * so a lookup that included the system address book would name any
+ * local user for whoever may write a contactPerson.
+ *
+ * @param string $uid The contact UID.
+ *
+ * @return ?array The contact, or null when not found there.
+ *
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ */
+ public function findOwnContactByUid(string $uid): ?array {
+ if ($uid === '' || $this->isAvailable() === false) {
+ return null;
+ }
+
+ // With enumeration and fullmatch both off, the contacts manager skips the
+ // system address book; strict_search turns the substring match off.
+ $options = ['limit' => 5, 'strict_search' => true, 'enumeration' => false, 'fullmatch' => false];
+ foreach ($this->contactsManager->search($uid, ['UID'], $options) as $result) {
+ if (($result['UID'] ?? '') === $uid) {
+ return $result;
+ }
+ }
+
+ return null;
+ }//end findOwnContactByUid()
+
/**
* Find a Nextcloud contact matching a relationship record's identity, by
* e-mail first and — for organisations — by CBS/KvK code as a fallback.
diff --git a/openspec/specs/application-usage-pages/spec.md b/openspec/specs/application-usage-pages/spec.md
index 8804ce59d..17ab52e66 100644
--- a/openspec/specs/application-usage-pages/spec.md
+++ b/openspec/specs/application-usage-pages/spec.md
@@ -46,6 +46,15 @@ A usage SHALL carry a business owner and a technical owner, each picked from the
- **WHEN** the supplier opens that usage
- **THEN** the owner contact persons do not open for the supplier
+#### Scenario: The usage list names an owner only for who can read the contact
+@e2e exclude Needs a Contacts address book per user; tests/Unit/Controller/ContactNamesControllerTest.php asserts the read with the caller's rights and the 401, and tests/Unit/Service/StackiqContactSyncServiceTest.php testANameLookupSkipsTheSystemAddressBookAndMatchesExactly asserts the lookup skips the system address book.
+
+- **GIVEN** a usage whose business owner is a contact person linked to a contact in the information manager's own address book
+- **WHEN** the information manager opens Applications in use
+- **THEN** the Business owner column shows the contact's name
+- **AND** a user without that contact in their own or shared address books sees the contact person's id, as before
+- **AND** a request that is not signed in gets 401 and no name, and a contactsUid that is a user id names nobody
+
### Requirement: REQ-UAP-004 A usage moves through its lifecycle from its page
The usage schema SHALL declare its lifecycle on the status values its rows hold, so the detail page offers Plan, Go live, Phase out and Retire from the matching status.
diff --git a/src/components/cells/ContactNameCell.vue b/src/components/cells/ContactNameCell.vue
index 06a7ec2f4..e8ef534dc 100644
--- a/src/components/cells/ContactNameCell.vue
+++ b/src/components/cells/ContactNameCell.vue
@@ -41,6 +41,7 @@ export default {
computed: {
/**
* @return {string} The contact person uuid, or ''.
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
*/
id() {
return contactPersonId(this.value)
@@ -55,6 +56,7 @@ export default {
*
* @param {string} id The contact person uuid.
* @return {Promise}
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
*/
async handler(id) {
this.name = null
diff --git a/src/utils/contactNames.js b/src/utils/contactNames.js
index 6051639f4..ed9513e46 100644
--- a/src/utils/contactNames.js
+++ b/src/utils/contactNames.js
@@ -30,6 +30,7 @@ let pending = new Map()
*
* @param {string|object|null} value A uuid, or a related object with `id` or `uuid`.
* @return {string} The uuid, or ''.
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
*/
export function contactPersonId(value) {
if (typeof value === 'string') {
@@ -44,7 +45,11 @@ export function contactPersonId(value) {
/**
* Ask the endpoint for the names of the collected ids, in chunks it accepts.
*
+ * A chunk whose request fails answers "no name" for now and is forgotten,
+ * so the next cell that asks for one of its ids asks the endpoint again.
+ *
* @return {Promise}
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
*/
async function flush() {
const batch = pending
@@ -53,6 +58,7 @@ async function flush() {
for (let start = 0; start < ids.length; start += MAX_IDS_PER_REQUEST) {
const chunk = ids.slice(start, start + MAX_IDS_PER_REQUEST)
let names = {}
+ let failed = false
try {
const { data } = await axios.get(
generateUrl('/apps/stackiq/api/contact-names'),
@@ -61,9 +67,13 @@ async function flush() {
names = data?.names ?? {}
} catch {
names = {}
+ failed = true
}
chunk.forEach((id) => {
const name = typeof names[id] === 'string' ? names[id] : ''
+ if (failed) {
+ cache.delete(id)
+ }
batch.get(id).resolve(name !== '' ? name : null)
})
}
@@ -75,6 +85,7 @@ async function flush() {
*
* @param {string} id The contact person uuid.
* @return {Promise} The name.
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
*/
export function contactName(id) {
const key = String(id ?? '').toLowerCase()
@@ -99,6 +110,7 @@ export function contactName(id) {
* Forget every name read so far (tests).
*
* @return {void}
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
*/
export function clearContactNames() {
cache.clear()
diff --git a/tests/Unit/Controller/ContactNamesControllerTest.php b/tests/Unit/Controller/ContactNamesControllerTest.php
index 0860873de..7ff34f662 100644
--- a/tests/Unit/Controller/ContactNamesControllerTest.php
+++ b/tests/Unit/Controller/ContactNamesControllerTest.php
@@ -109,12 +109,17 @@ public function testNamesTheReadableContactPersons(): void {
$this->objectService->expects($this->once())
->method('searchObjectsPaginated')
->with(
- $this->callback(static fn (array $query): bool => $query['@self'] === ['register' => 20, 'schema' => 7]),
+ // The ids go in the query as `_ids`: OpenRegister's single
+ // register+schema path ignores the `ids` argument.
+ $this->callback(
+ static fn (array $query): bool => $query['@self'] === ['register' => 20, 'schema' => 7]
+ && $query['_ids'] === [self::A, self::B]
+ ),
// The caller's own RBAC and organisation scope.
true,
true,
false,
- [self::A, self::B]
+ null
)
->willReturn(
[
@@ -124,7 +129,7 @@ public function testNamesTheReadableContactPersons(): void {
],
]
);
- $this->contactSync->method('findContactByUid')->willReturnMap(
+ $this->contactSync->method('findOwnContactByUid')->willReturnMap(
[
['uid-a', ['UID' => 'uid-a', 'FN' => 'Teamleider Applicatiebeheer']],
['uid-b', null],
@@ -146,7 +151,7 @@ public function testNamesOnlyTheRequestedIds(): void {
$this->objectService->method('searchObjectsPaginated')->willReturn(
['results' => [['id' => self::C, 'contactsUid' => 'uid-c']]]
);
- $this->contactSync->expects($this->never())->method('findContactByUid');
+ $this->contactSync->expects($this->never())->method('findOwnContactByUid');
$response = $this->controller(ids: [self::A])->index();
diff --git a/tests/Unit/Service/StackiqContactSyncServiceTest.php b/tests/Unit/Service/StackiqContactSyncServiceTest.php
index 7b0228a0a..62c26fc2a 100644
--- a/tests/Unit/Service/StackiqContactSyncServiceTest.php
+++ b/tests/Unit/Service/StackiqContactSyncServiceTest.php
@@ -113,6 +113,37 @@ static function (array $contact) use ($pattern, $properties): bool {
return new StackiqContactSyncService(contactsManager: $manager, logger: new NullLogger(), container: $container, userSession: $session);
}//end service()
+ /**
+ * A name lookup skips the system address book and matches the UID exactly.
+ *
+ * With enumeration and fullmatch both off, Nextcloud's contacts manager
+ * skips the system address book (one card per local user, UID = user id),
+ * so a contactsUid set to a user id names nobody.
+ *
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ *
+ * @return void
+ */
+ public function testANameLookupSkipsTheSystemAddressBookAndMatchesExactly(): void {
+ $manager = $this->createMock(IManager::class);
+ $manager->method('isEnabled')->willReturn(true);
+ $manager->expects($this->exactly(2))
+ ->method('search')
+ ->with(
+ $this->anything(),
+ ['UID'],
+ ['limit' => 5, 'strict_search' => true, 'enumeration' => false, 'fullmatch' => false]
+ )
+ ->willReturnCallback(
+ static fn (string $uid): array => ($uid === 'uid-a' ? [['UID' => 'uid-a-longer', 'FN' => 'Other'], ['UID' => 'uid-a', 'FN' => 'Owner']] : [])
+ );
+ $service = new StackiqContactSyncService(contactsManager: $manager, logger: new NullLogger());
+
+ $this->assertSame('Owner', $service->findOwnContactByUid(uid: 'uid-a')['FN']);
+ $this->assertNull($service->findOwnContactByUid(uid: 'admin'));
+ $this->assertNull($service->findOwnContactByUid(uid: ''));
+ }//end testANameLookupSkipsTheSystemAddressBookAndMatchesExactly()
+
/**
* The first contact creates the address book; the second reuses it; neither touches another address book.
*
diff --git a/tests/vitest/contactNames.spec.js b/tests/vitest/contactNames.spec.js
index f301c045f..e303e85db 100644
--- a/tests/vitest/contactNames.spec.js
+++ b/tests/vitest/contactNames.spec.js
@@ -72,9 +72,13 @@ describe('contactNames', () => {
expect(http.get.mock.calls[1][1].params.ids).toHaveLength(1)
})
- it('gives no name when the request fails', async () => {
- http.get.mockRejectedValue(new Error('offline'))
+ it('gives no name when the request fails, and asks again next time', async () => {
+ http.get.mockRejectedValueOnce(new Error('offline'))
expect(await contactName(A)).toBeNull()
+
+ http.get.mockResolvedValueOnce({ data: { names: { [A]: 'Teamleider' } } })
+ expect(await contactName(A)).toBe('Teamleider')
+ expect(http.get).toHaveBeenCalledTimes(2)
})
it('reads the uuid from a string or a related object', () => {
From 71e684c0d4e78bd8a0d9d7bdd07309930c55b724 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 15:44:45 +0200
Subject: [PATCH 028/124] fix(ui): keep the sidebar search focused, carry old
search links over, and update the e2e and spec
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review of #1290:
- created() carries an @spec tag (gate 16).
- The e2e scenario typed into the removed toolbar search box; it now
opens the Search and columns sidebar and types there.
- The list is no longer remounted when the facet counts change the
narrowing ids: the self-fetch list watches its filter prop and
refetches (nextcloud-vue 2.65.0 and later), so typing in the sidebar
search with a facet selected keeps the focus. Only a saved view or an
old link still remounts it.
- A link from before this change carries the term as _gf_search; it
moves to _search and the list reads it.
- gemma-faceted-search spec: the search is the sidebar's Search tab.
- The header docblock says the search and columns are CnIndexPage's
own sidebar; the unused strings "Search applications and services…"
and "Clear search" are gone from en and nl.
jest 187 pass, vitest facetStore/facetSchema 28 pass,
validate-manifest PASS, eslint clean on the changed files.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
l10n/en.js | 2 -
l10n/en.json | 2 -
l10n/nl.js | 2 -
l10n/nl.json | 2 -
openspec/specs/gemma-faceted-search/spec.md | 2 +-
src/views/FacetedCatalogIndexView.vue | 58 ++++++++++++++-----
.../gemma-faceted-search.spec.ts | 13 +++--
7 files changed, 52 insertions(+), 29 deletions(-)
diff --git a/l10n/en.js b/l10n/en.js
index 8dd84a2f4..914e08eab 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -508,8 +508,6 @@ OC.L10N.register(
"Organisation or new status is missing": "Organisation or new status is missing",
"An error occurred while changing the status": "An error occurred while changing the status",
"Search": "Search",
- "Search applications and services…": "Search applications and services…",
- "Clear search": "Clear search",
"Saved views": "Saved views",
"Save current filters as view": "Save current filters as view",
"GEMMA filters": "GEMMA filters",
diff --git a/l10n/en.json b/l10n/en.json
index 313f968ea..1126dc6d3 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -507,8 +507,6 @@
"Organisation or new status is missing": "Organisation or new status is missing",
"An error occurred while changing the status": "An error occurred while changing the status",
"Search": "Search",
- "Search applications and services…": "Search applications and services…",
- "Clear search": "Clear search",
"Saved views": "Saved views",
"Save current filters as view": "Save current filters as view",
"GEMMA filters": "GEMMA filters",
diff --git a/l10n/nl.js b/l10n/nl.js
index 3151813d6..da522849f 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -545,8 +545,6 @@ OC.L10N.register(
"Group members": "Groepsleden",
"Merge organisations": "Organisaties samenvoegen",
"Search": "Zoeken",
- "Search applications and services…": "Zoek applicaties en diensten…",
- "Clear search": "Zoekopdracht wissen",
"Saved views": "Opgeslagen weergaven",
"Save current filters as view": "Huidige filters opslaan als weergave",
"GEMMA filters": "GEMMA-filters",
diff --git a/l10n/nl.json b/l10n/nl.json
index 680f6f853..34f0d8862 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -544,8 +544,6 @@
"Group members": "Groepsleden",
"Merge organisations": "Organisaties samenvoegen",
"Search": "Zoeken",
- "Search applications and services…": "Zoek applicaties en diensten…",
- "Clear search": "Zoekopdracht wissen",
"Saved views": "Opgeslagen weergaven",
"Save current filters as view": "Huidige filters opslaan als weergave",
"GEMMA filters": "GEMMA-filters",
diff --git a/openspec/specs/gemma-faceted-search/spec.md b/openspec/specs/gemma-faceted-search/spec.md
index ead88fd9d..ee9e18b6c 100644
--- a/openspec/specs/gemma-faceted-search/spec.md
+++ b/openspec/specs/gemma-faceted-search/spec.md
@@ -137,7 +137,7 @@ The module (`Applications`) and dienst (`Services`) `CnIndexPage`-based index pa
- GIVEN a user navigates to the module index page
- WHEN the page renders
- THEN a facet filter panel MUST be visible showing all four GEMMA dimensions
-- AND the existing free-text search box and any `quickFilters` MUST continue to render and function unchanged
+- AND the free-text search (the Search tab of the page's Search and columns sidebar, sent by the list as `_search`) and any `quickFilters` MUST continue to render and function
#### Scenario: Selecting a facet value updates the object list without a full page reload
diff --git a/src/views/FacetedCatalogIndexView.vue b/src/views/FacetedCatalogIndexView.vue
index 97694580c..40c15b9e0 100644
--- a/src/views/FacetedCatalogIndexView.vue
+++ b/src/views/FacetedCatalogIndexView.vue
@@ -9,8 +9,13 @@ Layout: a `CnFacetSidebar` (from `@conduction/nextcloud-vue`, ADR-012) driven
by the `facets` Pinia store on the left, and a self-fetching `CnIndexPage`
(register+schema self-fetch mode) on the right.
-Why the facet sidebar is NOT `CnIndexPage`'s own embedded
-`sidebar.enabled`/`activeFilters` facet machinery: that machinery treats
+The free-text search and the column choice are `CnIndexPage`'s own Search
+and Columns sidebar (manifest `config.sidebar`): the list sends the term as
+`_search` and keeps it in the route, and `onListSearch` hands it to the facet
+counts.
+
+Why the GEMMA facets do NOT go through that sidebar's
+`activeFilters` facet machinery: that machinery treats
every active-filter key as a LITERAL, directly-filterable schema field and
applies it straight to the object-list fetch (`useListView`'s
`onFilterChange` → `activeFilters.value[key] = values`, merged verbatim into
@@ -315,26 +320,51 @@ export default {
},
/**
- * Remount key for `CnIndexPage`: its self-fetch mode has no reactive
- * watcher on the `filter` prop (only route/quick-filter/tab changes
- * trigger an internal refetch), so this view forces a clean remount —
- * and therefore a fresh `onMounted()` fetch with the current
- * `listFilter` — whenever the narrowing id set changes, and when a
- * saved view put its search term in the route (`listRemount`).
+ * Remount key for `CnIndexPage`. A change of the `filter` prop needs
+ * no remount: the self-fetch list watches it and refetches (2.65.0 and
+ * later), so the sidebar search keeps its focus while the facet counts
+ * follow the term. The list reads its search term from the route only
+ * when it mounts, so a saved view or a legacy `_gf_search` link that
+ * puts a term in the route bumps `listRemount`.
*
- * @return {string} A key that changes when `listFilter` or `listRemount` does.
+ * @return {string} A key that changes when `listRemount` does.
* @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-sidebar-ui-on-the-module-and-dienst-index-pages
*/
indexPageKey() {
- return `${this.schema}-${this.listRemount}-${JSON.stringify(this.listFilter)}`
+ return `${this.schema}-${this.listRemount}`
},
},
+ /**
+ * Restore the facet selection and the search term from the route.
+ *
+ * The list restores its search from `_search`; the facet counts follow
+ * the same term. A link from before the sidebar search carries the term
+ * as `_gf_search`: it moves to `_search` and the list remounts to read it.
+ *
+ * @return {void}
+ * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-filter-state-is-url-encoded-and-deep-linkable
+ */
created() {
- this.facetStore.setFiltersFromQuery(this.schema, this.$route.query)
- // The list restores its search from `_search` in the route; the facet
- // counts follow the same term.
- this.facetStore.setSearch(this.schema, routeSearch(this.$route.query))
+ const query = this.$route.query
+ this.facetStore.setFiltersFromQuery(this.schema, query)
+ const legacy =
+ routeSearch(query) === '' && typeof query._gf_search === 'string'
+ ? query._gf_search
+ : ''
+ this.facetStore.setSearch(this.schema, legacy || routeSearch(query))
+ if (legacy !== '') {
+ const next = { ...query, _search: legacy }
+ delete next._gf_search
+ this.$router
+ .replace({ query: next })
+ .catch(() => {
+ // NavigationDuplicated — same query, no-op.
+ })
+ .then(() => {
+ this.listRemount++
+ })
+ }
},
/**
diff --git a/tests/e2e/spec-coverage/gemma-faceted-search.spec.ts b/tests/e2e/spec-coverage/gemma-faceted-search.spec.ts
index e7b8009db..0d1d5610d 100644
--- a/tests/e2e/spec-coverage/gemma-faceted-search.spec.ts
+++ b/tests/e2e/spec-coverage/gemma-faceted-search.spec.ts
@@ -297,12 +297,13 @@ test('facets: the GEMMA panel renders beside the search box on the module index'
DIMENSIONS.length,
)
- // …AND the pre-existing toolbar still works alongside it, which is the
- // second half of the scenario ("the existing free-text search box ... MUST
- // continue to render and function unchanged").
- const search = page.locator('.faceted-catalog-index__search').first()
- await expect(search, 'the free-text search box disappeared').toBeVisible()
- await search.getByRole('textbox').first().fill(TOKEN)
+ // …AND the free-text search still works alongside it, which is the
+ // second half of the scenario ("the free-text search … MUST continue to
+ // render and function"): the Search tab of the Search and columns sidebar.
+ await page.getByRole('button', { name: 'Search and columns' }).first().click()
+ const search = page.locator('.app-sidebar').getByRole('textbox').first()
+ await expect(search, 'the sidebar search box did not open').toBeVisible()
+ await search.fill(TOKEN)
// The list re-fetches and settles on this run's seeded modules.
await expect(main.getByText(`${TOKEN} module 1`).first()).toBeVisible({
timeout: 30000,
From 36a095968b94ab0eeb404db746875143db230a4e Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 15:49:52 +0200
Subject: [PATCH 029/124] fix(usage): count a name only from the caller's own
and shared address books
Pre-push check of the previous commit: with enumeration and fullmatch
off the contacts manager skips the local system address book, but it
still searches the other books of the system principal, which include
the books synced from trusted servers (one card per remote user, UID =
user id). A contactsUid set to a remote user id could still name that
user.
findOwnContactByUid() now keeps a result only when its addressbook-key
is one of the books the CardDAV backend lists for the user's principal
(own and shared), and finds nothing without a backend or a signed-in
user. The controller docblock and the REQ-UAP-003 scenario say "local
or remote user id".
StackiqContactSyncServiceTest 5 and ContactNamesControllerTest 6 pass;
dropping the address-book check turns the service test red.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
lib/Controller/ContactNamesController.php | 5 +-
lib/Service/StackiqContactSyncService.php | 57 ++++++++++++++++---
.../specs/application-usage-pages/spec.md | 2 +-
.../Service/StackiqContactSyncServiceTest.php | 35 ++++++++++--
4 files changed, 84 insertions(+), 15 deletions(-)
diff --git a/lib/Controller/ContactNamesController.php b/lib/Controller/ContactNamesController.php
index 51bb9524a..ed91f9d13 100644
--- a/lib/Controller/ContactNamesController.php
+++ b/lib/Controller/ContactNamesController.php
@@ -46,8 +46,9 @@
* importing admin's "Stackiq CMDB owners" address book). This endpoint reads
* the contact persons with the caller's own OpenRegister rights, then looks
* each `contactsUid` up, by exact UID, in the caller's own and shared address
- * books; the system address book is never searched, so a contactsUid set to a
- * user id names nobody. A name is therefore returned only to someone who can
+ * books only; the system principal's books (the local system address book
+ * and the books synced from trusted servers) never count, so a contactsUid
+ * set to a local or remote user id names nobody. A name is therefore returned only to someone who can
* already read both the contact person and the contact. Nothing is stored,
* and there is no public route.
*
diff --git a/lib/Service/StackiqContactSyncService.php b/lib/Service/StackiqContactSyncService.php
index dc100c01f..7ebebdbca 100644
--- a/lib/Service/StackiqContactSyncService.php
+++ b/lib/Service/StackiqContactSyncService.php
@@ -473,11 +473,15 @@ public function findContactByUid(string $uid): ?array {
/**
* Find a contact by its exact UID in the signed-in user's own and shared address books only.
*
- * The system address book (one card per local user) is skipped, and the
- * UID is matched exactly, not as a substring. This is the lookup for a
- * name shown to a user: a contactsUid is free text on a contactPerson,
- * so a lookup that included the system address book would name any
- * local user for whoever may write a contactPerson.
+ * A result counts only when it comes from an address book the CardDAV
+ * backend lists for the user's principal (own and shared); every
+ * address book of the system principal is left out, the local system
+ * address book (one card per local user) and the books synced from
+ * trusted servers alike. The UID is matched exactly, not as a substring.
+ * This is the lookup for a name shown to a user: a contactsUid is free
+ * text on a contactPerson, so a lookup that read the system books would
+ * name any local or federated user for whoever may write a contactPerson.
+ * Without a CardDAV backend or a signed-in user nothing is found.
*
* @param string $uid The contact UID.
*
@@ -490,11 +494,17 @@ public function findOwnContactByUid(string $uid): ?array {
return null;
}
+ $keys = $this->userAddressBookKeys();
+ if ($keys === []) {
+ return null;
+ }
+
// With enumeration and fullmatch both off, the contacts manager skips the
- // system address book; strict_search turns the substring match off.
+ // local system address book; strict_search turns the substring match off.
$options = ['limit' => 5, 'strict_search' => true, 'enumeration' => false, 'fullmatch' => false];
foreach ($this->contactsManager->search($uid, ['UID'], $options) as $result) {
- if (($result['UID'] ?? '') === $uid) {
+ $key = (string)($result['addressbook-key'] ?? '');
+ if (($result['UID'] ?? '') === $uid && in_array($key, $keys, true) === true) {
return $result;
}
}
@@ -502,6 +512,39 @@ public function findOwnContactByUid(string $uid): ?array {
return null;
}//end findOwnContactByUid()
+ /**
+ * The keys of the signed-in user's own and shared address books.
+ *
+ * @return array CardDAV address book ids as strings; empty without a backend or a user.
+ */
+ private function userAddressBookKeys(): array {
+ try {
+ $backend = $this->container?->get(static::CARDDAV_BACKEND_CLASS);
+ $uid = $this->userSession?->getUser()?->getUID();
+ if (is_object($backend) === false || $uid === null) {
+ return [];
+ }
+
+ $books = $backend->getAddressBooksForUser('principals/users/' . $uid);
+ } catch (Throwable $e) {
+ $this->logger->warning('[StackiqContactSync] The address books of the user could not be read', ['exception' => get_class($e)]);
+ return [];
+ }
+
+ if (is_array($books) === false) {
+ return [];
+ }
+
+ $keys = [];
+ foreach ($books as $book) {
+ if (is_array($book) === true && isset($book['id']) === true) {
+ $keys[] = (string)$book['id'];
+ }
+ }
+
+ return $keys;
+ }//end userAddressBookKeys()
+
/**
* Find a Nextcloud contact matching a relationship record's identity, by
* e-mail first and — for organisations — by CBS/KvK code as a fallback.
diff --git a/openspec/specs/application-usage-pages/spec.md b/openspec/specs/application-usage-pages/spec.md
index 17ab52e66..e022b84f7 100644
--- a/openspec/specs/application-usage-pages/spec.md
+++ b/openspec/specs/application-usage-pages/spec.md
@@ -53,7 +53,7 @@ A usage SHALL carry a business owner and a technical owner, each picked from the
- **WHEN** the information manager opens Applications in use
- **THEN** the Business owner column shows the contact's name
- **AND** a user without that contact in their own or shared address books sees the contact person's id, as before
-- **AND** a request that is not signed in gets 401 and no name, and a contactsUid that is a user id names nobody
+- **AND** a request that is not signed in gets 401 and no name, and a contactsUid that is a local or remote user id names nobody
### Requirement: REQ-UAP-004 A usage moves through its lifecycle from its page
diff --git a/tests/Unit/Service/StackiqContactSyncServiceTest.php b/tests/Unit/Service/StackiqContactSyncServiceTest.php
index 62c26fc2a..670fe6cfa 100644
--- a/tests/Unit/Service/StackiqContactSyncServiceTest.php
+++ b/tests/Unit/Service/StackiqContactSyncServiceTest.php
@@ -117,8 +117,10 @@ static function (array $contact) use ($pattern, $properties): bool {
* A name lookup skips the system address book and matches the UID exactly.
*
* With enumeration and fullmatch both off, Nextcloud's contacts manager
- * skips the system address book (one card per local user, UID = user id),
- * so a contactsUid set to a user id names nobody.
+ * skips the local system address book (one card per local user, UID =
+ * user id); a result from any book outside the user's own and shared
+ * ones (such as a book synced from a trusted server) is dropped, so a
+ * contactsUid set to a local or remote user id names nobody.
*
* @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
*
@@ -127,7 +129,7 @@ static function (array $contact) use ($pattern, $properties): bool {
public function testANameLookupSkipsTheSystemAddressBookAndMatchesExactly(): void {
$manager = $this->createMock(IManager::class);
$manager->method('isEnabled')->willReturn(true);
- $manager->expects($this->exactly(2))
+ $manager->expects($this->exactly(3))
->method('search')
->with(
$this->anything(),
@@ -135,13 +137,36 @@ public function testANameLookupSkipsTheSystemAddressBookAndMatchesExactly(): voi
['limit' => 5, 'strict_search' => true, 'enumeration' => false, 'fullmatch' => false]
)
->willReturnCallback(
- static fn (string $uid): array => ($uid === 'uid-a' ? [['UID' => 'uid-a-longer', 'FN' => 'Other'], ['UID' => 'uid-a', 'FN' => 'Owner']] : [])
+ static fn (string $uid): array => match ($uid) {
+ // Book 7 is the user's own; 99 is a book synced from a trusted server.
+ 'uid-a' => [
+ ['UID' => 'uid-a-longer', 'FN' => 'Other', 'addressbook-key' => '7'],
+ ['UID' => 'uid-a', 'FN' => 'Owner', 'addressbook-key' => '7'],
+ ],
+ 'remote-user' => [['UID' => 'remote-user', 'FN' => 'Remote User', 'addressbook-key' => '99']],
+ default => [],
+ }
);
- $service = new StackiqContactSyncService(contactsManager: $manager, logger: new NullLogger());
+ $backend = new class {
+ public function getAddressBooksForUser(string $principal): array {
+ return $principal === 'principals/users/admin' ? [['id' => 7, 'uri' => 'contacts'], ['id' => 8, 'uri' => 'shared_by_bob']] : [];
+ }
+ };
+ $container = $this->createMock(ContainerInterface::class);
+ $container->method('get')->willReturn($backend);
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('admin');
+ $session = $this->createMock(IUserSession::class);
+ $session->method('getUser')->willReturn($user);
+ $service = new StackiqContactSyncService(contactsManager: $manager, logger: new NullLogger(), container: $container, userSession: $session);
$this->assertSame('Owner', $service->findOwnContactByUid(uid: 'uid-a')['FN']);
+ $this->assertNull($service->findOwnContactByUid(uid: 'remote-user'));
$this->assertNull($service->findOwnContactByUid(uid: 'admin'));
$this->assertNull($service->findOwnContactByUid(uid: ''));
+
+ $noUser = new StackiqContactSyncService(contactsManager: $manager, logger: new NullLogger(), container: $container);
+ $this->assertNull($noUser->findOwnContactByUid(uid: 'uid-a'));
}//end testANameLookupSkipsTheSystemAddressBookAndMatchesExactly()
/**
From 3671df07cb5c7a0fcad68fb4f85a56d260f3af55 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 16:54:37 +0200
Subject: [PATCH 030/124] fix(cmdb-import): stop counting rows as applications
in the reconciling progress
In the reconciling phase the service reports processed = rows + index and
total = rows + the municipality's applications, so the page showed
"410 of 810" as if it were applications missing from the export, and the bar
fell from 100% to about 50% when the phase began. The scope also holds
applications that are on the sheets and need no change.
The progress view now shows no count for that phase, says it checks the
applications of the municipality against the export, and keeps the bar full.
The l10n key is replaced in place; the docs describe the new progress line.
Review #1287 f3
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 7 ++++--
l10n/en.js | 2 +-
l10n/en.json | 2 +-
l10n/nl.js | 2 +-
l10n/nl.json | 2 +-
src/utils/cmdbImport.js | 16 ++++++++-----
src/utils/cmdbImport.spec.js | 45 ++++++++++++++++++++++++++++--------
7 files changed, 54 insertions(+), 22 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index ae9b2c240..b68dcd471 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -293,8 +293,11 @@ The comparison runs only when the import went through all rows: an import
cancelled or failed before the comparison archives or deletes nothing.
**Cancel import** during the comparison stops it before the next
application; what was archived or deleted until then stays so, and the
-result lists it. The progress bar shows the
-step as *Checking the applications missing from the export*. Each
+result lists it. During the comparison the progress line reads
+*Checking the applications of this municipality against the export* without
+a count, and the bar stays full: the comparison covers every application of
+the municipality, not only the ones missing from the export, so its length
+is not known in rows. Each
application is handled on its own; one that cannot be archived is reported
as *failed* with the step (`archive`, `restore` or `delete`) and the others
continue. Archiving needs module schema 0.3.9 and usage schema 1.5.7 or
diff --git a/l10n/en.js b/l10n/en.js
index b424aa790..f3cb07c12 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1052,7 +1052,7 @@ OC.L10N.register(
"An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Without that sheet in the file, applications are only archived. Choose Keep to leave them as they are.": "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Without that sheet in the file, applications are only archived. Choose Keep to leave them as they are.",
"Applications missing from the export cannot be archived.": "Applications missing from the export cannot be archived.",
"OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.",
- "Checking the applications missing from the export: {processed} of {total}": "Checking the applications missing from the export: {processed} of {total}",
+ "Checking the applications of this municipality against the export": "Checking the applications of this municipality against the export",
"Sheet \"%1$s\" has more than %2$s rows.": "Sheet \"%1$s\" has more than %2$s rows.",
"Sheet \"%1$s\" has no column \"%2$s\".": "Sheet \"%1$s\" has no column \"%2$s\".",
"Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is not configured: the register or its schemas cannot be found.",
diff --git a/l10n/en.json b/l10n/en.json
index c4e679026..af61de33e 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1051,7 +1051,7 @@
"An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Without that sheet in the file, applications are only archived. Choose Keep to leave them as they are.": "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Without that sheet in the file, applications are only archived. Choose Keep to leave them as they are.",
"Applications missing from the export cannot be archived.": "Applications missing from the export cannot be archived.",
"OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.",
- "Checking the applications missing from the export: {processed} of {total}": "Checking the applications missing from the export: {processed} of {total}",
+ "Checking the applications of this municipality against the export": "Checking the applications of this municipality against the export",
"Sheet \"%1$s\" has more than %2$s rows.": "Sheet \"%1$s\" has more than %2$s rows.",
"Sheet \"%1$s\" has no column \"%2$s\".": "Sheet \"%1$s\" has no column \"%2$s\".",
"Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is not configured: the register or its schemas cannot be found.",
diff --git a/l10n/nl.js b/l10n/nl.js
index b97e21dd2..325e4b120 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1120,7 +1120,7 @@ OC.L10N.register(
"An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Without that sheet in the file, applications are only archived. Choose Keep to leave them as they are.": "Een applicatie van deze gemeente die op geen van beide CMDB-tabbladen meer staat, wordt gearchiveerd als het tabblad \"Gearchiveerde Applicaties\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Staat dat tabblad niet in het bestand, dan worden applicaties alleen gearchiveerd. Kies Laat ze zoals ze zijn om ze ongemoeid te laten.",
"Applications missing from the export cannot be archived.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd.",
"OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister ontbreekt of is te oud om objecten te archiveren. Kies \"Laat ze zoals ze zijn\" en importeer opnieuw, of werk OpenRegister bij. Er is niets geïmporteerd.",
- "Checking the applications missing from the export: {processed} of {total}": "Applicaties controleren die in de export ontbreken: {processed} van {total}",
+ "Checking the applications of this municipality against the export": "De applicaties van deze gemeente met de export vergelijken",
"Sheet \"%1$s\" has more than %2$s rows.": "Tabblad \"%1$s\" heeft meer dan %2$s rijen.",
"Sheet \"%1$s\" has no column \"%2$s\".": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\".",
"Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is niet ingesteld: het register of de schema's ervan zijn niet gevonden.",
diff --git a/l10n/nl.json b/l10n/nl.json
index b5b334fce..9e075b958 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1119,7 +1119,7 @@
"An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Without that sheet in the file, applications are only archived. Choose Keep to leave them as they are.": "Een applicatie van deze gemeente die op geen van beide CMDB-tabbladen meer staat, wordt gearchiveerd als het tabblad \"Gearchiveerde Applicaties\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Staat dat tabblad niet in het bestand, dan worden applicaties alleen gearchiveerd. Kies Laat ze zoals ze zijn om ze ongemoeid te laten.",
"Applications missing from the export cannot be archived.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd.",
"OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister ontbreekt of is te oud om objecten te archiveren. Kies \"Laat ze zoals ze zijn\" en importeer opnieuw, of werk OpenRegister bij. Er is niets geïmporteerd.",
- "Checking the applications missing from the export: {processed} of {total}": "Applicaties controleren die in de export ontbreken: {processed} van {total}",
+ "Checking the applications of this municipality against the export": "De applicaties van deze gemeente met de export vergelijken",
"Sheet \"%1$s\" has more than %2$s rows.": "Tabblad \"%1$s\" heeft meer dan %2$s rijen.",
"Sheet \"%1$s\" has no column \"%2$s\".": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\".",
"Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is niet ingesteld: het register of de schema's ervan zijn niet gevonden.",
diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js
index da602f3a4..0b834dd2f 100644
--- a/src/utils/cmdbImport.js
+++ b/src/utils/cmdbImport.js
@@ -929,8 +929,12 @@ export function cancelFailureText(error) {
* server has set them, because the tracker's own percentage is weighted by
* the phases of the ArchiMate import.
*
- * In the phase `reconciling` the counts also hold the applications the
- * import checks against the export after its rows.
+ * In the phase `reconciling` the server's counts add the applications of the
+ * municipality to the rows, and those applications include ones that are on
+ * the sheets and need no change. They are neither rows nor applications
+ * missing from the export, so the view shows no "N of M" for this phase and
+ * keeps the bar where the rows left it (full) instead of letting it fall
+ * back when the total grows.
*
* @param {object|null} progress The snapshot from `GET /api/progress/{operationId}`
* @return {{percentage: number, detail: string}|null} The view, or null before any progress
@@ -947,13 +951,13 @@ export function cmdbProgressView(progress) {
total > 0
? Math.min(100, Math.round((processed / total) * 100))
: Number(progress.percentage) || 0
- if (total > 0 && progress.phase === 'reconciling') {
+ if (progress.phase === 'reconciling') {
return {
- percentage,
+ percentage: 100,
detail: t(
'stackiq',
- 'Checking the applications missing from the export: {processed} of {total}',
- { processed, total },
+ 'Checking the applications of this municipality against the export',
+ {},
AS_TEXT,
),
}
diff --git a/src/utils/cmdbImport.spec.js b/src/utils/cmdbImport.spec.js
index 24126d167..3282e3458 100644
--- a/src/utils/cmdbImport.spec.js
+++ b/src/utils/cmdbImport.spec.js
@@ -660,17 +660,42 @@ describe('cmdbProgressView', () => {
).toEqual({ percentage: 25, detail: '1 of 4 rows processed' })
})
- it('says when the import checks the applications missing from the export', () => {
- expect(
- cmdbProgressView({
- phase: 'reconciling',
- processed_items: 3,
- total_items: 4,
- }),
- ).toEqual({
- percentage: 75,
- detail: 'Checking the applications missing from the export: 3 of 4',
+ it('says the import checks the applications, without counting them as rows', () => {
+ // The service reports rows + the municipality's applications as the total.
+ const view = cmdbProgressView({
+ phase: 'reconciling',
+ processed_items: 400,
+ total_items: 810,
+ })
+ expect(view.detail).toBe(
+ 'Checking the applications of this municipality against the export',
+ )
+ expect(view.detail).not.toMatch(/\d/)
+ })
+
+ it('does not let the bar fall when the phase changes from the rows to the comparison', () => {
+ const lastOfTheRows = cmdbProgressView({
+ phase: 'importing',
+ processed_items: 400,
+ total_items: 400,
+ })
+ const firstOfTheComparison = cmdbProgressView({
+ phase: 'reconciling',
+ processed_items: 400,
+ total_items: 810,
+ })
+ const midComparison = cmdbProgressView({
+ phase: 'reconciling',
+ processed_items: 600,
+ total_items: 810,
})
+ expect(lastOfTheRows.percentage).toBe(100)
+ expect(firstOfTheComparison.percentage).toBeGreaterThanOrEqual(
+ lastOfTheRows.percentage,
+ )
+ expect(midComparison.percentage).toBeGreaterThanOrEqual(
+ lastOfTheRows.percentage,
+ )
})
it('shows nothing before any progress', () => {
From ecc9c8b0a493b93d4e858d3f7c568e80b1dfc48f Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:04:21 +0200
Subject: [PATCH 031/124] fix(cmdb-import): expose the import's write rules in
the mapping overview
The overview listed the pack field mappings only, so the usage fields
interneAnnotation and timeClassification looked like ordinary mappings while
the import writes them on create or when the stored value is empty, and the
module type Application and the never-written publication dates showed
nowhere. mappingOverview() now answers per pack `defaults`,
`createOnlyDefaults`, `createOnlyFields` and `neverWritten`, built from the
accessors the import uses, plus the profile's `externalKeyPrefix`. REQ-CMDB-020,
tasks.md and design.md name the new keys; the controller and profile tests
pin them.
Review #1288 f2
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Cmdb/CmdbImportProfile.php | 17 ++++++++++++++++-
.../changes/cmdb-import-mapping-view/design.md | 2 ++
.../specs/cmdb-export-import/spec.md | 3 ++-
.../changes/cmdb-import-mapping-view/tasks.md | 2 +-
.../SettingsControllerCmdbImportMappingTest.php | 17 +++++++++++++++++
.../Unit/Service/Cmdb/CmdbImportProfileTest.php | 4 ++++
6 files changed, 42 insertions(+), 3 deletions(-)
diff --git a/lib/Service/Cmdb/CmdbImportProfile.php b/lib/Service/Cmdb/CmdbImportProfile.php
index 238107cf5..81a256081 100644
--- a/lib/Service/Cmdb/CmdbImportProfile.php
+++ b/lib/Service/Cmdb/CmdbImportProfile.php
@@ -631,7 +631,12 @@ public function referencedColumns(): array {
* Loads and validates everything first, so a broken pack throws here as
* it does when an import starts. The transform of each field mapping is
* passed through as the pack stores it, so no key the engine reads is
- * hidden from the admin; `required` is normalised to a boolean.
+ * hidden from the admin; `required` is normalised to a boolean. Each pack
+ * also carries the write rules the import applies on top of the mapping:
+ * `defaults` (pack values set on every write), `createOnlyDefaults`
+ * (values set on create only), `createOnlyFields` (mapped fields written on
+ * create or when the stored value is empty) and `neverWritten` (fields the
+ * import never writes on an existing object).
*
* @return array{profile: array, packs: array>}
*
@@ -665,6 +670,11 @@ public function mappingOverview(): array {
];
}
+ $defaults = $pack['defaults'] ?? [];
+ if (is_array($defaults) === false) {
+ $defaults = [];
+ }
+
$packs[] = [
'target' => $target,
'file' => (string)($profile['packs'][$target] ?? ''),
@@ -673,6 +683,10 @@ public function mappingOverview(): array {
'version' => (string)($pack['version'] ?? ''),
'description' => (string)($pack['description'] ?? ''),
'fieldMappings' => $mappings,
+ 'defaults' => $defaults,
+ 'createOnlyDefaults' => $this->createOnlyDefaults(target: $target),
+ 'createOnlyFields' => $this->createOnlyFields(target: $target),
+ 'neverWritten' => $this->neverWrittenOnUpdate(target: $target),
];
}//end foreach
@@ -690,6 +704,7 @@ public function mappingOverview(): array {
'dateColumns' => $this->dateColumns(),
'idColumns' => $this->idColumns(),
'emptyValues' => $this->emptyValues(),
+ 'externalKeyPrefix' => $this->externalKeyPrefix(),
'missingRecords' => $this->missingRecordsModes(),
],
'packs' => $packs,
diff --git a/openspec/changes/cmdb-import-mapping-view/design.md b/openspec/changes/cmdb-import-mapping-view/design.md
index d8927e2f6..225ede564 100644
--- a/openspec/changes/cmdb-import-mapping-view/design.md
+++ b/openspec/changes/cmdb-import-mapping-view/design.md
@@ -46,6 +46,8 @@ The method carries no auth attribute (Nextcloud's default: admins with a CSRF to
`fieldMappings[].transform` is passed through as the pack stores it (`type` plus `map`/`default`, `fields`/`separator`, `value`, `sourceFormat`/`targetFormat`). Re-shaping per type would hide a key the engine reads. The page renders the known keys and lists any other scalar key as it is. `required` is normalised to a boolean.
+The write rules the import applies besides the mapping are answered per pack, built from the accessors the import uses (`createOnlyDefaults()`, `createOnlyFields()`, `neverWrittenOnUpdate()`) and the pack's own `defaults`, so the view does not overclaim for a field the import writes only on create or never.
+
### D4. The section takes the sheet names from the endpoint, with the constant as fallback
`CmdbImportMapping.vue` fetches once on creation and emits `loaded` with the answer; `CmdbImport.vue` computes `sheetNames` from it, or from `PROFILE_DEFAULTS.sheets` until it arrives or when it fails. The constant stays, as the design of cmdb-export-import intended: a fallback, never the source.
diff --git a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
index 1c516174e..9d807394b 100644
--- a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
@@ -16,7 +16,7 @@ Nextcloud OCP interfaces used: `OCP\IL10N` (messages). OpenRegister: `Service\Mi
### Requirement: The admin settings SHALL show the mapping the import uses (REQ-CMDB-020)
-`GET /api/settings/cmdb-import/mapping` SHALL be reachable only by Nextcloud admins and SHALL require Nextcloud's CSRF token; like the import routes (REQ-CMDB-001) it SHALL NOT carry `#[AuthorizedAdminSetting]`, `#[NoAdminRequired]` or `#[NoCSRFRequired]`. It SHALL load the import profile and every pack it names through the same loader and validator the import uses, and SHALL answer 200 with a flat envelope of two keys: `profile` (the profile's `id`, `name`, `version`, `sheets` with each sheet's `name`, `constants` and `absentColumns`, `sheetPrecedence`, `keyColumn`, `nameColumn`, `requiredColumns`, `dateColumns`, `idColumns`, `emptyValues`, `missingRecords` and `profileFile`) and `packs`, one entry per target in the order module, manufacturer, municipality, usage, businessOwner, each with `target`, `file`, `id`, `name`, `version`, `description` and `fieldMappings`. Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it: the `type`, and for `lookup` and `bool-map` the `map` and `default`, for `concat` the `fields` and `separator`, for `const` the `value`, for `date` the `sourceFormat` and `targetFormat`. The endpoint SHALL read nothing but those files and SHALL write nothing. When the validator is missing, or the profile or a pack is unreadable or invalid, it SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason`, so the administrator who edited a file learns what is wrong without the Nextcloud log. The "CMDB import" section SHALL show the answer in a collapsible block "Mapping (read-only)": the sheets, the key, name, required and date columns, and one table per pack with the source column, the target field, whether it is required, the transformation and its details. The block SHALL show a loading state until the endpoint answers and the error with its code when it fails, and SHALL offer no editing: the mapping is changed in the files on the server, as the documentation describes.
+`GET /api/settings/cmdb-import/mapping` SHALL be reachable only by Nextcloud admins and SHALL require Nextcloud's CSRF token; like the import routes (REQ-CMDB-001) it SHALL NOT carry `#[AuthorizedAdminSetting]`, `#[NoAdminRequired]` or `#[NoCSRFRequired]`. It SHALL load the import profile and every pack it names through the same loader and validator the import uses, and SHALL answer 200 with a flat envelope of two keys: `profile` (the profile's `id`, `name`, `version`, `sheets` with each sheet's `name`, `constants` and `absentColumns`, `sheetPrecedence`, `keyColumn`, `nameColumn`, `requiredColumns`, `dateColumns`, `idColumns`, `emptyValues`, `externalKeyPrefix`, `missingRecords` and `profileFile`) and `packs`, one entry per target in the order module, manufacturer, municipality, usage, businessOwner, each with `target`, `file`, `id`, `name`, `version`, `description`, `fieldMappings` and the write rules the import applies besides the mapping: `defaults` (the pack's values, field => value), `createOnlyDefaults` (values set on create only, field => value), `createOnlyFields` (mapped fields written on create or, on an existing object, only when the stored value is empty) and `neverWritten` (fields the import never writes on an existing object); the last three come from the profile's `createOnly` and `neverWritten` through the accessors the import itself uses. Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it: the `type`, and for `lookup` and `bool-map` the `map` and `default`, for `concat` the `fields` and `separator`, for `const` the `value`, for `date` the `sourceFormat` and `targetFormat`. The endpoint SHALL read nothing but those files and SHALL write nothing. When the validator is missing, or the profile or a pack is unreadable or invalid, it SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason`, so the administrator who edited a file learns what is wrong without the Nextcloud log. The "CMDB import" section SHALL show the answer in a collapsible block "Mapping (read-only)": the sheets, the key, name, required and date columns, and one table per pack with the source column, the target field, whether it is required, the transformation and its details. The block SHALL show a loading state until the endpoint answers and the error with its code when it fails, and SHALL offer no editing: the mapping is changed in the files on the server, as the documentation describes.
#### Scenario: An admin reads the mapping the import uses
@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
@@ -26,6 +26,7 @@ Nextcloud OCP interfaces used: `OCP\IL10N` (messages). OpenRegister: `Service\Mi
- **THEN** the endpoint SHALL answer 200 with `profile.sheets` naming "Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB", `profile.keyColumn` `APPID` and `profile.requiredColumns` `APPID` and `Applicatie Naam`
- **AND** `packs` SHALL hold five entries with targets module, manufacturer, municipality, usage and businessOwner, each with its pack id, name and version
- **AND** the usage pack SHALL list a mapping from "Applicatie Status" to `status` with a `lookup` transform whose `map` sends "In productie" to "In production"
+- **AND** the usage pack's `createOnlyFields` SHALL be `interneAnnotation` and `timeClassification`, the module pack's `createOnlyDefaults` SHALL be `type` = `Application` and its `neverWritten` SHALL be `publicationDate` and `depublicationDate`
#### Scenario: The section shows one table per pack
@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
diff --git a/openspec/changes/cmdb-import-mapping-view/tasks.md b/openspec/changes/cmdb-import-mapping-view/tasks.md
index fe8aa0e8f..36fe883c7 100644
--- a/openspec/changes/cmdb-import-mapping-view/tasks.md
+++ b/openspec/changes/cmdb-import-mapping-view/tasks.md
@@ -8,7 +8,7 @@ Spec: `openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.m
- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020)
- **files**: `lib/Service/Cmdb/CmdbImportProfile.php`, `tests/Unit/Service/Cmdb/CmdbImportProfileTest.php`
- **acceptance_criteria**:
- - GIVEN the shipped directory WHEN `mappingOverview()` is called THEN it loads and validates every pack first and answers `profile` and `packs` with five entries in TARGETS order, each with target, file, id, name, version, description and fieldMappings
+ - GIVEN the shipped directory WHEN `mappingOverview()` is called THEN it loads and validates every pack first and answers `profile` and `packs` with five entries in TARGETS order, each with target, file, id, name, version, description, fieldMappings and the write rules (defaults, createOnlyDefaults, createOnlyFields, neverWritten)
- GIVEN a field mapping WHEN it is shaped THEN `required` is a boolean and `transform` is the stored array
- GIVEN a broken pack WHEN `mappingOverview()` is called THEN it throws `CmdbImportException` MAPPING_UNAVAILABLE with the file in its message
- [x] Implement
diff --git a/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php b/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
index d02b54498..61ec942cb 100644
--- a/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
+++ b/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
@@ -210,6 +210,7 @@ public function testTheShippedMappingIsAnsweredFlat(): void {
$shipped = json_decode((string)file_get_contents(__DIR__ . '/../../../lib/Settings/cmdb-import/topdesk-profile.json'), true);
$this->assertSame($shipped['missingRecords'], $data['profile']['missingRecords']);
$this->assertSame('topdesk-profile.json', $data['profile']['profileFile']);
+ $this->assertSame('topdesk', $data['profile']['externalKeyPrefix']);
$this->assertSame(CmdbImportProfile::TARGETS, array_column($data['packs'], 'target'));
$this->assertCount(5, $data['packs']);
@@ -229,6 +230,22 @@ public function testTheShippedMappingIsAnsweredFlat(): void {
$this->assertCount(count($profile->pack(target: $pack['target'])['fieldMappings']), $pack['fieldMappings'], $pack['target']);
}
+ // The write rules the import applies besides the mapping (Review #1288 f2).
+ $byTarget = array_column($data['packs'], null, 'target');
+ $this->assertSame(['type' => 'Application'], $byTarget['module']['createOnlyDefaults']);
+ $this->assertSame(['type'], $byTarget['module']['createOnlyFields']);
+ $this->assertSame(['publicationDate', 'depublicationDate'], $byTarget['module']['neverWritten']);
+ $this->assertSame(['interneAnnotation', 'timeClassification'], $byTarget['usage']['createOnlyFields']);
+ $this->assertSame([], $byTarget['usage']['createOnlyDefaults']);
+ $this->assertSame([], $byTarget['usage']['neverWritten']);
+ $this->assertSame(['type' => 'Supplier', 'status' => 'Active', 'registeredBy' => 'Supplier'], $byTarget['manufacturer']['defaults']);
+ $this->assertSame(['type' => 'Municipality', 'status' => 'Active'], $byTarget['municipality']['defaults']);
+ $this->assertSame([], $byTarget['module']['defaults']);
+ foreach ($data['packs'] as $pack) {
+ $this->assertSame($profile->createOnlyFields(target: $pack['target']), $pack['createOnlyFields'], $pack['target']);
+ $this->assertSame($profile->neverWrittenOnUpdate(target: $pack['target']), $pack['neverWritten'], $pack['target']);
+ }
+
$module = $data['packs'][0]['fieldMappings'][0];
$this->assertSame(['source' => 'Applicatie Naam', 'target' => 'name', 'required' => true, 'transform' => ['type' => 'trim']], $module);
$this->assertFalse($data['packs'][0]['fieldMappings'][2]['required'], 'Applicatie Code is optional');
diff --git a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php
index 81a123406..5ca02614f 100644
--- a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php
+++ b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php
@@ -328,6 +328,10 @@ public function testTheOverviewShowsWhatTheImportRuns(): void {
foreach ($pack['fieldMappings'] as $mapping) {
$this->assertIsBool($mapping['required'], $pack['target']);
}
+
+ $this->assertSame($profile->createOnlyDefaults(target: $pack['target']), $pack['createOnlyDefaults'], $pack['target']);
+ $this->assertSame($profile->createOnlyFields(target: $pack['target']), $pack['createOnlyFields'], $pack['target']);
+ $this->assertSame($profile->neverWrittenOnUpdate(target: $pack['target']), $pack['neverWritten'], $pack['target']);
}
$directory = $this->copyOfShippedDirectory();
From bbf420962c760a492117e33470349e92b54d3f1b Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:04:23 +0200
Subject: [PATCH 032/124] fix(cmdb-import): say applications, not only rows, in
the cancel texts
After Cancel the section said the import "stops before the next row" and
"the rows processed before it stopped are kept". During the comparison it
stops before the next application, and the applications archived or deleted
until then stay too; the cancelled banner told the admin nothing of that
while the report listed them.
Both strings now name applications and the l10n keys are replaced in place;
the docs say the same. The spec mounts the section for real so the banner and
the status line are read from the page.
Review #1287 f4
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 5 +-
l10n/en.js | 4 +-
l10n/en.json | 4 +-
l10n/nl.js | 4 +-
l10n/nl.json | 4 +-
.../settings/sections/CmdbImport.spec.js | 155 ++++++++++++++++--
src/views/settings/sections/CmdbImport.vue | 4 +-
7 files changed, 151 insertions(+), 29 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index b68dcd471..84bd60acd 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -86,8 +86,9 @@ page in stackiq.
leave such applications untouched.
7. Press **Import**. A progress bar shows how many rows have been processed.
**Cancel import** stops the import before the next row, or, while the
- missing applications are compared, before the next application; what
- was already processed stays imported. The section says whether the server
+ applications are compared with the export, before the next application;
+ what was already processed stays: the rows imported, and the applications
+ archived or deleted so far. The section says whether the server
accepted the cancel; one pressed before the server has started on the
rows cannot take effect yet, and the section says so.
diff --git a/l10n/en.js b/l10n/en.js
index f3cb07c12..101da2012 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1017,7 +1017,7 @@ OC.L10N.register(
"The import failed unexpectedly.": "The import failed unexpectedly.",
"The import mapping cannot run.": "The import mapping cannot run.",
"The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.",
- "The import was cancelled. The rows processed before it stopped are kept.": "The import was cancelled. The rows processed before it stopped are kept.",
+ "The import was cancelled. The rows and applications processed before it stopped are kept.": "The import was cancelled. The rows and applications processed before it stopped are kept.",
"The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.",
"The server could not be reached.": "The server could not be reached.",
"The sheet \"{sheet}\" has no column \"{column}\".": "The sheet \"{sheet}\" has no column \"{column}\".",
@@ -1108,7 +1108,7 @@ OC.L10N.register(
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.",
"The import could not be cancelled: {reason}": "The import could not be cancelled: {reason}",
- "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "Cancelling the import. It stops before the next row; the rows already processed stay imported.",
+ "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.": "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.",
"Showing {shown} of {total} rows.": "Showing {shown} of {total} rows.",
"Show {count} more rows": "Show {count} more rows",
"The request field \"{field}\" has a value the import does not accept.": "The request field \"{field}\" has a value the import does not accept.",
diff --git a/l10n/en.json b/l10n/en.json
index af61de33e..f43f75422 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1016,7 +1016,7 @@
"The import failed unexpectedly.": "The import failed unexpectedly.",
"The import mapping cannot run.": "The import mapping cannot run.",
"The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.",
- "The import was cancelled. The rows processed before it stopped are kept.": "The import was cancelled. The rows processed before it stopped are kept.",
+ "The import was cancelled. The rows and applications processed before it stopped are kept.": "The import was cancelled. The rows and applications processed before it stopped are kept.",
"The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.",
"The server could not be reached.": "The server could not be reached.",
"The sheet \"{sheet}\" has no column \"{column}\".": "The sheet \"{sheet}\" has no column \"{column}\".",
@@ -1107,7 +1107,7 @@
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.",
"The import could not be cancelled: {reason}": "The import could not be cancelled: {reason}",
- "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "Cancelling the import. It stops before the next row; the rows already processed stay imported.",
+ "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.": "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.",
"Showing {shown} of {total} rows.": "Showing {shown} of {total} rows.",
"Show {count} more rows": "Show {count} more rows",
"The request field \"{field}\" has a value the import does not accept.": "The request field \"{field}\" has a value the import does not accept.",
diff --git a/l10n/nl.js b/l10n/nl.js
index 325e4b120..a8753498c 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1085,7 +1085,7 @@ OC.L10N.register(
"The import failed unexpectedly.": "De import is onverwacht mislukt.",
"The import mapping cannot run.": "De mapping van de import kan niet worden uitgevoerd.",
"The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "De import leest werkmappen met de spreadsheetbibliotheek die met OpenRegister wordt meegeleverd. Zorg dat OpenRegister is geïnstalleerd en ingeschakeld.",
- "The import was cancelled. The rows processed before it stopped are kept.": "De import is geannuleerd. De rijen die vóór het stoppen zijn verwerkt, blijven behouden.",
+ "The import was cancelled. The rows and applications processed before it stopped are kept.": "De import is geannuleerd. De rijen en applicaties die vóór het stoppen zijn verwerkt, blijven behouden.",
"The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "Het organisatieregister is niet ingesteld, dus bestaande gemeenten kunnen niet worden getoond. U kunt nog steeds de naam van een gemeente typen.",
"The server could not be reached.": "De server is niet bereikbaar.",
"The sheet \"{sheet}\" has no column \"{column}\".": "Het tabblad \"{sheet}\" heeft geen kolom \"{column}\".",
@@ -1176,7 +1176,7 @@ OC.L10N.register(
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "De verbinding werd verbroken voordat de import antwoordde, dus de import kan nog bezig zijn of al klaar zijn. Wacht een paar minuten en controleer de applicaties van de gemeente voordat u opnieuw importeert. Hetzelfde bestand opnieuw importeren maakt geen dubbele records aan.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "De import kan nu niet worden geannuleerd: de server is nog niet aan de rijen begonnen, of is er al mee klaar. Loopt de import door, druk dan zo dadelijk opnieuw op Import annuleren.",
"The import could not be cancelled: {reason}": "De import kon niet worden geannuleerd: {reason}",
- "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "De import wordt geannuleerd. Hij stopt voor de volgende rij; de rijen die al verwerkt zijn blijven geïmporteerd.",
+ "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.": "De import wordt geannuleerd. Hij stopt voor de volgende rij of, tijdens het vergelijken van de applicaties met de export, voor de volgende applicatie; wat al verwerkt is blijft staan.",
"Showing {shown} of {total} rows.": "{shown} van {total} rijen weergegeven.",
"Show {count} more rows": "Toon nog {count} rijen",
"The request field \"{field}\" has a value the import does not accept.": "Het veld \"{field}\" van het verzoek heeft een waarde die de import niet accepteert.",
diff --git a/l10n/nl.json b/l10n/nl.json
index 9e075b958..eccc3a4c7 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1084,7 +1084,7 @@
"The import failed unexpectedly.": "De import is onverwacht mislukt.",
"The import mapping cannot run.": "De mapping van de import kan niet worden uitgevoerd.",
"The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "De import leest werkmappen met de spreadsheetbibliotheek die met OpenRegister wordt meegeleverd. Zorg dat OpenRegister is geïnstalleerd en ingeschakeld.",
- "The import was cancelled. The rows processed before it stopped are kept.": "De import is geannuleerd. De rijen die vóór het stoppen zijn verwerkt, blijven behouden.",
+ "The import was cancelled. The rows and applications processed before it stopped are kept.": "De import is geannuleerd. De rijen en applicaties die vóór het stoppen zijn verwerkt, blijven behouden.",
"The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "Het organisatieregister is niet ingesteld, dus bestaande gemeenten kunnen niet worden getoond. U kunt nog steeds de naam van een gemeente typen.",
"The server could not be reached.": "De server is niet bereikbaar.",
"The sheet \"{sheet}\" has no column \"{column}\".": "Het tabblad \"{sheet}\" heeft geen kolom \"{column}\".",
@@ -1175,7 +1175,7 @@
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "De verbinding werd verbroken voordat de import antwoordde, dus de import kan nog bezig zijn of al klaar zijn. Wacht een paar minuten en controleer de applicaties van de gemeente voordat u opnieuw importeert. Hetzelfde bestand opnieuw importeren maakt geen dubbele records aan.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "De import kan nu niet worden geannuleerd: de server is nog niet aan de rijen begonnen, of is er al mee klaar. Loopt de import door, druk dan zo dadelijk opnieuw op Import annuleren.",
"The import could not be cancelled: {reason}": "De import kon niet worden geannuleerd: {reason}",
- "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "De import wordt geannuleerd. Hij stopt voor de volgende rij; de rijen die al verwerkt zijn blijven geïmporteerd.",
+ "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.": "De import wordt geannuleerd. Hij stopt voor de volgende rij of, tijdens het vergelijken van de applicaties met de export, voor de volgende applicatie; wat al verwerkt is blijft staan.",
"Showing {shown} of {total} rows.": "{shown} van {total} rijen weergegeven.",
"Show {count} more rows": "Toon nog {count} rijen",
"The request field \"{field}\" has a value the import does not accept.": "Het veld \"{field}\" van het verzoek heeft een waarde die de import niet accepteert.",
diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js
index 7a16b9858..9c1f16dfe 100644
--- a/src/views/settings/sections/CmdbImport.spec.js
+++ b/src/views/settings/sections/CmdbImport.spec.js
@@ -9,7 +9,7 @@
*/
import axios from '@nextcloud/axios'
-import { flushPromises, shallowMount } from '@vue/test-utils'
+import { flushPromises, mount, shallowMount } from '@vue/test-utils'
import CmdbImport from './CmdbImport.vue'
// Virtual: the package's `exports` field declares only the `import` condition, which jest does not resolve.
@@ -26,25 +26,85 @@ jest.mock('@nextcloud/router', () => ({
url.replace(/{(\w+)}/g, (match, key) => String(params[key] ?? match)),
generateOcsUrl: (url) => url,
}))
-// The component libraries are stubbed by shallowMount; virtual for the same `exports` reason.
+// The component libraries are stubbed; virtual for the same `exports` reason.
+// shallowMount replaces them with empty stubs. The ones below also render what
+// the tests that mount the section for real (`mountFull`) need to see: the
+// slots, the radio's value and the table's cell slots.
jest.mock(
'@nextcloud/vue',
- () => ({
- NcButton: { render: () => null },
- NcCheckboxRadioSwitch: { render: () => null },
- NcLoadingIcon: { render: () => null },
- NcNoteCard: { render: () => null },
- NcProgressBar: { render: () => null },
- NcSelect: { render: () => null },
- }),
+ () => {
+ const { h } = require('vue')
+ const withSlot = {
+ render() {
+ return h('div', this.$slots.default?.())
+ },
+ }
+ return {
+ NcButton: {
+ render() {
+ return h('button', this.$slots.default?.())
+ },
+ },
+ NcCheckboxRadioSwitch: {
+ props: {
+ modelValue: { default: null },
+ value: { default: null },
+ name: { default: '' },
+ type: { default: 'checkbox' },
+ disabled: { default: false },
+ },
+ emits: ['update:modelValue'],
+ render() {
+ const radio = this.type === 'radio'
+ return h('label', [
+ h('input', {
+ type: radio ? 'radio' : 'checkbox',
+ name: this.name,
+ value: this.value,
+ disabled: this.disabled,
+ checked: radio
+ ? this.modelValue === this.value
+ : Boolean(this.modelValue),
+ onChange: (event) =>
+ this.$emit(
+ 'update:modelValue',
+ radio ? this.value : event.target.checked,
+ ),
+ }),
+ this.$slots.default?.(),
+ ])
+ },
+ },
+ NcLoadingIcon: { render: () => null },
+ NcNoteCard: withSlot,
+ NcProgressBar: { render: () => null },
+ NcSelect: { render: () => null },
+ }
+ },
{ virtual: true },
)
jest.mock(
'@conduction/nextcloud-vue',
- () => ({
- CnDataTable: { render: () => null },
- CnStatusBadge: { render: () => null },
- }),
+ () => {
+ const { h } = require('vue')
+ return {
+ // One element per row, with the cell slots the section fills.
+ CnDataTable: {
+ props: { rows: { default: () => [] } },
+ render() {
+ return h(
+ 'div',
+ this.rows.map((row) =>
+ h('div', { 'data-testid': 'table-row' }, [
+ h('span', this.$slots['column-name']?.({ row })),
+ ]),
+ ),
+ )
+ },
+ },
+ CnStatusBadge: { render: () => null },
+ }
+ },
{ virtual: true },
)
// The icon SFCs live in node_modules, which jest does not transform.
@@ -55,9 +115,14 @@ jest.mock('vue-material-design-icons/DatabaseImport.vue', () => ({
jest.mock('vue-material-design-icons/TrayArrowUp.vue', () => ({
render: () => null,
}))
-jest.mock('../../../components/AlwaysVisibleSection.vue', () => ({
- render: () => null,
-}))
+jest.mock('../../../components/AlwaysVisibleSection.vue', () => {
+ const { h } = require('vue')
+ return {
+ render() {
+ return h('div', this.$slots.default?.())
+ },
+ }
+})
const ORGANISATIONS = [
{
@@ -104,6 +169,24 @@ async function mountSection() {
return wrapper
}
+/**
+ * Mount the section with its child components rendering what the tests read.
+ *
+ * @return {Promise} The wrapper, after the municipalities loaded
+ */
+async function mountFull() {
+ axios.get.mockImplementation((url) =>
+ Promise.resolve(
+ url.includes('/voorzieningen/config')
+ ? { data: { config: { register: 7, organisatie_schema: 33 } } }
+ : { data: { results: ORGANISATIONS } },
+ ),
+ )
+ const wrapper = mount(CmdbImport)
+ await flushPromises()
+ return wrapper
+}
+
describe('CmdbImport municipality chooser', () => {
afterEach(() => {
jest.clearAllMocks()
@@ -205,3 +288,41 @@ describe('CmdbImport applications missing from the export', () => {
)
})
})
+
+/**
+ * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-a-running-import-shall-report-its-progress-and-shall-stop-when-cancelled-req-cmdb-013
+ */
+describe('CmdbImport cancelling', () => {
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('says after Cancel that the comparison of the applications stops before the next application', async () => {
+ const wrapper = await mountFull()
+ axios.post.mockResolvedValue({ data: { success: true } })
+ await wrapper.setData({
+ importing: true,
+ operationId: 'cmdb-12345678',
+ progress: { phase: 'reconciling', processed_items: 400, total_items: 810 },
+ })
+
+ await wrapper.vm.cancelImport()
+ await flushPromises()
+
+ const status = wrapper.find('[data-testid="cmdb-import-cancel-status"]').text()
+ expect(status).toContain('before the next row')
+ expect(status).toContain('before the next application')
+ })
+
+ it('says that the applications processed before the stop are kept, not only rows', async () => {
+ const wrapper = await mountFull()
+
+ await wrapper.setData({
+ report: { cancelled: true, summary: { rowsRead: 400, archived: 3 }, rows: [] },
+ })
+
+ expect(wrapper.find('[data-testid="cmdb-import-cancelled"]').text()).toMatch(
+ /applications/,
+ )
+ })
+})
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index 6d728fec7..c4d804834 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -272,7 +272,7 @@
{{
t(
'stackiq',
- 'The import was cancelled. The rows processed before it stopped are kept.',
+ 'The import was cancelled. The rows and applications processed before it stopped are kept.',
)
}}
@@ -1082,7 +1082,7 @@ export default {
})
this.cancelStatus = t(
'stackiq',
- 'Cancelling the import. It stops before the next row; the rows already processed stay imported.',
+ 'Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.',
)
} catch (error) {
// The import keeps running; say why, and let the admin press Cancel again.
From 16337d9144d8d0ca1f32480a27ba0bf9eddd3267 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:04:54 +0200
Subject: [PATCH 033/124] fix(cmdb-import): report archive-sheet APPIDs that
have no cached value
The archive sheet is formula-derived. A row whose APPID formula carries no
cached value read as an empty row and was skipped without trace, so the
sheet reported present: true with one APPID fewer. The reconciliation would
then see that application on no sheet and soft-delete it instead of
archiving it, with nothing in the report saying why. REQ-CMDB-002 already
treats such a cell on the CMDB sheets as empty plus a row warning.
readRows() now hands back the numbers of rows that read as empty only
because a formula in them is uncached, and the reader returns them as
archive.uncachedRows. The spec delta (REQ-CMDB-015, new scenario), design
D3/D5 and task 2 state the rule: such a sheet is incomplete, so the import
warns naming the sheet and rows and archives every missing application,
deleting none. The service side follows in the stacked PR.
Review #1285 f1
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Cmdb/CmdbWorkbookReader.php | 22 +++++++++--
.../design.md | 9 +++--
.../specs/cmdb-export-import/spec.md | 10 ++++-
.../tasks.md | 1 +
.../Service/Cmdb/CmdbWorkbookReaderTest.php | 37 +++++++++++++++++--
5 files changed, 67 insertions(+), 12 deletions(-)
diff --git a/lib/Service/Cmdb/CmdbWorkbookReader.php b/lib/Service/Cmdb/CmdbWorkbookReader.php
index 9bdf9b9a6..4723f33fa 100644
--- a/lib/Service/Cmdb/CmdbWorkbookReader.php
+++ b/lib/Service/Cmdb/CmdbWorkbookReader.php
@@ -155,14 +155,18 @@ public function isAvailable(): bool {
* header row, once for the resolved columns of the data rows, so no other
* cell is ever materialised.
* The archive sheet, when the workbook has it, is read under the same
- * bounds as a source sheet, for its key column only.
+ * bounds as a source sheet, for its key column only. A row whose key is a
+ * formula without a cached value is read as empty, as on a source sheet,
+ * and its row number is listed in `archive.uncachedRows`: the sheet then
+ * lists an application the reader could not see, so a caller must not
+ * treat the sheet as complete.
*
* @param string $path The xlsx file, already checked by assertXlsx().
* @param CmdbImportProfile $profile The import profile.
*
* @return array `rows` (list of {sheet, row, cells, uncached}), `importWarnings`
* (list of {sheet, message}), `date1904` (bool) and `archive`
- * ({sheet, present, keyColumnMissing, appIds}).
+ * ({sheet, present, keyColumnMissing, appIds, uncachedRows}).
*
* @throws CmdbImportException WORKBOOK_TOO_LARGE, READER_UNAVAILABLE, NOT_XLSX, NO_SOURCE_SHEET,
* MISSING_COLUMN or TOO_MANY_ROWS.
@@ -344,6 +348,7 @@ private function readBounded(string $path, CmdbImportProfile $profile, object $s
'present' => false,
'keyColumnMissing' => ($loaded !== $present && $archiveColumns === []),
'appIds' => [],
+ 'uncachedRows' => [],
];
$letters = array_map(static fn (array $columns): array => array_keys($columns), $resolved['columns']);
$loaded = $present;
@@ -356,13 +361,16 @@ private function readBounded(string $path, CmdbImportProfile $profile, object $s
try {
$rows = $this->readSourceRows(spreadsheet: $spreadsheet, sheetNames: $present, columnsPerSheet: $resolved['columns'], limit: $limit);
if ($archiveColumns !== []) {
+ $unseen = [];
$archiveRows = $this->readRows(
worksheet: $spreadsheet->getSheetByName($archiveSheet),
columns: $archiveColumns,
sheetName: (string)$archiveSheet,
- limit: $limit
+ limit: $limit,
+ unseen: $unseen
);
$archive['present'] = true;
+ $archive['uncachedRows'] = $unseen;
$archive['appIds'] = array_column(array_column($archiveRows, 'cells'), $profile->keyColumn());
}
@@ -712,12 +720,14 @@ private function resolveColumns(object $worksheet, array $referenced): array {
* @param array $columns Column letter => column name.
* @param string $sheetName The sheet name.
* @param int $limit The maximum number of non-empty rows.
+ * @param array|null $unseen Filled with the number of each row that read as empty only because
+ * a formula in it has no cached value; null to not collect them.
*
* @return array, uncached: array}>
*
* @throws CmdbImportException TOO_MANY_ROWS.
*/
- private function readRows(object $worksheet, array $columns, string $sheetName, int $limit): array {
+ private function readRows(object $worksheet, array $columns, string $sheetName, int $limit, ?array &$unseen = null): array {
$rows = [];
$lastRow = (int)$worksheet->getHighestDataRow();
for ($rowNumber = 2; $rowNumber <= $lastRow; $rowNumber++) {
@@ -727,6 +737,10 @@ private function readRows(object $worksheet, array $columns, string $sheetName,
rowNumber: $rowNumber
);
if ($empty === true) {
+ if ($uncached !== [] && $unseen !== null) {
+ $unseen[] = $rowNumber;
+ }
+
continue;
}
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/design.md b/openspec/changes/cmdb-import-archive-reconciliation/design.md
index f71cfcce6..85ae87766 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/design.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/design.md
@@ -41,7 +41,7 @@ CmdbExportImportService::import()
load their modules in batches (_ids: working, then _archived=true; the rest from the trash)
per application: key on CMDB sheets → nothing
key on archive sheet → archive module + usage (restore first when deleted)
- key nowhere → soft-delete module + usage, or archive when the sheet is absent
+ key nowhere → soft-delete module + usage, or archive when the sheet is absent or incomplete (D3)
each in its own boundary → report rows archived | deleted | failed
▼
OpenRegister: module, usage (archived / deleted markers); contactPerson, organization untouched
@@ -68,9 +68,10 @@ With `updateExisting=false`, a matched row is skipped as `exists` before anythin
### D3. Reading the archive sheet
-The profile names the sheet (`archiveSheet.name`: "Gearchiveerde Applicaties"). The reader lists the workbook's sheets, and when the archive sheet is present it is loaded with the CMDB sheets under the same bounds: it counts as a read sheet for `CmdbPartReferences` (an oversized archive sheet is refused, not blanked) and for the referenced-string budget, its row span is bounded like a source sheet, and `TOO_MANY_ROWS` names it. Only its APPID column is resolved and read; the allowlist does not grow. The reader returns `archive: {present, keyColumnMissing, appIds}` next to `rows`.
+The profile names the sheet (`archiveSheet.name`: "Gearchiveerde Applicaties"). The reader lists the workbook's sheets, and when the archive sheet is present it is loaded with the CMDB sheets under the same bounds: it counts as a read sheet for `CmdbPartReferences` (an oversized archive sheet is refused, not blanked) and for the referenced-string budget, its row span is bounded like a source sheet, and `TOO_MANY_ROWS` names it. Only its APPID column is resolved and read; the allowlist does not grow. The reader returns `archive: {present, keyColumnMissing, appIds, uncachedRows}` next to `rows`.
- Sheet absent, or present without an APPID column: `present` false (with `keyColumnMissing`), and the service adds a report warning. The reconciliation then archives every missing application and soft-deletes nothing: a workbook saved without the sheet must not empty the trash-bound set by accident.
+- A row whose APPID is a formula without a cached value reads as empty, as on the CMDB sheets (REQ-CMDB-002), so the sheet would silently list one application fewer. The reader therefore lists such rows (their numbers) in `uncachedRows`, and the service treats the sheet like an absent one for the decision between archive and delete: it adds a warning naming the sheet and the rows, archives every missing application and soft-deletes nothing. A tool that saves formulas without cached values must not turn archived applications into trash.
- The archive sheet's other columns are not read; `Applicatie Status` there is always `Verwijderd` in the municipality's export and adds nothing.
### D4. Matching widens to archived and deleted objects, and revives what it matches
@@ -93,8 +94,8 @@ Per application, with `processed` = every APPID match key on the CMDB sheets (wh
| on the archive sheet | deleted | restore, then archive | `archived` |
| nowhere, sheet present | working or archived | soft-delete usage, then module | `deleted` |
| nowhere, sheet present | deleted | nothing | — |
-| nowhere, sheet absent | working | archive usage, then module | `archived` |
-| nowhere, sheet absent | archived or deleted | nothing | — |
+| nowhere, sheet absent or incomplete (no key column, or `uncachedRows` not empty) | working | archive usage, then module | `archived` |
+| nowhere, sheet absent or incomplete | archived or deleted | nothing | — |
The usage is written before the module, so a failure between the two leaves the municipality's usage in the new state and the module visible, never the reverse. A module another municipality still uses is archived or deleted all the same when this municipality's usage says so: the module carries this municipality's import key, so it is this municipality's record (REQ-CMDB-006 allows a shared module only when the other organisation's usage was added by hand). The archive reason is `cmdb-import: on sheet "Gearchiveerde Applicaties"` or `cmdb-import: not in the export`, plus the operation id, so the audit trail says why.
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
index 1ac827456..78903af22 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
@@ -16,7 +16,7 @@ OpenRegister: `OCA\OpenRegister\Contract\ObjectServiceInterface` (`searchObjects
### Requirement: An application missing from the CMDB sheets SHALL be archived when the archive sheet lists it and soft-deleted when no sheet does (REQ-CMDB-015)
-With `missingRecords` `archive`, after every row of an import that was not cancelled and did not fail, the import SHALL reconcile, under the register lock it already holds, every `usage` whose `consumer` is the imported municipality together with the `module` it points at, including archived and soft-deleted ones, and only those whose module carries this municipality's import key (`topdesk::`). It SHALL read the APPID column of the sheet "Gearchiveerde Applicaties" when the workbook has it, under the same size and row bounds as the CMDB sheets and without reading any other column of it. For an application whose APPID is on neither CMDB sheet: when the archive sheet lists the APPID, the import SHALL archive the usage and then the module through OpenRegister's archive state with a reason that names the import, restoring a soft-deleted one from the trash first; when no sheet lists it, the import SHALL soft-delete the usage and then the module through OpenRegister's trash. When the workbook has no archive sheet, or the sheet has no APPID column, the import SHALL archive and SHALL NOT soft-delete, and SHALL add a warning to the report. An application that is already in the state the export asks for SHALL be left alone and not reported. The import SHALL NOT touch contact persons, organisations, modules that carry another municipality's import key or no import key, or usages of another organisation. Each application SHALL be reconciled in its own error boundary: a failure SHALL be a `failed` report row naming the step and the exception class, and the other applications SHALL still be reconciled. The reconciliation SHALL report its progress as the phase `reconciling` of the import's operation. A cancel SHALL also be honoured after the last row and before each application of the reconciliation: the import SHALL stop there, SHALL keep what it archived or deleted so far, and SHALL report the run as cancelled. The report SHALL count `archived` and `deleted` in its summary and SHALL list each as a row with the APPID, the application name and both uuids, never person data. When OpenRegister's archive handler or object mapper is not available, an import with `missingRecords` `archive` SHALL be refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read.
+With `missingRecords` `archive`, after every row of an import that was not cancelled and did not fail, the import SHALL reconcile, under the register lock it already holds, every `usage` whose `consumer` is the imported municipality together with the `module` it points at, including archived and soft-deleted ones, and only those whose module carries this municipality's import key (`topdesk::`). It SHALL read the APPID column of the sheet "Gearchiveerde Applicaties" when the workbook has it, under the same size and row bounds as the CMDB sheets and without reading any other column of it. For an application whose APPID is on neither CMDB sheet: when the archive sheet lists the APPID, the import SHALL archive the usage and then the module through OpenRegister's archive state with a reason that names the import, restoring a soft-deleted one from the trash first; when no sheet lists it, the import SHALL soft-delete the usage and then the module through OpenRegister's trash. When the workbook has no archive sheet, or the sheet has no APPID column, or a row of the sheet holds an APPID formula without a cached value (read as empty, as REQ-CMDB-002 has it for the CMDB sheets, and listed by the reader in `archive.uncachedRows`), the import SHALL archive and SHALL NOT soft-delete, and SHALL add a warning to the report; for an uncached row the warning SHALL name the sheet and the row numbers. An application that is already in the state the export asks for SHALL be left alone and not reported. The import SHALL NOT touch contact persons, organisations, modules that carry another municipality's import key or no import key, or usages of another organisation. Each application SHALL be reconciled in its own error boundary: a failure SHALL be a `failed` report row naming the step and the exception class, and the other applications SHALL still be reconciled. The reconciliation SHALL report its progress as the phase `reconciling` of the import's operation. A cancel SHALL also be honoured after the last row and before each application of the reconciliation: the import SHALL stop there, SHALL keep what it archived or deleted so far, and SHALL report the run as cancelled. The report SHALL count `archived` and `deleted` in its summary and SHALL list each as a row with the APPID, the application name and both uuids, never person data. When OpenRegister's archive handler or object mapper is not available, an import with `missingRecords` `archive` SHALL be refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read.
#### Scenario: An application that moved to the archive sheet is archived
@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts
@@ -36,6 +36,14 @@ With `missingRecords` `archive`, after every row of an import that was not cance
- **THEN** the module and the usage of `7` SHALL be archived and SHALL NOT be deleted
- **AND** the report SHALL carry a warning that the sheet "Gearchiveerde Applicaties" was not found
+#### Scenario: An archive row whose APPID has no cached value never turns an archive into a deletion
+@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testAnUncachedArchiveRowArchivesInsteadOfDeleting asserts the archive state, no delete, and the warning naming the sheet and the row; tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php testAnUncachedArchiveKeyIsListedNotDropped asserts the reader lists the row.
+
+- **GIVEN** the module with APPID `7` and its usage were imported, and the workbook's archive sheet lists `7` in a formula that carries no cached value
+- **WHEN** the workbook is imported with `archive` and `7` is on no CMDB sheet
+- **THEN** the module and the usage of `7` SHALL be archived and SHALL NOT be deleted
+- **AND** the report SHALL carry a warning naming the sheet "Gearchiveerde Applicaties" and the row
+
#### Scenario: Keeping missing records, a cancelled import and another municipality leave everything as it is
@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testRecordsMissingFromTheExportStayWhenKept, testACancelledImportDoesNotReconcile and testAnotherMunicipalitysUsagesAreNeverReconciled assert no archive, no delete and no reconciliation row.
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
index 4ae74b821..cc3583dca 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
@@ -19,6 +19,7 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp
- **acceptance_criteria**:
- GIVEN the anonymised fixture THEN `read()` returns `archive.present` true and `archive.appIds` `["1198"]`, and the rows of the CMDB sheets are unchanged
- GIVEN a workbook without the sheet, or with the sheet but without an APPID column THEN `archive.present` is false (and `keyColumnMissing` says which), and the CMDB rows are still read
+ - GIVEN an archive sheet whose APPID cell is a formula without a cached value THEN `archive.uncachedRows` lists its row and `appIds` does not, so the service can refuse to soft-delete (task 4)
- GIVEN an archive sheet beyond the row limit THEN `TOO_MANY_ROWS` names it; only its APPID column is materialised
- [x] Implement
- [x] Test
diff --git a/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php b/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php
index b20bba79b..94311d315 100644
--- a/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php
+++ b/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php
@@ -965,15 +965,15 @@ public function testTheArchiveSheetIsOptionalAndReadForItsKeyOnly(): void {
$cases = [
'with the sheet' => [
['Beheerde Applicaties CMDB' => $cmdb, 'Gearchiveerde Applicaties' => [['APPID', 'Applicatie Naam', 'Bron'], [7, 'Zeven', 'P-0007'], [], [8, 'Acht', 'P-0008']]],
- ['present' => true, 'keyColumnMissing' => false, 'appIds' => ['7', '8']],
+ ['present' => true, 'keyColumnMissing' => false, 'appIds' => ['7', '8'], 'uncachedRows' => []],
],
'without the sheet' => [
['Beheerde Applicaties CMDB' => $cmdb],
- ['present' => false, 'keyColumnMissing' => false, 'appIds' => []],
+ ['present' => false, 'keyColumnMissing' => false, 'appIds' => [], 'uncachedRows' => []],
],
'without its APPID column' => [
['Beheerde Applicaties CMDB' => $cmdb, 'Gearchiveerde Applicaties' => [['Applicatie Code', 'Applicatie Naam'], ['APP-7', 'Zeven']]],
- ['present' => false, 'keyColumnMissing' => true, 'appIds' => []],
+ ['present' => false, 'keyColumnMissing' => true, 'appIds' => [], 'uncachedRows' => []],
],
];
@@ -1024,6 +1024,37 @@ public function testAnArchiveSheetOverTheRowLimitIsRefused(): void {
}
}//end testAnArchiveSheetOverTheRowLimitIsRefused()
+ /**
+ * An archive APPID that is a formula without a cached value is listed by its row, not dropped without trace.
+ *
+ * The cell reads as empty, as on a CMDB sheet (REQ-CMDB-002); a caller that
+ * took the sheet for complete would soft-delete an application that is archived.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function testAnUncachedArchiveKeyIsListedNotDropped(): void {
+ $this->requireSpreadsheet();
+ $path = CmdbTestSupport::buildWorkbook(
+ sheets: [
+ 'Beheerde Applicaties CMDB' => [['APPID', 'Applicatie Naam'], [1, 'Een']],
+ 'Gearchiveerde Applicaties' => [['APPID'], [['f' => 'Invoer!A2']], [8], [], [['f' => 'Invoer!A9']]],
+ ]
+ );
+
+ try {
+ $archive = (new CmdbWorkbookReader())->read(path: $path, profile: $this->profile())['archive'];
+ } finally {
+ unlink($path);
+ }
+
+ $this->assertSame('Gearchiveerde Applicaties', $archive['sheet']);
+ $this->assertTrue($archive['present']);
+ $this->assertSame(['8'], array_map('strval', $archive['appIds']));
+ $this->assertSame([2, 5], $archive['uncachedRows'], 'the rows whose APPID formula has no cached value');
+ }//end testAnUncachedArchiveKeyIsListedNotDropped()
+
/**
* A text file named .xlsx, a .xlsm and a CSV are refused before PhpSpreadsheet is touched.
*
From 11d9a23c23e311f9f78145b1a0736d5dbd641986 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:05:00 +0200
Subject: [PATCH 034/124] test(cmdb-import): pin every bound the archive sheet
is read under
REQ-CMDB-015 and design D3 promise that the archive sheet is read under the
same bounds as the CMDB sheets, for its key column only, but only the row
limit had a test. The part size, the row span, the referenced-string
budget, the key-only read filter and the null cases of archiveSheetName()
could regress unnoticed; swapping readSheetNames() for sheetNames() in
read() left the reader tests green, which would blank an oversized archive
sheet instead of refusing it.
Adds one test per bound. RecordingXlsxReader now also keeps the read
filter of each load, so the key-only claim is asserted on what the data
pass is allowed to read, not on the returned array. Each test was checked
by mutating the reader (archive sheet left out of that bound) and goes red.
Review #1285 f5
Co-Authored-By: Claude Opus 5.5
---
.../Service/Cmdb/CmdbWorkbookReaderTest.php | 178 ++++++++++++++++++
tests/Unit/Support/RecordingXlsxReader.php | 12 +-
2 files changed, 188 insertions(+), 2 deletions(-)
diff --git a/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php b/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php
index 94311d315..b4d561809 100644
--- a/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php
+++ b/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php
@@ -1055,6 +1055,184 @@ public function testAnUncachedArchiveKeyIsListedNotDropped(): void {
$this->assertSame([2, 5], $archive['uncachedRows'], 'the rows whose APPID formula has no cached value');
}//end testAnUncachedArchiveKeyIsListedNotDropped()
+ /**
+ * An archive sheet whose last used row lies beyond twice the row limit stops before any sheet is loaded.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function testAnArchiveSheetRowSpanBeyondTheLimitStopsBeforeLoading(): void {
+ $this->requireSpreadsheet();
+ require_once __DIR__ . '/../../Support/RecordingXlsxReader.php';
+ RecordingXlsxReader::$loads = 0;
+
+ // maxRowsPerSheet 1 reads up to row 3; the third data row of the archive sheet sits on row 4.
+ $path = CmdbTestSupport::buildWorkbook(
+ sheets: [
+ 'Beheerde Applicaties CMDB' => [['APPID', 'Applicatie Naam'], [1, 'Een']],
+ 'Gearchiveerde Applicaties' => [['APPID'], [7], [8], [9]],
+ ]
+ );
+ $directory = CmdbTestSupport::profileDirectory(overrides: ['maxRowsPerSheet' => 1]);
+ $reader = new class extends CmdbWorkbookReader {
+ public const READER_CLASS = RecordingXlsxReader::class;
+ };
+
+ try {
+ $reader->read(path: $path, profile: $this->profile(directory: $directory));
+ $this->fail('TOO_MANY_ROWS expected');
+ } catch (CmdbImportException $e) {
+ $this->assertSame('TOO_MANY_ROWS', $e->getErrorCode());
+ $this->assertSame(['sheet' => 'Gearchiveerde Applicaties', 'limit' => 1], $e->getDetails());
+ $this->assertSame(0, RecordingXlsxReader::$loads, 'no sheet was loaded');
+ } finally {
+ unlink($path);
+ CmdbTestSupport::removeDirectory(directory: $directory);
+ }
+ }//end testAnArchiveSheetRowSpanBeyondTheLimitStopsBeforeLoading()
+
+ /**
+ * An archive sheet part beyond maxPartBytes is refused and named, not blanked into an empty sheet.
+ *
+ * A blanked archive sheet would read as present with no APPIDs, and every
+ * missing application would then be soft-deleted.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function testAnArchiveSheetPartBeyondThePartLimitIsRefusedNotBlanked(): void {
+ $this->requireSpreadsheet();
+ require_once __DIR__ . '/../../Support/RecordingXlsxReader.php';
+ RecordingXlsxReader::$loads = 0;
+ $path = CmdbTestSupport::buildWorkbook(
+ sheets: [
+ 'Beheerde Applicaties CMDB' => [['APPID', 'Applicatie Naam'], [1, 'Een']],
+ 'Gearchiveerde Applicaties' => self::manyRows(count: 3000),
+ ]
+ );
+ $directory = CmdbTestSupport::profileDirectory(overrides: ['maxPartBytes' => 50000, 'maxSharedStrings' => 1000000]);
+ $reader = new class extends CmdbWorkbookReader {
+ public const READER_CLASS = RecordingXlsxReader::class;
+ };
+
+ try {
+ $reader->read(path: $path, profile: $this->profile(directory: $directory));
+ $this->fail('WORKBOOK_TOO_LARGE expected');
+ } catch (CmdbImportException $e) {
+ $this->assertSame('WORKBOOK_TOO_LARGE', $e->getErrorCode());
+ $this->assertSame(
+ ['maxPartBytes' => 50000, 'part' => 'xl/worksheets/sheet2.xml', 'size' => self::partSize(path: $path, part: 'xl/worksheets/sheet2.xml'), 'sheet' => 'Gearchiveerde Applicaties'],
+ $e->getDetails()
+ );
+ $this->assertSame(0, RecordingXlsxReader::$loads, 'no sheet was loaded');
+ } finally {
+ unlink($path);
+ CmdbTestSupport::removeDirectory(directory: $directory);
+ }
+ }//end testAnArchiveSheetPartBeyondThePartLimitIsRefusedNotBlanked()
+
+ /**
+ * The referenced-string budget is shared by the source sheets and the archive sheet.
+ *
+ * The workbook has no archive sheet, so the budget is the profile's limit
+ * divided by the sheets the profile reads: the two source sheets and the
+ * archive sheet. Text that fits a budget of half the limit but not of a third
+ * is refused; the same workbook with less text is read.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function testTheReferencedStringBudgetIsDividedOverTheArchiveSheetToo(): void {
+ $this->requireSpreadsheet();
+ // 50 cells of 32 + 2400 bytes = 121,600 bytes; the limit 300,000 gives 100,000 over three sheets, 150,000 over two.
+ $heavy = self::sharedStringWorkbook(entry: '' . str_repeat('x', 2400) . '', cells: 50);
+ $light = self::sharedStringWorkbook(entry: '' . str_repeat('x', 1600) . '', cells: 50);
+ $directory = CmdbTestSupport::profileDirectory(overrides: ['maxReferencedStringBytes' => 300000]);
+
+ try {
+ $this->assertCount(50, (new CmdbWorkbookReader())->read(path: $light, profile: $this->profile(directory: $directory))['rows'], 'under a third of the limit');
+ try {
+ (new CmdbWorkbookReader())->read(path: $heavy, profile: $this->profile(directory: $directory));
+ $this->fail('WORKBOOK_TOO_LARGE expected');
+ } catch (CmdbImportException $e) {
+ $this->assertSame('WORKBOOK_TOO_LARGE', $e->getErrorCode());
+ $this->assertSame(['maxReferencedStringBytes' => 300000], $e->getDetails());
+ }
+ } finally {
+ unlink($heavy);
+ unlink($light);
+ CmdbTestSupport::removeDirectory(directory: $directory);
+ }
+ }//end testTheReferencedStringBudgetIsDividedOverTheArchiveSheetToo()
+
+ /**
+ * The data pass admits the APPID column of the archive sheet and no other cell of it.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function testTheDataPassAdmitsOnlyTheKeyColumnOfTheArchiveSheet(): void {
+ $this->requireSpreadsheet();
+ require_once __DIR__ . '/../../Support/RecordingXlsxReader.php';
+ RecordingXlsxReader::$loads = 0;
+ RecordingXlsxReader::$filters = [];
+ $path = CmdbTestSupport::buildWorkbook(
+ sheets: [
+ 'Beheerde Applicaties CMDB' => [['APPID', 'Applicatie Naam'], [1, 'Een']],
+ 'Gearchiveerde Applicaties' => [['APPID', 'Applicatie Naam', 'Bron'], [7, 'Zeven', 'P-0007']],
+ ]
+ );
+ $reader = new class extends CmdbWorkbookReader {
+ public const READER_CLASS = RecordingXlsxReader::class;
+ };
+
+ try {
+ $reader->read(path: $path, profile: $this->profile());
+ } finally {
+ unlink($path);
+ }
+
+ $this->assertCount(2, RecordingXlsxReader::$filters, 'a header pass and a data pass');
+ $data = RecordingXlsxReader::$filters[1];
+ $sheet = 'Gearchiveerde Applicaties';
+ $this->assertTrue($data->readCell('A', 2, $sheet), 'the APPID column is read');
+ $this->assertFalse($data->readCell('B', 2, $sheet), 'the name column is not');
+ $this->assertFalse($data->readCell('C', 2, $sheet), 'the column outside the allowlist is not');
+ $this->assertTrue($data->readCell('B', 2, 'Beheerde Applicaties CMDB'), 'the CMDB sheet keeps its allowlisted columns');
+ }//end testTheDataPassAdmitsOnlyTheKeyColumnOfTheArchiveSheet()
+
+ /**
+ * The archive sheet is none when the profile names none, names a non-string, or names a source sheet.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function testTheArchiveSheetNameIsNullWhenTheProfileCannotNameOne(): void {
+ $this->requireSpreadsheet();
+ $cases = [
+ 'an empty name' => '',
+ 'a non-string name' => 42,
+ 'the name of a source sheet' => 'Beheerde Applicaties CMDB',
+ ];
+
+ $this->assertSame('Gearchiveerde Applicaties', $this->profile()->archiveSheetName(), 'the shipped profile names it');
+ foreach ($cases as $case => $name) {
+ $directory = CmdbTestSupport::profileDirectory(overrides: ['archiveSheet' => ['name' => $name]]);
+ try {
+ $profile = $this->profile(directory: $directory);
+ $this->assertNull($profile->archiveSheetName(), $case);
+ $this->assertSame($profile->sheetNames(), $profile->readSheetNames(), $case);
+ } finally {
+ CmdbTestSupport::removeDirectory(directory: $directory);
+ }
+ }
+ }//end testTheArchiveSheetNameIsNullWhenTheProfileCannotNameOne()
+
/**
* A text file named .xlsx, a .xlsm and a CSV are refused before PhpSpreadsheet is touched.
*
diff --git a/tests/Unit/Support/RecordingXlsxReader.php b/tests/Unit/Support/RecordingXlsxReader.php
index 629ab0a90..d7f1fd00d 100644
--- a/tests/Unit/Support/RecordingXlsxReader.php
+++ b/tests/Unit/Support/RecordingXlsxReader.php
@@ -1,10 +1,10 @@
+ */
+ public static array $filters = [];
+
/**
* Load a workbook and count it.
*
@@ -48,6 +55,7 @@ class RecordingXlsxReader extends Xlsx {
*/
public function load(string $filename, int $flags = 0): Spreadsheet {
self::$loads++;
+ self::$filters[] = $this->getReadFilter();
return parent::load($filename, $flags);
}//end load()
}//end class
From ac3305a76fcea645f679b5aaa6f9db6c216b1f8d Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:08:31 +0200
Subject: [PATCH 035/124] fix(cmdb-import): name archived, unarchived, deleted
and restored in the finished sentence
The sentence the aria-live region announces (and the report heading shows)
only had the row counts, so an import that read 0 rows and archived one
application and deleted one was announced as "0 rows read: 0 created,
0 updated, 0 unchanged" and a screen reader user never heard that
applications were deleted (WCAG 4.1.3).
The sentence now ends with "Applications: ..." listing the non-zero counts,
after a cancel too, and nothing when no application was touched. The docs
mention it.
Review #1287 f5
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 3 +-
l10n/en.js | 7 +-
l10n/en.json | 7 +-
l10n/nl.js | 7 +-
l10n/nl.json | 7 +-
.../settings/sections/CmdbImport.spec.js | 54 +++++++++++++++
src/views/settings/sections/CmdbImport.vue | 67 ++++++++++++++++---
7 files changed, 137 insertions(+), 15 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index 84bd60acd..477287026 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -103,7 +103,8 @@ When the import finishes, the section shows:
and warnings, and, when publishing was off, how many applications were
created unpublished; after a repeat import also how many applications
were archived, unarchived, deleted (moved to the trash) or restored, when
- there are any;
+ there are any. The sentence above it, which a screen reader announces,
+ names those counts too, for example *Applications: 1 archived, 1 deleted.*;
- **warnings for the whole file**, for example an optional column that is
missing;
- the **rows** table: sheet, row number, APPID, application, outcome,
diff --git a/l10n/en.js b/l10n/en.js
index 101da2012..3e3c7c0d7 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1146,7 +1146,12 @@ OC.L10N.register(
"The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.",
"In development since": "In development since",
- "In use since": "In use since"
+ "In use since": "In use since",
+ "{count} archived": "{count} archived",
+ "{count} unarchived": "{count} unarchived",
+ "{count} deleted": "{count} deleted",
+ "{count} restored": "{count} restored",
+ "Applications: {changes}.": "Applications: {changes}."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index f43f75422..e2d6fd9e0 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1145,6 +1145,11 @@
"The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.",
"In development since": "In development since",
- "In use since": "In use since"
+ "In use since": "In use since",
+ "{count} archived": "{count} archived",
+ "{count} unarchived": "{count} unarchived",
+ "{count} deleted": "{count} deleted",
+ "{count} restored": "{count} restored",
+ "Applications: {changes}.": "Applications: {changes}."
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index a8753498c..135559111 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1214,7 +1214,12 @@ OC.L10N.register(
"The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.",
"In development since": "In ontwikkeling sinds",
- "In use since": "In gebruik sinds"
+ "In use since": "In gebruik sinds",
+ "{count} archived": "{count} gearchiveerd",
+ "{count} unarchived": "{count} uit het archief gehaald",
+ "{count} deleted": "{count} verwijderd",
+ "{count} restored": "{count} teruggezet",
+ "Applications: {changes}.": "Applicaties: {changes}."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index eccc3a4c7..98cdc370d 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1213,6 +1213,11 @@
"The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.",
"In development since": "In ontwikkeling sinds",
- "In use since": "In gebruik sinds"
+ "In use since": "In gebruik sinds",
+ "{count} archived": "{count} gearchiveerd",
+ "{count} unarchived": "{count} uit het archief gehaald",
+ "{count} deleted": "{count} verwijderd",
+ "{count} restored": "{count} teruggezet",
+ "Applications: {changes}.": "Applicaties: {changes}."
}
}
diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js
index 9c1f16dfe..6896c2c33 100644
--- a/src/views/settings/sections/CmdbImport.spec.js
+++ b/src/views/settings/sections/CmdbImport.spec.js
@@ -326,3 +326,57 @@ describe('CmdbImport cancelling', () => {
)
})
})
+
+/**
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+describe('CmdbImport finished announcement', () => {
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('names the archived and deleted applications in the sentence the live region announces', async () => {
+ const wrapper = await mountFull()
+
+ await wrapper.setData({
+ report: {
+ municipality: { name: 'Gemeente Bergen' },
+ summary: { rowsRead: 0, archived: 1, deleted: 1 },
+ rows: [],
+ },
+ })
+
+ const announced = wrapper.find('[data-testid="cmdb-import-finished"]').text()
+ expect(announced).toContain('0 rows read')
+ expect(announced).toContain('1 archived')
+ expect(announced).toContain('1 deleted')
+ expect(announced).not.toContain('unarchived')
+ expect(announced).not.toContain('restored')
+ })
+
+ it('names every kind of change after a cancel too, and adds nothing when no application was touched', async () => {
+ const wrapper = await mountFull()
+
+ await wrapper.setData({
+ report: {
+ cancelled: true,
+ municipality: { name: 'Gemeente Bergen' },
+ summary: { rowsRead: 5, archived: 2, unarchived: 3, deleted: 4, restored: 5 },
+ rows: [],
+ },
+ })
+ expect(wrapper.vm.finishedText).toBe(
+ 'Import for Gemeente Bergen cancelled after 5 rows. Applications: 2 archived, 3 unarchived, 4 deleted, 5 restored.',
+ )
+
+ // setData merges into the report; a new report replaces it.
+ wrapper.vm.report = {
+ municipality: { name: 'Gemeente Bergen' },
+ summary: { rowsRead: 2, created: 2 },
+ rows: [],
+ }
+ expect(wrapper.vm.finishedText).toBe(
+ 'Import for Gemeente Bergen finished. 2 rows read: 2 created, 0 updated, 0 unchanged.',
+ )
+ })
+})
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index c4d804834..0f92b67ae 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -678,28 +678,75 @@ export default {
updated: Number(summary.updated) || 0,
unchanged: Number(summary.unchanged) || 0,
}
+ let sentence
if (this.report?.cancelled) {
- return t(
+ sentence = t(
'stackiq',
'Import for {name} cancelled after {read} rows.',
counts,
AS_TEXT,
)
- }
- if (this.report?.municipality?.created) {
- return t(
+ } else if (this.report?.municipality?.created) {
+ sentence = t(
'stackiq',
'Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.',
counts,
AS_TEXT,
)
+ } else {
+ sentence = t(
+ 'stackiq',
+ 'Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.',
+ counts,
+ AS_TEXT,
+ )
}
- return t(
- 'stackiq',
- 'Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.',
- counts,
- AS_TEXT,
- )
+ const applications = this.applicationChangesText
+ return applications === '' ? sentence : `${sentence} ${applications}`
+ },
+
+ /**
+ * What the import did to applications that are not on the sheets: the
+ * non-zero counts, for the sentence the live region announces.
+ *
+ * @return {string} A sentence, or '' when no application was touched
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+ applicationChangesText() {
+ const summary = this.report?.summary || {}
+ const parts = [
+ [
+ 'archived',
+ (count) =>
+ t('stackiq', '{count} archived', { count }, AS_TEXT),
+ ],
+ [
+ 'unarchived',
+ (count) =>
+ t('stackiq', '{count} unarchived', { count }, AS_TEXT),
+ ],
+ [
+ 'deleted',
+ (count) =>
+ t('stackiq', '{count} deleted', { count }, AS_TEXT),
+ ],
+ [
+ 'restored',
+ (count) =>
+ t('stackiq', '{count} restored', { count }, AS_TEXT),
+ ],
+ ]
+ .map(([key, text]) => [Number(summary[key]) || 0, text])
+ .filter(([count]) => count > 0)
+ .map(([count, text]) => text(count))
+ return parts.length === 0
+ ? ''
+ : t(
+ 'stackiq',
+ 'Applications: {changes}.',
+ { changes: parts.join(', ') },
+ AS_TEXT,
+ )
},
/**
From 08acba6d38dd9a3eef847d89502471998308035d Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:09:04 +0200
Subject: [PATCH 036/124] fix(cmdb-import): never soft-delete on an archive
sheet with unreadable APPIDs
The reader now lists the archive-sheet rows whose APPID formula has no
cached value (archive.uncachedRows). The service treats such a sheet like
an absent one for the choice between archive and delete: an application on
no readable sheet may be the one that row holds, so it is archived, never
soft-deleted, and the report carries a warning naming the sheet and the
rows (cut after ten). Applications the sheet does list are archived as
before; keep is unaffected. Without this a workbook saved without cached
values turned every archived application into a deletion, unannounced.
The warning is translated (en, nl) and the OpenAPI description of
missingRecords names the extra case.
Review #1285 f1
Co-Authored-By: Claude Opus 5.5
---
l10n/en.js | 1 +
l10n/en.json | 1 +
l10n/nl.js | 1 +
l10n/nl.json | 1 +
lib/Service/CmdbExportImportService.php | 38 +++++++++++----
openapi.json | 2 +-
.../Service/CmdbExportImportServiceTest.php | 48 +++++++++++++++++--
7 files changed, 80 insertions(+), 12 deletions(-)
diff --git a/l10n/en.js b/l10n/en.js
index 8e95ef029..39566fb2a 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1041,6 +1041,7 @@ OC.L10N.register(
"Applications missing from the export can only be archived or kept.": "Applications missing from the export can only be archived or kept.",
"Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.",
"Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.",
+ "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, which was read as empty, so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, which was read as empty, so applications missing from the export are archived, not deleted.",
"Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.",
"Unarchived": "Unarchived",
"Restored": "Restored",
diff --git a/l10n/en.json b/l10n/en.json
index 0d85cfbaf..18f9c061f 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1040,6 +1040,7 @@
"Applications missing from the export can only be archived or kept.": "Applications missing from the export can only be archived or kept.",
"Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.",
"Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.",
+ "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, which was read as empty, so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, which was read as empty, so applications missing from the export are archived, not deleted.",
"Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.",
"Unarchived": "Unarchived",
"Restored": "Restored",
diff --git a/l10n/nl.js b/l10n/nl.js
index b03118333..c5c7dd953 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1109,6 +1109,7 @@ OC.L10N.register(
"Applications missing from the export can only be archived or kept.": "Applicaties die in de export ontbreken, kunnen alleen worden gearchiveerd of behouden.",
"Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd: OpenRegister ontbreekt of is te oud. Kies ervoor ze te behouden, of werk OpenRegister bij.",
"Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\", dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, which was read as empty, so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft een APPID zonder opgeslagen waarde in rij %2$s, die als leeg is gelezen, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
"Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Tabblad \"%s\" is niet gevonden, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
"Unarchived": "Uit archief gehaald",
"Restored": "Hersteld",
diff --git a/l10n/nl.json b/l10n/nl.json
index 92a16d73e..13297a8d9 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1108,6 +1108,7 @@
"Applications missing from the export can only be archived or kept.": "Applicaties die in de export ontbreken, kunnen alleen worden gearchiveerd of behouden.",
"Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd: OpenRegister ontbreekt of is te oud. Kies ervoor ze te behouden, of werk OpenRegister bij.",
"Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\", dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, which was read as empty, so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft een APPID zonder opgeslagen waarde in rij %2$s, die als leeg is gelezen, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
"Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Tabblad \"%s\" is niet gevonden, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
"Unarchived": "Uit archief gehaald",
"Restored": "Hersteld",
diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php
index 89a51526b..c7659934c 100644
--- a/lib/Service/CmdbExportImportService.php
+++ b/lib/Service/CmdbExportImportService.php
@@ -238,6 +238,13 @@ class CmdbExportImportService {
*/
private const UNMATCHED_STATUSES = ['merged', 'Inactive'];
+ /**
+ * How many row numbers an archive-sheet warning names before it cuts the list.
+ *
+ * @var int
+ */
+ private const WARNED_ROWS = 10;
+
/**
* Page size for loading the organisations a name may match.
*/
@@ -615,7 +622,7 @@ private function runImport(string $path, array $options, string $startedAt): arr
*
* @param CmdbImportReport $report The report.
* @param array{uuid: string, name: string, created: bool} $municipality The consumer, as resolveMunicipality() found it.
- * @param array{sheet: string, present: bool, keyColumnMissing: bool, keys: array} $archive The archive sheet.
+ * @param array{sheet: string, present: bool, keyColumnMissing: bool, uncachedRows: array, keys: array} $archive The archive sheet.
* @param string $missingRecords The import's `missingRecords` mode.
*
* @return void
@@ -624,7 +631,7 @@ private function runImport(string $path, array $options, string $startedAt): arr
* @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
*/
private function addRunWarnings(CmdbImportReport $report, array $municipality, array $archive, string $missingRecords): void {
- if ($missingRecords === 'archive' && $archive['present'] === false) {
+ if ($missingRecords === 'archive' && ($archive['present'] === false || $archive['uncachedRows'] !== [])) {
$report->addImportWarnings(warnings: [['sheet' => $archive['sheet'], 'message' => $this->archiveSheetWarning(archive: $archive)]]);
}
@@ -1926,7 +1933,7 @@ private static function missingRecordsFrom(array $options): string {
*
* @param array $workbook What the reader returned.
*
- * @return array{sheet: string, present: bool, keyColumnMissing: bool, keys: array}
+ * @return array{sheet: string, present: bool, keyColumnMissing: bool, uncachedRows: array, keys: array}
*
* @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
*/
@@ -1946,20 +1953,34 @@ private function archiveSheet(array $workbook): array {
'sheet' => (string)($archive['sheet'] ?? ($this->profile->archiveSheetName() ?? '')),
'present' => (($archive['present'] ?? false) === true),
'keyColumnMissing' => (($archive['keyColumnMissing'] ?? false) === true),
+ 'uncachedRows' => array_values(array_map('intval', (array)($archive['uncachedRows'] ?? []))),
'keys' => $keys,
];
}//end archiveSheet()
/**
- * The report warning of an archiving import whose workbook lacks the archive sheet or its key column.
+ * The report warning of an archiving import whose workbook lacks the archive sheet or its key column, or whose archive sheet has keys that could not be read.
*
- * @param array{sheet: string, keyColumnMissing: bool} $archive The archive sheet.
+ * @param array{sheet: string, keyColumnMissing: bool, uncachedRows: array} $archive The archive sheet.
*
* @return string
*
* @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
*/
private function archiveSheetWarning(array $archive): string {
+ if ($archive['uncachedRows'] !== []) {
+ // A long list is cut: the warning names where to look, not every row.
+ $rows = implode(', ', array_slice($archive['uncachedRows'], 0, self::WARNED_ROWS));
+ if (count($archive['uncachedRows']) > self::WARNED_ROWS) {
+ $rows .= ', …';
+ }
+
+ return $this->l10n->t(
+ 'Sheet "%1$s" has an APPID without a cached value in row %2$s, which was read as empty, so applications missing from the export are archived, not deleted.',
+ [$archive['sheet'], $rows]
+ );
+ }
+
if ($archive['keyColumnMissing'] === true) {
return $this->l10n->t(
'Sheet "%1$s" has no column "%2$s", so applications missing from the export are archived, not deleted.',
@@ -2181,7 +2202,7 @@ private static function registerTrash(mixed $deleted, array $coordinates): array
* stays, and the report says the run was cancelled.
*
* @param string $municipalityUuid The consumer of this import.
- * @param array{sheet: string, present: bool, keyColumnMissing: bool, keys: array} $archive The archive sheet.
+ * @param array{sheet: string, present: bool, keyColumnMissing: bool, uncachedRows: array, keys: array} $archive The archive sheet.
* @param string $operationId The operation id, for the archive reason.
* @param int $processedItems The rows already counted as processed.
* @param CmdbImportReport $report The report.
@@ -2393,7 +2414,7 @@ private function modulesInChunk(array $chunk, array $coordinates): array {
* An application already in the state the export asks for is not reported.
*
* @param array{key: string, usage: object, module: object, states: array} $application Application.
- * @param array{sheet: string, present: bool, keyColumnMissing: bool, keys: array} $archive The archive sheet.
+ * @param array{sheet: string, present: bool, keyColumnMissing: bool, uncachedRows: array, keys: array} $archive The archive sheet.
* @param string $operationId The operation id, for the archive reason.
* @param CmdbImportReport $report The report.
*
@@ -2416,7 +2437,8 @@ private function reconcileApplication(array $application, array $archive, string
if ($onArchiveSheet === true) {
$entry['sheet'] = $archive['sheet'];
$reason = self::REASON_PREFIX . 'on sheet "' . $archive['sheet'] . '" (' . $operationId . ')';
- } elseif ($archive['present'] === true) {
+ } elseif ($archive['present'] === true && $archive['uncachedRows'] === []) {
+ // A sheet with an unreadable key may list this application, so it is not authoritative for a deletion.
$target = CmdbImportReport::DELETED;
}
diff --git a/openapi.json b/openapi.json
index 6469cf208..4d4bb058b 100644
--- a/openapi.json
+++ b/openapi.json
@@ -66,7 +66,7 @@
"archive"
],
"default": "archive",
- "description": "What happens to the municipality's applications whose APPID is no longer on either CMDB sheet. archive: after the last row, archive module and usage when the sheet \"Gearchiveerde Applicaties\" lists the APPID, soft-delete them when no sheet does, and archive only when the workbook has no archive sheet; an archived or deleted application that returns is unarchived or restored. keep: leave them as they are. mark and remove are reserved and refused with 422 MISSING_RECORDS_UNSUPPORTED"
+ "description": "What happens to the municipality's applications whose APPID is no longer on either CMDB sheet. archive: after the last row, archive module and usage when the sheet \"Gearchiveerde Applicaties\" lists the APPID, soft-delete them when no sheet does, and archive only when the workbook has no archive sheet, the sheet has no APPID column, or an APPID in it has no cached value; an archived or deleted application that returns is unarchived or restored. keep: leave them as they are. mark and remove are reserved and refused with 422 MISSING_RECORDS_UNSUPPORTED"
},
"operationId": {
"type": "string",
diff --git a/tests/Unit/Service/CmdbExportImportServiceTest.php b/tests/Unit/Service/CmdbExportImportServiceTest.php
index 0a5d66b94..62257fec6 100644
--- a/tests/Unit/Service/CmdbExportImportServiceTest.php
+++ b/tests/Unit/Service/CmdbExportImportServiceTest.php
@@ -978,20 +978,23 @@ function (&$value): void {
*
* @param array}> $rows The rows.
* @param array|null $archived The APPIDs on the archive sheet, or null when there is no such sheet.
+ * @param array $uncachedRows The archive sheet's rows whose APPID formula has no cached value.
*
* @return CmdbWorkbookReader
*/
- private function rowsReader(array $rows, ?array $archived = []): CmdbWorkbookReader {
- return new class($rows, $archived) extends CmdbWorkbookReader {
+ private function rowsReader(array $rows, ?array $archived = [], array $uncachedRows = []): CmdbWorkbookReader {
+ return new class($rows, $archived, $uncachedRows) extends CmdbWorkbookReader {
/**
* Constructor.
*
* @param array> $rows The rows.
* @param array|null $archived The archive sheet's APPIDs, or null.
+ * @param array $uncachedRows The archive sheet's rows without a cached APPID.
*/
public function __construct(
private array $rows,
private ?array $archived,
+ private array $uncachedRows,
) {
}
@@ -1004,7 +1007,7 @@ public function __construct(
* @return array
*/
public function read(string $path, CmdbImportProfile $profile): array {
- $archive = ['sheet' => 'Gearchiveerde Applicaties', 'present' => ($this->archived !== null), 'keyColumnMissing' => false, 'appIds' => ($this->archived ?? [])];
+ $archive = ['sheet' => 'Gearchiveerde Applicaties', 'present' => ($this->archived !== null), 'keyColumnMissing' => false, 'appIds' => ($this->archived ?? []), 'uncachedRows' => $this->uncachedRows];
return ['rows' => $this->rows, 'importWarnings' => [], 'date1904' => false, 'archive' => $archive];
}
};
@@ -1938,6 +1941,45 @@ public function testWithoutTheArchiveSheetMissingApplicationsAreArchivedNotDelet
);
}//end testWithoutTheArchiveSheetMissingApplicationsAreArchivedNotDeleted()
+ /**
+ * An archive sheet with an APPID the reader could not see is not authoritative for a deletion: the import
+ * archives what it would have deleted, and warns naming the sheet and the row.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function testAnUncachedArchiveRowArchivesInsteadOfDeleting(): void {
+ $this->importOneSevenAndEight();
+ [$seven, $eight] = [$this->moduleOf(appId: '7'), $this->moduleOf(appId: '8')];
+
+ // APPID 7 is listed on the archive sheet; APPID 8 is on no sheet, and row 4 of the sheet holds a formula without a cached value.
+ $report = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: ['7'], uncachedRows: [4]))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1']);
+
+ $this->assertNotContains('delete', array_column($this->transitions, 0), 'nothing is soft-deleted');
+ $this->assertSame([true, false], $this->stateOf(schema: self::MODULE, uuid: $seven));
+ $this->assertSame([true, false], $this->stateOf(schema: self::MODULE, uuid: $eight), 'APPID 8 may be the unreadable row, so it is archived');
+ $this->assertSame([true, false], $this->stateOf(schema: self::USAGE, uuid: $this->usageOf(moduleUuid: $eight)));
+ $this->assertSame('cmdb-import: not in the export (' . $report['operationId'] . ')', $this->transitions[2][3]);
+ $this->assertSame(
+ [['sheet' => 'Gearchiveerde Applicaties', 'message' => 'Sheet "Gearchiveerde Applicaties" has an APPID without a cached value in row 4, which was read as empty, so applications missing from the export are archived, not deleted.']],
+ $report['importWarnings']
+ );
+ $this->assertSame([2, 0], [$report['summary']['archived'], $report['summary']['deleted']]);
+
+ // A long list of rows is cut.
+ $this->transitions = [];
+ $long = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: ['7'], uncachedRows: range(2, 13)))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1', 'missingRecords' => 'archive']);
+ $this->assertStringContainsString('in row 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, …,', $long['importWarnings'][0]['message']);
+
+ // Keeping missing records needs no sheet and gives no warning.
+ $kept = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: ['7'], uncachedRows: [4]))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1', 'missingRecords' => 'keep']);
+ $this->assertSame([], $kept['importWarnings']);
+ }//end testAnUncachedArchiveRowArchivesInsteadOfDeleting()
+
/**
* A cancelled import reconciles nothing.
*
From f99c0630b2bd623e252c49215ea835ededb9c88a Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:17:18 +0200
Subject: [PATCH 037/124] fix(cmdb-import): let the Archived tab of the
Applications page ignore the facet id set
The Applications page narrows its list with `_ids` (the objects a GEMMA facet
or the search matched) and merges the active quick filter's map next to it,
so the tab's `_archived=true` was ANDed with an id set that FacetService
builds from the working set. OpenRegister's default lens leaves archived
objects out of that set, so with a facet selected the Archived tab asked for
live ids that are archived and listed nothing.
The Archived tab's filter now also carries an empty-string `_ids`, which wins
over the page's `_ids` in the merge and is left out of the request. An empty
array would not do: the filter resolver drops it before the merge. The tab
lists the whole archive and ignores the GEMMA selection and the search term;
the docs say so. The vitest runs the package's own filter resolver and query
builder, and pins that the active tab is merged last.
Read in @conduction/nextcloud-vue 2.65.0 (the installed version).
Review #1287 f6
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 5 +-
src/manifest.json | 3 +-
src/views/FacetedCatalogIndexView.vue | 12 +++
tests/vitest/cmdbArchiveQuickFilter.spec.js | 84 +++++++++++++++++----
4 files changed, 88 insertions(+), 16 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index 477287026..f66fd6626 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -277,7 +277,10 @@ APPID is on neither CMDB sheet:
An archived or deleted application disappears from the stackiq lists, from
OpenCatalogi and from Portaliq's **Software we use**. In stackiq, the quick
filter **Archived** on the **Applications** and **Applications in use** pages
-lists the archive. An OpenCatalogi caller finds archived applications by
+lists the archive. On the **Applications** page the **Archived** tab lists
+the whole archive: it ignores the GEMMA filters and the search box, because
+their counts and matches describe the working set, which leaves archived
+applications out. An OpenCatalogi caller finds archived applications by
adding `_archived=true` to its request. Contact persons are never archived
or deleted: an owner is shared with the municipality's other applications.
An application that is already in the state the export asks for is left
diff --git a/src/manifest.json b/src/manifest.json
index 7df63875f..245f8c802 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -666,7 +666,8 @@
{
"label": "Archived",
"filter": {
- "_archived": "true"
+ "_archived": "true",
+ "_ids": ""
},
"icon": "ArchiveOutline"
}
diff --git a/src/views/FacetedCatalogIndexView.vue b/src/views/FacetedCatalogIndexView.vue
index f80deda08..cc29bb2ec 100644
--- a/src/views/FacetedCatalogIndexView.vue
+++ b/src/views/FacetedCatalogIndexView.vue
@@ -233,6 +233,18 @@ export default {
* `_ids`, so the merge is additive — both the facet-matched id set AND
* the active quick filter's field constraints apply together
* (logical AND), never clobbering one another.
+ *
+ * The exception is the Archived tab of the Applications page
+ * (cmdb-import-archive-reconciliation): the facet id set is built from
+ * the working set, which OpenRegister's default lens limits to objects
+ * that are not archived, so ANDing `_ids` with `_archived=true` could
+ * never match an archived application. That tab's filter therefore
+ * carries an empty-string `_ids` (not an empty array, which the filter
+ * resolver drops before the merge), which wins over this view's `_ids`
+ * and which the request leaves out: the tab lists the whole archive
+ * and ignores the GEMMA selection and the search term. The tab's
+ * filter, not this view, owns that, so it holds however the page's
+ * tab state is restored.
*/
quickFilters: {
type: Array,
diff --git a/tests/vitest/cmdbArchiveQuickFilter.spec.js b/tests/vitest/cmdbArchiveQuickFilter.spec.js
index 0fef16d56..c048abf8f 100644
--- a/tests/vitest/cmdbArchiveQuickFilter.spec.js
+++ b/tests/vitest/cmdbArchiveQuickFilter.spec.js
@@ -17,7 +17,14 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
+// resolveFilterTokens reads the current user at import time, which needs a
+// browser; the tokens this spec merges (`_archived`, `_ids`) resolve no user.
+vi.mock('@nextcloud/auth', () => ({ getCurrentUser: () => null }))
+// The REAL implementations from the installed package — not a local restatement.
+import { buildQueryString } from '../../node_modules/@conduction/nextcloud-vue/src/utils/headers.js'
+import { resolveFilterMap } from '../../node_modules/@conduction/nextcloud-vue/src/utils/routeFilters.js'
+import { facetNarrowingFilter } from '../../src/utils/facetNarrowing.js'
const here = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(here, '../..')
@@ -41,19 +48,68 @@ function pages() {
}
describe('the archive quick filter', () => {
- it.each(['Modules', 'Gebruik'])(
- 'the %s page lists the archive alone under "Archived"',
- (pageId) => {
- const page = pages().find((candidate) => candidate.id === pageId)
- const archived = (page?.config?.quickFilters ?? []).filter(
- (tab) => tab.label === 'Archived',
- )
-
- expect(archived).toHaveLength(1)
- expect(archived[0].filter).toEqual({ _archived: 'true' })
- expect(archived[0].default).not.toBe(true)
- },
- )
+ it('the Gebruik page lists the archive alone under "Archived"', () => {
+ const page = pages().find((candidate) => candidate.id === 'Gebruik')
+ const archived = (page?.config?.quickFilters ?? []).filter(
+ (tab) => tab.label === 'Archived',
+ )
+
+ expect(archived).toHaveLength(1)
+ expect(archived[0].filter).toEqual({ _archived: 'true' })
+ expect(archived[0].default).not.toBe(true)
+ })
+
+ it('the Modules page lists the archive under "Archived" without the facet id set', () => {
+ const page = pages().find((candidate) => candidate.id === 'Modules')
+ const archived = (page?.config?.quickFilters ?? []).filter(
+ (tab) => tab.label === 'Archived',
+ )
+
+ expect(archived).toHaveLength(1)
+ expect(archived[0].filter).toEqual({ _archived: 'true', _ids: '' })
+ expect(archived[0].default).not.toBe(true)
+ })
+
+ // The Applications page sends `_ids` (the working set a facet or search
+ // matched) and merges the active tab's filter over it. OpenRegister leaves
+ // archived objects out of that id set, so `_ids` AND `_archived=true` lists
+ // nothing: with a facet selected, the Archived tab must not send `_ids`.
+ it('sends no _ids with the Archived tab when a facet narrowed the list', () => {
+ const page = pages().find((candidate) => candidate.id === 'Modules')
+ const archived = page.config.quickFilters.find(
+ (tab) => tab.label === 'Archived',
+ )
+ const facetFilter = facetNarrowingFilter(['live-1', 'live-2'])
+
+ // The merge useSelfFetchList#fixedFilters does in single-tab mode:
+ // the page's filter, then the active tab's filter spread last.
+ const archivedQuery = buildQueryString({
+ ...facetFilter,
+ ...resolveFilterMap(archived.filter, {}, {}),
+ })
+ const allQuery = buildQueryString({
+ ...facetFilter,
+ ...resolveFilterMap({}, {}, {}),
+ })
+
+ expect(new URLSearchParams(archivedQuery).get('_archived')).toBe('true')
+ expect(archivedQuery).not.toContain('_ids')
+ // A tab without the override keeps the facet narrowing.
+ expect(allQuery).toContain('_ids')
+ })
+
+ it('still merges the active tab last, which the override above relies on', () => {
+ const source = fs.readFileSync(
+ path.join(
+ repoRoot,
+ 'node_modules/@conduction/nextcloud-vue/src/components/CnIndexPage/useSelfFetchList.js',
+ ),
+ 'utf8',
+ )
+ expect(source).toMatch(
+ /\.\.\.base, \.\.\.resolveFilterMap\(tabFilter, params, ctx\)/,
+ )
+ })
it('uses an icon the app registers', () => {
const icons = fs.readFileSync(path.join(repoRoot, 'src/icons.js'), 'utf8')
From b30cd7f75c95480168bfb8551f38175c5a44776f Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:17:30 +0200
Subject: [PATCH 038/124] fix(cmdb-import): reconcile one application per
import key
The reconciliation listed every usage of the municipality, so a stale
module deleted by hand next to the one a later import created got its own
scope entry next to the working one. With the key on the archive sheet the
trashed pair was restored and archived: a long-deleted copy of the
application appeared in the archive. Every other match path (matchModule,
upsertUsage, findInAnyState) prefers working over archived over trash.
reconciliationScope() now keeps one entry per key through onePerKey(): the
best module state wins, the best usage state breaks a tie, the first
listed the rest. The other entries are left as they are. REQ-CMDB-015, the
design D5 text and task 4 name the duplicate case.
Review #1286 f4
Co-Authored-By: Claude Opus 5.5
---
lib/Service/CmdbExportImportService.php | 53 ++++++++++++++-
.../design.md | 2 +
.../specs/cmdb-export-import/spec.md | 10 ++-
.../tasks.md | 1 +
.../Service/CmdbExportImportServiceTest.php | 66 +++++++++++++++++++
5 files changed, 129 insertions(+), 3 deletions(-)
diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php
index c7659934c..096767971 100644
--- a/lib/Service/CmdbExportImportService.php
+++ b/lib/Service/CmdbExportImportService.php
@@ -2243,7 +2243,8 @@ private function reconcile(string $municipalityUuid, array $archive, string $ope
* imported, is left out: only what this municipality's import created is
* reconciled. Working and archived usages are paged with one lens each,
* soft-deleted ones come from the trash (design D9); each object keeps the
- * state its lens or the trash gave it.
+ * state its lens or the trash gave it. When an import key occurs more than
+ * once, only the entry the import would match is kept (onePerKey()).
*
* @param string $municipalityUuid The consumer of this import.
*
@@ -2285,9 +2286,57 @@ private function reconciliationScope(string $municipalityUuid): array {
}
}
- return $scope;
+ return self::onePerKey(scope: $scope);
}//end reconciliationScope()
+ /**
+ * Keep one application per import key: the one the import itself would match.
+ *
+ * The import takes a working module before an archived one, and an archived
+ * one before one in the trash (design D4), so a module deleted by hand next
+ * to the one a later import created is never revived. The reconciliation
+ * follows the same order: the entry with the best module state wins, the
+ * best usage state breaks a tie, and the first one listed breaks the rest.
+ * A stale duplicate is left as it is, never restored into the archive.
+ *
+ * @param array}> $scope The entries.
+ *
+ * @return array}>
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private static function onePerKey(array $scope): array {
+ $preferred = [];
+ $ranks = [];
+ foreach ($scope as $application) {
+ $rank = ((self::stateRank(state: $application['states']['module']) * 3) + self::stateRank(state: $application['states']['usage']));
+ $key = $application['key'];
+ if (isset($ranks[$key]) === false || $rank < $ranks[$key]) {
+ $ranks[$key] = $rank;
+ $preferred[$key] = $application;
+ }
+ }
+
+ return array_values($preferred);
+ }//end onePerKey()
+
+ /**
+ * How far down the preference order a state is: working 0, archived 1, deleted 2.
+ *
+ * @param array{archived: bool, deleted: bool} $state The state.
+ *
+ * @return int
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private static function stateRank(array $state): int {
+ if ($state['deleted'] === true) {
+ return 2;
+ }
+
+ return ($state['archived'] === true) ? 1 : 0;
+ }//end stateRank()
+
/**
* The usages that name this municipality as consumer, in every state, each with that state.
*
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/design.md b/openspec/changes/cmdb-import-archive-reconciliation/design.md
index 85ae87766..e1557f3d7 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/design.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/design.md
@@ -97,6 +97,8 @@ Per application, with `processed` = every APPID match key on the CMDB sheets (wh
| nowhere, sheet absent or incomplete (no key column, or `uncachedRows` not empty) | working | archive usage, then module | `archived` |
| nowhere, sheet absent or incomplete | archived or deleted | nothing | — |
+The table decides on one entry per key. When a municipality has several usages and modules under the same import key (D4 says earlier imports created exactly such duplicates, a module deleted by hand next to the one a later import created), the scope keeps the entry the import would match: the best module state wins (working, then archived, then deleted), the best usage state breaks a tie, the first listed breaks the rest. The other entries are left as they are: a stale trashed duplicate is not restored into the archive when the key is on the archive sheet, and is not touched when it is on no sheet.
+
The usage is written before the module, so a failure between the two leaves the municipality's usage in the new state and the module visible, never the reverse. A module another municipality still uses is archived or deleted all the same when this municipality's usage says so: the module carries this municipality's import key, so it is this municipality's record (REQ-CMDB-006 allows a shared module only when the other organisation's usage was added by hand). The archive reason is `cmdb-import: on sheet "Gearchiveerde Applicaties"` or `cmdb-import: not in the export`, plus the operation id, so the audit trail says why.
Each application runs in its own boundary: a failure is a `failed` row naming the step (`archive`, `restore`, `delete`) and the exception class, as row failures do, and the other applications continue. Contact persons are never touched.
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
index 78903af22..48f43be64 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
@@ -16,7 +16,7 @@ OpenRegister: `OCA\OpenRegister\Contract\ObjectServiceInterface` (`searchObjects
### Requirement: An application missing from the CMDB sheets SHALL be archived when the archive sheet lists it and soft-deleted when no sheet does (REQ-CMDB-015)
-With `missingRecords` `archive`, after every row of an import that was not cancelled and did not fail, the import SHALL reconcile, under the register lock it already holds, every `usage` whose `consumer` is the imported municipality together with the `module` it points at, including archived and soft-deleted ones, and only those whose module carries this municipality's import key (`topdesk::`). It SHALL read the APPID column of the sheet "Gearchiveerde Applicaties" when the workbook has it, under the same size and row bounds as the CMDB sheets and without reading any other column of it. For an application whose APPID is on neither CMDB sheet: when the archive sheet lists the APPID, the import SHALL archive the usage and then the module through OpenRegister's archive state with a reason that names the import, restoring a soft-deleted one from the trash first; when no sheet lists it, the import SHALL soft-delete the usage and then the module through OpenRegister's trash. When the workbook has no archive sheet, or the sheet has no APPID column, or a row of the sheet holds an APPID formula without a cached value (read as empty, as REQ-CMDB-002 has it for the CMDB sheets, and listed by the reader in `archive.uncachedRows`), the import SHALL archive and SHALL NOT soft-delete, and SHALL add a warning to the report; for an uncached row the warning SHALL name the sheet and the row numbers. An application that is already in the state the export asks for SHALL be left alone and not reported. The import SHALL NOT touch contact persons, organisations, modules that carry another municipality's import key or no import key, or usages of another organisation. Each application SHALL be reconciled in its own error boundary: a failure SHALL be a `failed` report row naming the step and the exception class, and the other applications SHALL still be reconciled. The reconciliation SHALL report its progress as the phase `reconciling` of the import's operation. A cancel SHALL also be honoured after the last row and before each application of the reconciliation: the import SHALL stop there, SHALL keep what it archived or deleted so far, and SHALL report the run as cancelled. The report SHALL count `archived` and `deleted` in its summary and SHALL list each as a row with the APPID, the application name and both uuids, never person data. When OpenRegister's archive handler or object mapper is not available, an import with `missingRecords` `archive` SHALL be refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read.
+With `missingRecords` `archive`, after every row of an import that was not cancelled and did not fail, the import SHALL reconcile, under the register lock it already holds, every `usage` whose `consumer` is the imported municipality together with the `module` it points at, including archived and soft-deleted ones, and only those whose module carries this municipality's import key (`topdesk::`). It SHALL read the APPID column of the sheet "Gearchiveerde Applicaties" when the workbook has it, under the same size and row bounds as the CMDB sheets and without reading any other column of it. For an application whose APPID is on neither CMDB sheet: when the archive sheet lists the APPID, the import SHALL archive the usage and then the module through OpenRegister's archive state with a reason that names the import, restoring a soft-deleted one from the trash first; when no sheet lists it, the import SHALL soft-delete the usage and then the module through OpenRegister's trash. When the workbook has no archive sheet, or the sheet has no APPID column, or a row of the sheet holds an APPID formula without a cached value (read as empty, as REQ-CMDB-002 has it for the CMDB sheets, and listed by the reader in `archive.uncachedRows`), the import SHALL archive and SHALL NOT soft-delete, and SHALL add a warning to the report; for an uncached row the warning SHALL name the sheet and the row numbers. When a municipality has more than one module or usage under the same import key (a module deleted by hand next to the one a later import created), the import SHALL reconcile only the one it would match, a working one before an archived one and an archived one before one in the trash, and SHALL leave the others as they are, so a stale duplicate is never restored into the archive. An application that is already in the state the export asks for SHALL be left alone and not reported. The import SHALL NOT touch contact persons, organisations, modules that carry another municipality's import key or no import key, or usages of another organisation. Each application SHALL be reconciled in its own error boundary: a failure SHALL be a `failed` report row naming the step and the exception class, and the other applications SHALL still be reconciled. The reconciliation SHALL report its progress as the phase `reconciling` of the import's operation. A cancel SHALL also be honoured after the last row and before each application of the reconciliation: the import SHALL stop there, SHALL keep what it archived or deleted so far, and SHALL report the run as cancelled. The report SHALL count `archived` and `deleted` in its summary and SHALL list each as a row with the APPID, the application name and both uuids, never person data. When OpenRegister's archive handler or object mapper is not available, an import with `missingRecords` `archive` SHALL be refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read.
#### Scenario: An application that moved to the archive sheet is archived
@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts
@@ -36,6 +36,14 @@ With `missingRecords` `archive`, after every row of an import that was not cance
- **THEN** the module and the usage of `7` SHALL be archived and SHALL NOT be deleted
- **AND** the report SHALL carry a warning that the sheet "Gearchiveerde Applicaties" was not found
+#### Scenario: A stale duplicate under the same import key is left in the trash
+@e2e exclude Data condition from earlier imports; tests/Unit/Service/CmdbExportImportServiceTest.php testAStaleDuplicateIsNeverRestoredByTheReconciliation stores a working and a trashed module and usage for APPID `7`, lists `7` on the archive sheet, and asserts the working pair is archived, no `restore` happens and the trashed pair stays deleted.
+
+- **GIVEN** a working module with APPID `7` and its usage, and a module with the same import key in the trash with its own usage
+- **WHEN** an export without `7` on the CMDB sheets, with `7` on "Gearchiveerde Applicaties", is imported with `archive`
+- **THEN** the working module and usage SHALL be archived
+- **AND** the module and the usage in the trash SHALL stay there, and nothing SHALL be restored
+
#### Scenario: An archive row whose APPID has no cached value never turns an archive into a deletion
@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testAnUncachedArchiveRowArchivesInsteadOfDeleting asserts the archive state, no delete, and the warning naming the sheet and the row; tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php testAnUncachedArchiveKeyIsListedNotDropped asserts the reader lists the row.
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
index cc3583dca..a38eb2c93 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
@@ -45,6 +45,7 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp
- GIVEN APPIDs 1 and 7 imported WHEN an export with row 1 and 7 on the archive sheet is imported THEN module and usage of 7 are archived with a reason, the report row is `archived`, and the owner contact person is untouched
- GIVEN APPID 7 on no sheet THEN module and usage are soft-deleted and the row is `deleted`; GIVEN the archive sheet absent THEN they are archived instead and the report warns
- GIVEN APPID 7 soft-deleted and listed on the archive sheet THEN module and usage are restored and archived; GIVEN it already archived or, when on no sheet, already deleted THEN nothing is written
+ - GIVEN a working pair and a trashed pair under the same import key and the key on the archive sheet THEN the working pair is archived, nothing is restored and the trashed pair stays deleted
- GIVEN `missingRecords` `keep`, a cancelled import, or a usage of another municipality THEN nothing is archived or deleted
- GIVEN a cancel during the last row THEN nothing is archived or deleted; GIVEN a cancel during the reconciliation THEN it stops before the next application and the report is cancelled
- GIVEN the archive of one application throws THEN that application is a `failed` row and the others are reconciled; GIVEN no ArchiveHandler THEN 503 `ARCHIVE_UNAVAILABLE` before reading
diff --git a/tests/Unit/Service/CmdbExportImportServiceTest.php b/tests/Unit/Service/CmdbExportImportServiceTest.php
index 62257fec6..3a7a0e9b6 100644
--- a/tests/Unit/Service/CmdbExportImportServiceTest.php
+++ b/tests/Unit/Service/CmdbExportImportServiceTest.php
@@ -2086,6 +2086,72 @@ public function testAnotherMunicipalitysUsagesAreNeverReconciled(): void {
$this->assertSame(2, count($this->transitions));
}//end testAnotherMunicipalitysUsagesAreNeverReconciled()
+ /**
+ * A working pair and a stale pair in the trash under the same import key: the reconciliation acts on the
+ * working one, as the import's own match does, and never restores the stale one into the archive.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function testAStaleDuplicateIsNeverRestoredByTheReconciliation(): void {
+ $this->importOneSevenAndEight();
+ $seven = $this->moduleOf(appId: '7');
+ $usage = $this->usageOf(moduleUuid: $seven);
+ // A stale module deleted by hand, with its own usage, carries the same import key as the working one.
+ $this->store[self::MODULE] = ['module-stale' => array_merge($this->store[self::MODULE][$seven], ['id' => 'module-stale'])] + $this->store[self::MODULE];
+ $this->store[self::USAGE] = ['usage-stale' => array_merge($this->store[self::USAGE][$usage], ['id' => 'usage-stale', 'module' => 'module-stale'])] + $this->store[self::USAGE];
+ $this->states[self::MODULE]['module-stale'] = ['deleted' => ['deletedBy' => 'admin', 'deletedAt' => '2026-10-01T00:00:00+00:00']];
+ $this->states[self::USAGE]['usage-stale'] = ['deleted' => ['deletedBy' => 'admin', 'deletedAt' => '2026-10-01T00:00:00+00:00']];
+
+ // APPID 7 is on the archive sheet; APPID 8 is on no sheet.
+ $report = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: ['7']))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1']);
+
+ $this->assertNotContains('restore', array_column($this->transitions, 0), 'the stale duplicate is not restored');
+ $this->assertSame([false, true], $this->stateOf(schema: self::MODULE, uuid: 'module-stale'));
+ $this->assertSame([false, true], $this->stateOf(schema: self::USAGE, uuid: 'usage-stale'));
+ $this->assertSame([true, false], $this->stateOf(schema: self::MODULE, uuid: $seven));
+ $this->assertSame([true, false], $this->stateOf(schema: self::USAGE, uuid: $usage));
+ $reconciled = array_values(array_filter($report['rows'], static fn (array $row): bool => $row['row'] === 0));
+ $this->assertSame([['7', 'archived', $seven], ['8', 'deleted', $this->moduleOf(appId: '8')]], array_map(static fn (array $row): array => [$row['appId'], $row['outcome'], $row['moduleUuid']], $reconciled));
+
+ // A second run finds the working pair archived and the stale pair in the trash: nothing to do.
+ $this->transitions = [];
+ $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: ['7']))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1']);
+ $this->assertSame([], $this->transitions, 'the archived pair is in the state the export asks for, and the stale one is left alone');
+ $this->assertSame([false, true], $this->stateOf(schema: self::MODULE, uuid: 'module-stale'));
+ }//end testAStaleDuplicateIsNeverRestoredByTheReconciliation()
+
+ /**
+ * Of two usages under one import key, the one of the module the import would match is reconciled, whatever
+ * state the usage is in: the module's state ranks first, the usage's second.
+ *
+ * The working usage is listed before the one in the trash but belongs to the duplicate module in the trash.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function testTheModulesStateDecidesWhichDuplicateIsReconciled(): void {
+ $this->importOneSevenAndEight();
+ $seven = $this->moduleOf(appId: '7');
+ $usage = $this->usageOf(moduleUuid: $seven);
+ $this->store[self::MODULE]['module-stale'] = array_merge($this->store[self::MODULE][$seven], ['id' => 'module-stale']);
+ $this->store[self::USAGE]['usage-stale'] = array_merge($this->store[self::USAGE][$usage], ['id' => 'usage-stale', 'module' => 'module-stale']);
+ $this->states[self::MODULE]['module-stale'] = ['deleted' => ['deletedBy' => 'admin']];
+ $this->states[self::USAGE][$usage] = ['deleted' => ['deletedBy' => 'admin']];
+
+ $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1'), $this->row(appId: '8', row: 3)], archived: ['7']))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1']);
+
+ $this->assertSame([true, false], $this->stateOf(schema: self::MODULE, uuid: $seven), 'the working module is archived');
+ $this->assertSame([true, false], $this->stateOf(schema: self::USAGE, uuid: $usage), 'its usage is restored from the trash and archived');
+ $this->assertSame([false, true], $this->stateOf(schema: self::MODULE, uuid: 'module-stale'));
+ $this->assertSame([false, false], $this->stateOf(schema: self::USAGE, uuid: 'usage-stale'), 'the working usage of the stale module is not touched');
+ }//end testTheModulesStateDecidesWhichDuplicateIsReconciled()
+
/**
* One application that cannot be archived is a failed row; the others are still reconciled.
*
From a2b951dad8d8c3415f31d90b049db3a74ad2129b Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:17:33 +0200
Subject: [PATCH 039/124] fix(cmdb-import): show the import's write rules in
the mapping block
The mapping tables listed every mapped field as written on each import, but
the import writes the usage's interneAnnotation and timeClassification only
on create or when the stored value is empty, sets the module type on create,
and never writes the module's publication dates on an existing record. The
block now shows, per row, when the import writes the field, and under each
table the pack defaults, the create-only values and the never-written fields
that the endpoint answers since the previous slice. New strings in en and nl;
REQ-CMDB-020, the user docs, the vitest and the e2e API check name them.
Review #1288 f2
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 7 ++
l10n/en.js | 10 +-
l10n/en.json | 10 +-
l10n/nl.js | 10 +-
l10n/nl.json | 10 +-
.../specs/cmdb-export-import/spec.md | 3 +-
src/utils/cmdbImport.js | 106 +++++++++++++++++-
.../settings/sections/CmdbImportMapping.vue | 23 ++++
.../spec-coverage/cmdb-import-mapping.spec.ts | 14 +++
tests/vitest/cmdbImportMapping.spec.js | 71 +++++++++++-
10 files changed, 253 insertions(+), 11 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index 0cdc4f481..d6c0d7468 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -348,6 +348,13 @@ The section shows the mapping the next import runs, read-only. Open
whether the column is required, the transformation (as is, trim, date,
lookup, yes/no lookup, join, constant) and its details, such as the export
values a lookup recognises and what each becomes.
+- per row, when the import writes the field: on create and update, only on
+ create or when the stored value is empty (such as the usage's TIME
+ classification and internal annotation), or never on an existing record;
+- under each table, the pack's write rules that are not columns: the pack's
+ defaults, the values set only when a record is created (such as the module
+ type `Application`) and the fields never written on an existing record
+ (the module's publication and depublication dates).
The block reads the files through the same loader the import uses, so what
it shows is what the import does. When a file is invalid, the block shows
diff --git a/l10n/en.js b/l10n/en.js
index 60a730e51..8c8fc1d29 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1165,7 +1165,15 @@ OC.L10N.register(
"Field": "Field",
"Transformation": "Transformation",
"The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.",
- "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log."
+ "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log.",
+ "Written": "Written",
+ "On create and update": "On create and update",
+ "Only on create, or when empty": "Only on create, or when empty",
+ "Never on an existing record": "Never on an existing record",
+ "Pack defaults: {values}": "Pack defaults: {values}",
+ "Set only when the record is created: {values}": "Set only when the record is created: {values}",
+ "Written on create, or on update only when the stored value is empty: {fields}": "Written on create, or on update only when the stored value is empty: {fields}",
+ "Never written on an existing record: {fields}": "Never written on an existing record: {fields}"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index 5e5a32434..14cf19581 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1164,6 +1164,14 @@
"Field": "Field",
"Transformation": "Transformation",
"The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.",
- "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log."
+ "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log.",
+ "Written": "Written",
+ "On create and update": "On create and update",
+ "Only on create, or when empty": "Only on create, or when empty",
+ "Never on an existing record": "Never on an existing record",
+ "Pack defaults: {values}": "Pack defaults: {values}",
+ "Set only when the record is created: {values}": "Set only when the record is created: {values}",
+ "Written on create, or on update only when the stored value is empty: {fields}": "Written on create, or on update only when the stored value is empty: {fields}",
+ "Never written on an existing record: {fields}": "Never written on an existing record: {fields}"
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index e51e4af38..7f0a389f7 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1233,7 +1233,15 @@ OC.L10N.register(
"Field": "Veld",
"Transformation": "Transformatie",
"The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "De mapping van de import kan niet worden getoond: OpenRegister ontbreekt of een mappingbestand is ongeldig.",
- "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek."
+ "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek.",
+ "Written": "Geschreven",
+ "On create and update": "Bij aanmaken en bijwerken",
+ "Only on create, or when empty": "Alleen bij aanmaken, of als leeg",
+ "Never on an existing record": "Nooit op een bestaand record",
+ "Pack defaults: {values}": "Standaardwaarden van het pack: {values}",
+ "Set only when the record is created: {values}": "Alleen ingesteld bij het aanmaken van het record: {values}",
+ "Written on create, or on update only when the stored value is empty: {fields}": "Geschreven bij aanmaken, of bij bijwerken alleen als de opgeslagen waarde leeg is: {fields}",
+ "Never written on an existing record: {fields}": "Nooit geschreven op een bestaand record: {fields}"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index 6ab4bbf98..6d9121ab5 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1232,6 +1232,14 @@
"Field": "Veld",
"Transformation": "Transformatie",
"The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "De mapping van de import kan niet worden getoond: OpenRegister ontbreekt of een mappingbestand is ongeldig.",
- "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek."
+ "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek.",
+ "Written": "Geschreven",
+ "On create and update": "Bij aanmaken en bijwerken",
+ "Only on create, or when empty": "Alleen bij aanmaken, of als leeg",
+ "Never on an existing record": "Nooit op een bestaand record",
+ "Pack defaults: {values}": "Standaardwaarden van het pack: {values}",
+ "Set only when the record is created: {values}": "Alleen ingesteld bij het aanmaken van het record: {values}",
+ "Written on create, or on update only when the stored value is empty: {fields}": "Geschreven bij aanmaken, of bij bijwerken alleen als de opgeslagen waarde leeg is: {fields}",
+ "Never written on an existing record: {fields}": "Nooit geschreven op een bestaand record: {fields}"
}
}
diff --git a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
index 9d807394b..7ff22e728 100644
--- a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
@@ -16,7 +16,7 @@ Nextcloud OCP interfaces used: `OCP\IL10N` (messages). OpenRegister: `Service\Mi
### Requirement: The admin settings SHALL show the mapping the import uses (REQ-CMDB-020)
-`GET /api/settings/cmdb-import/mapping` SHALL be reachable only by Nextcloud admins and SHALL require Nextcloud's CSRF token; like the import routes (REQ-CMDB-001) it SHALL NOT carry `#[AuthorizedAdminSetting]`, `#[NoAdminRequired]` or `#[NoCSRFRequired]`. It SHALL load the import profile and every pack it names through the same loader and validator the import uses, and SHALL answer 200 with a flat envelope of two keys: `profile` (the profile's `id`, `name`, `version`, `sheets` with each sheet's `name`, `constants` and `absentColumns`, `sheetPrecedence`, `keyColumn`, `nameColumn`, `requiredColumns`, `dateColumns`, `idColumns`, `emptyValues`, `externalKeyPrefix`, `missingRecords` and `profileFile`) and `packs`, one entry per target in the order module, manufacturer, municipality, usage, businessOwner, each with `target`, `file`, `id`, `name`, `version`, `description`, `fieldMappings` and the write rules the import applies besides the mapping: `defaults` (the pack's values, field => value), `createOnlyDefaults` (values set on create only, field => value), `createOnlyFields` (mapped fields written on create or, on an existing object, only when the stored value is empty) and `neverWritten` (fields the import never writes on an existing object); the last three come from the profile's `createOnly` and `neverWritten` through the accessors the import itself uses. Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it: the `type`, and for `lookup` and `bool-map` the `map` and `default`, for `concat` the `fields` and `separator`, for `const` the `value`, for `date` the `sourceFormat` and `targetFormat`. The endpoint SHALL read nothing but those files and SHALL write nothing. When the validator is missing, or the profile or a pack is unreadable or invalid, it SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason`, so the administrator who edited a file learns what is wrong without the Nextcloud log. The "CMDB import" section SHALL show the answer in a collapsible block "Mapping (read-only)": the sheets, the key, name, required and date columns, and one table per pack with the source column, the target field, whether it is required, the transformation and its details. The block SHALL show a loading state until the endpoint answers and the error with its code when it fails, and SHALL offer no editing: the mapping is changed in the files on the server, as the documentation describes.
+`GET /api/settings/cmdb-import/mapping` SHALL be reachable only by Nextcloud admins and SHALL require Nextcloud's CSRF token; like the import routes (REQ-CMDB-001) it SHALL NOT carry `#[AuthorizedAdminSetting]`, `#[NoAdminRequired]` or `#[NoCSRFRequired]`. It SHALL load the import profile and every pack it names through the same loader and validator the import uses, and SHALL answer 200 with a flat envelope of two keys: `profile` (the profile's `id`, `name`, `version`, `sheets` with each sheet's `name`, `constants` and `absentColumns`, `sheetPrecedence`, `keyColumn`, `nameColumn`, `requiredColumns`, `dateColumns`, `idColumns`, `emptyValues`, `externalKeyPrefix`, `missingRecords` and `profileFile`) and `packs`, one entry per target in the order module, manufacturer, municipality, usage, businessOwner, each with `target`, `file`, `id`, `name`, `version`, `description`, `fieldMappings` and the write rules the import applies besides the mapping: `defaults` (the pack's values, field => value), `createOnlyDefaults` (values set on create only, field => value), `createOnlyFields` (mapped fields written on create or, on an existing object, only when the stored value is empty) and `neverWritten` (fields the import never writes on an existing object); the last three come from the profile's `createOnly` and `neverWritten` through the accessors the import itself uses. Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it: the `type`, and for `lookup` and `bool-map` the `map` and `default`, for `concat` the `fields` and `separator`, for `const` the `value`, for `date` the `sourceFormat` and `targetFormat`. The endpoint SHALL read nothing but those files and SHALL write nothing. When the validator is missing, or the profile or a pack is unreadable or invalid, it SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason`, so the administrator who edited a file learns what is wrong without the Nextcloud log. The "CMDB import" section SHALL show the answer in a collapsible block "Mapping (read-only)": the sheets, the key, name, required and date columns, and one table per pack with the source column, the target field, whether it is required, the transformation and its details, and when the import writes the field (on create and update, only on create or when the stored value is empty, or never on an existing record). Below each table the block SHALL list the pack's write rules that are not columns: its `defaults`, the `createOnlyDefaults` and the `neverWritten` fields. The block SHALL show a loading state until the endpoint answers and the error with its code when it fails, and SHALL offer no editing: the mapping is changed in the files on the server, as the documentation describes.
#### Scenario: An admin reads the mapping the import uses
@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
@@ -35,6 +35,7 @@ Nextcloud OCP interfaces used: `OCP\IL10N` (messages). OpenRegister: `Service\Mi
- **WHEN** they open "Mapping (read-only)" in the "CMDB import" section
- **THEN** the section SHALL show five tables, one per pack, each named after its target and its file
- **AND** the usage table SHALL have a row for the column "Applicatie Status" with the field `status`, the transformation Lookup and the pair "In productie" to "In production"
+- **AND** the usage table SHALL show "Only on create, or when empty" for the fields `interneAnnotation` and `timeClassification`, and the module table SHALL list `type = Application` as set on create only and `publicationDate` and `depublicationDate` as never written on an existing record
- **AND** the block SHALL have no control that edits a mapping
#### Scenario: A broken pack is reported with its reason
diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js
index 4646c5a53..07f494e7d 100644
--- a/src/utils/cmdbImport.js
+++ b/src/utils/cmdbImport.js
@@ -1185,11 +1185,114 @@ export function transformDetails(transform) {
return lines
}
+/**
+ * A list of names from the endpoint, as strings.
+ *
+ * @param {unknown} values A list, or anything else
+ * @return {Array} The names, empty when it is not a list
+ */
+function nameList(values) {
+ return Array.isArray(values) ? values.map((value) => String(value)) : []
+}
+
+/**
+ * A field => value object of the endpoint as "field = value" pairs.
+ *
+ * @param {unknown} values The object, or anything else
+ * @return {string} The pairs, joined with commas; empty when there are none
+ */
+function pairsText(values) {
+ if (!values || typeof values !== 'object' || Array.isArray(values)) {
+ return ''
+ }
+ return Object.entries(values)
+ .map(([field, value]) => `${field} = ${valueText(value)}`)
+ .join(', ')
+}
+
+/**
+ * When the import writes a mapped field: on create and update, only on
+ * create or when the stored value is empty, or never on an existing record.
+ * Never wins when a profile lists the field in both.
+ *
+ * @param {object} pack One entry of the endpoint's `packs`
+ * @param {string} target The field a mapping writes to
+ * @return {string} The translated words
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+export function writtenLabel(pack, target) {
+ if (nameList(pack?.neverWritten).includes(target)) {
+ return t('stackiq', 'Never on an existing record')
+ }
+ if (nameList(pack?.createOnlyFields).includes(target)) {
+ return t('stackiq', 'Only on create, or when empty')
+ }
+ return t('stackiq', 'On create and update')
+}
+
+/**
+ * The write rules of a pack that its table rows cannot show, as lines: the
+ * pack's defaults, the values set on create only, and the fields the import
+ * leaves alone on an existing record (which need not be mapped columns).
+ *
+ * @param {object} pack One entry of the endpoint's `packs`
+ * @return {Array} The lines, empty when the pack has no such rule
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+export function packRules(pack) {
+ const lines = []
+ const defaults = pairsText(pack?.defaults)
+ if (defaults !== '') {
+ lines.push(t('stackiq', 'Pack defaults: {values}', { values: defaults }, AS_TEXT))
+ }
+ const createOnly = pairsText(pack?.createOnlyDefaults)
+ if (createOnly !== '') {
+ lines.push(
+ t(
+ 'stackiq',
+ 'Set only when the record is created: {values}',
+ { values: createOnly },
+ AS_TEXT,
+ ),
+ )
+ }
+ const setOnCreate = Object.keys(
+ pack?.createOnlyDefaults && !Array.isArray(pack.createOnlyDefaults)
+ ? pack.createOnlyDefaults
+ : {},
+ )
+ const createOnlyFields = nameList(pack?.createOnlyFields).filter(
+ (field) => !setOnCreate.includes(field),
+ )
+ if (createOnlyFields.length > 0) {
+ lines.push(
+ t(
+ 'stackiq',
+ 'Written on create, or on update only when the stored value is empty: {fields}',
+ { fields: createOnlyFields.join(', ') },
+ AS_TEXT,
+ ),
+ )
+ }
+ const never = nameList(pack?.neverWritten)
+ if (never.length > 0) {
+ lines.push(
+ t(
+ 'stackiq',
+ 'Never written on an existing record: {fields}',
+ { fields: never.join(', ') },
+ AS_TEXT,
+ ),
+ )
+ }
+ return lines
+}
+
/**
* The rows of one pack's table: one per field mapping, in the pack's order.
*
* @param {object} pack One entry of the endpoint's `packs`
- * @return {Array<{key: string, source: string, target: string, required: boolean, transform: string, details: Array}>} The rows
+ * @return {Array<{key: string, source: string, target: string, required: boolean, transform: string, details: Array, written: string}>} The rows
* @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
*/
export function mappingRows(pack) {
@@ -1204,5 +1307,6 @@ export function mappingRows(pack) {
required: Boolean(mapping?.required),
transform: transformLabel(mapping?.transform?.type),
details: transformDetails(mapping?.transform),
+ written: writtenLabel(pack, String(mapping?.target ?? '')),
}))
}
diff --git a/src/views/settings/sections/CmdbImportMapping.vue b/src/views/settings/sections/CmdbImportMapping.vue
index 127226a9a..06928f735 100644
--- a/src/views/settings/sections/CmdbImportMapping.vue
+++ b/src/views/settings/sections/CmdbImportMapping.vue
@@ -118,6 +118,11 @@
: t('stackiq', 'No')
}}
+
+ {{
+ row.written
+ }}
+
—
+
+
+ {{ line }}
+
+
@@ -149,6 +162,7 @@ import {
loadCmdbMapping,
mappingRows,
normaliseError,
+ packRules,
packTargetLabel,
} from '../../../utils/cmdbImport.js'
@@ -260,6 +274,7 @@ export default {
{ key: 'required', label: t('stackiq', 'Required') },
{ key: 'transform', label: t('stackiq', 'Transformation') },
{ key: 'details', label: t('stackiq', 'Details') },
+ { key: 'written', label: t('stackiq', 'Written') },
]
},
},
@@ -277,6 +292,7 @@ export default {
methods: {
t,
mappingRows,
+ packRules,
packTargetLabel,
/**
@@ -350,6 +366,13 @@ export default {
overflow-wrap: anywhere;
}
+.cmdb-import-mapping__rules {
+ margin: 0.5rem 0 0;
+ padding-inline-start: 1.25rem;
+ list-style: disc;
+ font-size: 0.875rem;
+}
+
.cmdb-import-mapping__details {
margin: 0;
padding-inline-start: 1.25rem;
diff --git a/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts b/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
index df1164397..a4c41c44b 100644
--- a/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
+++ b/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
@@ -109,6 +109,20 @@ test.describe('CMDB import mapping (read-only)', () => {
expect(status.target).toBe('status')
expect(status.transform.type).toBe('lookup')
expect(status.transform.map['In productie']).toBe('In production')
+
+ // The write rules the import applies besides the mapping.
+ expect(usage.createOnlyFields).toEqual([
+ 'interneAnnotation',
+ 'timeClassification',
+ ])
+ const moduleRules = body.packs.find(
+ (pack: { target: string }) => pack.target === 'module',
+ )
+ expect(moduleRules.createOnlyDefaults).toEqual({ type: 'Application' })
+ expect(moduleRules.neverWritten).toEqual([
+ 'publicationDate',
+ 'depublicationDate',
+ ])
} finally {
await ctx.dispose()
}
diff --git a/tests/vitest/cmdbImportMapping.spec.js b/tests/vitest/cmdbImportMapping.spec.js
index b7ad8715f..ee8e70f16 100644
--- a/tests/vitest/cmdbImportMapping.spec.js
+++ b/tests/vitest/cmdbImportMapping.spec.js
@@ -70,6 +70,7 @@ vi.mock('@conduction/nextcloud-vue', () => ({
const {
mappingRows,
mappingSheetNames,
+ packRules,
packTargetLabel,
PROFILE_DEFAULTS,
transformDetails,
@@ -85,7 +86,7 @@ const CmdbImportMapping = (
* @return {{profile: object, packs: Array}} The answer
*/
function answer() {
- const pack = (target, fieldMappings = []) => ({
+ const pack = (target, fieldMappings = [], rules = {}) => ({
target,
file: `topdesk-${target}.json`,
id: `stackiq-topdesk-${target}`,
@@ -93,6 +94,11 @@ function answer() {
version: '1.0.0',
description: '',
fieldMappings,
+ defaults: {},
+ createOnlyDefaults: {},
+ createOnlyFields: [],
+ neverWritten: [],
+ ...rules,
})
return {
profile: {
@@ -115,8 +121,14 @@ function answer() {
required: true,
transform: { type: 'trim' },
},
- ]),
- pack('manufacturer'),
+ ], {
+ createOnlyDefaults: { type: 'Application' },
+ createOnlyFields: ['type'],
+ neverWritten: ['publicationDate', 'depublicationDate'],
+ }),
+ pack('manufacturer', [], {
+ defaults: { type: 'Supplier', status: 'Active' },
+ }),
pack('municipality'),
pack('usage', [
{
@@ -129,7 +141,13 @@ function answer() {
default: null,
},
},
- ]),
+ {
+ source: 'Classificatie',
+ target: 'timeClassification',
+ required: false,
+ transform: { type: 'trim' },
+ },
+ ], { createOnlyFields: ['timeClassification'] }),
pack('businessOwner'),
],
}
@@ -178,6 +196,30 @@ describe('CmdbImportMapping', () => {
expect(usage.text()).toContain('status')
expect(usage.text()).toContain('Lookup')
expect(usage.text()).toContain('In productie → In production')
+ // The write rules the import applies besides the mapping (Review #1288 f2).
+ const writtenOf = (table) =>
+ table
+ .findAll('[data-testid="cmdb-import-mapping-written"]')
+ .map((cell) => cell.text())
+ expect(writtenOf(usage)).toEqual([
+ 'On create and update',
+ 'Only on create, or when empty',
+ ])
+ const rules = (target) =>
+ wrapper
+ .find(`[data-testid="cmdb-import-mapping-${target}"]`)
+ .element.parentElement.querySelectorAll(
+ '[data-testid="cmdb-import-mapping-rules"] li',
+ )
+ expect(Array.from(rules('module'), (li) => li.textContent.trim())).toEqual([
+ 'Set only when the record is created: type = Application',
+ 'Never written on an existing record: publicationDate, depublicationDate',
+ ])
+ expect(Array.from(rules('manufacturer'), (li) => li.textContent.trim())).toEqual([
+ 'Pack defaults: type = Supplier, status = Active',
+ ])
+ expect(rules('usage')).toHaveLength(1)
+ expect(rules('municipality')).toHaveLength(0)
expect(
wrapper.find('[data-testid="cmdb-import-mapping-profile"]').text(),
).toContain('Sheet A, Sheet B')
@@ -233,6 +275,23 @@ describe('cmdbImport mapping helpers', () => {
).toEqual(PROFILE_DEFAULTS.sheets)
})
+ it('words the write rules of a pack, and tolerates an answer without them', () => {
+ expect(packRules({})).toEqual([])
+ expect(packRules({ defaults: [], createOnlyDefaults: [], createOnlyFields: [] })).toEqual([])
+ expect(
+ packRules({
+ createOnlyDefaults: { type: 'Application' },
+ createOnlyFields: ['type', 'note'],
+ }),
+ ).toEqual([
+ 'Set only when the record is created: type = Application',
+ 'Written on create, or on update only when the stored value is empty: note',
+ ])
+ expect(mappingRows({ fieldMappings: [{ source: 'A', target: 'x' }], neverWritten: ['x'] })[0].written).toBe(
+ 'Never on an existing record',
+ )
+ })
+
it('describes every transformation the packs use', () => {
expect(transformLabel(undefined)).toBe('As is')
expect(transformLabel('bool-map')).toBe('Yes/no lookup')
@@ -262,7 +321,7 @@ describe('cmdbImport mapping helpers', () => {
it('builds one row per field mapping and names each target', () => {
const rows = mappingRows(answer().packs[3])
- expect(rows).toHaveLength(1)
+ expect(rows).toHaveLength(2)
expect(rows[0]).toMatchObject({
source: 'Applicatie Status',
target: 'status',
@@ -273,6 +332,8 @@ describe('cmdbImport mapping helpers', () => {
'In productie → In production',
'Any other value: —',
])
+ expect(rows[0].written).toBe('On create and update')
+ expect(rows[1].written).toBe('Only on create, or when empty')
expect(mappingRows({})).toEqual([])
expect(packTargetLabel('businessOwner')).toBe(
'Business owner (contact person)',
From 2e381f7b195f46b9ba58f8176e3034a328349585 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:22:32 +0200
Subject: [PATCH 040/124] fix(cmdb-import): name every sheet the profile reads
in the help texts
mappingSheetNames() fell back to the shipped names unless the profile had at
least two sheets, and the file help and the info panel only read the first
two entries and tied "(applications without arranged maintenance)" to
position 0. A profile with one sheet showed two sheets the import does not
read, one with three lost the third, and a reordered profile mislabelled both.
The profile's list is now used whenever it names a sheet, and both texts are
built from the whole list, with a singular and a plural form and no
position-bound description. The two replaced strings are removed from en and
nl; the vitest pins the 0, 1, 2 and 3 sheet cases.
Review #1289 f1
Co-Authored-By: Claude Opus 5.5
---
l10n/en.js | 8 +-
l10n/en.json | 8 +-
l10n/nl.js | 8 +-
l10n/nl.json | 8 +-
.../cmdb-import-mapping-view/design.md | 2 +-
.../specs/cmdb-export-import/spec.md | 3 +-
.../changes/cmdb-import-mapping-view/tasks.md | 1 +
src/utils/cmdbImport.js | 78 +++++++++++++++++--
src/views/settings/sections/CmdbImport.vue | 51 ++++++------
tests/vitest/cmdbImportMapping.spec.js | 42 ++++++++--
10 files changed, 159 insertions(+), 50 deletions(-)
diff --git a/l10n/en.js b/l10n/en.js
index 8c8fc1d29..ad4e755b9 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1088,8 +1088,6 @@ OC.L10N.register(
"The file is larger than the server accepts.": "The file is larger than the server accepts.",
"A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.",
"Split the export and import the parts one after the other.": "Split the export and import the parts one after the other.",
- "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.",
"The page got no answer from the import.": "The page got no answer from the import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.",
@@ -1173,7 +1171,11 @@ OC.L10N.register(
"Pack defaults: {values}": "Pack defaults: {values}",
"Set only when the record is created: {values}": "Set only when the record is created: {values}",
"Written on create, or on update only when the stored value is empty: {fields}": "Written on create, or on update only when the stored value is empty: {fields}",
- "Never written on an existing record: {fields}": "Never written on an existing record: {fields}"
+ "Never written on an existing record: {fields}": "Never written on an existing record: {fields}",
+ "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.",
+ "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.",
+ "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index 14cf19581..c18324251 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1087,8 +1087,6 @@
"The file is larger than the server accepts.": "The file is larger than the server accepts.",
"A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.",
"Split the export and import the parts one after the other.": "Split the export and import the parts one after the other.",
- "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.",
"The page got no answer from the import.": "The page got no answer from the import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.",
@@ -1172,6 +1170,10 @@
"Pack defaults: {values}": "Pack defaults: {values}",
"Set only when the record is created: {values}": "Set only when the record is created: {values}",
"Written on create, or on update only when the stored value is empty: {fields}": "Written on create, or on update only when the stored value is empty: {fields}",
- "Never written on an existing record: {fields}": "Never written on an existing record: {fields}"
+ "Never written on an existing record: {fields}": "Never written on an existing record: {fields}",
+ "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.",
+ "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.",
+ "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored."
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index 7f0a389f7..dbd4d8771 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1156,8 +1156,6 @@ OC.L10N.register(
"The file is larger than the server accepts.": "Het bestand is groter dan de server accepteert.",
"A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar.",
"Split the export and import the parts one after the other.": "Splits de export en importeer de delen na elkaar.",
- "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad \"{first}\" of \"{second}\". Standaard mag het bestand hoogstens {size} zijn.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
"The page got no answer from the import.": "De pagina kreeg geen antwoord van de import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "De verbinding werd verbroken voordat de import antwoordde, dus de import kan nog bezig zijn of al klaar zijn. Wacht een paar minuten en controleer de applicaties van de gemeente voordat u opnieuw importeert. Hetzelfde bestand opnieuw importeren maakt geen dubbele records aan.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "De import kan nu niet worden geannuleerd: de server is nog niet aan de rijen begonnen, of is er al mee klaar. Loopt de import door, druk dan zo dadelijk opnieuw op Import annuleren.",
@@ -1241,7 +1239,11 @@ OC.L10N.register(
"Pack defaults: {values}": "Standaardwaarden van het pack: {values}",
"Set only when the record is created: {values}": "Alleen ingesteld bij het aanmaken van het record: {values}",
"Written on create, or on update only when the stored value is empty: {fields}": "Geschreven bij aanmaken, of bij bijwerken alleen als de opgeslagen waarde leeg is: {fields}",
- "Never written on an existing record: {fields}": "Nooit geschreven op een bestaand record: {fields}"
+ "Never written on an existing record: {fields}": "Nooit geschreven op een bestaand record: {fields}",
+ "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
+ "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met een van de tabbladen {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
+ "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "Het tabblad {sheets} wordt gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen {sheets} worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index 6d9121ab5..4f89dd5b9 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1155,8 +1155,6 @@
"The file is larger than the server accepts.": "Het bestand is groter dan de server accepteert.",
"A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar.",
"Split the export and import the parts one after the other.": "Splits de export en importeer de delen na elkaar.",
- "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad \"{first}\" of \"{second}\". Standaard mag het bestand hoogstens {size} zijn.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
"The page got no answer from the import.": "De pagina kreeg geen antwoord van de import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "De verbinding werd verbroken voordat de import antwoordde, dus de import kan nog bezig zijn of al klaar zijn. Wacht een paar minuten en controleer de applicaties van de gemeente voordat u opnieuw importeert. Hetzelfde bestand opnieuw importeren maakt geen dubbele records aan.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "De import kan nu niet worden geannuleerd: de server is nog niet aan de rijen begonnen, of is er al mee klaar. Loopt de import door, druk dan zo dadelijk opnieuw op Import annuleren.",
@@ -1240,6 +1238,10 @@
"Pack defaults: {values}": "Standaardwaarden van het pack: {values}",
"Set only when the record is created: {values}": "Alleen ingesteld bij het aanmaken van het record: {values}",
"Written on create, or on update only when the stored value is empty: {fields}": "Geschreven bij aanmaken, of bij bijwerken alleen als de opgeslagen waarde leeg is: {fields}",
- "Never written on an existing record: {fields}": "Nooit geschreven op een bestaand record: {fields}"
+ "Never written on an existing record: {fields}": "Nooit geschreven op een bestaand record: {fields}",
+ "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
+ "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met een van de tabbladen {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
+ "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "Het tabblad {sheets} wordt gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen {sheets} worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd."
}
}
diff --git a/openspec/changes/cmdb-import-mapping-view/design.md b/openspec/changes/cmdb-import-mapping-view/design.md
index 225ede564..23b3357b9 100644
--- a/openspec/changes/cmdb-import-mapping-view/design.md
+++ b/openspec/changes/cmdb-import-mapping-view/design.md
@@ -50,7 +50,7 @@ The write rules the import applies besides the mapping are answered per pack, bu
### D4. The section takes the sheet names from the endpoint, with the constant as fallback
-`CmdbImportMapping.vue` fetches once on creation and emits `loaded` with the answer; `CmdbImport.vue` computes `sheetNames` from it, or from `PROFILE_DEFAULTS.sheets` until it arrives or when it fails. The constant stays, as the design of cmdb-export-import intended: a fallback, never the source.
+`CmdbImportMapping.vue` fetches once on creation and emits `loaded` with the answer; `CmdbImport.vue` computes `sheetNames` from it, or from `PROFILE_DEFAULTS.sheets` until it arrives or when it fails. The constant stays, as the design of cmdb-export-import intended: a fallback, never the source. The help text and the info text name every sheet the profile lists, whatever their number: they are built from the list (`quotedSheetList`), not from its first two entries, and say nothing that depends on a sheet's position.
### D5. One component, fetched once, collapsed by default
diff --git a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
index 7ff22e728..68351e7cc 100644
--- a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
@@ -59,7 +59,7 @@ Nextcloud OCP interfaces used: `OCP\IL10N` (messages). OpenRegister: `Service\Mi
### Requirement: The admin settings SHALL offer a CMDB import section (REQ-CMDB-014)
-Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary and a report table that can be filtered by outcome. The section SHALL also show the mapping the import uses in a collapsible block "Mapping (read-only)" (REQ-CMDB-020), and the sheet names in its help text SHALL come from that mapping, with the shipped names as the fallback until the mapping has loaded or when it cannot be loaded. Every control SHALL have a visible label, and every string SHALL be translatable.
+Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary and a report table that can be filtered by outcome. The section SHALL also show the mapping the import uses in a collapsible block "Mapping (read-only)" (REQ-CMDB-020), and the sheet names in its help text SHALL come from that mapping, every sheet it names and in its order, whatever their number, with the shipped names as the fallback until the mapping has loaded or when it cannot be loaded. Every control SHALL have a visible label, and every string SHALL be translatable.
(Previously: the section had no mapping block, and the sheet names in its help text were a constant in the page, which could drift from the profile on the server.)
@@ -78,6 +78,7 @@ Stackiq's admin settings page SHALL show a section "CMDB import", rendered by th
- **GIVEN** a Nextcloud admin on stackiq's admin settings page
- **WHEN** the mapping endpoint has answered
- **THEN** the help text under the file control SHALL name the sheets the endpoint returned
+- **AND** a profile with one, two or three sheets SHALL give a help text and an info text that name exactly those sheets, with no sheet the profile does not name and no description tied to a sheet's position
- **AND** when the endpoint fails, it SHALL name the shipped sheets and the mapping block SHALL show the error
## Non-Functional Requirements
diff --git a/openspec/changes/cmdb-import-mapping-view/tasks.md b/openspec/changes/cmdb-import-mapping-view/tasks.md
index 36fe883c7..4e5d964bc 100644
--- a/openspec/changes/cmdb-import-mapping-view/tasks.md
+++ b/openspec/changes/cmdb-import-mapping-view/tasks.md
@@ -31,6 +31,7 @@ Spec: `openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.m
- GIVEN the endpoint's answer WHEN the block is expanded THEN one table per pack renders with `data-testid="cmdb-import-mapping-"`, the usage table holding "Applicatie Status" → `status` with the lookup pairs
- GIVEN a 503 WHEN the block loads THEN it shows the error and the code and no table
- GIVEN the answer WHEN the section renders its help text THEN the sheet names are the endpoint's; before and on failure they are `PROFILE_DEFAULTS.sheets`
+ - GIVEN a profile with one, two or three sheets WHEN the section renders its help text and its info text THEN both name exactly those sheets in the profile's order
- [x] Implement
- [x] Test
diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js
index 07f494e7d..883d15e56 100644
--- a/src/utils/cmdbImport.js
+++ b/src/utils/cmdbImport.js
@@ -1009,12 +1009,13 @@ export async function loadCmdbMapping({ http }) {
}
/**
- * The names of the source sheets: the profile's when the mapping has loaded,
- * the shipped defaults until then, when it could not be loaded, or when it
- * names fewer than the two sheets the help texts name.
+ * The names of the source sheets: the profile's when the mapping has loaded
+ * and names at least one sheet, the shipped defaults until then or when it
+ * could not be loaded. Every sheet the profile names is returned, in the
+ * profile's order, so the help texts and the block show the same list.
*
* @param {object|null} mapping The mapping endpoint's answer, or null
- * @return {Array} At least the two default names
+ * @return {Array} The sheet names, never empty
* @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
*/
export function mappingSheetNames(mapping) {
@@ -1024,7 +1025,74 @@ export function mappingSheetNames(mapping) {
typeof sheet === 'string' ? sheet : String(sheet?.name ?? ''),
)
.filter((name) => name !== '')
- return names.length >= 2 ? names : [...PROFILE_DEFAULTS.sheets]
+ return names.length >= 1 ? names : [...PROFILE_DEFAULTS.sheets]
+}
+
+/**
+ * The sheet names as one quoted list: "A", "B", "C".
+ *
+ * @param {Array} names The sheet names
+ * @return {string} The list
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+export function quotedSheetList(names) {
+ return names.map((name) => `"${name}"`).join(', ')
+}
+
+/**
+ * The help text under the file chooser: which sheets the workbook needs and
+ * the default size limit. It names every sheet the profile reads, whatever
+ * their number, and does not depend on their order.
+ *
+ * @param {Array} names The sheet names, from mappingSheetNames()
+ * @param {number} maxFileBytes The default size limit in bytes
+ * @return {string} The text
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+export function fileHelpText(names, maxFileBytes) {
+ const values = {
+ sheets: quotedSheetList(names),
+ size: formatMegabytes(maxFileBytes),
+ }
+ if (names.length === 1) {
+ return t(
+ 'stackiq',
+ 'Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.',
+ values,
+ AS_TEXT,
+ )
+ }
+ return t(
+ 'stackiq',
+ 'Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.',
+ values,
+ AS_TEXT,
+ )
+}
+
+/**
+ * The sentence of the info panel that says which sheets are read.
+ *
+ * @param {Array} names The sheet names, from mappingSheetNames()
+ * @return {string} The text
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+export function sheetsReadText(names) {
+ const values = { sheets: quotedSheetList(names) }
+ if (names.length === 1) {
+ return t(
+ 'stackiq',
+ 'The sheet {sheets} is read; other sheets, including the "Invoer" sheets, are ignored.',
+ values,
+ AS_TEXT,
+ )
+ }
+ return t(
+ 'stackiq',
+ 'The sheets {sheets} are read; other sheets, including the "Invoer" sheets, are ignored.',
+ values,
+ AS_TEXT,
+ )
}
/**
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index 8b5a6ff62..8fb27fdc5 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -84,18 +84,7 @@
}}
- {{
- t(
- 'stackiq',
- 'Excel workbook (.xlsx) with the sheet "{first}" or "{second}". By default the file may be at most {size}.',
- {
- first: sheetNames[0],
- second: sheetNames[1],
- size: formatMegabytes(profileDefaults.maxFileBytes),
- },
- asText,
- )
- }}
+ {{ fileHelp }}
@@ -380,17 +369,7 @@
{{ t('stackiq', 'The file') }}
- {{
- t(
- 'stackiq',
- 'The sheets "{first}" (applications without arranged maintenance) and "{second}" (with arranged maintenance) are read; other sheets, including the "Invoer" sheets, are ignored.',
- {
- first: sheetNames[0],
- second: sheetNames[1],
- },
- asText,
- )
- }}
+ {{ sheetsReadInfo }}
{{
@@ -449,8 +428,8 @@ import {
checkFile,
cmdbProgressView,
errorText,
+ fileHelpText,
followInterruptedImport,
- formatMegabytes,
importUrl,
interruptedImportError,
isKnownError,
@@ -464,6 +443,7 @@ import {
PROFILE_DEFAULTS,
REPORT_PAGE_SIZE,
reportRows,
+ sheetsReadText,
sortReportRows,
typedMunicipalityOption,
} from '../../../utils/cmdbImport.js'
@@ -539,13 +519,33 @@ export default {
* profile's once the mapping block has loaded it, the shipped names
* until then or when it could not be loaded.
*
- * @return {Array} At least two names
+ * @return {Array} Every sheet the profile names, at least one
* @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
*/
sheetNames() {
return mappingSheetNames(this.mapping)
},
+ /**
+ * The help text under the file chooser, naming every sheet the import reads.
+ *
+ * @return {string} The text
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+ fileHelp() {
+ return fileHelpText(this.sheetNames, this.profileDefaults.maxFileBytes)
+ },
+
+ /**
+ * The info-panel sentence naming the sheets the import reads.
+ *
+ * @return {string} The text
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+ sheetsReadInfo() {
+ return sheetsReadText(this.sheetNames)
+ },
+
/**
* Whether everything the import needs has been chosen.
*
@@ -786,7 +786,6 @@ export default {
methods: {
t,
- formatMegabytes,
moduleUrl,
outcomeLabel,
diff --git a/tests/vitest/cmdbImportMapping.spec.js b/tests/vitest/cmdbImportMapping.spec.js
index ee8e70f16..e7c21781a 100644
--- a/tests/vitest/cmdbImportMapping.spec.js
+++ b/tests/vitest/cmdbImportMapping.spec.js
@@ -68,11 +68,13 @@ vi.mock('@conduction/nextcloud-vue', () => ({
}))
const {
+ fileHelpText,
mappingRows,
mappingSheetNames,
packRules,
packTargetLabel,
PROFILE_DEFAULTS,
+ sheetsReadText,
transformDetails,
transformLabel,
} = await import('../../src/utils/cmdbImport.js')
@@ -269,11 +271,41 @@ describe('cmdbImport mapping helpers', () => {
)
})
- it('keeps the shipped sheet names when the answer names only one sheet', () => {
- expect(
- mappingSheetNames({ profile: { sheets: [{ name: 'Sheet A' }] } }),
- ).toEqual(PROFILE_DEFAULTS.sheets)
- })
+ it.each([
+ [0, PROFILE_DEFAULTS.sheets],
+ [1, ['Sheet A']],
+ [2, ['Sheet A', 'Sheet B']],
+ [3, ['Sheet A', 'Sheet B', 'Sheet C']],
+ ])(
+ 'names every sheet of a profile with %i sheets in both help texts, in its order',
+ (count, expected) => {
+ const names = ['Sheet A', 'Sheet B', 'Sheet C'].slice(0, count)
+ const mapping = {
+ profile: { sheets: names.map((name) => ({ name })) },
+ }
+ const sheets = mappingSheetNames(mapping)
+ expect(sheets).toEqual(expected)
+
+ const quoted = expected.map((name) => `"${name}"`).join(', ')
+ const help = fileHelpText(sheets, 10 * 1024 * 1024)
+ expect(help).toContain(quoted)
+ expect(help).toContain('at most 10 MB')
+ expect(help).toContain(
+ expected.length === 1 ? 'with the sheet ' : 'with one of the sheets ',
+ )
+ const info = sheetsReadText(sheets)
+ expect(info).toContain(quoted)
+ expect(info).toContain(expected.length === 1 ? 'is read' : 'are read')
+ // No sheet the profile does not name, and no position-bound description.
+ for (const shipped of PROFILE_DEFAULTS.sheets) {
+ if (!expected.includes(shipped)) {
+ expect(help).not.toContain(shipped)
+ expect(info).not.toContain(shipped)
+ }
+ }
+ expect(info).not.toContain('maintenance')
+ },
+ )
it('words the write rules of a pack, and tolerates an answer without them', () => {
expect(packRules({})).toEqual([])
From b1d249a606b89ad561339ee60e06468fe3aa427b Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:22:39 +0200
Subject: [PATCH 041/124] test(cmdb-import): make the missing-applications
choice and the deleted-row rule testable
The section's tests set `missingRecords` on the component and stubbed the
radio away, so `v-model`, `value`, `name` and `:disabled` on the two radios
were never exercised, no test clicked "Keep them as they are", and the rule
that a deleted application has no link to its module had no test at all.
The jest spec now mounts the section with radio and table stubs that render
what the page does, clicks Keep and reads the posted form, checks the default,
the shared name and the disabled state, and asserts the report table links an
archived or failed row and not a deleted one, and shows no row number for a
reconciliation row. Each test goes red when the wiring or the rule is
mutated. The e2e adds a Keep import that must archive and delete nothing and
a check that the archived application is absent from the Applications page
before the Archived tab; the spec scenario for the choice now points at it.
Review #1287 f8
Co-Authored-By: Claude Opus 5.5
---
.../specs/cmdb-export-import/spec.md | 2 +-
.../settings/sections/CmdbImport.spec.js | 144 ++++++++++++++++++
tests/e2e/spec-coverage/cmdb-import.spec.ts | 62 ++++++++
3 files changed, 207 insertions(+), 1 deletion(-)
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
index 1ac827456..fd915d1f0 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
@@ -162,7 +162,7 @@ Stackiq's admin settings page SHALL show a section "CMDB import", rendered by th
- **AND** filtering the table on `created` SHALL show the two imported rows
#### Scenario: The admin chooses to keep missing applications
-@e2e exclude Covered by the Jest test; src/utils/cmdbImport.spec.js asserts buildImportForm() sends `archive` by default and `keep` when chosen, and src/views/settings/sections/CmdbImport.spec.js asserts the section's default.
+@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts
- **GIVEN** the section
- **WHEN** the admin selects "Keep them as they are" and starts the import
diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js
index 6896c2c33..ca01b9517 100644
--- a/src/views/settings/sections/CmdbImport.spec.js
+++ b/src/views/settings/sections/CmdbImport.spec.js
@@ -96,6 +96,11 @@ jest.mock(
'div',
this.rows.map((row) =>
h('div', { 'data-testid': 'table-row' }, [
+ h(
+ 'span',
+ { 'data-testid': 'table-row-number' },
+ this.$slots['column-row']?.({ row }),
+ ),
h('span', this.$slots['column-name']?.({ row })),
]),
),
@@ -380,3 +385,142 @@ describe('CmdbImport finished announcement', () => {
)
})
})
+
+/**
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+describe('CmdbImport the choice for missing applications, as the admin makes it', () => {
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ const radio = (wrapper, which) =>
+ wrapper.find(`[data-testid="cmdb-import-missing-records-${which}"] input`)
+
+ it('starts on "Archive or delete them" and the radios share one name', async () => {
+ const wrapper = await mountFull()
+
+ expect(radio(wrapper, 'archive').element.checked).toBe(true)
+ expect(radio(wrapper, 'keep').element.checked).toBe(false)
+ expect(radio(wrapper, 'archive').attributes('name')).toBe(
+ radio(wrapper, 'keep').attributes('name'),
+ )
+ expect(radio(wrapper, 'archive').attributes('value')).toBe('archive')
+ expect(radio(wrapper, 'keep').attributes('value')).toBe('keep')
+ })
+
+ it('posts missingRecords=keep after the admin clicks "Keep them as they are", and only that field changes', async () => {
+ const wrapper = await mountFull()
+ axios.post.mockResolvedValue({
+ data: { success: true, summary: {}, rows: [] },
+ })
+ wrapper.vm.municipality = wrapper.vm.municipalityOptions[0]
+ wrapper.vm.selectedFile = new File(['x'], 'export.xlsx')
+
+ await radio(wrapper, 'keep').setValue(true)
+ await flushPromises()
+
+ expect(wrapper.vm.missingRecords).toBe('keep')
+ expect(radio(wrapper, 'keep').element.checked).toBe(true)
+ expect(radio(wrapper, 'archive').element.checked).toBe(false)
+ // The choice is not wired to another option: they keep their defaults.
+ expect(wrapper.vm.publish).toBe(true)
+ expect(wrapper.vm.updateExisting).toBe(true)
+
+ await wrapper.vm.startImport()
+ const form = axios.post.mock.calls[0][1]
+ expect(form.get('missingRecords')).toBe('keep')
+
+ await radio(wrapper, 'archive').setValue(true)
+ expect(wrapper.vm.missingRecords).toBe('archive')
+ })
+
+ it('disables both radios while an import runs', async () => {
+ const wrapper = await mountFull()
+
+ await wrapper.setData({ importing: true })
+
+ expect(radio(wrapper, 'archive').attributes('disabled')).toBeDefined()
+ expect(radio(wrapper, 'keep').attributes('disabled')).toBeDefined()
+ })
+})
+
+/**
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+describe('CmdbImport report rows of archived and deleted applications', () => {
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ const reconciliationRow = (outcome, extra = {}) => ({
+ sheet: 'Gearchiveerde Applicaties',
+ row: 0,
+ appId: outcome === 'deleted' ? '2' : '1234',
+ name: outcome === 'deleted' ? 'Naamtest' : 'Mailen',
+ outcome,
+ moduleUuid: 'module-uuid-' + outcome,
+ ...extra,
+ })
+
+ it('links an archived application to its module, and a deleted one to nothing', async () => {
+ const wrapper = await mountFull()
+
+ await wrapper.setData({
+ report: {
+ summary: { rowsRead: 0, archived: 1, deleted: 1 },
+ rows: [
+ reconciliationRow('archived'),
+ reconciliationRow('deleted'),
+ ],
+ },
+ })
+
+ const rows = wrapper.findAll('[data-testid="table-row"]')
+ expect(rows).toHaveLength(2)
+ const [archived, deleted] = rows
+ expect(
+ archived.find('[data-testid="cmdb-import-module-link"]').attributes('href'),
+ ).toBe('/apps/stackiq/modules/module-uuid-archived')
+ expect(deleted.find('[data-testid="cmdb-import-module-link"]').exists()).toBe(
+ false,
+ )
+ // The name of the deleted application is still shown, as text.
+ expect(deleted.text()).toContain('Naamtest')
+ })
+
+ it('shows no row number for a reconciliation row, and links one that failed', async () => {
+ const wrapper = await mountFull()
+
+ await wrapper.setData({
+ report: {
+ summary: { rowsRead: 1, failed: 1, archived: 1 },
+ rows: [
+ reconciliationRow('failed', {
+ reasons: ['archive: RuntimeException'],
+ }),
+ reconciliationRow('archived'),
+ {
+ sheet: 'Beheerde Applicaties CMDB',
+ row: 2,
+ appId: '5',
+ name: 'Gewoon',
+ outcome: 'created',
+ moduleUuid: 'module-uuid-created',
+ },
+ ],
+ },
+ })
+
+ const rows = wrapper.findAll('[data-testid="table-row"]')
+ expect(rows.map((row) => row.get('[data-testid="table-row-number"]').text())).toEqual(
+ ['', '', '2'],
+ )
+ expect(
+ rows[0].find('[data-testid="cmdb-import-module-link"]').exists(),
+ ).toBe(true)
+ expect(
+ rows[2].find('[data-testid="cmdb-import-module-link"]').exists(),
+ ).toBe(true)
+ })
+})
diff --git a/tests/e2e/spec-coverage/cmdb-import.spec.ts b/tests/e2e/spec-coverage/cmdb-import.spec.ts
index 82ae8e695..c0bd8d3e3 100644
--- a/tests/e2e/spec-coverage/cmdb-import.spec.ts
+++ b/tests/e2e/spec-coverage/cmdb-import.spec.ts
@@ -560,6 +560,9 @@ test.describe.serial('CMDB import section', () => {
// 2. The Applications page lists 1234 only under the quick filter "Archived".
await gotoAppRoute(page, '/modules')
await dismissSupportDialog(page)
+ // Not under the default tab: wait for the list to settle, then look.
+ await page.waitForLoadState('networkidle')
+ await expect(page.getByText(ARCHIVED_APPLICATION_NAME)).toHaveCount(0)
await page.getByRole('tab', { name: 'Archived', exact: true }).click()
await expect(page.getByText(ARCHIVED_APPLICATION_NAME).first()).toBeVisible({
timeout: 30000,
@@ -581,6 +584,65 @@ test.describe.serial('CMDB import section', () => {
await ctx.dispose()
})
+ // @e2e cmdb-export-import::the-admin-chooses-to-keep-missing-applications
+ test('choosing "Keep them as they are" leaves the applications a later export no longer lists as they are', async ({
+ page,
+ }) => {
+ requireFixture(EXPORT_FIXTURE)
+ requireFixture(ARCHIVED_FIXTURE)
+ const ctx = await newApiContext()
+ const before = await countWritten(ctx)
+ expect(
+ before.modules,
+ 'the first import must have written the modules this test keeps',
+ ).toBeGreaterThan(0)
+
+ // The same later export as the archive test, which archives 1234 and deletes 2.
+ const section = await gotoCmdbSection(page)
+ await chooseMunicipality(page)
+ const keep = section.locator(
+ '[data-testid="cmdb-import-missing-records-keep"] input',
+ )
+ await keep.check()
+ await expect(keep).toBeChecked()
+ await expect(
+ section.locator(
+ '[data-testid="cmdb-import-missing-records-archive"] input',
+ ),
+ ).not.toBeChecked()
+ const response = await runImport(page, ARCHIVED_FIXTURE)
+ expect(response.status()).toBe(200)
+ // The request carries the choice, not the default.
+ expect(response.request().postData() ?? '').toMatch(
+ /name="missingRecords"\s+keep/,
+ )
+
+ await expect(summaryValue(page, 'rowsRead')).toHaveText('0')
+ // The archived and deleted tiles only exist when the import did either.
+ await expect(
+ section.locator('[data-testid="cmdb-import-summary-archived"]'),
+ ).toHaveCount(0)
+ await expect(
+ section.locator('[data-testid="cmdb-import-summary-deleted"]'),
+ ).toHaveCount(0)
+ await expect(
+ reportRows(page).locator('[data-outcome="archived"]'),
+ ).toHaveCount(0)
+ await expect(
+ reportRows(page).locator('[data-outcome="deleted"]'),
+ ).toHaveCount(0)
+
+ // Both applications are still in the working set; nothing is archived.
+ expect(await countWritten(ctx)).toEqual(before)
+ expect(
+ await objectsInLens(ctx, config.module_schema, {
+ archived: 'true',
+ includeDeleted: false,
+ }),
+ ).toEqual([])
+ await ctx.dispose()
+ })
+
// @e2e cmdb-export-import::a-missing-required-column-is-named-in-the-422-response
test('an export without a required column names the column and the sheet', async ({
page,
From 8a1562aee83819584ff028ccf7ca0ed033d460ab Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:24:37 +0200
Subject: [PATCH 042/124] fix(cmdb-import): name the archive sheet where the
section says which sheets the import reads
The WORKBOOK_TOO_LARGE hint said the import "reads only" the two CMDB sheets
and told the admin to delete the sheets it does not read; the section's file
help said other sheets are ignored. The import also reads the APPID column of
"Gearchiveerde Applicaties", and an admin who followed the hint would delete
that sheet, so the next import archives and never deletes.
Both strings now name the archive sheet and its APPID column (replacing the
l10n keys in place), and the spec pins the new hint.
Review #1287 f9
Co-Authored-By: Claude Opus 5.5
---
l10n/en.js | 4 ++--
l10n/en.json | 4 ++--
l10n/nl.js | 4 ++--
l10n/nl.json | 4 ++--
src/utils/cmdbImport.js | 5 ++++-
src/utils/cmdbImport.spec.js | 2 +-
src/views/settings/sections/CmdbImport.vue | 3 ++-
7 files changed, 15 insertions(+), 11 deletions(-)
diff --git a/l10n/en.js b/l10n/en.js
index 3e3c7c0d7..c56d7c8d4 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1103,7 +1103,7 @@ OC.L10N.register(
"A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".",
"Split the export and import the parts one after the other with \"Keep them as they are\".": "Split the export and import the parts one after the other with \"Keep them as they are\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.",
"The page got no answer from the import.": "The page got no answer from the import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.",
@@ -1142,7 +1142,7 @@ OC.L10N.register(
"Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.",
- "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.",
+ "Delete the sheets the import does not read from a copy of the export (it reads \"{first}\" and \"{second}\", and the APPID column of \"{archive}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads \"{first}\" and \"{second}\", and the APPID column of \"{archive}\"), and import that copy.",
"The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.",
"In development since": "In development since",
diff --git a/l10n/en.json b/l10n/en.json
index e2d6fd9e0..5eea76d88 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1102,7 +1102,7 @@
"A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".",
"Split the export and import the parts one after the other with \"Keep them as they are\".": "Split the export and import the parts one after the other with \"Keep them as they are\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.",
"The page got no answer from the import.": "The page got no answer from the import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.",
@@ -1141,7 +1141,7 @@
"Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.",
- "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.",
+ "Delete the sheets the import does not read from a copy of the export (it reads \"{first}\" and \"{second}\", and the APPID column of \"{archive}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads \"{first}\" and \"{second}\", and the APPID column of \"{archive}\"), and import that copy.",
"The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.",
"In development since": "In development since",
diff --git a/l10n/nl.js b/l10n/nl.js
index 135559111..011cf1668 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1171,7 +1171,7 @@ OC.L10N.register(
"A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
"Split the export and import the parts one after the other with \"Keep them as they are\".": "Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad \"{first}\" of \"{second}\". Standaard mag het bestand hoogstens {size} zijn.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen, en van het tabblad \"{archive}\" alleen de kolom APPID; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
"The page got no answer from the import.": "De pagina kreeg geen antwoord van de import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "De verbinding werd verbroken voordat de import antwoordde, dus de import kan nog bezig zijn of al klaar zijn. Wacht een paar minuten en controleer de applicaties van de gemeente voordat u opnieuw importeert. Hetzelfde bestand opnieuw importeren maakt geen dubbele records aan.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "De import kan nu niet worden geannuleerd: de server is nog niet aan de rijen begonnen, of is er al mee klaar. Loopt de import door, druk dan zo dadelijk opnieuw op Import annuleren.",
@@ -1210,7 +1210,7 @@ OC.L10N.register(
"Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Verdeel de rijen over twee kopieën van de export en importeer beide met \"Laat ze zoals ze zijn\": een import die archiveert, ziet de applicaties van de andere kopie als ontbrekend.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "Het tabblad \"{sheet}\" is te groot om te importeren: uitgepakt is het {size}, en de import leest van één tabblad hooguit {limit}.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "Het onderdeel {part} van de werkmap is te groot om te importeren: uitgepakt is het {size}, en de import leest van één onderdeel hooguit {limit}.",
- "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen \"{first}\" en \"{second}\"), en importeer die kopie.",
+ "Delete the sheets the import does not read from a copy of the export (it reads \"{first}\" and \"{second}\", and the APPID column of \"{archive}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest \"{first}\" en \"{second}\", en de kolom APPID van \"{archive}\"), en importeer die kopie.",
"The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.",
"In development since": "In ontwikkeling sinds",
diff --git a/l10n/nl.json b/l10n/nl.json
index 98cdc370d..f327c032e 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1170,7 +1170,7 @@
"A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
"Split the export and import the parts one after the other with \"Keep them as they are\".": "Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad \"{first}\" of \"{second}\". Standaard mag het bestand hoogstens {size} zijn.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen, en van het tabblad \"{archive}\" alleen de kolom APPID; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
"The page got no answer from the import.": "De pagina kreeg geen antwoord van de import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "De verbinding werd verbroken voordat de import antwoordde, dus de import kan nog bezig zijn of al klaar zijn. Wacht een paar minuten en controleer de applicaties van de gemeente voordat u opnieuw importeert. Hetzelfde bestand opnieuw importeren maakt geen dubbele records aan.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "De import kan nu niet worden geannuleerd: de server is nog niet aan de rijen begonnen, of is er al mee klaar. Loopt de import door, druk dan zo dadelijk opnieuw op Import annuleren.",
@@ -1209,7 +1209,7 @@
"Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Verdeel de rijen over twee kopieën van de export en importeer beide met \"Laat ze zoals ze zijn\": een import die archiveert, ziet de applicaties van de andere kopie als ontbrekend.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "Het tabblad \"{sheet}\" is te groot om te importeren: uitgepakt is het {size}, en de import leest van één tabblad hooguit {limit}.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "Het onderdeel {part} van de werkmap is te groot om te importeren: uitgepakt is het {size}, en de import leest van één onderdeel hooguit {limit}.",
- "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen \"{first}\" en \"{second}\"), en importeer die kopie.",
+ "Delete the sheets the import does not read from a copy of the export (it reads \"{first}\" and \"{second}\", and the APPID column of \"{archive}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest \"{first}\" en \"{second}\", en de kolom APPID van \"{archive}\"), en importeer die kopie.",
"The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.",
"In development since": "In ontwikkeling sinds",
diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js
index 0b834dd2f..2c83018a0 100644
--- a/src/utils/cmdbImport.js
+++ b/src/utils/cmdbImport.js
@@ -31,6 +31,8 @@ export const PROFILE_DEFAULTS = Object.freeze({
maxFileBytes: 10 * 1024 * 1024,
maxRowsPerSheet: 10000,
sheets: Object.freeze(['Onbeh Applicaties CMDB', 'Beheerde Applicaties CMDB']),
+ // Read for its APPID column only, in every import.
+ archiveSheet: 'Gearchiveerde Applicaties',
})
/**
@@ -564,10 +566,11 @@ function workbookTooLarge(details) {
+ ' '
+ t(
'stackiq',
- 'Delete the sheets the import does not read from a copy of the export (it reads only "{first}" and "{second}"), and import that copy.',
+ 'Delete the sheets the import does not read from a copy of the export (it reads "{first}" and "{second}", and the APPID column of "{archive}"), and import that copy.',
{
first: PROFILE_DEFAULTS.sheets[0],
second: PROFILE_DEFAULTS.sheets[1],
+ archive: PROFILE_DEFAULTS.archiveSheet,
},
AS_TEXT,
)
diff --git a/src/utils/cmdbImport.spec.js b/src/utils/cmdbImport.spec.js
index 3282e3458..a5579b985 100644
--- a/src/utils/cmdbImport.spec.js
+++ b/src/utils/cmdbImport.spec.js
@@ -426,7 +426,7 @@ describe('errorText', () => {
expect(
errorText({ error: 'WORKBOOK_TOO_LARGE', details }).hint,
).toContain(
- '(it reads only "Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB")',
+ '(it reads "Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB", and the APPID column of "Gearchiveerde Applicaties")',
)
}
})
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index 0f92b67ae..3590c47f7 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -414,10 +414,11 @@
{{
t(
'stackiq',
- 'The sheets "{first}" (applications without arranged maintenance) and "{second}" (with arranged maintenance) are read; other sheets, including the "Invoer" sheets, are ignored.',
+ 'The sheets "{first}" (applications without arranged maintenance) and "{second}" (with arranged maintenance) are read, and of the sheet "{archive}" only the APPID column; other sheets, including the "Invoer" sheets, are ignored.',
{
first: profileDefaults.sheets[0],
second: profileDefaults.sheets[1],
+ archive: profileDefaults.archiveSheet,
},
asText,
)
From a4570e79e324ec5b782aeaebfb265e29d73d5408 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:24:37 +0200
Subject: [PATCH 043/124] test(cmdb-import): pin the totality promises of the
reconciliation
REQ-CMDB-015 promises that only this municipality's usages and what its
import created are reconciled, that each application has its own error
boundary, that a cancel before the scope is loaded reads nothing, and that
the match never duplicates (working before archived before deleted). Most
of these branches had no test that a mutation could turn red; the first
one, the consumer re-check on a trash listing that is not filtered by
consumer, guarded the only thing between another municipality's module and
the trash.
Adds tests for: a trashed usage of another consumer; modules with no
external key, a bare prefix or another origin; a delete that OpenRegister
refuses and a restore that throws, each with a following application still
reconciled; more than one page of usages and one chunk of modules; archived
beating deleted and working beating archived in the match; and an archived
match failing its row under keep when the archive services are missing
(design D7). The cancel test now also asserts the trash was not read and
the usages were not paged. Each test was checked by mutating the service
and goes red.
Review #1286 f3
Co-Authored-By: Claude Opus 5.5
---
.../Service/CmdbExportImportServiceTest.php | 207 ++++++++++++++++++
1 file changed, 207 insertions(+)
diff --git a/tests/Unit/Service/CmdbExportImportServiceTest.php b/tests/Unit/Service/CmdbExportImportServiceTest.php
index 3a7a0e9b6..b21e88ae7 100644
--- a/tests/Unit/Service/CmdbExportImportServiceTest.php
+++ b/tests/Unit/Service/CmdbExportImportServiceTest.php
@@ -119,6 +119,13 @@ class CmdbExportImportServiceTest extends TestCase {
*/
private int $trashReads = 0;
+ /**
+ * The uuid whose delete OpenRegister refuses (deleteObject() answers false), when set.
+ *
+ * @var string|null
+ */
+ private ?string $deleteRefusedFor = null;
+
/**
* How many of the next trash reads throw.
*
@@ -261,6 +268,7 @@ protected function setUp(): void {
$this->transitions = [];
$this->beforeTransition = null;
$this->trashReads = 0;
+ $this->deleteRefusedFor = null;
$this->trashFailures = 0;
$this->archiveServicesAvailable = true;
$this->contacts = [];
@@ -516,6 +524,10 @@ function (array $query = [], bool $_rbac = true, bool $_multitenancy = true): ar
$service->method('deleteObject')->willReturnCallback(
function (string $uuid, $register = null, $schema = null, bool $_rbac = true, bool $_multitenancy = true): bool {
$this->noteScope(method: 'deleteObject', rbac: $_rbac, multitenancy: $_multitenancy);
+ if ($this->deleteRefusedFor === $uuid) {
+ return false;
+ }
+
$this->changeState(verb: 'delete', uuid: $uuid, reason: null);
return true;
}
@@ -2020,11 +2032,16 @@ public function testACancelDuringTheLastRowArchivesNothing(): void {
}
};
$service = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1', cells: ['Applicatie Naam' => 'Nieuw'])], archived: ['7', '8']));
+ $this->trashReads = 0;
+ $this->searches = [];
$report = $service->import(path: '', options: ['municipalityUuid' => 'muni-1', 'operationId' => 'cmdb-cancel-03']);
$this->assertTrue($report['cancelled']);
$this->assertSame([], $this->transitions);
+ $this->assertSame(0, $this->trashReads, 'the scope was not loaded: the trash was not read');
+ $usageScans = array_filter($this->searches, static fn (array $query): bool => ($query['consumer'] ?? null) === 'muni-1' && isset($query['_offset']) === true);
+ $this->assertSame([], $usageScans, 'the municipality\'s usages were not paged');
$this->assertSame(['updated'], array_column($report['rows'], 'outcome'));
$this->assertSame('cancelled', $this->cache['progress_cmdb-cancel-03']['status']);
$this->assertArrayNotHasKey('cancel_cmdb-cancel-03', $this->cache);
@@ -2152,6 +2169,196 @@ public function testTheModulesStateDecidesWhichDuplicateIsReconciled(): void {
$this->assertSame([false, false], $this->stateOf(schema: self::USAGE, uuid: 'usage-stale'), 'the working usage of the stale module is not touched');
}//end testTheModulesStateDecidesWhichDuplicateIsReconciled()
+ /**
+ * A usage in the trash that names another consumer is never reconciled, though the trash is not filtered by consumer.
+ *
+ * The usage points at a working module that carries this municipality's import key; only the consumer
+ * check keeps that module out of the scope, and with it out of the trash.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function testATrashedUsageOfAnotherConsumerNeverReconcilesItsModule(): void {
+ $this->importOneSevenAndEight();
+ $this->seedOrganisation(uuid: 'muni-2', name: 'Gemeente Anders', type: 'Municipality');
+ $seven = $this->moduleOf(appId: '7');
+ $this->store[self::MODULE]['module-nine'] = ['id' => 'module-nine', 'name' => 'Negen', 'externalKey' => 'topdesk:muni-1:9', 'externalNumber' => '9'];
+ $this->store[self::USAGE]['usage-other'] = ['id' => 'usage-other', 'consumer' => 'muni-2', 'module' => 'module-nine'];
+ $this->states[self::USAGE]['usage-other'] = ['deleted' => ['deletedBy' => 'admin', 'deletedAt' => '2026-10-01T00:00:00+00:00']];
+
+ // APPID 9 is on no sheet, so a scope that took the trashed usage in would soft-delete its module.
+ $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1'), $this->row(appId: '7', row: 3), $this->row(appId: '8', row: 4)], archived: []))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1']);
+
+ $this->assertSame([false, false], $this->stateOf(schema: self::MODULE, uuid: 'module-nine'), 'the module stays working');
+ $this->assertSame([false, true], $this->stateOf(schema: self::USAGE, uuid: 'usage-other'), 'and the other consumer\'s usage stays in the trash');
+ $this->assertSame([], $this->transitions);
+ $this->assertSame([false, false], $this->stateOf(schema: self::MODULE, uuid: $seven));
+ }//end testATrashedUsageOfAnotherConsumerNeverReconcilesItsModule()
+
+ /**
+ * Only a module whose key is exactly this municipality's import key plus an APPID is reconciled.
+ *
+ * A module without an external key, one whose key is the bare prefix, and one whose key has another origin
+ * are used by this municipality but are not what its import created.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function testOnlyWhatTheImportCreatedIsReconciled(): void {
+ $this->importOneSevenAndEight();
+ $others = [
+ 'module-nokey' => ['id' => 'module-nokey', 'name' => 'Zonder sleutel'],
+ 'module-bare' => ['id' => 'module-bare', 'name' => 'Kale sleutel', 'externalKey' => 'topdesk:muni-1:'],
+ 'module-origin' => ['id' => 'module-origin', 'name' => 'Andere herkomst', 'externalKey' => 'xxxxxxx:muni-1:9'],
+ ];
+ foreach ($others as $uuid => $module) {
+ $this->store[self::MODULE][$uuid] = $module;
+ $this->store[self::USAGE]['usage-' . $uuid] = ['id' => 'usage-' . $uuid, 'consumer' => 'muni-1', 'module' => $uuid];
+ }
+
+ $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1'), $this->row(appId: '7', row: 3), $this->row(appId: '8', row: 4)], archived: []))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1']);
+
+ $this->assertSame([], $this->transitions, 'nothing the import did not create is archived or deleted');
+ $this->assertSame([], $this->states);
+ }//end testOnlyWhatTheImportCreatedIsReconciled()
+
+ /**
+ * A delete step that OpenRegister refuses, and a restore step that throws, each fail only their own application.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function testAFailingDeleteOrRestoreStepFailsOnlyItsApplication(): void {
+ $this->importOneSevenAndEight();
+ [$seven, $eight] = [$this->moduleOf(appId: '7'), $this->moduleOf(appId: '8')];
+
+ // APPID 7 and 8 are on no sheet: OpenRegister refuses to delete the usage of 7, then deletes 8.
+ $this->deleteRefusedFor = $this->usageOf(moduleUuid: $seven);
+ $report = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: []))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1']);
+ $rows = array_values(array_filter($report['rows'], static fn (array $row): bool => $row['row'] === 0));
+ $this->assertSame([['7', 'failed'], ['8', 'deleted']], array_map(static fn (array $row): array => [$row['appId'], $row['outcome']], $rows));
+ $this->assertSame(['step "delete" failed (RuntimeException)'], $rows[0]['reasons']);
+ $this->assertSame([false, false], $this->stateOf(schema: self::MODULE, uuid: $seven), 'the module of the failed application is untouched');
+ $this->assertSame([false, true], $this->stateOf(schema: self::MODULE, uuid: $eight));
+
+ // APPID 7 and 8 are in the trash and on the archive sheet: the restore of 7 throws, 8 is restored and archived.
+ $this->deleteRefusedFor = null;
+ $this->states = [];
+ foreach ([$seven, $eight] as $uuid) {
+ $this->states[self::MODULE][$uuid] = ['deleted' => ['deletedBy' => 'admin']];
+ $this->states[self::USAGE][$this->usageOf(moduleUuid: $uuid)] = ['deleted' => ['deletedBy' => 'admin']];
+ }
+
+ $this->beforeTransition = function (string $verb, string $uuid) use ($seven): void {
+ if ($verb === 'restore' && $uuid === $this->usageOf(moduleUuid: $seven)) {
+ throw new RuntimeException('restore failed');
+ }
+ };
+ $report = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: ['7', '8']))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1']);
+ $rows = array_values(array_filter($report['rows'], static fn (array $row): bool => $row['row'] === 0));
+ $this->assertSame([['7', 'failed'], ['8', 'archived']], array_map(static fn (array $row): array => [$row['appId'], $row['outcome']], $rows));
+ $this->assertSame(['step "restore" failed (RuntimeException)'], $rows[0]['reasons']);
+ $this->assertSame([false, true], $this->stateOf(schema: self::MODULE, uuid: $seven), 'the failed application stays in the trash');
+ $this->assertSame([true, false], $this->stateOf(schema: self::MODULE, uuid: $eight));
+ }//end testAFailingDeleteOrRestoreStepFailsOnlyItsApplication()
+
+ /**
+ * More usages than one page, and more modules than one chunk, are all reconciled.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function testMoreApplicationsThanOnePageAreAllReconciled(): void {
+ $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality');
+ $count = 501;
+ for ($index = 1; $index <= $count; $index++) {
+ $this->store[self::MODULE]['module-' . $index] = ['id' => 'module-' . $index, 'name' => 'Applicatie ' . $index, 'externalKey' => 'topdesk:muni-1:' . $index, 'externalNumber' => (string)$index];
+ $this->store[self::USAGE]['usage-' . $index] = ['id' => 'usage-' . $index, 'consumer' => 'muni-1', 'module' => 'module-' . $index];
+ }
+
+ // Every application is on no sheet: every one is soft-deleted, the last of both lists included.
+ $report = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1000')], archived: []))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1']);
+
+ $this->assertSame($count, $report['summary']['deleted']);
+ $this->assertSame([false, true], $this->stateOf(schema: self::MODULE, uuid: 'module-' . $count));
+ $this->assertSame([false, true], $this->stateOf(schema: self::USAGE, uuid: 'usage-' . $count));
+ $this->assertCount(($count * 2), $this->transitions);
+ }//end testMoreApplicationsThanOnePageAreAllReconciled()
+
+ /**
+ * An archived match wins over a deleted one with the same import key, and a working match over an archived one.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
+ */
+ public function testTheMatchPrefersWorkingThenArchivedThenDeleted(): void {
+ $this->importOneSevenAndEight();
+ [$seven, $eight] = [$this->moduleOf(appId: '7'), $this->moduleOf(appId: '8')];
+ $sevenUsage = $this->usageOf(moduleUuid: $seven);
+ $eightUsage = $this->usageOf(moduleUuid: $eight);
+ foreach ([['7', $seven, $sevenUsage], ['8', $eight, $eightUsage]] as [$appId, $module, $usage]) {
+ $this->store[self::MODULE]['stale-module-' . $appId] = array_merge($this->store[self::MODULE][$module], ['id' => 'stale-module-' . $appId]);
+ $this->store[self::USAGE]['stale-usage-' . $appId] = array_merge($this->store[self::USAGE][$usage], ['id' => 'stale-usage-' . $appId, 'module' => 'stale-module-' . $appId]);
+ }
+
+ // APPID 7: the real pair is archived, the stale one deleted. APPID 8: the real pair is working, the stale one archived.
+ $gone = ['deleted' => ['deletedBy' => 'admin', 'deletedAt' => '2026-10-01T00:00:00+00:00']];
+ $shelved = ['archived' => ['by' => 'admin', 'at' => '2026-10-01T00:00:00+00:00', 'reason' => 'earlier']];
+ $this->states[self::MODULE][$seven] = $shelved;
+ $this->states[self::USAGE][$sevenUsage] = $shelved;
+ $this->states[self::MODULE]['stale-module-7'] = $gone;
+ $this->states[self::USAGE]['stale-usage-7'] = $gone;
+ $this->states[self::MODULE]['stale-module-8'] = $shelved;
+ $this->states[self::USAGE]['stale-usage-8'] = $shelved;
+ $counts = [count($this->store[self::MODULE]), count($this->store[self::USAGE])];
+
+ $report = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '7', row: 2), $this->row(appId: '8', row: 3, sheet: 'Onbeh Applicaties CMDB')]))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1', 'missingRecords' => 'keep']);
+
+ $this->assertSame(['unarchived', 'unchanged'], array_column($report['rows'], 'outcome'));
+ $this->assertSame([$seven, $eight], array_column($report['rows'], 'moduleUuid'));
+ $this->assertSame(
+ [['unarchive', $seven], ['unarchive', $sevenUsage]],
+ array_map(static fn (array $t): array => [$t[0], $t[2]], $this->transitions),
+ 'only the preferred pair is revived; nothing is restored from the trash'
+ );
+ $this->assertSame([false, true], $this->stateOf(schema: self::MODULE, uuid: 'stale-module-7'));
+ $this->assertSame([true, false], $this->stateOf(schema: self::MODULE, uuid: 'stale-module-8'));
+ $this->assertSame($counts, [count($this->store[self::MODULE]), count($this->store[self::USAGE])], 'no module or usage is created');
+ }//end testTheMatchPrefersWorkingThenArchivedThenDeleted()
+
+ /**
+ * With keep and without OpenRegister's archive handler a row whose match is archived fails at the module step.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
+ */
+ public function testKeepWithoutTheArchiveServicesFailsARowWhoseMatchIsArchived(): void {
+ $this->importOneSevenAndEight();
+ $seven = $this->moduleOf(appId: '7');
+ $this->states[self::MODULE][$seven] = ['archived' => ['by' => 'admin', 'at' => '2026-10-01T00:00:00+00:00', 'reason' => 'earlier']];
+ $this->archiveServicesAvailable = false;
+
+ $report = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1'), $this->row(appId: '7', row: 3, sheet: 'Onbeh Applicaties CMDB')]))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1', 'missingRecords' => 'keep']);
+
+ $this->assertSame(['unchanged', 'failed'], array_column($report['rows'], 'outcome'));
+ $this->assertSame(['step "module" failed (CmdbImportException)'], $report['rows'][1]['reasons']);
+ $this->assertSame([], $this->transitions);
+ $this->assertSame(0, $report['summary']['archived'] + $report['summary']['deleted']);
+ }//end testKeepWithoutTheArchiveServicesFailsARowWhoseMatchIsArchived()
+
/**
* One application that cannot be archived is a failed row; the others are still reconciled.
*
From cdbec3d3ac09a986bb82a49c16edc5da90dc4a42 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:24:48 +0200
Subject: [PATCH 044/124] docs(cmdb-import): say the archive sheet is read, and
bounded, in every import
The docs said the import "ignores all other sheets" and that a sheet it does
not read may exceed the part size limit. The archive sheet is read (its APPID
column only) in every import, also with Keep, so it counts against the row
span and part-size limits like a CMDB sheet and can refuse a keep import.
The file section, the row limit, the part-size limit and the WORKBOOK_TOO_LARGE
row now say so, and that deleting the sheet from a copy makes the import
archive and never delete.
Review #1285 f2
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 17 +++++++++++------
1 file changed, 11 insertions(+), 6 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index f66fd6626..1e33207d5 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -123,7 +123,12 @@ the same organisation.
The import reads the two CMDB sheets of the export, and of the sheet
`Gearchiveerde Applicaties` only the `APPID` column (see
[Repeat imports](#repeat-imports)). It ignores all other sheets, including
-the `Invoer` sheets they are derived from:
+the `Invoer` sheets they are derived from. The archive sheet is read in every
+import, also with **Keep them as they are**, and it is bounded like the two
+CMDB sheets (see [Limits](#limits)): an archive sheet over the limits refuses
+the import even when you keep missing applications. Delete the sheet from a
+copy of the export to import it without one; the import then archives and
+never deletes.
| Sheet | What it holds | Recorded on the usage |
|---|---|---|
@@ -136,8 +141,8 @@ surrounding spaces and a trailing `:` or `⚡` do not matter, and the order of
the columns does not matter. A column that one sheet has and the other has
not (such as `Nickname`, only on `Beheerde Applicaties CMDB`) is optional on
the sheet that lacks it. Empty rows, including formatted rows below the data,
-are ignored and not counted. A sheet may hold at most 10,000 rows with data
-by default (see [Limits](#limits)).
+are ignored and not counted. A sheet, the archive sheet included, may hold at
+most 10,000 rows with data by default (see [Limits](#limits)).
Two columns are **required** on every CMDB sheet that is present: `APPID`
and `Applicatie Naam`. Every other column is optional; when one is missing,
@@ -359,7 +364,7 @@ and the section shows the reason and the error code.
| `NO_SOURCE_SHEET` | Neither `Onbeh Applicaties CMDB` nor `Beheerde Applicaties CMDB` is in the workbook. | Check the sheet names; they must match exactly. |
| `MISSING_COLUMN` | A present CMDB sheet has no `APPID` or `Applicatie Naam` column. The message names the sheet and the column. | Add the column to that sheet. |
| `TOO_MANY_ROWS` | A CMDB sheet has more rows with data than the row limit (10,000 by default). The message names the sheet and the limit. | Split the export and import the parts one after the other with **Keep them as they are**: an import that archives treats the applications of the other parts as missing. Applications that left the export are then not archived or deleted. |
-| `WORKBOOK_TOO_LARGE` | Unpacked, the workbook is larger than the import reads: 50 MB in all, 10 MB for one sheet the import reads or for the table of texts the sheets share, or more than 200,000 different texts, by default. Sheets the import does not read may be larger than 10 MB. An `.xlsx` is a compressed package, so a file of a few MB can unpack to far more. The message names the limit, and for a sheet the sheet and its unpacked size. | For a sheet that is too large: split its rows over two copies of the export and import both with **Keep them as they are**. For a workbook that is too large in all: delete the sheets the import does not read from a copy and import that copy. |
+| `WORKBOOK_TOO_LARGE` | Unpacked, the workbook is larger than the import reads: 50 MB in all, 10 MB for one sheet the import reads (the two CMDB sheets and the archive sheet, in every import) or for the table of texts the sheets share, or more than 200,000 different texts, by default. Sheets the import does not read may be larger than 10 MB. An `.xlsx` is a compressed package, so a file of a few MB can unpack to far more. The message names the limit, and for a sheet the sheet and its unpacked size. | For a sheet that is too large: split its rows over two copies of the export and import both with **Keep them as they are**. For a workbook that is too large in all: delete the sheets the import does not read from a copy and import that copy. |
| `MUNICIPALITY_AMBIGUOUS` | More than one municipality has the typed name. The import does not guess which one. | Pick the municipality from the list instead of typing its name. |
| `IMPORT_IN_PROGRESS` | Another CMDB import is running. Only one import runs at a time. | Wait until it has finished and try again. |
| `FIELD_INVALID` | A form field of the request has a value the import does not accept, for example an `updateExisting` or `publish` that is neither `true` nor `false`. The message names the field. | Not reachable from the section; reported for API callers. |
@@ -394,9 +399,9 @@ every import:
| Setting | Default | What it limits |
|---|---|---|
| `maxFileBytes` | `10485760` (10 MB) | the size of the uploaded file |
-| `maxRowsPerSheet` | `10000` | the rows with data on one CMDB sheet |
+| `maxRowsPerSheet` | `10000` | the rows with data on one CMDB sheet, and on the archive sheet |
| `maxUncompressedBytes` | `52428800` (50 MB) | the size of the workbook once unpacked, checked before a sheet is parsed |
-| `maxPartBytes` | `10485760` (10 MB) | the size of one part the import parses once unpacked, such as a source sheet or the shared-strings table, checked before a sheet is parsed; a sheet the import does not read may be larger, and is read as an empty sheet |
+| `maxPartBytes` | `10485760` (10 MB) | the size of one part the import parses once unpacked, such as a source sheet or the shared-strings table, checked before a sheet is parsed; a sheet the import does not read may be larger, and is read as an empty sheet; the archive sheet is read, so it is bounded like a CMDB sheet |
| `maxSharedStrings` | `200000` | the number of different texts in the workbook's shared-strings table, each formatted piece of a text with mixed formatting counted as one more, also in text written in a cell itself, counted before a sheet is parsed |
| `maxReferencedStringBytes` | `67108864` (64 MB) | the shared text all cells of the workbook reference together, counted once per cell and once per CMDB sheet before a sheet is parsed; a long text that many cells repeat counts as often as it is repeated |
From ae929478c2e394c07a1c5352025444737ae1d3b5 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:30:34 +0200
Subject: [PATCH 045/124] test(cmdb-import): run the sheet-name wiring and the
mapping error states
CmdbImport.spec.js shallow-mounted the section, so the mapping block was
stubbed and never emitted `loaded`: no executed test failed when
onMappingLoaded, the sheetNames computed or the help-text binding went
missing. The section is now mounted with a wrapper that renders its slots; the
test emits `loaded` from the mapping block and asserts the help text and the
info panel name the emitted sheets, the shipped ones before it, and the one
sheet of a one-sheet profile. The vitest suite gains the mapping block's
errors without a reason (403 with a message, no answer, 500), which only the
503 path covered.
Review #1289 f3
Co-Authored-By: Claude Opus 5.5
---
.../settings/sections/CmdbImport.spec.js | 105 +++++++++++++++++-
tests/vitest/cmdbImportMapping.spec.js | 56 ++++++++++
2 files changed, 159 insertions(+), 2 deletions(-)
diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js
index d0598b407..b20691f00 100644
--- a/src/views/settings/sections/CmdbImport.spec.js
+++ b/src/views/settings/sections/CmdbImport.spec.js
@@ -10,7 +10,9 @@
import axios from '@nextcloud/axios'
import { flushPromises, shallowMount } from '@vue/test-utils'
+import { h } from 'vue'
import CmdbImport from './CmdbImport.vue'
+import CmdbImportMapping from './CmdbImportMapping.vue'
// Virtual: the package's `exports` field declares only the `import` condition, which jest does not resolve.
jest.mock(
@@ -97,7 +99,7 @@ const ORGANISATIONS = [
*
* @return {Promise} The wrapper, after the municipalities loaded
*/
-async function mountSection() {
+async function mountSection(options = {}) {
axios.get.mockImplementation((url) =>
Promise.resolve(
url.includes('/voorzieningen/config')
@@ -105,7 +107,7 @@ async function mountSection() {
: { data: { results: ORGANISATIONS } },
),
)
- const wrapper = shallowMount(CmdbImport)
+ const wrapper = shallowMount(CmdbImport, options)
await flushPromises()
return wrapper
}
@@ -153,3 +155,102 @@ describe('CmdbImport municipality chooser', () => {
expect(wrapper.vm.report).toBe(null)
})
})
+
+// shallowMount stubs the section's wrapper without its slots; this one renders them, so the help text and the info panel are in the DOM.
+const SLOT_RENDERING_SECTION = {
+ render() {
+ return h('div', [
+ this.$slots.default?.(),
+ this.$slots['info-content']?.(),
+ ])
+ },
+}
+
+/**
+ * Mount the section with the wrapper rendering its slots.
+ *
+ * @return {Promise} The wrapper
+ */
+function mountWithSlots() {
+ return mountSection({
+ global: { stubs: { AlwaysVisibleSection: SLOT_RENDERING_SECTION } },
+ })
+}
+
+describe('CmdbImport sheet names in the help texts', () => {
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ const SHIPPED = ['Onbeh Applicaties CMDB', 'Beheerde Applicaties CMDB']
+
+ /**
+ * The mapping endpoint's answer for a profile with the given sheets.
+ *
+ * @param {Array} names The sheet names
+ * @return {object} The answer, reduced to what the section reads
+ */
+ function mappingOf(names) {
+ return {
+ profile: { sheets: names.map((name) => ({ name })) },
+ packs: [],
+ }
+ }
+
+ /**
+ * The help text under the file chooser and the info panel's sheet sentence.
+ *
+ * @param {object} wrapper The mounted section
+ * @return {{help: string, info: string}} Both texts, as rendered
+ */
+ function renderedTexts(wrapper) {
+ return {
+ help: wrapper.find('#cmdb-import-file-help').text(),
+ info: wrapper
+ .findAll('.cmdb-import-info li')
+ .map((li) => li.text())
+ .find((text) => /sheets? .* (is|are) read/.test(text)),
+ }
+ }
+
+ it('names the shipped sheets until the mapping block has loaded', async () => {
+ const wrapper = await mountWithSlots()
+
+ const { help, info } = renderedTexts(wrapper)
+ expect(help).toContain('"' + SHIPPED[0] + '", "' + SHIPPED[1] + '"')
+ expect(info).toContain('"' + SHIPPED[0] + '", "' + SHIPPED[1] + '"')
+ })
+
+ it('names the sheets the mapping block emits, in the help text and the info panel', async () => {
+ const wrapper = await mountWithSlots()
+
+ wrapper
+ .findComponent(CmdbImportMapping)
+ .vm.$emit('loaded', mappingOf(['Alpha', 'Beta', 'Gamma']))
+ await flushPromises()
+
+ const { help, info } = renderedTexts(wrapper)
+ expect(help).toContain('"Alpha", "Beta", "Gamma"')
+ expect(info).toContain('"Alpha", "Beta", "Gamma"')
+ for (const shipped of SHIPPED) {
+ expect(help).not.toContain(shipped)
+ expect(info).not.toContain(shipped)
+ }
+ })
+
+ it('names only the one sheet of a profile with one sheet', async () => {
+ const wrapper = await mountWithSlots()
+
+ wrapper
+ .findComponent(CmdbImportMapping)
+ .vm.$emit('loaded', mappingOf(['Alpha']))
+ await flushPromises()
+
+ const { help, info } = renderedTexts(wrapper)
+ expect(help).toContain('with the sheet "Alpha".')
+ expect(info).toContain('The sheet "Alpha" is read')
+ for (const shipped of SHIPPED) {
+ expect(help).not.toContain(shipped)
+ }
+ })
+})
diff --git a/tests/vitest/cmdbImportMapping.spec.js b/tests/vitest/cmdbImportMapping.spec.js
index e7c21781a..b12c1b956 100644
--- a/tests/vitest/cmdbImportMapping.spec.js
+++ b/tests/vitest/cmdbImportMapping.spec.js
@@ -262,6 +262,62 @@ describe('CmdbImportMapping', () => {
})
})
+describe('CmdbImportMapping errors without a reason', () => {
+ beforeEach(() => {
+ http.get.mockReset()
+ })
+
+ /**
+ * Load the block against a failing endpoint and return its error card.
+ *
+ * @param {object} failure The rejection the endpoint produces
+ * @return {Promise} The error card
+ */
+ async function errorCard(failure) {
+ http.get.mockRejectedValue(failure)
+ const wrapper = mount(CmdbImportMapping)
+ await flushPromises()
+ await wrapper
+ .find('[data-testid="cmdb-import-mapping-toggle"]')
+ .trigger('click')
+ expect(wrapper.emitted('loaded')).toBeUndefined()
+ return wrapper.find('[data-testid="cmdb-import-mapping-error"]')
+ }
+
+ it('shows the server message and the code on a 403 without a reason', async () => {
+ const error = await errorCard({
+ response: {
+ status: 403,
+ data: { success: false, error: 'NOT_ADMIN', message: 'Admins only.' },
+ },
+ })
+
+ expect(error.exists()).toBe(true)
+ expect(error.text()).toContain('Admins only.')
+ expect(error.text()).toContain('NOT_ADMIN')
+ })
+
+ it('shows only the code when the request never got an answer', async () => {
+ const error = await errorCard(new Error('Network Error'))
+
+ expect(error.exists()).toBe(true)
+ expect(error.text()).toContain('NETWORK_ERROR')
+ expect(error.findAll('p')).toHaveLength(1)
+ })
+
+ it('shows the code of a 500 whose body carries no reason', async () => {
+ const error = await errorCard({
+ response: {
+ status: 500,
+ data: { success: false, error: 'IMPORT_FAILED', details: {} },
+ },
+ })
+
+ expect(error.text()).toContain('IMPORT_FAILED')
+ expect(error.findAll('p')).toHaveLength(1)
+ })
+})
+
describe('cmdbImport mapping helpers', () => {
it('takes the sheet names from the answer, and the shipped ones without it', () => {
expect(mappingSheetNames(answer())).toEqual(['Sheet A', 'Sheet B'])
From 7db0b2a09f0bf439100467d125f310009f0ae918 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:30:57 +0200
Subject: [PATCH 046/124] docs(cmdb-import): narrow the mapping block's claim
to what it shows
The user docs said the block "shows what the import does", and that it shows
which file and entry hold a column. It shows the column-to-field mappings and,
since the write-rule rows, when the import writes each field; the sheet
constants, absent columns, sheet precedence, id columns, empty-value
placeholders, missingRecords modes and limits are returned by the endpoint but
not displayed, and a pack's idStrategy is only in its file. The docs now say
that and point to the files that hold them. The same overclaim in the change's
proposal, design and spec purpose is narrowed the same way.
Review #1289 f2
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 22 +++++++++++++------
.../cmdb-import-mapping-view/design.md | 2 +-
.../cmdb-import-mapping-view/proposal.md | 2 +-
.../specs/cmdb-export-import/spec.md | 2 +-
4 files changed, 18 insertions(+), 10 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index d6c0d7468..e6f08ed07 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -356,11 +356,18 @@ The section shows the mapping the next import runs, read-only. Open
type `Application`) and the fields never written on an existing record
(the module's publication and depublication dates).
-The block reads the files through the same loader the import uses, so what
-it shows is what the import does. When a file is invalid, the block shows
-`MAPPING_UNAVAILABLE` with the file and the reason instead of the tables, and
-the import refuses to run with the same code. The sheet names in the
-section's help text come from the same answer.
+The block reads the files through the same loader the import uses, so the
+column-to-field mappings and the write rules it shows are the ones the next
+import applies. It does not show everything in the files: the constant each
+sheet adds to its rows (`Beheer`) and the columns a sheet is known to lack,
+the order in which sheets win a duplicate `APPID`, the id columns, the
+placeholder values that mean empty (such as `NB` in `BNN Classificatie`), the
+`missingRecords` modes and the limits are in the files and in the
+endpoint's answer, and a pack's `idStrategy` only in the file; none of them
+is in the block. When a file is
+invalid, the block shows `MAPPING_UNAVAILABLE` with the file and the reason
+instead of the tables, and the import refuses to run with the same code. The
+sheet names in the section's help text come from the same answer.
The block is only for viewing: nothing in it changes the mapping. The data
behind it is also available to Nextcloud administrators as
@@ -370,8 +377,9 @@ behind it is also available to Nextcloud administrators as
The mapping from columns to fields is not in code. It is a set of JSON files
in `lib/Settings/cmdb-import/`, executed by OpenRegister's mapping engine.
-It cannot be changed in the section; **Mapping (read-only)** shows which
-file and which entry hold a column, so you know what to change:
+It cannot be changed in the section; **Mapping (read-only)** shows the pack
+file and the column mapping that hold a column, and the files below hold the
+settings the block does not show, so you know what to change:
| File | What it maps |
|---|---|
diff --git a/openspec/changes/cmdb-import-mapping-view/design.md b/openspec/changes/cmdb-import-mapping-view/design.md
index 23b3357b9..6b3a29e96 100644
--- a/openspec/changes/cmdb-import-mapping-view/design.md
+++ b/openspec/changes/cmdb-import-mapping-view/design.md
@@ -8,7 +8,7 @@ The CMDB import (archived change `2026-10-05-cmdb-export-import`) maps a TOPdesk
**Goals**
-- An administrator sees, in the section, exactly the mapping the next import runs.
+- An administrator sees, in the section, the column mapping and the write rules the next import applies, read from the files the import loads. The profile's other settings (sheet constants, absent columns, sheet precedence, id columns, empty-value placeholders, missing-records modes, limits) are returned by the endpoint but not shown; the user documentation says so.
- The section's sheet names cannot drift from the profile.
- Nothing is written; nothing new is parsed.
diff --git a/openspec/changes/cmdb-import-mapping-view/proposal.md b/openspec/changes/cmdb-import-mapping-view/proposal.md
index e651febe6..b537011dd 100644
--- a/openspec/changes/cmdb-import-mapping-view/proposal.md
+++ b/openspec/changes/cmdb-import-mapping-view/proposal.md
@@ -7,7 +7,7 @@ depends_on: []
## Summary
-The "CMDB import" section of stackiq's admin settings gets a collapsible, read-only block "Mapping (read-only)" that shows the column mapping the import really uses: the sheets, the key and required columns, and one table per migration pack with the source column, the target field, whether it is required, and the transformation with its lookup values. A new admin endpoint `GET /api/settings/cmdb-import/mapping` loads the import profile and the five packs through the same loader and validator the import uses, so what the administrator sees is what the next import runs. Editing stays in the JSON files on the server; the documentation says where they are and what an administrator can change.
+The "CMDB import" section of stackiq's admin settings gets a collapsible, read-only block "Mapping (read-only)" that shows the column mapping the import really uses: the sheets, the key and required columns, and one table per migration pack with the source column, the target field, whether it is required, the transformation with its lookup values and when the import writes the field, plus each pack's defaults, create-only values and never-written fields. A new admin endpoint `GET /api/settings/cmdb-import/mapping` loads the import profile and the five packs through the same loader and validator the import uses, so the column mapping and write rules the administrator sees are the ones the next import applies (the profile's sheet constants, empty-value placeholders and similar settings are in the answer but not in the block). Editing stays in the JSON files on the server; the documentation says where they are and what an administrator can change.
## Motivation
diff --git a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
index 68351e7cc..174b1ce02 100644
--- a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
@@ -8,7 +8,7 @@
## Purpose
-The CMDB import maps the columns of a TOPdesk export to stackiq fields through an import profile and five migration packs, JSON files under `lib/Settings/cmdb-import/` that OpenRegister's mapping engine executes (REQ-CMDB-005). Until now an administrator could only learn that mapping by reading the JSON on the server. This delta adds a read-only view of it to the "CMDB import" section of the admin settings, served by an endpoint that loads the files through the same loader the import uses, so what the administrator sees is what the next import runs (Jira WOO-588, sub-task of WOO-586).
+The CMDB import maps the columns of a TOPdesk export to stackiq fields through an import profile and five migration packs, JSON files under `lib/Settings/cmdb-import/` that OpenRegister's mapping engine executes (REQ-CMDB-005). Until now an administrator could only learn that mapping by reading the JSON on the server. This delta adds a read-only view of it to the "CMDB import" section of the admin settings, served by an endpoint that loads the files through the same loader the import uses, so the column mapping and the write rules the administrator sees are the ones the next import applies; the profile's other settings (sheet constants, absent columns, sheet precedence, id columns, empty-value placeholders, missing-records modes) are in the answer but not in the block (Jira WOO-588, sub-task of WOO-586).
Nextcloud OCP interfaces used: `OCP\IL10N` (messages). OpenRegister: `Service\MigrationPack\PackDefinitionValidator` (through the existing `CmdbImportProfile`, guarded).
From 2795770c556ae9a526ca57f12b8e33f61fcd4e2d Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:32:07 +0200
Subject: [PATCH 047/124] style(cmdb-import): bring the two reconciliation
fixes within phpcs
The archive-sheet and per-key fixes lengthened a few docblock lines and
the new warning past the 150-character limit, and stateRank() used an
inline if. Names the two array shapes once as phpstan types, shortens the
warning (en, nl, test) and spells the rank out. No behaviour change.
Follows Review #1285 f1 and Review #1286 f4; fold into those commits when
squashing.
Co-Authored-By: Claude Opus 5.5
---
l10n/en.js | 2 +-
l10n/en.json | 2 +-
l10n/nl.js | 2 +-
l10n/nl.json | 2 +-
lib/Service/CmdbExportImportService.php | 26 ++++++++++++-------
.../Service/CmdbExportImportServiceTest.php | 2 +-
6 files changed, 22 insertions(+), 14 deletions(-)
diff --git a/l10n/en.js b/l10n/en.js
index 39566fb2a..6eba930ad 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1041,7 +1041,7 @@ OC.L10N.register(
"Applications missing from the export can only be archived or kept.": "Applications missing from the export can only be archived or kept.",
"Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.",
"Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.",
- "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, which was read as empty, so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, which was read as empty, so applications missing from the export are archived, not deleted.",
+ "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.",
"Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.",
"Unarchived": "Unarchived",
"Restored": "Restored",
diff --git a/l10n/en.json b/l10n/en.json
index 18f9c061f..274b75bdc 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1040,7 +1040,7 @@
"Applications missing from the export can only be archived or kept.": "Applications missing from the export can only be archived or kept.",
"Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.",
"Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.",
- "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, which was read as empty, so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, which was read as empty, so applications missing from the export are archived, not deleted.",
+ "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.",
"Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.",
"Unarchived": "Unarchived",
"Restored": "Restored",
diff --git a/l10n/nl.js b/l10n/nl.js
index c5c7dd953..ea4be54bb 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1109,7 +1109,7 @@ OC.L10N.register(
"Applications missing from the export can only be archived or kept.": "Applicaties die in de export ontbreken, kunnen alleen worden gearchiveerd of behouden.",
"Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd: OpenRegister ontbreekt of is te oud. Kies ervoor ze te behouden, of werk OpenRegister bij.",
"Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\", dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
- "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, which was read as empty, so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft een APPID zonder opgeslagen waarde in rij %2$s, die als leeg is gelezen, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft een APPID zonder opgeslagen waarde in rij %2$s, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
"Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Tabblad \"%s\" is niet gevonden, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
"Unarchived": "Uit archief gehaald",
"Restored": "Hersteld",
diff --git a/l10n/nl.json b/l10n/nl.json
index 13297a8d9..ccc443ad8 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1108,7 +1108,7 @@
"Applications missing from the export can only be archived or kept.": "Applicaties die in de export ontbreken, kunnen alleen worden gearchiveerd of behouden.",
"Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd: OpenRegister ontbreekt of is te oud. Kies ervoor ze te behouden, of werk OpenRegister bij.",
"Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\", dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
- "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, which was read as empty, so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft een APPID zonder opgeslagen waarde in rij %2$s, die als leeg is gelezen, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft een APPID zonder opgeslagen waarde in rij %2$s, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
"Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Tabblad \"%s\" is niet gevonden, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
"Unarchived": "Uit archief gehaald",
"Restored": "Hersteld",
diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php
index 096767971..282219792 100644
--- a/lib/Service/CmdbExportImportService.php
+++ b/lib/Service/CmdbExportImportService.php
@@ -84,6 +84,10 @@
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-5
*
+ * @phpstan-type ArchiveSheet array{sheet: string, present: bool, keyColumnMissing: bool, uncachedRows: array, keys: array}
+ *
+ * @phpstan-type ScopeEntry array{key: string, usage: object, module: object, states: array}
+ *
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity) One import run resolves four linked
* object kinds per row (supplier, module, owners, usage), each with its own match rule,
* create-only fields and run cache. Splitting them over several classes would hand the
@@ -622,7 +626,7 @@ private function runImport(string $path, array $options, string $startedAt): arr
*
* @param CmdbImportReport $report The report.
* @param array{uuid: string, name: string, created: bool} $municipality The consumer, as resolveMunicipality() found it.
- * @param array{sheet: string, present: bool, keyColumnMissing: bool, uncachedRows: array, keys: array} $archive The archive sheet.
+ * @param ArchiveSheet $archive The archive sheet.
* @param string $missingRecords The import's `missingRecords` mode.
*
* @return void
@@ -1933,7 +1937,7 @@ private static function missingRecordsFrom(array $options): string {
*
* @param array $workbook What the reader returned.
*
- * @return array{sheet: string, present: bool, keyColumnMissing: bool, uncachedRows: array, keys: array}
+ * @return ArchiveSheet
*
* @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
*/
@@ -1959,7 +1963,7 @@ private function archiveSheet(array $workbook): array {
}//end archiveSheet()
/**
- * The report warning of an archiving import whose workbook lacks the archive sheet or its key column, or whose archive sheet has keys that could not be read.
+ * The report warning of an archiving import whose archive sheet is absent or has no key column, or has unreadable keys.
*
* @param array{sheet: string, keyColumnMissing: bool, uncachedRows: array} $archive The archive sheet.
*
@@ -1976,7 +1980,7 @@ private function archiveSheetWarning(array $archive): string {
}
return $this->l10n->t(
- 'Sheet "%1$s" has an APPID without a cached value in row %2$s, which was read as empty, so applications missing from the export are archived, not deleted.',
+ 'Sheet "%1$s" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.',
[$archive['sheet'], $rows]
);
}
@@ -2202,7 +2206,7 @@ private static function registerTrash(mixed $deleted, array $coordinates): array
* stays, and the report says the run was cancelled.
*
* @param string $municipalityUuid The consumer of this import.
- * @param array{sheet: string, present: bool, keyColumnMissing: bool, uncachedRows: array, keys: array} $archive The archive sheet.
+ * @param ArchiveSheet $archive The archive sheet.
* @param string $operationId The operation id, for the archive reason.
* @param int $processedItems The rows already counted as processed.
* @param CmdbImportReport $report The report.
@@ -2299,9 +2303,9 @@ private function reconciliationScope(string $municipalityUuid): array {
* best usage state breaks a tie, and the first one listed breaks the rest.
* A stale duplicate is left as it is, never restored into the archive.
*
- * @param array}> $scope The entries.
+ * @param array $scope The entries.
*
- * @return array}>
+ * @return array
*
* @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
*/
@@ -2334,7 +2338,11 @@ private static function stateRank(array $state): int {
return 2;
}
- return ($state['archived'] === true) ? 1 : 0;
+ if ($state['archived'] === true) {
+ return 1;
+ }
+
+ return 0;
}//end stateRank()
/**
@@ -2463,7 +2471,7 @@ private function modulesInChunk(array $chunk, array $coordinates): array {
* An application already in the state the export asks for is not reported.
*
* @param array{key: string, usage: object, module: object, states: array} $application Application.
- * @param array{sheet: string, present: bool, keyColumnMissing: bool, uncachedRows: array, keys: array} $archive The archive sheet.
+ * @param ArchiveSheet $archive The archive sheet.
* @param string $operationId The operation id, for the archive reason.
* @param CmdbImportReport $report The report.
*
diff --git a/tests/Unit/Service/CmdbExportImportServiceTest.php b/tests/Unit/Service/CmdbExportImportServiceTest.php
index b21e88ae7..7f9608fa8 100644
--- a/tests/Unit/Service/CmdbExportImportServiceTest.php
+++ b/tests/Unit/Service/CmdbExportImportServiceTest.php
@@ -1975,7 +1975,7 @@ public function testAnUncachedArchiveRowArchivesInsteadOfDeleting(): void {
$this->assertSame([true, false], $this->stateOf(schema: self::USAGE, uuid: $this->usageOf(moduleUuid: $eight)));
$this->assertSame('cmdb-import: not in the export (' . $report['operationId'] . ')', $this->transitions[2][3]);
$this->assertSame(
- [['sheet' => 'Gearchiveerde Applicaties', 'message' => 'Sheet "Gearchiveerde Applicaties" has an APPID without a cached value in row 4, which was read as empty, so applications missing from the export are archived, not deleted.']],
+ [['sheet' => 'Gearchiveerde Applicaties', 'message' => 'Sheet "Gearchiveerde Applicaties" has an APPID without a cached value in row 4, so applications missing from the export are archived, not deleted.']],
$report['importWarnings']
);
$this->assertSame([2, 0], [$report['summary']['archived'], $report['summary']['deleted']]);
From aa3b664880428229dce162c086fa217309aebc14 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:45:31 +0200
Subject: [PATCH 048/124] docs(openspec): state the archive-only rule in every
copy
The rule "archive, never soft-delete" was written as "when the workbook has
no archive sheet" in the design risk list, the proposal summary and Risk 2,
and in task 4. It also applies when the sheet has no APPID column or holds
an APPID the reader cannot see (a formula without a cached value).
Review pre-push p1
Co-Authored-By: Claude Opus 5.5
---
.../changes/cmdb-import-archive-reconciliation/design.md | 2 +-
.../changes/cmdb-import-archive-reconciliation/proposal.md | 6 +++---
.../changes/cmdb-import-archive-reconciliation/tasks.md | 2 +-
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/design.md b/openspec/changes/cmdb-import-archive-reconciliation/design.md
index 85ae87766..5928e26b6 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/design.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/design.md
@@ -192,7 +192,7 @@ No seed objects change. The fragment's three seed modules stay as they are; the
## Risks / Trade-offs
- [The default archives on the first re-import after the upgrade] → The section shows the choice with help text; the report lists every archived and deleted application; nothing is hard-deleted.
-- [A workbook saved without the archive sheet] → archive only, never delete, plus a warning (D3).
+- [A workbook saved without the archive sheet, or whose archive sheet has no APPID column or an APPID without a cached value] → archive only, never delete, plus a warning (D3).
- [A failure between the usage and the module] → the usage is written first, so the municipality's view is right and the module is at worst still visible (D5).
- [Reviving before writing adds calls per returning row] → only for rows whose match is archived or deleted; a row whose module is working costs the same searches as before, a new row one more search (the archive lens).
- [The trash listing scans every magic table of the instance] → read once per import, not per row (D9).
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/proposal.md b/openspec/changes/cmdb-import-archive-reconciliation/proposal.md
index 7c27fdd85..dd4017f30 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/proposal.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/proposal.md
@@ -34,7 +34,7 @@ None.
### In Scope
- The sheet "Gearchiveerde Applicaties" (columns Applicatiecomponent, APPID, Applicatie Code, Applicatie Naam, Roepnaam, Applicatie Status, Bron, Datum Interface, Datum wijziging) is read for its APPID column only, when it is present.
-- After every row of a completed import: the municipality's usages, with their modules, are compared with the APPIDs on the CMDB sheets and on the archive sheet. Missing and on the archive sheet: module and usage archived. Missing everywhere: module and usage soft-deleted. Without the archive sheet: archived only, with a warning in the report.
+- After every row of a completed import: the municipality's usages, with their modules, are compared with the APPIDs on the CMDB sheets and on the archive sheet. Missing and on the archive sheet: module and usage archived. Missing everywhere: module and usage soft-deleted. Without the archive sheet, or when it has no APPID column or an APPID the reader cannot see (a formula without a cached value): archived only, with a warning in the report.
- A returning APPID: an archived module or usage is unarchived, a soft-deleted one is restored from the trash, and the row is then updated as before.
- `missingRecords`: `keep` leaves everything as it is (the behaviour so far), `archive` reconciles and is the default; `remove` stays reserved and is refused.
- The settings section offers the choice with help text; the report shows the new outcomes in its summary and rows.
@@ -77,8 +77,8 @@ None. `ArchiveHandler` and `MagicMapper` come from OpenRegister, which stackiq a
### Risk 1: A re-import with the new default archives what an admin did not expect
**Severity:** Medium — **Mitigation:** only the municipality's own usages and the modules they point at are in scope, never a supplier's or another municipality's module; nothing is hard-deleted; the choice is visible in the section with help text, and the report lists every archived and deleted application. A cancelled or failed import never reconciles.
-### Risk 2: A workbook without the archive sheet soft-deletes everything that moved there
-**Severity:** Medium — **Mitigation:** without the sheet, missing applications are archived, never soft-deleted, and the report warns that the sheet was not found.
+### Risk 2: A workbook without a usable archive sheet soft-deletes everything that moved there
+**Severity:** Medium — **Mitigation:** without the sheet, without its APPID column, or with an APPID the reader cannot see (a formula without a cached value), missing applications are archived, never soft-deleted, and the report warns, naming the sheet (and the rows).
### Risk 3: The archive services are OpenRegister internals
**Severity:** Low — **Mitigation:** resolved through the container behind a guard; when missing, an import with `missingRecords=archive` is refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read, and `keep` still works.
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
index cc3583dca..1ff7af0d9 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
@@ -43,7 +43,7 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp
- **files**: `lib/Service/CmdbExportImportService.php`, `lib/Service/ProgressTracker.php`, `lib/Exception/CmdbImportException.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php`
- **acceptance_criteria**:
- GIVEN APPIDs 1 and 7 imported WHEN an export with row 1 and 7 on the archive sheet is imported THEN module and usage of 7 are archived with a reason, the report row is `archived`, and the owner contact person is untouched
- - GIVEN APPID 7 on no sheet THEN module and usage are soft-deleted and the row is `deleted`; GIVEN the archive sheet absent THEN they are archived instead and the report warns
+ - GIVEN APPID 7 on no sheet THEN module and usage are soft-deleted and the row is `deleted`; GIVEN the archive sheet absent, without an APPID column, or with an APPID that has no cached value THEN they are archived instead and the report warns
- GIVEN APPID 7 soft-deleted and listed on the archive sheet THEN module and usage are restored and archived; GIVEN it already archived or, when on no sheet, already deleted THEN nothing is written
- GIVEN `missingRecords` `keep`, a cancelled import, or a usage of another municipality THEN nothing is archived or deleted
- GIVEN a cancel during the last row THEN nothing is archived or deleted; GIVEN a cancel during the reconciliation THEN it stops before the next application and the report is cancelled
From 70d6f9344775505712775ca0f8caf5b616cb81e8 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:45:31 +0200
Subject: [PATCH 049/124] docs(openspec): the Archived tab ignores the facet
narrowing
D8 said the facet narrowing's _ids composes with the Archived quick filter.
The Archived tab of the Applications page overrides _ids with an empty
value, so it lists the whole archive and ignores facets and search.
Review pre-push p2
Co-Authored-By: Claude Opus 5.5
---
openspec/changes/cmdb-import-archive-reconciliation/design.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/design.md b/openspec/changes/cmdb-import-archive-reconciliation/design.md
index 5928e26b6..51daa0580 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/design.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/design.md
@@ -115,7 +115,7 @@ Progress: the operation gets the phase `reconciling` (added to `ProgressTracker:
### D8. Stackiq UI
-The Applications (`Modules`, a `FacetedCatalogIndexView` that forwards `quickFilters` to `CnIndexPage`) and Applications in use (`Gebruik`) pages get the quick filter `{"label": "Archived", "filter": {"_archived": "true"}}`. `useSelfFetchList` spreads the active tab's filter into the fetch parameters as they are, so `_archived=true` reaches OpenRegister and the list shows the archive alone; the facet narrowing's `_ids` composes with it (AND). The icon `ArchiveOutline` is registered in `src/icons.js`. `@conduction/nextcloud-vue` 2.65 renders no `@self.archived` on a detail page or in the sidebar metadata, so the detail pages show no archive state yet; that is a library follow-up, not a stackiq widget.
+The Applications (`Modules`, a `FacetedCatalogIndexView` that forwards `quickFilters` to `CnIndexPage`) and Applications in use (`Gebruik`) pages get the quick filter `{"label": "Archived", "filter": {"_archived": "true"}}`. `useSelfFetchList` spreads the active tab's filter into the fetch parameters as they are, so `_archived=true` reaches OpenRegister and the list shows the archive alone. The facet narrowing's `_ids` would compose with it (AND) and hide archived applications the facets do not know; the Archived tab of the Applications page therefore overrides `_ids` with an empty value, so it lists the whole archive and ignores the facets and the search. The icon `ArchiveOutline` is registered in `src/icons.js`. `@conduction/nextcloud-vue` 2.65 renders no `@self.archived` on a detail page or in the sidebar metadata, so the detail pages show no archive state yet; that is a library follow-up, not a stackiq widget.
### D9. How the import reads the state of a stored object
From df28777e4a8881d8ca48e81b388f097950255afa Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:45:58 +0200
Subject: [PATCH 050/124] fix(cmdb-import): keep create-only defaults out of
createOnlyFields
createOnlyFields is documented as mapped fields, but for the module pack the
accessor returned ['type'], a key of the create-only defaults that merge()
never reads on update (it only walks mapped fields). The overview now answers
that value under createOnlyDefaults only and subtracts the default keys from
createOnlyFields; the import's accessors are unchanged. REQ-CMDB-020 and
design.md say so, and the controller and profile tests pin it.
Review pre-push p6
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Cmdb/CmdbImportProfile.php | 20 +++++++++++++++----
.../cmdb-import-mapping-view/design.md | 2 +-
.../specs/cmdb-export-import/spec.md | 2 +-
...ettingsControllerCmdbImportMappingTest.php | 9 +++++++--
.../Service/Cmdb/CmdbImportProfileTest.php | 2 +-
5 files changed, 26 insertions(+), 9 deletions(-)
diff --git a/lib/Service/Cmdb/CmdbImportProfile.php b/lib/Service/Cmdb/CmdbImportProfile.php
index 81a256081..73dc75e5c 100644
--- a/lib/Service/Cmdb/CmdbImportProfile.php
+++ b/lib/Service/Cmdb/CmdbImportProfile.php
@@ -634,8 +634,9 @@ public function referencedColumns(): array {
* hidden from the admin; `required` is normalised to a boolean. Each pack
* also carries the write rules the import applies on top of the mapping:
* `defaults` (pack values set on every write), `createOnlyDefaults`
- * (values set on create only), `createOnlyFields` (mapped fields written on
- * create or when the stored value is empty) and `neverWritten` (fields the
+ * (values set on create only, not mapped columns), `createOnlyFields` (mapped
+ * fields written on create or, on update, only when the stored value is
+ * empty; never a key of `createOnlyDefaults`) and `neverWritten` (fields the
* import never writes on an existing object).
*
* @return array{profile: array, packs: array>}
@@ -670,6 +671,17 @@ public function mappingOverview(): array {
];
}
+ // A create-only default (the module type) is a value the import sets on
+ // create, not a mapped field: merge() never reads it on update, so it is
+ // listed under createOnlyDefaults only.
+ $createOnlyDefaults = $this->createOnlyDefaults(target: $target);
+ $createOnlyFields = array_values(
+ array_diff(
+ $this->createOnlyFields(target: $target),
+ array_map('strval', array_keys($createOnlyDefaults))
+ )
+ );
+
$defaults = $pack['defaults'] ?? [];
if (is_array($defaults) === false) {
$defaults = [];
@@ -684,8 +696,8 @@ public function mappingOverview(): array {
'description' => (string)($pack['description'] ?? ''),
'fieldMappings' => $mappings,
'defaults' => $defaults,
- 'createOnlyDefaults' => $this->createOnlyDefaults(target: $target),
- 'createOnlyFields' => $this->createOnlyFields(target: $target),
+ 'createOnlyDefaults' => $createOnlyDefaults,
+ 'createOnlyFields' => $createOnlyFields,
'neverWritten' => $this->neverWrittenOnUpdate(target: $target),
];
}//end foreach
diff --git a/openspec/changes/cmdb-import-mapping-view/design.md b/openspec/changes/cmdb-import-mapping-view/design.md
index 225ede564..0bd69a911 100644
--- a/openspec/changes/cmdb-import-mapping-view/design.md
+++ b/openspec/changes/cmdb-import-mapping-view/design.md
@@ -46,7 +46,7 @@ The method carries no auth attribute (Nextcloud's default: admins with a CSRF to
`fieldMappings[].transform` is passed through as the pack stores it (`type` plus `map`/`default`, `fields`/`separator`, `value`, `sourceFormat`/`targetFormat`). Re-shaping per type would hide a key the engine reads. The page renders the known keys and lists any other scalar key as it is. `required` is normalised to a boolean.
-The write rules the import applies besides the mapping are answered per pack, built from the accessors the import uses (`createOnlyDefaults()`, `createOnlyFields()`, `neverWrittenOnUpdate()`) and the pack's own `defaults`, so the view does not overclaim for a field the import writes only on create or never.
+The write rules the import applies besides the mapping are answered per pack, built from the accessors the import uses (`createOnlyDefaults()`, `createOnlyFields()`, `neverWrittenOnUpdate()`) and the pack's own `defaults`, so the view does not overclaim for a field the import writes only on create or never. A create-only default such as the module `type` is answered in `createOnlyDefaults` only: it is not a mapped field and `merge()` never reads it on update, so `createOnlyFields` leaves it out.
### D4. The section takes the sheet names from the endpoint, with the constant as fallback
diff --git a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
index 9d807394b..9675bb693 100644
--- a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
@@ -16,7 +16,7 @@ Nextcloud OCP interfaces used: `OCP\IL10N` (messages). OpenRegister: `Service\Mi
### Requirement: The admin settings SHALL show the mapping the import uses (REQ-CMDB-020)
-`GET /api/settings/cmdb-import/mapping` SHALL be reachable only by Nextcloud admins and SHALL require Nextcloud's CSRF token; like the import routes (REQ-CMDB-001) it SHALL NOT carry `#[AuthorizedAdminSetting]`, `#[NoAdminRequired]` or `#[NoCSRFRequired]`. It SHALL load the import profile and every pack it names through the same loader and validator the import uses, and SHALL answer 200 with a flat envelope of two keys: `profile` (the profile's `id`, `name`, `version`, `sheets` with each sheet's `name`, `constants` and `absentColumns`, `sheetPrecedence`, `keyColumn`, `nameColumn`, `requiredColumns`, `dateColumns`, `idColumns`, `emptyValues`, `externalKeyPrefix`, `missingRecords` and `profileFile`) and `packs`, one entry per target in the order module, manufacturer, municipality, usage, businessOwner, each with `target`, `file`, `id`, `name`, `version`, `description`, `fieldMappings` and the write rules the import applies besides the mapping: `defaults` (the pack's values, field => value), `createOnlyDefaults` (values set on create only, field => value), `createOnlyFields` (mapped fields written on create or, on an existing object, only when the stored value is empty) and `neverWritten` (fields the import never writes on an existing object); the last three come from the profile's `createOnly` and `neverWritten` through the accessors the import itself uses. Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it: the `type`, and for `lookup` and `bool-map` the `map` and `default`, for `concat` the `fields` and `separator`, for `const` the `value`, for `date` the `sourceFormat` and `targetFormat`. The endpoint SHALL read nothing but those files and SHALL write nothing. When the validator is missing, or the profile or a pack is unreadable or invalid, it SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason`, so the administrator who edited a file learns what is wrong without the Nextcloud log. The "CMDB import" section SHALL show the answer in a collapsible block "Mapping (read-only)": the sheets, the key, name, required and date columns, and one table per pack with the source column, the target field, whether it is required, the transformation and its details. The block SHALL show a loading state until the endpoint answers and the error with its code when it fails, and SHALL offer no editing: the mapping is changed in the files on the server, as the documentation describes.
+`GET /api/settings/cmdb-import/mapping` SHALL be reachable only by Nextcloud admins and SHALL require Nextcloud's CSRF token; like the import routes (REQ-CMDB-001) it SHALL NOT carry `#[AuthorizedAdminSetting]`, `#[NoAdminRequired]` or `#[NoCSRFRequired]`. It SHALL load the import profile and every pack it names through the same loader and validator the import uses, and SHALL answer 200 with a flat envelope of two keys: `profile` (the profile's `id`, `name`, `version`, `sheets` with each sheet's `name`, `constants` and `absentColumns`, `sheetPrecedence`, `keyColumn`, `nameColumn`, `requiredColumns`, `dateColumns`, `idColumns`, `emptyValues`, `externalKeyPrefix`, `missingRecords` and `profileFile`) and `packs`, one entry per target in the order module, manufacturer, municipality, usage, businessOwner, each with `target`, `file`, `id`, `name`, `version`, `description`, `fieldMappings` and the write rules the import applies besides the mapping: `defaults` (the pack's values, field => value), `createOnlyDefaults` (values the import sets on create only, field => value; they are not mapped columns), `createOnlyFields` (mapped fields written on create or, on an existing object, only when the stored value is empty; never a field that is a key of `createOnlyDefaults`) and `neverWritten` (fields the import never writes on an existing object); the last three come from the profile's `createOnly` and `neverWritten` through the accessors the import itself uses. Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it: the `type`, and for `lookup` and `bool-map` the `map` and `default`, for `concat` the `fields` and `separator`, for `const` the `value`, for `date` the `sourceFormat` and `targetFormat`. The endpoint SHALL read nothing but those files and SHALL write nothing. When the validator is missing, or the profile or a pack is unreadable or invalid, it SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason`, so the administrator who edited a file learns what is wrong without the Nextcloud log. The "CMDB import" section SHALL show the answer in a collapsible block "Mapping (read-only)": the sheets, the key, name, required and date columns, and one table per pack with the source column, the target field, whether it is required, the transformation and its details. The block SHALL show a loading state until the endpoint answers and the error with its code when it fails, and SHALL offer no editing: the mapping is changed in the files on the server, as the documentation describes.
#### Scenario: An admin reads the mapping the import uses
@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
diff --git a/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php b/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
index 61ec942cb..46d6badd4 100644
--- a/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
+++ b/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
@@ -233,7 +233,7 @@ public function testTheShippedMappingIsAnsweredFlat(): void {
// The write rules the import applies besides the mapping (Review #1288 f2).
$byTarget = array_column($data['packs'], null, 'target');
$this->assertSame(['type' => 'Application'], $byTarget['module']['createOnlyDefaults']);
- $this->assertSame(['type'], $byTarget['module']['createOnlyFields']);
+ $this->assertSame([], $byTarget['module']['createOnlyFields'], 'type is a create-only default, not a mapped field');
$this->assertSame(['publicationDate', 'depublicationDate'], $byTarget['module']['neverWritten']);
$this->assertSame(['interneAnnotation', 'timeClassification'], $byTarget['usage']['createOnlyFields']);
$this->assertSame([], $byTarget['usage']['createOnlyDefaults']);
@@ -242,7 +242,12 @@ public function testTheShippedMappingIsAnsweredFlat(): void {
$this->assertSame(['type' => 'Municipality', 'status' => 'Active'], $byTarget['municipality']['defaults']);
$this->assertSame([], $byTarget['module']['defaults']);
foreach ($data['packs'] as $pack) {
- $this->assertSame($profile->createOnlyFields(target: $pack['target']), $pack['createOnlyFields'], $pack['target']);
+ $this->assertSame(
+ array_values(array_diff($profile->createOnlyFields(target: $pack['target']), array_keys($pack['createOnlyDefaults']))),
+ $pack['createOnlyFields'],
+ $pack['target']
+ );
+ $this->assertSame([], array_intersect($pack['createOnlyFields'], array_keys($pack['createOnlyDefaults'])), $pack['target']);
$this->assertSame($profile->neverWrittenOnUpdate(target: $pack['target']), $pack['neverWritten'], $pack['target']);
}
diff --git a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php
index 5ca02614f..d76e4203f 100644
--- a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php
+++ b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php
@@ -330,7 +330,7 @@ public function testTheOverviewShowsWhatTheImportRuns(): void {
}
$this->assertSame($profile->createOnlyDefaults(target: $pack['target']), $pack['createOnlyDefaults'], $pack['target']);
- $this->assertSame($profile->createOnlyFields(target: $pack['target']), $pack['createOnlyFields'], $pack['target']);
+ $this->assertSame([], array_intersect($pack['createOnlyFields'], array_keys($pack['createOnlyDefaults'])), $pack['target']);
$this->assertSame($profile->neverWrittenOnUpdate(target: $pack['target']), $pack['neverWritten'], $pack['target']);
}
From 9fb2dfdb0d620cb8b4684935d91cc2cb47779202 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:46:12 +0200
Subject: [PATCH 051/124] docs(cmdb-import): state all three cases where an
import archives and never deletes
The docs and the choice's help said an import only archives when the archive
sheet is missing (the docs added "or has no APPID column" and said the result
warns the sheet was not found). The service also archives, with its own
warning for each case, when the sheet has no APPID column or an APPID in it
has no cached value.
The docs table row and the section help now name the three cases and say the
result warns; the l10n key is replaced in place and a jest case pins the help.
Review pre-push p1
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 2 +-
l10n/en.js | 2 +-
l10n/en.json | 2 +-
l10n/nl.js | 2 +-
l10n/nl.json | 2 +-
src/views/settings/sections/CmdbImport.spec.js | 11 +++++++++++
src/views/settings/sections/CmdbImport.vue | 2 +-
7 files changed, 17 insertions(+), 6 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index 1e33207d5..364bcf5f6 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -277,7 +277,7 @@ APPID is on neither CMDB sheet:
|---|---|
| on the sheet `Gearchiveerde Applicaties` | are **archived**: OpenRegister's archive state, with the reason `cmdb-import: on sheet "Gearchiveerde Applicaties"`. Reported as *archived*. |
| on no sheet at all | are **deleted**: moved to OpenRegister's trash, where they stay restorable for 30 days. Reported as *deleted*. |
-| on no sheet, and the workbook has no sheet `Gearchiveerde Applicaties` (or it has no `APPID` column) | are **archived**, never deleted, and the result warns that the sheet was not found. A workbook saved without that sheet therefore never moves anything to the trash. |
+| on no sheet, but the archive sheet cannot be relied on: the workbook has no sheet `Gearchiveerde Applicaties`, the sheet has no `APPID` column, or an `APPID` in it has no cached value (a formula whose result the file does not store) | are **archived**, never deleted. Each case has its own warning in the result: *sheet not found*, *no column APPID*, or *an APPID without a cached value in row …* (the first rows are named). Such a workbook therefore never moves anything to the trash. |
An archived or deleted application disappears from the stackiq lists, from
OpenCatalogi and from Portaliq's **Software we use**. In stackiq, the quick
diff --git a/l10n/en.js b/l10n/en.js
index 1c20fdbc0..0dd84f275 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1050,7 +1050,7 @@ OC.L10N.register(
"Applications missing from the export": "Applications missing from the export",
"Archive or delete them": "Archive or delete them",
"Keep them as they are": "Keep them as they are",
- "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Without that sheet in the file, applications are only archived. Choose Keep to leave them as they are.": "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Without that sheet in the file, applications are only archived. Choose Keep to leave them as they are.",
+ "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.": "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.",
"Applications missing from the export cannot be archived.": "Applications missing from the export cannot be archived.",
"OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.",
"Checking the applications of this municipality against the export": "Checking the applications of this municipality against the export",
diff --git a/l10n/en.json b/l10n/en.json
index faa7cf8db..e2f6ad6cf 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1049,7 +1049,7 @@
"Applications missing from the export": "Applications missing from the export",
"Archive or delete them": "Archive or delete them",
"Keep them as they are": "Keep them as they are",
- "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Without that sheet in the file, applications are only archived. Choose Keep to leave them as they are.": "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Without that sheet in the file, applications are only archived. Choose Keep to leave them as they are.",
+ "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.": "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.",
"Applications missing from the export cannot be archived.": "Applications missing from the export cannot be archived.",
"OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.",
"Checking the applications of this municipality against the export": "Checking the applications of this municipality against the export",
diff --git a/l10n/nl.js b/l10n/nl.js
index ce2e71c60..c3ac2b4b1 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1118,7 +1118,7 @@ OC.L10N.register(
"Applications missing from the export": "Applicaties die in de export ontbreken",
"Archive or delete them": "Archiveren of verwijderen",
"Keep them as they are": "Laat ze zoals ze zijn",
- "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Without that sheet in the file, applications are only archived. Choose Keep to leave them as they are.": "Een applicatie van deze gemeente die op geen van beide CMDB-tabbladen meer staat, wordt gearchiveerd als het tabblad \"Gearchiveerde Applicaties\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Staat dat tabblad niet in het bestand, dan worden applicaties alleen gearchiveerd. Kies Laat ze zoals ze zijn om ze ongemoeid te laten.",
+ "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.": "Een applicatie van deze gemeente die op geen van beide CMDB-tabbladen meer staat, wordt gearchiveerd als het tabblad \"Gearchiveerde Applicaties\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Ontbreekt dat tabblad, heeft het geen kolom APPID of heeft een APPID geen opgeslagen waarde, dan worden applicaties alleen gearchiveerd en zegt het resultaat dat. Kies Laat ze zoals ze zijn om ze ongemoeid te laten.",
"Applications missing from the export cannot be archived.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd.",
"OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister ontbreekt of is te oud om objecten te archiveren. Kies \"Laat ze zoals ze zijn\" en importeer opnieuw, of werk OpenRegister bij. Er is niets geïmporteerd.",
"Checking the applications of this municipality against the export": "De applicaties van deze gemeente met de export vergelijken",
diff --git a/l10n/nl.json b/l10n/nl.json
index 059c7ada7..e62e0749a 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1117,7 +1117,7 @@
"Applications missing from the export": "Applicaties die in de export ontbreken",
"Archive or delete them": "Archiveren of verwijderen",
"Keep them as they are": "Laat ze zoals ze zijn",
- "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Without that sheet in the file, applications are only archived. Choose Keep to leave them as they are.": "Een applicatie van deze gemeente die op geen van beide CMDB-tabbladen meer staat, wordt gearchiveerd als het tabblad \"Gearchiveerde Applicaties\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Staat dat tabblad niet in het bestand, dan worden applicaties alleen gearchiveerd. Kies Laat ze zoals ze zijn om ze ongemoeid te laten.",
+ "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.": "Een applicatie van deze gemeente die op geen van beide CMDB-tabbladen meer staat, wordt gearchiveerd als het tabblad \"Gearchiveerde Applicaties\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Ontbreekt dat tabblad, heeft het geen kolom APPID of heeft een APPID geen opgeslagen waarde, dan worden applicaties alleen gearchiveerd en zegt het resultaat dat. Kies Laat ze zoals ze zijn om ze ongemoeid te laten.",
"Applications missing from the export cannot be archived.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd.",
"OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister ontbreekt of is te oud om objecten te archiveren. Kies \"Laat ze zoals ze zijn\" en importeer opnieuw, of werk OpenRegister bij. Er is niets geïmporteerd.",
"Checking the applications of this municipality against the export": "De applicaties van deze gemeente met de export vergelijken",
diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js
index ca01b9517..5f43536cc 100644
--- a/src/views/settings/sections/CmdbImport.spec.js
+++ b/src/views/settings/sections/CmdbImport.spec.js
@@ -435,6 +435,17 @@ describe('CmdbImport the choice for missing applications, as the admin makes it'
expect(wrapper.vm.missingRecords).toBe('archive')
})
+ it('says in the help when applications are only archived, never deleted', async () => {
+ const wrapper = await mountFull()
+
+ const help = wrapper.get('#cmdb-import-missing-records-help').text()
+
+ expect(help).toContain('is missing')
+ expect(help).toContain('has no APPID column')
+ expect(help).toContain('an APPID without a cached value')
+ expect(help).toContain('only archived')
+ })
+
it('disables both radios while an import runs', async () => {
const wrapper = await mountFull()
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index 3590c47f7..88eaf5d75 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -172,7 +172,7 @@
{{
t(
'stackiq',
- 'An application of this municipality that is on neither CMDB sheet any more is archived when the sheet "Gearchiveerde Applicaties" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Without that sheet in the file, applications are only archived. Choose Keep to leave them as they are.',
+ 'An application of this municipality that is on neither CMDB sheet any more is archived when the sheet "Gearchiveerde Applicaties" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.',
)
}}
From e7f9125cf467430f43b98adcb5f9e16efbdfc94e Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:46:22 +0200
Subject: [PATCH 052/124] test(cmdb-import): fixture follows the overview's
createOnlyFields
The endpoint no longer lists the module's create-only default `type` under
createOnlyFields; the vitest answer fixture mirrors that, and the block still
shows `type = Application` as set on create only.
Review pre-push p6
Co-Authored-By: Claude Opus 5.5
---
tests/vitest/cmdbImportMapping.spec.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/vitest/cmdbImportMapping.spec.js b/tests/vitest/cmdbImportMapping.spec.js
index b12c1b956..f5ddf9062 100644
--- a/tests/vitest/cmdbImportMapping.spec.js
+++ b/tests/vitest/cmdbImportMapping.spec.js
@@ -125,7 +125,7 @@ function answer() {
},
], {
createOnlyDefaults: { type: 'Application' },
- createOnlyFields: ['type'],
+ createOnlyFields: [],
neverWritten: ['publicationDate', 'depublicationDate'],
}),
pack('manufacturer', [], {
From e8772a746819d58572d3761c11b8a0e97d23cd78 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:46:25 +0200
Subject: [PATCH 053/124] docs(cmdb-import): state the full archive-only rule
in the service docblocks
The class docblock and the addRunWarnings() docblock still named only a
missing archive sheet as the reason to archive instead of delete; the rule
also covers a sheet without an APPID column and one with an APPID that has
no cached value.
Review pre-push p1
Co-Authored-By: Claude Opus 5.5
---
lib/Service/CmdbExportImportService.php | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php
index 282219792..fae8bd431 100644
--- a/lib/Service/CmdbExportImportService.php
+++ b/lib/Service/CmdbExportImportService.php
@@ -34,7 +34,8 @@
* `missingRecords` `keep`. With `archive` (the default) the municipality's
* usages and their modules are reconciled after the last row: archived
* when the APPID is on the archive sheet, soft-deleted when it is on no
- * sheet, archived only when the workbook has no archive sheet
+ * sheet, archived only when the workbook has no archive sheet, the sheet
+ * has no APPID column or an APPID in it has no cached value
* (openspec/changes/cmdb-import-archive-reconciliation, design D5). A
* module or usage matched among archived or soft-deleted objects is
* unarchived or restored before it is updated, never duplicated.
@@ -622,7 +623,7 @@ private function runImport(string $path, array $options, string $startedAt): arr
}//end runImport()
/**
- * Add the run's own warnings: a missing archive sheet on an archiving import, and a created municipality.
+ * Add the run's own warnings: an archive sheet that is missing, has no key column or has unreadable keys, and a created municipality.
*
* @param CmdbImportReport $report The report.
* @param array{uuid: string, name: string, created: bool} $municipality The consumer, as resolveMunicipality() found it.
From 2d288acd3f113d8a2c6ae31635f8892089dd0081 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:46:29 +0200
Subject: [PATCH 054/124] docs(cmdb-import): correct the Modules page note on
how quick filters merge with the facet ids
The manifest note and the view's docblock said a quick filter composes with
the facet `_ids` as an additive AND that never clobbers. Since the Archived
tab carries an empty-string `_ids`, that tab replaces the facet id set and
lists the whole archive, ignoring the GEMMA facets and the search term. Both
now name that exception; the docs already said so. A vitest pins the note.
Review pre-push p2
Co-Authored-By: Claude Opus 5.5
---
src/manifest.json | 2 +-
src/views/FacetedCatalogIndexView.vue | 3 ++-
tests/vitest/cmdbArchiveQuickFilter.spec.js | 8 ++++++++
3 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/src/manifest.json b/src/manifest.json
index 245f8c802..1541f303e 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -616,7 +616,7 @@
"type": "custom",
"title": "Applications",
"component": "FacetedCatalogIndexView",
- "_note": "Faceted index page (gemma-faceted-search): CnFacetSidebar (live GEMMA-dimension counts from FacetService) + a standalone CnIndexPage narrowed via { _ids: matchedObjectIds } (OpenRegister's list parameter; the objects endpoint ignores a plain id filter). Columns are objects: `provider` is a reference, so it renders through the fkResolve cell widget (uuid → organisation name) and carries its own label — object-typed properties get no schema-generated column, so a bare key showed the raw uuid under the header 'provider'. Stays custom rather than type:index — see customComponents.js's FacetedCatalogIndexView entry for why CnIndexPage's own embedded facet sidebar cannot drive this narrowing. bio-compliance-assessment: bbnLevel/dpiaStatus columns + BBN/DPIA quickFilters reconciled onto this page after gemma-faceted-search (#382) merged — see FacetedCatalogIndexView.vue's quickFilters prop docblock for how the quick filter's field constraints compose with the facet { _ids: ... } narrowing (disjoint keys, additive AND, no clobber).",
+ "_note": "Faceted index page (gemma-faceted-search): CnFacetSidebar (live GEMMA-dimension counts from FacetService) + a standalone CnIndexPage narrowed via { _ids: matchedObjectIds } (OpenRegister's list parameter; the objects endpoint ignores a plain id filter). Columns are objects: `provider` is a reference, so it renders through the fkResolve cell widget (uuid → organisation name) and carries its own label — object-typed properties get no schema-generated column, so a bare key showed the raw uuid under the header 'provider'. Stays custom rather than type:index — see customComponents.js's FacetedCatalogIndexView entry for why CnIndexPage's own embedded facet sidebar cannot drive this narrowing. bio-compliance-assessment: bbnLevel/dpiaStatus columns + BBN/DPIA quickFilters reconciled onto this page after gemma-faceted-search (#382) merged — see FacetedCatalogIndexView.vue's quickFilters prop docblock for how the quick filter's field constraints compose with the facet { _ids: ... } narrowing (disjoint keys, additive AND, no clobber). The one exception is the Archived tab: its filter carries an empty-string `_ids` that replaces the facet id set in the merge, because that set is built from the working set, which leaves archived applications out, so ANDing it with `_archived=true` could never match. That tab lists the whole archive and ignores the GEMMA facets and the search term.",
"config": {
"register": "@resolve:voorzieningen_register",
"schema": "module",
diff --git a/src/views/FacetedCatalogIndexView.vue b/src/views/FacetedCatalogIndexView.vue
index cc29bb2ec..427245c47 100644
--- a/src/views/FacetedCatalogIndexView.vue
+++ b/src/views/FacetedCatalogIndexView.vue
@@ -232,7 +232,8 @@ export default {
* filter's keys (`bbnLevel`, `dpiaStatus`, …) never collide with
* `_ids`, so the merge is additive — both the facet-matched id set AND
* the active quick filter's field constraints apply together
- * (logical AND), never clobbering one another.
+ * (logical AND), never clobbering one another. The one exception is
+ * the Archived tab of the Applications page, below.
*
* The exception is the Archived tab of the Applications page
* (cmdb-import-archive-reconciliation): the facet id set is built from
diff --git a/tests/vitest/cmdbArchiveQuickFilter.spec.js b/tests/vitest/cmdbArchiveQuickFilter.spec.js
index c048abf8f..ff4df73ee 100644
--- a/tests/vitest/cmdbArchiveQuickFilter.spec.js
+++ b/tests/vitest/cmdbArchiveQuickFilter.spec.js
@@ -111,6 +111,14 @@ describe('the archive quick filter', () => {
)
})
+ it('the Modules page note names the Archived tab as the exception to the additive merge', () => {
+ const page = pages().find((candidate) => candidate.id === 'Modules')
+
+ expect(page._note).toContain('additive AND')
+ expect(page._note).toMatch(/exception is the Archived tab/)
+ expect(page._note).toMatch(/ignores the GEMMA facets and the search term/)
+ })
+
it('uses an icon the app registers', () => {
const icons = fs.readFileSync(path.join(repoRoot, 'src/icons.js'), 'utf8')
expect(icons).toMatch(/^\tArchiveOutline,$/m)
From 16de6470a4c712b6dafb894d3654bda0855c1c68 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:46:55 +0200
Subject: [PATCH 055/124] fix(cmdb-import): count the rows in the
uncached-archive-key warning
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The warning said "in row 2, 3, 4, …" for any number of rows and, once the
list was cut after ten, did not say how many rows there were. It now uses a
singular form for one row and "in N rows (first ten, …)" otherwise, with the
total count; en and nl, and the test covers one, a few and a cut list.
Review pre-push p5
Co-Authored-By: Claude Opus 5.5
---
l10n/en.js | 1 +
l10n/en.json | 1 +
l10n/nl.js | 1 +
l10n/nl.json | 1 +
lib/Service/CmdbExportImportService.php | 16 ++++++++++++----
.../Unit/Service/CmdbExportImportServiceTest.php | 10 +++++++++-
6 files changed, 25 insertions(+), 5 deletions(-)
diff --git a/l10n/en.js b/l10n/en.js
index 6eba930ad..ca33bfe31 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1042,6 +1042,7 @@ OC.L10N.register(
"Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.",
"Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.",
"Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.",
+ "Sheet \"%1$s\" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.",
"Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.",
"Unarchived": "Unarchived",
"Restored": "Restored",
diff --git a/l10n/en.json b/l10n/en.json
index 274b75bdc..f94e81ea8 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1041,6 +1041,7 @@
"Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.",
"Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.",
"Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.",
+ "Sheet \"%1$s\" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.",
"Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.",
"Unarchived": "Unarchived",
"Restored": "Restored",
diff --git a/l10n/nl.js b/l10n/nl.js
index ea4be54bb..6769c4b32 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1110,6 +1110,7 @@ OC.L10N.register(
"Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd: OpenRegister ontbreekt of is te oud. Kies ervoor ze te behouden, of werk OpenRegister bij.",
"Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\", dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
"Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft een APPID zonder opgeslagen waarde in rij %2$s, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Sheet \"%1$s\" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft een APPID zonder opgeslagen waarde in %2$s rijen (%3$s), dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
"Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Tabblad \"%s\" is niet gevonden, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
"Unarchived": "Uit archief gehaald",
"Restored": "Hersteld",
diff --git a/l10n/nl.json b/l10n/nl.json
index ccc443ad8..b596fe558 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1109,6 +1109,7 @@
"Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd: OpenRegister ontbreekt of is te oud. Kies ervoor ze te behouden, of werk OpenRegister bij.",
"Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\", dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
"Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft een APPID zonder opgeslagen waarde in rij %2$s, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Sheet \"%1$s\" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft een APPID zonder opgeslagen waarde in %2$s rijen (%3$s), dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
"Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Tabblad \"%s\" is niet gevonden, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
"Unarchived": "Uit archief gehaald",
"Restored": "Hersteld",
diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php
index fae8bd431..fc3b15e34 100644
--- a/lib/Service/CmdbExportImportService.php
+++ b/lib/Service/CmdbExportImportService.php
@@ -1974,15 +1974,23 @@ private function archiveSheet(array $workbook): array {
*/
private function archiveSheetWarning(array $archive): string {
if ($archive['uncachedRows'] !== []) {
- // A long list is cut: the warning names where to look, not every row.
+ // A long list is cut: the warning counts the rows and names where to look, not every one.
+ $count = count($archive['uncachedRows']);
$rows = implode(', ', array_slice($archive['uncachedRows'], 0, self::WARNED_ROWS));
- if (count($archive['uncachedRows']) > self::WARNED_ROWS) {
+ if ($count > self::WARNED_ROWS) {
$rows .= ', …';
}
+ if ($count === 1) {
+ return $this->l10n->t(
+ 'Sheet "%1$s" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.',
+ [$archive['sheet'], $rows]
+ );
+ }
+
return $this->l10n->t(
- 'Sheet "%1$s" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.',
- [$archive['sheet'], $rows]
+ 'Sheet "%1$s" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.',
+ [$archive['sheet'], $count, $rows]
);
}
diff --git a/tests/Unit/Service/CmdbExportImportServiceTest.php b/tests/Unit/Service/CmdbExportImportServiceTest.php
index 7f9608fa8..da576de5d 100644
--- a/tests/Unit/Service/CmdbExportImportServiceTest.php
+++ b/tests/Unit/Service/CmdbExportImportServiceTest.php
@@ -1984,7 +1984,15 @@ public function testAnUncachedArchiveRowArchivesInsteadOfDeleting(): void {
$this->transitions = [];
$long = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: ['7'], uncachedRows: range(2, 13)))
->import(path: '', options: ['municipalityUuid' => 'muni-1', 'missingRecords' => 'archive']);
- $this->assertStringContainsString('in row 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, …,', $long['importWarnings'][0]['message']);
+ $this->assertSame(
+ 'Sheet "Gearchiveerde Applicaties" has an APPID without a cached value in 12 rows (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, …), so applications missing from the export are archived, not deleted.',
+ $long['importWarnings'][0]['message'],
+ 'the warning counts the rows and cuts the list'
+ );
+
+ $few = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: ['7'], uncachedRows: [3, 9]))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1', 'missingRecords' => 'archive']);
+ $this->assertStringContainsString('in 2 rows (3, 9), so', $few['importWarnings'][0]['message']);
// Keeping missing records needs no sheet and gives no warning.
$kept = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: ['7'], uncachedRows: [4]))
From f04bf8233df4ccc62cf76308f8f590ec22d0dc4c Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:47:46 +0200
Subject: [PATCH 056/124] fix(cmdb-import): name every sheet in the
missing-sheet and too-large hints
The NO_SOURCE_SHEET hint read only the first two expected sheets (or the
shipped names) and the WORKBOOK_TOO_LARGE hint always named the shipped two,
so a one- or three-sheet profile was misreported there too, unlike the help
texts. errorText() now takes the profile's sheet names, the section passes the
ones from the mapping block, and both hints list all of them; the server's
`expected` still wins for NO_SOURCE_SHEET. The three replaced strings are
removed from en and nl, with a singular and a plural form for the missing-sheet
hint. The jest cases pin a one- and a three-sheet profile, in the helper and
through the section.
Review pre-push p7
Co-Authored-By: Claude Opus 5.5
---
l10n/en.js | 7 +--
l10n/en.json | 7 +--
l10n/nl.js | 7 +--
l10n/nl.json | 7 +--
src/utils/cmdbImport.js | 43 +++++++++++--------
src/utils/cmdbImport.spec.js | 31 +++++++++++--
.../settings/sections/CmdbImport.spec.js | 19 ++++++++
src/views/settings/sections/CmdbImport.vue | 2 +-
8 files changed, 88 insertions(+), 35 deletions(-)
diff --git a/l10n/en.js b/l10n/en.js
index ad4e755b9..f4d72a00c 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -974,7 +974,6 @@ OC.L10N.register(
"Error code: {code}": "Error code: {code}",
"Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".",
"Existing municipalities could not be loaded. You can still type the name of a municipality.": "Existing municipalities could not be loaded. You can still type the name of a municipality.",
- "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.",
"Failed": "Failed",
"Import": "Import",
"Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses",
@@ -1126,7 +1125,6 @@ OC.L10N.register(
"Split the rows over two copies of the export and import them one after the other.": "Split the rows over two copies of the export and import them one after the other.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.",
- "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.",
"The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.",
"In development since": "In development since",
@@ -1175,7 +1173,10 @@ OC.L10N.register(
"Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.",
"Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.",
"The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.",
- "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored."
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "Expected the sheet {sheets}. Sheet names must match exactly.": "Expected the sheet {sheets}. Sheet names must match exactly.",
+ "Expected one of the sheets {sheets}. Sheet names must match exactly.": "Expected one of the sheets {sheets}. Sheet names must match exactly.",
+ "Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index c18324251..5ffcb140a 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -973,7 +973,6 @@
"Error code: {code}": "Error code: {code}",
"Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".",
"Existing municipalities could not be loaded. You can still type the name of a municipality.": "Existing municipalities could not be loaded. You can still type the name of a municipality.",
- "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.",
"Failed": "Failed",
"Import": "Import",
"Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses",
@@ -1125,7 +1124,6 @@
"Split the rows over two copies of the export and import them one after the other.": "Split the rows over two copies of the export and import them one after the other.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.",
- "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.",
"The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.",
"In development since": "In development since",
@@ -1174,6 +1172,9 @@
"Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.",
"Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.",
"The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.",
- "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored."
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "Expected the sheet {sheets}. Sheet names must match exactly.": "Expected the sheet {sheets}. Sheet names must match exactly.",
+ "Expected one of the sheets {sheets}. Sheet names must match exactly.": "Expected one of the sheets {sheets}. Sheet names must match exactly.",
+ "Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy."
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index dbd4d8771..460350e2c 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1042,7 +1042,6 @@ OC.L10N.register(
"Error code: {code}": "Foutcode: {code}",
"Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel-werkmap (.xlsx), hoogstens 10 MB, met het tabblad \"Onbeh Applicaties CMDB\" of \"Beheerde Applicaties CMDB\".",
"Existing municipalities could not be loaded. You can still type the name of a municipality.": "Bestaande gemeenten konden niet worden geladen. U kunt nog steeds de naam van een gemeente typen.",
- "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Verwacht werd een tabblad met de naam \"{first}\" of \"{second}\". De naam van het tabblad moet precies overeenkomen.",
"Failed": "Mislukt",
"Import": "Importeren",
"Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Importeer een TOPdesk CMDB-export (.xlsx) als de applicaties die één gemeente gebruikt",
@@ -1194,7 +1193,6 @@ OC.L10N.register(
"Split the rows over two copies of the export and import them one after the other.": "Verdeel de rijen over twee kopieën van de export en importeer die na elkaar.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "Het tabblad \"{sheet}\" is te groot om te importeren: uitgepakt is het {size}, en de import leest van één tabblad hooguit {limit}.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "Het onderdeel {part} van de werkmap is te groot om te importeren: uitgepakt is het {size}, en de import leest van één onderdeel hooguit {limit}.",
- "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen \"{first}\" en \"{second}\"), en importeer die kopie.",
"The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.",
"In development since": "In ontwikkeling sinds",
@@ -1243,7 +1241,10 @@ OC.L10N.register(
"Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
"Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met een van de tabbladen {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
"The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "Het tabblad {sheets} wordt gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
- "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen {sheets} worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd."
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen {sheets} worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "Expected the sheet {sheets}. Sheet names must match exactly.": "Verwacht werd het tabblad {sheets}. De naam van het tabblad moet precies overeenkomen.",
+ "Expected one of the sheets {sheets}. Sheet names must match exactly.": "Verwacht werd een van de tabbladen {sheets}. De namen van de tabbladen moeten precies overeenkomen.",
+ "Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen {sheets}), en importeer die kopie."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index 4f89dd5b9..fdbee7ae0 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1041,7 +1041,6 @@
"Error code: {code}": "Foutcode: {code}",
"Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel-werkmap (.xlsx), hoogstens 10 MB, met het tabblad \"Onbeh Applicaties CMDB\" of \"Beheerde Applicaties CMDB\".",
"Existing municipalities could not be loaded. You can still type the name of a municipality.": "Bestaande gemeenten konden niet worden geladen. U kunt nog steeds de naam van een gemeente typen.",
- "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Verwacht werd een tabblad met de naam \"{first}\" of \"{second}\". De naam van het tabblad moet precies overeenkomen.",
"Failed": "Mislukt",
"Import": "Importeren",
"Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Importeer een TOPdesk CMDB-export (.xlsx) als de applicaties die één gemeente gebruikt",
@@ -1193,7 +1192,6 @@
"Split the rows over two copies of the export and import them one after the other.": "Verdeel de rijen over twee kopieën van de export en importeer die na elkaar.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "Het tabblad \"{sheet}\" is te groot om te importeren: uitgepakt is het {size}, en de import leest van één tabblad hooguit {limit}.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "Het onderdeel {part} van de werkmap is te groot om te importeren: uitgepakt is het {size}, en de import leest van één onderdeel hooguit {limit}.",
- "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen \"{first}\" en \"{second}\"), en importeer die kopie.",
"The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.",
"In development since": "In ontwikkeling sinds",
@@ -1242,6 +1240,9 @@
"Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
"Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met een van de tabbladen {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
"The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "Het tabblad {sheets} wordt gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
- "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen {sheets} worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd."
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen {sheets} worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "Expected the sheet {sheets}. Sheet names must match exactly.": "Verwacht werd het tabblad {sheets}. De naam van het tabblad moet precies overeenkomen.",
+ "Expected one of the sheets {sheets}. Sheet names must match exactly.": "Verwacht werd een van de tabbladen {sheets}. De namen van de tabbladen moeten precies overeenkomen.",
+ "Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen {sheets}), en importeer die kopie."
}
}
diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js
index 883d15e56..379eae833 100644
--- a/src/utils/cmdbImport.js
+++ b/src/utils/cmdbImport.js
@@ -490,10 +490,11 @@ export function isKnownError(code) {
* to which sheet or part, and what to do about it.
*
* @param {object} details The error details: `maxPartBytes`, `part`, `size` and, for a sheet, `sheet`; `maxSharedStrings`; `maxReferencedStringBytes`; or `maxUncompressedBytes`
+ * @param {Array} sheetNames The sheets the import reads, for the hint
* @return {{title: string, hint: string}} The words
* @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-the-workbook-shall-be-read-as-stored-data-without-evaluating-formulas-or-following-links-req-cmdb-002
*/
-function workbookTooLarge(details) {
+function workbookTooLarge(details, sheetNames) {
const compressed = t(
'stackiq',
'An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.',
@@ -532,11 +533,8 @@ function workbookTooLarge(details) {
+ ' '
+ t(
'stackiq',
- 'Delete the sheets the import does not read from a copy of the export (it reads only "{first}" and "{second}"), and import that copy.',
- {
- first: PROFILE_DEFAULTS.sheets[0],
- second: PROFILE_DEFAULTS.sheets[1],
- },
+ 'Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy.',
+ { sheets: quotedSheetList(sheetNames) },
AS_TEXT,
)
if (Number(details.maxSharedStrings) > 0) {
@@ -584,10 +582,11 @@ function workbookTooLarge(details) {
* when the server's message is not.
*
* @param {{error: string, message?: string, details?: object}} error The error in the server's shape
+ * @param {Array} [sheetNames] The sheets the import reads, from mappingSheetNames(); the shipped names when left out. Hints name all of them, whatever their number
* @return {{title: string, hint: string}} The words
* @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-columns-shall-be-resolved-by-header-name-and-a-missing-required-column-shall-stop-the-import-with-422-req-cmdb-003
*/
-export function errorText(error) {
+export function errorText(error, sheetNames = PROFILE_DEFAULTS.sheets) {
const details = error?.details || {}
switch (error?.error) {
case 'NO_FILE_UPLOADED':
@@ -682,22 +681,28 @@ export function errorText(error) {
case 'NO_SOURCE_SHEET': {
const expected =
Array.isArray(details.expected) && details.expected.length > 0
- ? details.expected
- : PROFILE_DEFAULTS.sheets
+ ? details.expected.map((name) => String(name))
+ : sheetNames
+ const values = { sheets: quotedSheetList(expected) }
return {
title: t(
'stackiq',
'The workbook has none of the sheets the import reads.',
),
- hint: t(
- 'stackiq',
- 'Expected a sheet named "{first}" or "{second}". Sheet names must match exactly.',
- {
- first: String(expected[0] ?? PROFILE_DEFAULTS.sheets[0]),
- second: String(expected[1] ?? PROFILE_DEFAULTS.sheets[1]),
- },
- AS_TEXT,
- ),
+ hint:
+ expected.length === 1
+ ? t(
+ 'stackiq',
+ 'Expected the sheet {sheets}. Sheet names must match exactly.',
+ values,
+ AS_TEXT,
+ )
+ : t(
+ 'stackiq',
+ 'Expected one of the sheets {sheets}. Sheet names must match exactly.',
+ values,
+ AS_TEXT,
+ ),
}
}
case 'MISSING_COLUMN':
@@ -767,7 +772,7 @@ export function errorText(error) {
),
}
case 'WORKBOOK_TOO_LARGE':
- return workbookTooLarge(details)
+ return workbookTooLarge(details, sheetNames)
case 'SCHEMA_OUTDATED':
return {
title: details.schema
diff --git a/src/utils/cmdbImport.spec.js b/src/utils/cmdbImport.spec.js
index 8f566d6cb..2e4ac7942 100644
--- a/src/utils/cmdbImport.spec.js
+++ b/src/utils/cmdbImport.spec.js
@@ -363,12 +363,37 @@ describe('errorText', () => {
error: 'NO_SOURCE_SHEET',
details: { expected: ['Blad A', 'Blad B'] },
}).hint,
- ).toContain('"Blad A" or "Blad B"')
+ ).toContain('one of the sheets "Blad A", "Blad B"')
expect(errorText({ error: 'NO_SOURCE_SHEET', details: {} }).hint).toContain(
- PROFILE_DEFAULTS.sheets[1],
+ '"' + PROFILE_DEFAULTS.sheets.join('", "') + '"',
)
})
+ it.each([
+ [1, ['Blad A'], 'Expected the sheet "Blad A".'],
+ [3, ['Blad A', 'Blad B', 'Blad C'], 'one of the sheets "Blad A", "Blad B", "Blad C".'],
+ ])(
+ 'names all %i sheets of the profile in the missing-sheet and too-large hints',
+ (count, names, expected) => {
+ const quoted = names.map((name) => '"' + name + '"').join(', ')
+
+ const missing = errorText({ error: 'NO_SOURCE_SHEET', details: {} }, names)
+ expect(missing.hint).toContain(expected)
+ for (const shipped of PROFILE_DEFAULTS.sheets) {
+ expect(missing.hint).not.toContain(shipped)
+ }
+
+ const tooLarge = errorText(
+ { error: 'WORKBOOK_TOO_LARGE', details: { maxSharedStrings: 200000 } },
+ names,
+ )
+ expect(tooLarge.hint).toContain('(it reads only ' + quoted + ')')
+ for (const shipped of PROFILE_DEFAULTS.sheets) {
+ expect(tooLarge.hint).not.toContain(shipped)
+ }
+ },
+ )
+
it('names the unpacked size limit the server applied', () => {
expect(
errorText({
@@ -408,7 +433,7 @@ describe('errorText', () => {
expect(
errorText({ error: 'WORKBOOK_TOO_LARGE', details }).hint,
).toContain(
- '(it reads only "Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB")',
+ '(it reads only "Onbeh Applicaties CMDB", "Beheerde Applicaties CMDB")',
)
}
})
diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js
index b20691f00..dbe41aa47 100644
--- a/src/views/settings/sections/CmdbImport.spec.js
+++ b/src/views/settings/sections/CmdbImport.spec.js
@@ -253,4 +253,23 @@ describe('CmdbImport sheet names in the help texts', () => {
expect(help).not.toContain(shipped)
}
})
+
+ it('names the profile\'s sheets in the section\'s missing-sheet and too-large errors', async () => {
+ const wrapper = await mountWithSlots()
+ wrapper
+ .findComponent(CmdbImportMapping)
+ .vm.$emit('loaded', mappingOf(['Alpha', 'Beta', 'Gamma']))
+ await flushPromises()
+
+ await wrapper.setData({ error: { error: 'NO_SOURCE_SHEET', details: {} } })
+ expect(wrapper.vm.errorView.hint).toContain('"Alpha", "Beta", "Gamma"')
+
+ await wrapper.setData({
+ error: {
+ error: 'WORKBOOK_TOO_LARGE',
+ details: { maxUncompressedBytes: 50 * 1024 * 1024 },
+ },
+ })
+ expect(wrapper.vm.errorView.hint).toContain('(it reads only "Alpha", "Beta", "Gamma")')
+ })
})
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index 8fb27fdc5..d2dc4b0a4 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -582,7 +582,7 @@ export default {
if (!this.error) {
return null
}
- const words = errorText(this.error)
+ const words = errorText(this.error, this.sheetNames)
return {
...words,
code: this.error.error,
From d805c5bcaa5a50fa800f2c11e1ae55bf4b20209e Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:55:01 +0200
Subject: [PATCH 057/124] fix(cmdb-import): bring every usage of a leaving key
to the same state
onePerKey() reconciled only the best entry per import key and left the
usages of the dropped entries untouched. A module trashed by hand while its
usage stayed working, next to a working pair with the same key, kept that
stray usage visible after the key left the export; so did a second usage of
one module, and a working duplicate module.
The dropped entries now travel as extras of the application: their usages,
and their modules when those are not in the trash, are archived or
soft-deleted with it, once, in the same error boundary and report row.
Objects in the trash are still never restored for an extra, so a stale
duplicate does not come back into the archive. REQ-CMDB-015, design D5 and
task 4 say so.
Review pre-push p4
Co-Authored-By: Claude Opus 5.5
---
lib/Service/CmdbExportImportService.php | 91 +++++++++++++++++--
.../design.md | 2 +-
.../specs/cmdb-export-import/spec.md | 5 +-
.../tasks.md | 2 +-
.../Service/CmdbExportImportServiceTest.php | 60 +++++++++++-
5 files changed, 145 insertions(+), 15 deletions(-)
diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php
index fc3b15e34..54c9e4b86 100644
--- a/lib/Service/CmdbExportImportService.php
+++ b/lib/Service/CmdbExportImportService.php
@@ -87,7 +87,9 @@
*
* @phpstan-type ArchiveSheet array{sheet: string, present: bool, keyColumnMissing: bool, uncachedRows: array, keys: array}
*
- * @phpstan-type ScopeEntry array{key: string, usage: object, module: object, states: array}
+ * @phpstan-type ScopeExtra array{schemaKey: string, uuid: string, state: array{archived: bool, deleted: bool}}
+ * @phpstan-type ScopeStates array
+ * @phpstan-type ScopeEntry array{key: string, usage: object, module: object, states: ScopeStates, extras?: array}
*
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity) One import run resolves four linked
* object kinds per row (supplier, module, owners, usage), each with its own match rule,
@@ -2303,14 +2305,18 @@ private function reconciliationScope(string $municipalityUuid): array {
}//end reconciliationScope()
/**
- * Keep one application per import key: the one the import itself would match.
+ * One application per import key: the entry the import would match, with the other usages of the key as extras.
*
* The import takes a working module before an archived one, and an archived
* one before one in the trash (design D4), so a module deleted by hand next
* to the one a later import created is never revived. The reconciliation
- * follows the same order: the entry with the best module state wins, the
- * best usage state breaks a tie, and the first one listed breaks the rest.
- * A stale duplicate is left as it is, never restored into the archive.
+ * follows the same order: the entry with the best module state is the
+ * application, the best usage state breaks a tie, and the first one listed
+ * breaks the rest. The other entries of the key are not dropped: their
+ * usages, and their modules when those are not in the trash, are carried
+ * as `extras` and brought to the same state, so a stray usage of a stale
+ * duplicate does not stay visible. Nothing in the trash is restored for an
+ * extra, and nothing the application itself holds is listed twice.
*
* @param array $scope The entries.
*
@@ -2321,18 +2327,54 @@ private function reconciliationScope(string $municipalityUuid): array {
private static function onePerKey(array $scope): array {
$preferred = [];
$ranks = [];
+ $entries = [];
foreach ($scope as $application) {
$rank = ((self::stateRank(state: $application['states']['module']) * 3) + self::stateRank(state: $application['states']['usage']));
$key = $application['key'];
+ $entries[$key][] = $application;
if (isset($ranks[$key]) === false || $rank < $ranks[$key]) {
$ranks[$key] = $rank;
$preferred[$key] = $application;
}
}
+ foreach ($preferred as $key => $application) {
+ $preferred[$key]['extras'] = self::extrasOf(application: $application, entries: $entries[$key]);
+ }
+
return array_values($preferred);
}//end onePerKey()
+ /**
+ * The usages and modules of the other entries of a key that the reconciliation brings to the same state.
+ *
+ * A usage or module appears once, and not when the application holds it
+ * itself. One in the trash is left out: it is not visible, and restoring
+ * a stale duplicate is what the preference rule exists to prevent.
+ *
+ * @param ScopeEntry $application The preferred entry of the key.
+ * @param array $entries Every entry of the key.
+ *
+ * @return array Usages first, then modules.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private static function extrasOf(array $application, array $entries): array {
+ $seen = [(string)$application['usage']->getUuid() => true, (string)$application['module']->getUuid() => true];
+ $found = ['usage' => [], 'module' => []];
+ foreach ($entries as $entry) {
+ foreach (['usage', 'module'] as $schemaKey) {
+ $uuid = (string)$entry[$schemaKey]->getUuid();
+ if (isset($seen[$uuid]) === false && $entry['states'][$schemaKey]['deleted'] === false) {
+ $seen[$uuid] = true;
+ $found[$schemaKey][] = ['schemaKey' => $schemaKey, 'uuid' => $uuid, 'state' => $entry['states'][$schemaKey]];
+ }
+ }
+ }
+
+ return array_merge($found['usage'], $found['module']);
+ }//end extrasOf()
+
/**
* How far down the preference order a state is: working 0, archived 1, deleted 2.
*
@@ -2479,7 +2521,7 @@ private function modulesInChunk(array $chunk, array $coordinates): array {
* leaves the municipality's view right and the module at worst visible.
* An application already in the state the export asks for is not reported.
*
- * @param array{key: string, usage: object, module: object, states: array} $application Application.
+ * @param ScopeEntry $application The application.
* @param ArchiveSheet $archive The archive sheet.
* @param string $operationId The operation id, for the archive reason.
* @param CmdbImportReport $report The report.
@@ -2512,9 +2554,9 @@ private function reconcileApplication(array $application, array $archive, string
$step = 'archive';
$changed = false;
try {
- foreach (['usage', 'module'] as $schemaKey) {
- foreach (self::transitions(state: $application['states'][$schemaKey], target: $target, restoreDeleted: $onArchiveSheet) as $step) {
- $this->transition(step: $step, schemaKey: $schemaKey, uuid: $uuids[$schemaKey], reason: $reason);
+ foreach (self::itemsOf(application: $application, restoreDeleted: $onArchiveSheet) as $item) {
+ foreach (self::transitions(state: $item['state'], target: $target, restoreDeleted: $item['restore']) as $step) {
+ $this->transition(step: $step, schemaKey: $item['schemaKey'], uuid: $item['uuid'], reason: $reason);
$changed = true;
}
}
@@ -2530,6 +2572,37 @@ private function reconcileApplication(array $application, array $archive, string
}
}//end reconcileApplication()
+ /**
+ * What one application brings to the state the export asks for: its usage and module, then the other usages and modules of its key.
+ *
+ * Only the application's own objects are restored from the trash, and only
+ * when `restoreDeleted` says so; the extras never are.
+ *
+ * @param ScopeEntry $application The application.
+ * @param bool $restoreDeleted Whether the application's own objects in the trash are restored to be archived.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private static function itemsOf(array $application, bool $restoreDeleted): array {
+ $items = [];
+ foreach (['usage', 'module'] as $schemaKey) {
+ $items[] = [
+ 'schemaKey' => $schemaKey,
+ 'uuid' => (string)$application[$schemaKey]->getUuid(),
+ 'state' => $application['states'][$schemaKey],
+ 'restore' => $restoreDeleted,
+ ];
+ }
+
+ foreach (($application['extras'] ?? []) as $extra) {
+ $items[] = $extra + ['restore' => false];
+ }
+
+ return $items;
+ }//end itemsOf()
+
/**
* The steps that bring one module or usage to the state the export asks for (design D5).
*
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/design.md b/openspec/changes/cmdb-import-archive-reconciliation/design.md
index 79c9444dc..770c074e6 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/design.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/design.md
@@ -97,7 +97,7 @@ Per application, with `processed` = every APPID match key on the CMDB sheets (wh
| nowhere, sheet absent or incomplete (no key column, or `uncachedRows` not empty) | working | archive usage, then module | `archived` |
| nowhere, sheet absent or incomplete | archived or deleted | nothing | — |
-The table decides on one entry per key. When a municipality has several usages and modules under the same import key (D4 says earlier imports created exactly such duplicates, a module deleted by hand next to the one a later import created), the scope keeps the entry the import would match: the best module state wins (working, then archived, then deleted), the best usage state breaks a tie, the first listed breaks the rest. The other entries are left as they are: a stale trashed duplicate is not restored into the archive when the key is on the archive sheet, and is not touched when it is on no sheet.
+The table decides on one entry per key. When a municipality has several usages and modules under the same import key (D4 says earlier imports created exactly such duplicates, a module deleted by hand next to the one a later import created), the scope keeps the entry the import would match: the best module state wins (working, then archived, then deleted), the best usage state breaks a tie, the first listed breaks the rest. The other usages of the key follow the application, so a stray usage stays visible nowhere: a working or archived usage of a stale duplicate, a second usage of the same module, and a working or archived duplicate module are archived or soft-deleted as the key asks, once, in the same error boundary and the same report row (usages first, then modules). What is in the trash is left as it is: a stale trashed duplicate is not restored into the archive when the key is on the archive sheet, and is not touched when it is on no sheet.
The usage is written before the module, so a failure between the two leaves the municipality's usage in the new state and the module visible, never the reverse. A module another municipality still uses is archived or deleted all the same when this municipality's usage says so: the module carries this municipality's import key, so it is this municipality's record (REQ-CMDB-006 allows a shared module only when the other organisation's usage was added by hand). The archive reason is `cmdb-import: on sheet "Gearchiveerde Applicaties"` or `cmdb-import: not in the export`, plus the operation id, so the audit trail says why.
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
index 48f43be64..d03c04ee7 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
@@ -16,7 +16,7 @@ OpenRegister: `OCA\OpenRegister\Contract\ObjectServiceInterface` (`searchObjects
### Requirement: An application missing from the CMDB sheets SHALL be archived when the archive sheet lists it and soft-deleted when no sheet does (REQ-CMDB-015)
-With `missingRecords` `archive`, after every row of an import that was not cancelled and did not fail, the import SHALL reconcile, under the register lock it already holds, every `usage` whose `consumer` is the imported municipality together with the `module` it points at, including archived and soft-deleted ones, and only those whose module carries this municipality's import key (`topdesk::`). It SHALL read the APPID column of the sheet "Gearchiveerde Applicaties" when the workbook has it, under the same size and row bounds as the CMDB sheets and without reading any other column of it. For an application whose APPID is on neither CMDB sheet: when the archive sheet lists the APPID, the import SHALL archive the usage and then the module through OpenRegister's archive state with a reason that names the import, restoring a soft-deleted one from the trash first; when no sheet lists it, the import SHALL soft-delete the usage and then the module through OpenRegister's trash. When the workbook has no archive sheet, or the sheet has no APPID column, or a row of the sheet holds an APPID formula without a cached value (read as empty, as REQ-CMDB-002 has it for the CMDB sheets, and listed by the reader in `archive.uncachedRows`), the import SHALL archive and SHALL NOT soft-delete, and SHALL add a warning to the report; for an uncached row the warning SHALL name the sheet and the row numbers. When a municipality has more than one module or usage under the same import key (a module deleted by hand next to the one a later import created), the import SHALL reconcile only the one it would match, a working one before an archived one and an archived one before one in the trash, and SHALL leave the others as they are, so a stale duplicate is never restored into the archive. An application that is already in the state the export asks for SHALL be left alone and not reported. The import SHALL NOT touch contact persons, organisations, modules that carry another municipality's import key or no import key, or usages of another organisation. Each application SHALL be reconciled in its own error boundary: a failure SHALL be a `failed` report row naming the step and the exception class, and the other applications SHALL still be reconciled. The reconciliation SHALL report its progress as the phase `reconciling` of the import's operation. A cancel SHALL also be honoured after the last row and before each application of the reconciliation: the import SHALL stop there, SHALL keep what it archived or deleted so far, and SHALL report the run as cancelled. The report SHALL count `archived` and `deleted` in its summary and SHALL list each as a row with the APPID, the application name and both uuids, never person data. When OpenRegister's archive handler or object mapper is not available, an import with `missingRecords` `archive` SHALL be refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read.
+With `missingRecords` `archive`, after every row of an import that was not cancelled and did not fail, the import SHALL reconcile, under the register lock it already holds, every `usage` whose `consumer` is the imported municipality together with the `module` it points at, including archived and soft-deleted ones, and only those whose module carries this municipality's import key (`topdesk::`). It SHALL read the APPID column of the sheet "Gearchiveerde Applicaties" when the workbook has it, under the same size and row bounds as the CMDB sheets and without reading any other column of it. For an application whose APPID is on neither CMDB sheet: when the archive sheet lists the APPID, the import SHALL archive the usage and then the module through OpenRegister's archive state with a reason that names the import, restoring a soft-deleted one from the trash first; when no sheet lists it, the import SHALL soft-delete the usage and then the module through OpenRegister's trash. When the workbook has no archive sheet, or the sheet has no APPID column, or a row of the sheet holds an APPID formula without a cached value (read as empty, as REQ-CMDB-002 has it for the CMDB sheets, and listed by the reader in `archive.uncachedRows`), the import SHALL archive and SHALL NOT soft-delete, and SHALL add a warning to the report; for an uncached row the warning SHALL name the sheet and the row numbers. When a municipality has more than one module or usage under the same import key (a module deleted by hand next to the one a later import created), the import SHALL reconcile as the application the one it would match, a working one before an archived one and an archived one before one in the trash, and SHALL bring every other usage of that key, and every other module of it that is not in the trash, to the same state, so no stray usage stays visible; it SHALL leave what is in the trash as it is, so a stale duplicate is never restored into the archive. An application that is already in the state the export asks for SHALL be left alone and not reported. The import SHALL NOT touch contact persons, organisations, modules that carry another municipality's import key or no import key, or usages of another organisation. Each application SHALL be reconciled in its own error boundary: a failure SHALL be a `failed` report row naming the step and the exception class, and the other applications SHALL still be reconciled. The reconciliation SHALL report its progress as the phase `reconciling` of the import's operation. A cancel SHALL also be honoured after the last row and before each application of the reconciliation: the import SHALL stop there, SHALL keep what it archived or deleted so far, and SHALL report the run as cancelled. The report SHALL count `archived` and `deleted` in its summary and SHALL list each as a row with the APPID, the application name and both uuids, never person data. When OpenRegister's archive handler or object mapper is not available, an import with `missingRecords` `archive` SHALL be refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read.
#### Scenario: An application that moved to the archive sheet is archived
@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts
@@ -37,12 +37,13 @@ With `missingRecords` `archive`, after every row of an import that was not cance
- **AND** the report SHALL carry a warning that the sheet "Gearchiveerde Applicaties" was not found
#### Scenario: A stale duplicate under the same import key is left in the trash
-@e2e exclude Data condition from earlier imports; tests/Unit/Service/CmdbExportImportServiceTest.php testAStaleDuplicateIsNeverRestoredByTheReconciliation stores a working and a trashed module and usage for APPID `7`, lists `7` on the archive sheet, and asserts the working pair is archived, no `restore` happens and the trashed pair stays deleted.
+@e2e exclude Data condition from earlier imports; tests/Unit/Service/CmdbExportImportServiceTest.php testAStaleDuplicateIsNeverRestoredByTheReconciliation stores a working and a trashed module and usage for APPID `7`, lists `7` on the archive sheet, and asserts the working pair is archived, no `restore` happens and the trashed pair stays deleted; testEveryOtherUsageOfAKeyFollowsTheApplication asserts the stray and second usages follow.
- **GIVEN** a working module with APPID `7` and its usage, and a module with the same import key in the trash with its own usage
- **WHEN** an export without `7` on the CMDB sheets, with `7` on "Gearchiveerde Applicaties", is imported with `archive`
- **THEN** the working module and usage SHALL be archived
- **AND** the module and the usage in the trash SHALL stay there, and nothing SHALL be restored
+- **AND** a working usage that points at the module in the trash, or a second usage of the working module, SHALL be archived as well
#### Scenario: An archive row whose APPID has no cached value never turns an archive into a deletion
@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testAnUncachedArchiveRowArchivesInsteadOfDeleting asserts the archive state, no delete, and the warning naming the sheet and the row; tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php testAnUncachedArchiveKeyIsListedNotDropped asserts the reader lists the row.
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
index 59914b112..ca4c335d5 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
@@ -45,7 +45,7 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp
- GIVEN APPIDs 1 and 7 imported WHEN an export with row 1 and 7 on the archive sheet is imported THEN module and usage of 7 are archived with a reason, the report row is `archived`, and the owner contact person is untouched
- GIVEN APPID 7 on no sheet THEN module and usage are soft-deleted and the row is `deleted`; GIVEN the archive sheet absent, without an APPID column, or with an APPID that has no cached value THEN they are archived instead and the report warns
- GIVEN APPID 7 soft-deleted and listed on the archive sheet THEN module and usage are restored and archived; GIVEN it already archived or, when on no sheet, already deleted THEN nothing is written
- - GIVEN a working pair and a trashed pair under the same import key and the key on the archive sheet THEN the working pair is archived, nothing is restored and the trashed pair stays deleted
+ - GIVEN a working pair and a trashed pair under the same import key and the key on the archive sheet THEN the working pair is archived, nothing is restored and the trashed pair stays deleted; GIVEN a further working usage of the key (of the trashed module, or a second one of the working module) THEN it is archived or deleted with the application
- GIVEN `missingRecords` `keep`, a cancelled import, or a usage of another municipality THEN nothing is archived or deleted
- GIVEN a cancel during the last row THEN nothing is archived or deleted; GIVEN a cancel during the reconciliation THEN it stops before the next application and the report is cancelled
- GIVEN the archive of one application throws THEN that application is a `failed` row and the others are reconciled; GIVEN no ArchiveHandler THEN 503 `ARCHIVE_UNAVAILABLE` before reading
diff --git a/tests/Unit/Service/CmdbExportImportServiceTest.php b/tests/Unit/Service/CmdbExportImportServiceTest.php
index da576de5d..6fb99267f 100644
--- a/tests/Unit/Service/CmdbExportImportServiceTest.php
+++ b/tests/Unit/Service/CmdbExportImportServiceTest.php
@@ -2153,7 +2153,8 @@ public function testAStaleDuplicateIsNeverRestoredByTheReconciliation(): void {
* Of two usages under one import key, the one of the module the import would match is reconciled, whatever
* state the usage is in: the module's state ranks first, the usage's second.
*
- * The working usage is listed before the one in the trash but belongs to the duplicate module in the trash.
+ * The working usage is listed before the one in the trash but belongs to the duplicate module in the trash;
+ * it is archived as well (a stray usage stays visible otherwise), and the module in the trash is not restored.
*
* @return void
*
@@ -2174,7 +2175,7 @@ public function testTheModulesStateDecidesWhichDuplicateIsReconciled(): void {
$this->assertSame([true, false], $this->stateOf(schema: self::MODULE, uuid: $seven), 'the working module is archived');
$this->assertSame([true, false], $this->stateOf(schema: self::USAGE, uuid: $usage), 'its usage is restored from the trash and archived');
$this->assertSame([false, true], $this->stateOf(schema: self::MODULE, uuid: 'module-stale'));
- $this->assertSame([false, false], $this->stateOf(schema: self::USAGE, uuid: 'usage-stale'), 'the working usage of the stale module is not touched');
+ $this->assertSame([true, false], $this->stateOf(schema: self::USAGE, uuid: 'usage-stale'), 'the working usage of the stale module is archived with the key');
}//end testTheModulesStateDecidesWhichDuplicateIsReconciled()
/**
@@ -2367,6 +2368,61 @@ public function testKeepWithoutTheArchiveServicesFailsARowWhoseMatchIsArchived()
$this->assertSame(0, $report['summary']['archived'] + $report['summary']['deleted']);
}//end testKeepWithoutTheArchiveServicesFailsARowWhoseMatchIsArchived()
+ /**
+ * Every other usage of a key leaving the export follows the application: a working usage of a module deleted
+ * by hand, and a second usage of the same module. A stale module in the trash is neither restored nor touched.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function testEveryOtherUsageOfAKeyFollowsTheApplication(): void {
+ $this->importOneSevenAndEight();
+ [$seven, $eight] = [$this->moduleOf(appId: '7'), $this->moduleOf(appId: '8')];
+ $sevenUsage = $this->usageOf(moduleUuid: $seven);
+ // APPID 7: a module deleted by hand whose usage stayed working, and a second usage of the working module.
+ $this->store[self::MODULE]['module-stale'] = array_merge($this->store[self::MODULE][$seven], ['id' => 'module-stale']);
+ $this->states[self::MODULE]['module-stale'] = ['deleted' => ['deletedBy' => 'admin']];
+ $this->store[self::USAGE]['usage-stray'] = array_merge($this->store[self::USAGE][$sevenUsage], ['id' => 'usage-stray', 'module' => 'module-stale']);
+ $this->store[self::USAGE]['usage-second'] = array_merge($this->store[self::USAGE][$sevenUsage], ['id' => 'usage-second']);
+ // APPID 8 also has a stale pair in the trash: nothing to follow.
+ $this->store[self::MODULE]['module-stale-8'] = array_merge($this->store[self::MODULE][$eight], ['id' => 'module-stale-8']);
+ $this->store[self::USAGE]['usage-stale-8'] = array_merge($this->store[self::USAGE][$this->usageOf(moduleUuid: $eight)], ['id' => 'usage-stale-8', 'module' => 'module-stale-8']);
+ $this->states[self::MODULE]['module-stale-8'] = ['deleted' => ['deletedBy' => 'admin']];
+ $this->states[self::USAGE]['usage-stale-8'] = ['deleted' => ['deletedBy' => 'admin']];
+
+ // APPID 8 also has a second working module with its own usage, which a first-hit match never reaches.
+ $this->store[self::MODULE]['module-dup-8'] = array_merge($this->store[self::MODULE][$eight], ['id' => 'module-dup-8']);
+ $this->store[self::USAGE]['usage-dup-8'] = array_merge($this->store[self::USAGE][$this->usageOf(moduleUuid: $eight)], ['id' => 'usage-dup-8', 'module' => 'module-dup-8']);
+
+ // 7 is on the archive sheet, 8 on no sheet.
+ $report = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: ['7']))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1']);
+ $this->assertSame([[false, true], [false, true]], [$this->stateOf(schema: self::MODULE, uuid: 'module-dup-8'), $this->stateOf(schema: self::USAGE, uuid: 'usage-dup-8')], 'a working duplicate of 8 is deleted with it');
+
+ foreach ([$sevenUsage, 'usage-stray', 'usage-second'] as $usage) {
+ $this->assertSame([true, false], $this->stateOf(schema: self::USAGE, uuid: $usage), $usage . ' is archived');
+ }
+
+ $this->assertSame([true, false], $this->stateOf(schema: self::MODULE, uuid: $seven));
+ $this->assertSame([false, true], $this->stateOf(schema: self::MODULE, uuid: 'module-stale'), 'the stale module stays in the trash');
+ $this->assertSame([false, true], $this->stateOf(schema: self::USAGE, uuid: 'usage-stale-8'), 'a usage in the trash is never restored');
+ $this->assertNotContains('restore', array_column($this->transitions, 0));
+ $this->assertSame([1, 1], [$report['summary']['archived'], $report['summary']['deleted']], 'one report row per application');
+
+ // The same key on no sheet: every usage is soft-deleted, once.
+ $this->transitions = [];
+ $this->states = array_replace_recursive($this->states, [self::USAGE => [$sevenUsage => ['archived' => null], 'usage-stray' => ['archived' => null], 'usage-second' => ['archived' => null]], self::MODULE => [$seven => ['archived' => null]]]);
+ $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: []))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1']);
+ foreach ([$sevenUsage, 'usage-stray', 'usage-second'] as $usage) {
+ $this->assertSame([false, true], $this->stateOf(schema: self::USAGE, uuid: $usage), $usage . ' is soft-deleted');
+ }
+
+ $deletes = array_column(array_filter($this->transitions, static fn (array $t): bool => $t[0] === 'delete'), 2);
+ $this->assertSame(count($deletes), count(array_unique($deletes)), 'nothing is deleted twice');
+ }//end testEveryOtherUsageOfAKeyFollowsTheApplication()
+
/**
* One application that cannot be archived is a failed row; the others are still reconciled.
*
From 6ecb274a9fda05940578fad316e20d7a99b53774 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 18:05:19 +0200
Subject: [PATCH 058/124] fix(cmdb-import): keep a mapped field with a create
default in createOnlyFields
The overview subtracted every key of createOnlyDefaults from
createOnlyFields. merge() guards a mapped create-only field on update, so a
mapped field that also had a create default would have shown as written on
every update. Only defaults that are not mapped fields leave the list now
(the shipped module type); REQ-CMDB-020, design and the overlap assertions
say the same.
Mutation-checked: testAMappedCreateOnlyDefaultStaysACreateOnlyField fails
on the previous subtraction and passes now.
Review pre-push q1
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Cmdb/CmdbImportProfile.php | 17 +++----
.../cmdb-import-mapping-view/design.md | 2 +-
.../specs/cmdb-export-import/spec.md | 2 +-
...ettingsControllerCmdbImportMappingTest.php | 4 +-
.../Service/Cmdb/CmdbImportProfileTest.php | 48 ++++++++++++++++++-
5 files changed, 58 insertions(+), 15 deletions(-)
diff --git a/lib/Service/Cmdb/CmdbImportProfile.php b/lib/Service/Cmdb/CmdbImportProfile.php
index 73dc75e5c..fb455ac7a 100644
--- a/lib/Service/Cmdb/CmdbImportProfile.php
+++ b/lib/Service/Cmdb/CmdbImportProfile.php
@@ -636,7 +636,7 @@ public function referencedColumns(): array {
* `defaults` (pack values set on every write), `createOnlyDefaults`
* (values set on create only, not mapped columns), `createOnlyFields` (mapped
* fields written on create or, on update, only when the stored value is
- * empty; never a key of `createOnlyDefaults`) and `neverWritten` (fields the
+ * empty; a key of `createOnlyDefaults` only when it is also mapped) and `neverWritten` (fields the
* import never writes on an existing object).
*
* @return array{profile: array, packs: array>}
@@ -671,16 +671,13 @@ public function mappingOverview(): array {
];
}
- // A create-only default (the module type) is a value the import sets on
- // create, not a mapped field: merge() never reads it on update, so it is
- // listed under createOnlyDefaults only.
+ // A create-only default that is not a mapped field (the module type) is a
+ // value the import sets on create; merge() never reads it on update, so it
+ // is listed under createOnlyDefaults only. A mapped field keeps its place
+ // in createOnlyFields, since merge() guards it on update.
$createOnlyDefaults = $this->createOnlyDefaults(target: $target);
- $createOnlyFields = array_values(
- array_diff(
- $this->createOnlyFields(target: $target),
- array_map('strval', array_keys($createOnlyDefaults))
- )
- );
+ $unmappedDefaults = array_diff(array_map('strval', array_keys($createOnlyDefaults)), array_column($mappings, 'target'));
+ $createOnlyFields = array_values(array_diff($this->createOnlyFields(target: $target), $unmappedDefaults));
$defaults = $pack['defaults'] ?? [];
if (is_array($defaults) === false) {
diff --git a/openspec/changes/cmdb-import-mapping-view/design.md b/openspec/changes/cmdb-import-mapping-view/design.md
index 0bd69a911..e0c8f5871 100644
--- a/openspec/changes/cmdb-import-mapping-view/design.md
+++ b/openspec/changes/cmdb-import-mapping-view/design.md
@@ -46,7 +46,7 @@ The method carries no auth attribute (Nextcloud's default: admins with a CSRF to
`fieldMappings[].transform` is passed through as the pack stores it (`type` plus `map`/`default`, `fields`/`separator`, `value`, `sourceFormat`/`targetFormat`). Re-shaping per type would hide a key the engine reads. The page renders the known keys and lists any other scalar key as it is. `required` is normalised to a boolean.
-The write rules the import applies besides the mapping are answered per pack, built from the accessors the import uses (`createOnlyDefaults()`, `createOnlyFields()`, `neverWrittenOnUpdate()`) and the pack's own `defaults`, so the view does not overclaim for a field the import writes only on create or never. A create-only default such as the module `type` is answered in `createOnlyDefaults` only: it is not a mapped field and `merge()` never reads it on update, so `createOnlyFields` leaves it out.
+The write rules the import applies besides the mapping are answered per pack, built from the accessors the import uses (`createOnlyDefaults()`, `createOnlyFields()`, `neverWrittenOnUpdate()`) and the pack's own `defaults`, so the view does not overclaim for a field the import writes only on create or never. A create-only default that is not a mapped field, such as the module `type`, is answered in `createOnlyDefaults` only: `merge()` never reads it on update, so `createOnlyFields` leaves it out. A mapped field with a create default stays in `createOnlyFields`, since `merge()` guards it on update.
### D4. The section takes the sheet names from the endpoint, with the constant as fallback
diff --git a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
index 9675bb693..e1e7f2430 100644
--- a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
@@ -16,7 +16,7 @@ Nextcloud OCP interfaces used: `OCP\IL10N` (messages). OpenRegister: `Service\Mi
### Requirement: The admin settings SHALL show the mapping the import uses (REQ-CMDB-020)
-`GET /api/settings/cmdb-import/mapping` SHALL be reachable only by Nextcloud admins and SHALL require Nextcloud's CSRF token; like the import routes (REQ-CMDB-001) it SHALL NOT carry `#[AuthorizedAdminSetting]`, `#[NoAdminRequired]` or `#[NoCSRFRequired]`. It SHALL load the import profile and every pack it names through the same loader and validator the import uses, and SHALL answer 200 with a flat envelope of two keys: `profile` (the profile's `id`, `name`, `version`, `sheets` with each sheet's `name`, `constants` and `absentColumns`, `sheetPrecedence`, `keyColumn`, `nameColumn`, `requiredColumns`, `dateColumns`, `idColumns`, `emptyValues`, `externalKeyPrefix`, `missingRecords` and `profileFile`) and `packs`, one entry per target in the order module, manufacturer, municipality, usage, businessOwner, each with `target`, `file`, `id`, `name`, `version`, `description`, `fieldMappings` and the write rules the import applies besides the mapping: `defaults` (the pack's values, field => value), `createOnlyDefaults` (values the import sets on create only, field => value; they are not mapped columns), `createOnlyFields` (mapped fields written on create or, on an existing object, only when the stored value is empty; never a field that is a key of `createOnlyDefaults`) and `neverWritten` (fields the import never writes on an existing object); the last three come from the profile's `createOnly` and `neverWritten` through the accessors the import itself uses. Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it: the `type`, and for `lookup` and `bool-map` the `map` and `default`, for `concat` the `fields` and `separator`, for `const` the `value`, for `date` the `sourceFormat` and `targetFormat`. The endpoint SHALL read nothing but those files and SHALL write nothing. When the validator is missing, or the profile or a pack is unreadable or invalid, it SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason`, so the administrator who edited a file learns what is wrong without the Nextcloud log. The "CMDB import" section SHALL show the answer in a collapsible block "Mapping (read-only)": the sheets, the key, name, required and date columns, and one table per pack with the source column, the target field, whether it is required, the transformation and its details. The block SHALL show a loading state until the endpoint answers and the error with its code when it fails, and SHALL offer no editing: the mapping is changed in the files on the server, as the documentation describes.
+`GET /api/settings/cmdb-import/mapping` SHALL be reachable only by Nextcloud admins and SHALL require Nextcloud's CSRF token; like the import routes (REQ-CMDB-001) it SHALL NOT carry `#[AuthorizedAdminSetting]`, `#[NoAdminRequired]` or `#[NoCSRFRequired]`. It SHALL load the import profile and every pack it names through the same loader and validator the import uses, and SHALL answer 200 with a flat envelope of two keys: `profile` (the profile's `id`, `name`, `version`, `sheets` with each sheet's `name`, `constants` and `absentColumns`, `sheetPrecedence`, `keyColumn`, `nameColumn`, `requiredColumns`, `dateColumns`, `idColumns`, `emptyValues`, `externalKeyPrefix`, `missingRecords` and `profileFile`) and `packs`, one entry per target in the order module, manufacturer, municipality, usage, businessOwner, each with `target`, `file`, `id`, `name`, `version`, `description`, `fieldMappings` and the write rules the import applies besides the mapping: `defaults` (the pack's values, field => value), `createOnlyDefaults` (values the import sets on create only, field => value; they are not mapped columns), `createOnlyFields` (mapped fields written on create or, on an existing object, only when the stored value is empty; a key of `createOnlyDefaults` only when it is also a mapped field) and `neverWritten` (fields the import never writes on an existing object); the last three come from the profile's `createOnly` and `neverWritten` through the accessors the import itself uses. Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it: the `type`, and for `lookup` and `bool-map` the `map` and `default`, for `concat` the `fields` and `separator`, for `const` the `value`, for `date` the `sourceFormat` and `targetFormat`. The endpoint SHALL read nothing but those files and SHALL write nothing. When the validator is missing, or the profile or a pack is unreadable or invalid, it SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason`, so the administrator who edited a file learns what is wrong without the Nextcloud log. The "CMDB import" section SHALL show the answer in a collapsible block "Mapping (read-only)": the sheets, the key, name, required and date columns, and one table per pack with the source column, the target field, whether it is required, the transformation and its details. The block SHALL show a loading state until the endpoint answers and the error with its code when it fails, and SHALL offer no editing: the mapping is changed in the files on the server, as the documentation describes.
#### Scenario: An admin reads the mapping the import uses
@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
diff --git a/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php b/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
index 46d6badd4..69bdc844c 100644
--- a/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
+++ b/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
@@ -243,11 +243,11 @@ public function testTheShippedMappingIsAnsweredFlat(): void {
$this->assertSame([], $byTarget['module']['defaults']);
foreach ($data['packs'] as $pack) {
$this->assertSame(
- array_values(array_diff($profile->createOnlyFields(target: $pack['target']), array_keys($pack['createOnlyDefaults']))),
+ array_values(array_diff($profile->createOnlyFields(target: $pack['target']), array_diff(array_keys($pack['createOnlyDefaults']), array_column($pack['fieldMappings'], 'target')))),
$pack['createOnlyFields'],
$pack['target']
);
- $this->assertSame([], array_intersect($pack['createOnlyFields'], array_keys($pack['createOnlyDefaults'])), $pack['target']);
+ $this->assertSame([], array_intersect($pack['createOnlyFields'], array_diff(array_keys($pack['createOnlyDefaults']), array_column($pack['fieldMappings'], 'target'))), $pack['target']);
$this->assertSame($profile->neverWrittenOnUpdate(target: $pack['target']), $pack['neverWritten'], $pack['target']);
}
diff --git a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php
index d76e4203f..74fdbe784 100644
--- a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php
+++ b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php
@@ -297,6 +297,52 @@ public function testAMissingPackOrValidatorIsMappingUnavailable(): void {
}
}//end testAMissingPackOrValidatorIsMappingUnavailable()
+ /**
+ * A create-only default keeps its mapped field in createOnlyFields; only an unmapped default leaves it.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+ public function testAMappedCreateOnlyDefaultStaysACreateOnlyField(): void {
+ CmdbTestSupport::loadMigrationPack();
+ $profile = new class(container: $this->emptyContainer()) extends CmdbImportProfile {
+ /**
+ * The usage pack's create-only fields with a create default on one mapped field and one unmapped one.
+ *
+ * @param string $target The pack target.
+ *
+ * @return array
+ */
+ public function createOnlyDefaults(string $target): array {
+ if ($target !== 'usage') {
+ return parent::createOnlyDefaults(target: $target);
+ }
+
+ return ['timeClassification' => 'Tolerate', 'unmappedFlag' => true];
+ }
+
+ /**
+ * The usage pack's create-only fields plus the unmapped default's key.
+ *
+ * @param string $target The pack target.
+ *
+ * @return array
+ */
+ public function createOnlyFields(string $target): array {
+ if ($target !== 'usage') {
+ return parent::createOnlyFields(target: $target);
+ }
+
+ return [...parent::createOnlyFields(target: $target), 'unmappedFlag'];
+ }
+ };
+
+ $usage = array_values(array_filter($profile->mappingOverview()['packs'], static fn (array $pack): bool => $pack['target'] === 'usage'))[0];
+
+ $this->assertSame(['interneAnnotation', 'timeClassification'], $usage['createOnlyFields'], 'the mapped field stays, the unmapped default leaves');
+ }//end testAMappedCreateOnlyDefaultStaysACreateOnlyField()
+
/**
* The overview the admin settings show is built from the validated packs, and a broken pack breaks it too.
*
@@ -330,7 +376,7 @@ public function testTheOverviewShowsWhatTheImportRuns(): void {
}
$this->assertSame($profile->createOnlyDefaults(target: $pack['target']), $pack['createOnlyDefaults'], $pack['target']);
- $this->assertSame([], array_intersect($pack['createOnlyFields'], array_keys($pack['createOnlyDefaults'])), $pack['target']);
+ $this->assertSame([], array_intersect($pack['createOnlyFields'], array_diff(array_keys($pack['createOnlyDefaults']), array_column($pack['fieldMappings'], 'target'))), $pack['target']);
$this->assertSame($profile->neverWrittenOnUpdate(target: $pack['target']), $pack['neverWritten'], $pack['target']);
}
From 323d7615d0480448f351907a6e191cd873e6d72a Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 18:09:42 +0200
Subject: [PATCH 059/124] docs(cmdb-import): quote both forms of the
uncached-APPID warning
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The warning has a one-row form and an "in N rows (…)" form that names the
first ten rows; the docs quoted only the first.
Review pre-push q4
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index 364bcf5f6..1f06a572f 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -277,7 +277,7 @@ APPID is on neither CMDB sheet:
|---|---|
| on the sheet `Gearchiveerde Applicaties` | are **archived**: OpenRegister's archive state, with the reason `cmdb-import: on sheet "Gearchiveerde Applicaties"`. Reported as *archived*. |
| on no sheet at all | are **deleted**: moved to OpenRegister's trash, where they stay restorable for 30 days. Reported as *deleted*. |
-| on no sheet, but the archive sheet cannot be relied on: the workbook has no sheet `Gearchiveerde Applicaties`, the sheet has no `APPID` column, or an `APPID` in it has no cached value (a formula whose result the file does not store) | are **archived**, never deleted. Each case has its own warning in the result: *sheet not found*, *no column APPID*, or *an APPID without a cached value in row …* (the first rows are named). Such a workbook therefore never moves anything to the trash. |
+| on no sheet, but the archive sheet cannot be relied on: the workbook has no sheet `Gearchiveerde Applicaties`, the sheet has no `APPID` column, or an `APPID` in it has no cached value (a formula whose result the file does not store) | are **archived**, never deleted. Each case has its own warning in the result: *sheet not found*, *no column APPID*, or *an APPID without a cached value in row …* (for more rows: *in N rows (…)*, naming the first ten). Such a workbook therefore never moves anything to the trash. |
An archived or deleted application disappears from the stackiq lists, from
OpenCatalogi and from Portaliq's **Software we use**. In stackiq, the quick
From 024122ba1292e83eade4feaff9990cbf12551deb Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 18:52:04 +0200
Subject: [PATCH 060/124] feat(cmdb-import): run the mapping packs stored in
OpenRegister
The module, manufacturer, usage and business-owner packs now live in
OpenRegister's migration-pack store, where an administrator edits them,
and the import runs what is stored there. A repair step seeds missing packs
as built-in packs and replaces a pack nobody edited when a release ships a
newer one; an x-stackiq marker (shipped version and the hash of the shipped
pack) tells an edit from a release, and an edited pack is never overwritten.
The shipped files stay the seed, and the fallback while nothing was stored.
Every pack must follow the import's rules: the required name and APPID
mappings, and only fields of its target schema, never object metadata,
match keys or the relations the import sets. An invalid or rule-breaking
pack, a renamed pack, or a store that is gone after a seed answers 503
MAPPING_UNAVAILABLE naming the pack and its id. A column a pack maps with a
date transform is converted from an Excel date, and the note cleanup uses
the pack's own separator. The municipality pack stays a shipped file.
GET /api/settings/cmdb-import/mapping answers per pack its source, OpenRegister
id, stored and shipped versions, edited and newerShipped, editable and an
editUrl to OpenRegister's pack editor, plus openRegisterPacks. The OpenSpec
change and the documentation describe editing in OpenRegister.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
appinfo/info.xml | 10 +
docs/features/cmdb-import.md | 108 ++--
l10n/en.js | 5 +-
l10n/en.json | 5 +-
l10n/nl.js | 5 +-
l10n/nl.json | 5 +-
lib/Controller/CmdbImportController.php | 29 +-
lib/Controller/SettingsController.php | 79 ++-
lib/Repair/SeedCmdbImportPacks.php | 146 ++++++
lib/Service/Cmdb/CmdbImportPackMarker.php | 175 +++++++
lib/Service/Cmdb/CmdbImportPackRules.php | 377 ++++++++++++++
lib/Service/Cmdb/CmdbImportPackStore.php | 462 ++++++++++++++++++
lib/Service/Cmdb/CmdbImportProfile.php | 364 ++++++++++++--
lib/Service/CmdbExportImportService.php | 25 +-
.../cmdb-import-mapping-view/design.md | 151 ++++--
.../cmdb-import-mapping-view/proposal.md | 60 ++-
.../specs/cmdb-export-import/spec.md | 197 ++++++--
.../changes/cmdb-import-mapping-view/tasks.md | 67 ++-
openspec/specs/cmdb-export-import/spec.md | 2 +-
.../Controller/CmdbImportControllerTest.php | 43 ++
...ettingsControllerCmdbImportMappingTest.php | 131 ++++-
tests/Unit/Repair/SeedCmdbImportPacksTest.php | 212 ++++++++
.../Service/Cmdb/CmdbImportPackRulesTest.php | 103 ++++
.../Service/Cmdb/CmdbImportPackStoreTest.php | 296 +++++++++++
.../Service/Cmdb/CmdbImportProfileTest.php | 337 ++++++++++++-
.../Service/CmdbExportImportServiceTest.php | 85 +++-
.../Unit/Support/FakeMigrationPackService.php | 286 +++++++++++
27 files changed, 3537 insertions(+), 228 deletions(-)
create mode 100644 lib/Repair/SeedCmdbImportPacks.php
create mode 100644 lib/Service/Cmdb/CmdbImportPackMarker.php
create mode 100644 lib/Service/Cmdb/CmdbImportPackRules.php
create mode 100644 lib/Service/Cmdb/CmdbImportPackStore.php
create mode 100644 tests/Unit/Repair/SeedCmdbImportPacksTest.php
create mode 100644 tests/Unit/Service/Cmdb/CmdbImportPackRulesTest.php
create mode 100644 tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php
create mode 100644 tests/Unit/Support/FakeMigrationPackService.php
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 895cb48bc..4efc599c9 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -250,6 +250,14 @@ Vrij en open source onder de EUPL-licentie.
instead of failing.
-->
OCA\Stackiq\Repair\RenameDutchCatalogValues
+
+ OCA\Stackiq\Repair\SeedCmdbImportPacksOCA\Stackiq\Repair\RepointEolSyncConfigOCA\Stackiq\Repair\InitializeSettings
+
+ OCA\Stackiq\Repair\SeedCmdbImportPacks
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index b9d505abc..0fe0f258a 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -150,6 +150,9 @@ date stored there, including one set by hand.
### Columns and where they go
+This is the mapping as it ships; an administrator can change it in
+OpenRegister (see [Viewing and editing the mapping](#viewing-and-editing-the-mapping)).
+
| Column | Goes to | Rule |
|---|---|---|
| APPID | module external number, and the match key | required, see [Repeat imports](#repeat-imports) |
@@ -294,7 +297,7 @@ and the section shows the reason and the error code.
| `FIELD_INVALID` | A form field of the request has a value the import does not accept, for example an `updateExisting` or `publish` that is neither `true` nor `false`. The message names the field. | Not reachable from the section; reported for API callers. |
| `UPLOAD_FAILED` | The file reached the server but could not be stored there. | Try again; the Nextcloud log has the details. |
| `MISSING_RECORDS_UNSUPPORTED` | The request asked to mark or remove records missing from the export. Only keeping them is supported. | Not reachable from the section; reported for API callers. |
-| `MAPPING_UNAVAILABLE` | OpenRegister's mapping engine is missing, or one of the mapping files is invalid. | Update OpenRegister. If you changed a mapping file, check it against the Nextcloud log. |
+| `MAPPING_UNAVAILABLE` | OpenRegister's mapping engine or pack store is missing, or a mapping pack is invalid or breaks a rule of the import. The message names the pack when one is at fault. | Update or enable OpenRegister. If you changed a pack, correct it in OpenRegister; see [Viewing and editing the mapping](#viewing-and-editing-the-mapping). |
| `READER_UNAVAILABLE` | The Excel reader that ships with OpenRegister cannot be loaded. | Make sure OpenRegister is installed and enabled. |
| `NOT_CONFIGURED` | The stackiq register or its schemas cannot be found. | Run **Auto Configure** at the top of the stackiq admin settings. |
| `SCHEMA_OUTDATED` | A stackiq schema lacks a property the import recognises records by, for example `externalKey` on the module schema, or the module schema is older than version 0.3.8: it has no `applicationType`, or `externalKey` lacks the rule that only an administrator may change it. Importing anyway would create every application again, or leave the import key open to every editor. The message names the schema. | Press **Force Update** at the top of the stackiq admin settings to import the register configuration again. |
@@ -331,35 +334,82 @@ every import:
The section's help text shows the defaults; when the server refuses a file,
the message shows the limit the server applied. A larger file also has to
pass PHP's `upload_max_filesize` and `post_max_size` and the web server's
-request size limit. Like the mapping files, the profile is part of the app:
-a change made on the server is overwritten by the next app update.
+request size limit. The profile is part of the app: a change made on the
+server is overwritten by the next app update.
-## Adjusting the mapping
+## Viewing and editing the mapping
-The mapping from columns to fields is not in code. It is a set of JSON files
-in `lib/Settings/cmdb-import/`, executed by OpenRegister's mapping engine:
+The mapping from export columns to stackiq fields is not in code. It is a
+set of mapping packs that OpenRegister's mapping engine runs, one per kind of
+record the import writes. Four of them are kept in OpenRegister, where you
+can view and change them:
-| File | What it maps |
+| Pack (in OpenRegister) | What it maps |
|---|---|
-| `topdesk-profile.json` | the sheets, the constant each sheet adds to its rows (`Beheer`) and the columns it is known to lack, the match column, the required, date and id columns, the placeholder values that mean empty, the limits, and which pack is used for which target |
-| `topdesk-module.json` | a row to the module (hosting model and BBN lookups) |
-| `topdesk-manufacturer.json` | "Vendor" to the Supplier organisation |
-| `topdesk-municipality.json` | a typed municipality name to a new organisation |
-| `topdesk-usage.json` | a row to the usage (status and TIME lookups, dates, annotation) |
-| `topdesk-business-owner.json` | the owner columns |
-
-Each pack has a list of `fieldMappings`, one per column: `source` (the column
-name in the export), `target` (the field), optionally `required`, and a
-`transform` such as `trim`, `date` or a `lookup` with a `map` of export values
-to stored values. For example, to accept a new "Applicatiesoort" value, add
-it to the `map` of the hosting-model lookup in `topdesk-module.json`:
-
-```json
-"Cloud": ["SaaS"]
-```
-
-To accept a new "Applicatie Status" value, add it to the `map` of the status
-lookup in `topdesk-usage.json`. The packs are checked by OpenRegister when an import
-starts; an invalid pack stops the import with `MAPPING_UNAVAILABLE` before
-any row is read. A mapping file changed on the server is overwritten by the
-next app update, so propose lasting changes to the app itself.
+| `stackiq-topdesk-module` | a row to the application (hosting model and BBN lookups, dates) |
+| `stackiq-topdesk-manufacturer` | "Vendor" to the Supplier organisation |
+| `stackiq-topdesk-usage` | a row to the usage (status and TIME lookups, phase-out date, internal note) |
+| `stackiq-topdesk-business-owner` | the owner columns |
+
+Stackiq puts these four packs into OpenRegister when it is installed or
+updated. The **CMDB import** section shows, per pack, where the import takes
+it from (OpenRegister, or the copy that ships with stackiq), its version, and
+an **Edit in OpenRegister** link that opens the pack. The import always runs
+what is saved in OpenRegister.
+
+Each pack has a list of field mappings, one per column: the column name in
+the export, the field it goes to, whether it is required, and a
+transformation such as trim, a date, or a lookup table of export values to
+stored values. For example, to accept a new "Applicatie Status" value, add it
+to the lookup table of the status mapping in `stackiq-topdesk-usage`. To
+accept a new "Applicatiesoort" value as a hosting model, add it to the lookup
+table of the hosting-model mapping in `stackiq-topdesk-module`, for example
+`Cloud` → `SaaS`. When a pack maps a column with a date transformation, the
+import reads that column as an Excel date.
+
+**What the import refuses.** The import writes past OpenRegister's access
+rules, so it checks every pack before it reads a row. It refuses a pack, with
+`MAPPING_UNAVAILABLE` and the name and id of the pack, when:
+
+- OpenRegister finds the pack invalid;
+- the application name, the APPID (to the source number), the vendor name or
+ the owner name is no longer mapped, or no longer marked required;
+- a mapping writes a field the import sets itself or that the record does not
+ have: the import key, the provider, the consumer, the application of a
+ usage, the business owner, the publication dates of an application, or
+ the type of an organisation (a Supplier stays a Supplier).
+
+The section shows the same reason, so you can correct the pack in
+OpenRegister and try again.
+
+**What a pack does not change.** Which sheets are read, the APPID and
+"Applicatie Naam" columns, the values that mean empty (`NB`), the constant
+each sheet adds (`Beheer geregeld: ja` or `nee`), the limits, and which fields
+are written only on create or never on an existing record are part of the app
+(`lib/Settings/cmdb-import/topdesk-profile.json`), and the section shows them
+read-only. The municipality is mapped from the name you type in the section,
+not from the export; that pack is not edited in OpenRegister either.
+
+**Reading more columns.** The import reads only the columns the mapping
+names. When you map a new column in a pack, the import reads that column from
+then on.
+
+**One mapping per Nextcloud.** The packs in OpenRegister apply to every
+import on this Nextcloud, whatever the municipality.
+
+**App updates.** A pack you have not changed follows the app: when an update
+ships a newer version, the stored pack is replaced by it. A pack you changed
+is never overwritten. When an update ships a newer version of a pack you
+changed, the section says so; compare the two and copy what you need into
+your pack in OpenRegister.
+
+**Back to the shipped pack.** Delete the pack in OpenRegister. The next
+import, or the next app update, puts the shipped pack back. Do not rename a
+pack: the import finds it by its id (such as `stackiq-topdesk-usage`), and
+refuses to run while a pack it stored has another id.
+
+**Without OpenRegister's pack store.** On an OpenRegister version without
+migration packs, the import uses the packs that ship with stackiq and the
+result says so. Once the packs have been stored in OpenRegister, the import
+does not fall back to those copies: when OpenRegister's pack store is gone,
+it refuses with `MAPPING_UNAVAILABLE` until OpenRegister is back.
diff --git a/l10n/en.js b/l10n/en.js
index 8dd84a2f4..afd93e55c 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1046,7 +1046,10 @@ OC.L10N.register(
"The file is larger than the maximum of %s MB.": "The file is larger than the maximum of %s MB.",
"The file is not an Excel workbook (.xlsx).": "The file is not an Excel workbook (.xlsx).",
"The import failed. The details are in the Nextcloud log.": "The import failed. The details are in the Nextcloud log.",
- "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.": "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.",
+ "The import mapping cannot run: OpenRegister is missing, or a mapping pack is invalid or unavailable.": "The import mapping cannot run: OpenRegister is missing, or a mapping pack is invalid or unavailable.",
+ "The import mapping cannot run: the OpenRegister migration pack %s is invalid or cannot be read. The details are in the Nextcloud log.": "The import mapping cannot run: the OpenRegister migration pack %s is invalid or cannot be read. The details are in the Nextcloud log.",
+ "The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.": "The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.",
+ "OpenRegister's migration pack store is not available, so this import used the mapping that ships with stackiq.": "OpenRegister's migration pack store is not available, so this import used the mapping that ships with stackiq.",
"The workbook has neither of the sheets %s.": "The workbook has neither of the sheets %s.",
"Column \"%1$s\": %2$s": "Column \"%1$s\": %2$s",
"Optional column \"%s\" not found": "Optional column \"%s\" not found",
diff --git a/l10n/en.json b/l10n/en.json
index 313f968ea..349882f8f 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1045,7 +1045,10 @@
"The file is larger than the maximum of %s MB.": "The file is larger than the maximum of %s MB.",
"The file is not an Excel workbook (.xlsx).": "The file is not an Excel workbook (.xlsx).",
"The import failed. The details are in the Nextcloud log.": "The import failed. The details are in the Nextcloud log.",
- "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.": "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.",
+ "The import mapping cannot run: OpenRegister is missing, or a mapping pack is invalid or unavailable.": "The import mapping cannot run: OpenRegister is missing, or a mapping pack is invalid or unavailable.",
+ "The import mapping cannot run: the OpenRegister migration pack %s is invalid or cannot be read. The details are in the Nextcloud log.": "The import mapping cannot run: the OpenRegister migration pack %s is invalid or cannot be read. The details are in the Nextcloud log.",
+ "The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.": "The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.",
+ "OpenRegister's migration pack store is not available, so this import used the mapping that ships with stackiq.": "OpenRegister's migration pack store is not available, so this import used the mapping that ships with stackiq.",
"The workbook has neither of the sheets %s.": "The workbook has neither of the sheets %s.",
"Column \"%1$s\": %2$s": "Column \"%1$s\": %2$s",
"Optional column \"%s\" not found": "Optional column \"%s\" not found",
diff --git a/l10n/nl.js b/l10n/nl.js
index 3151813d6..2cf474396 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1114,7 +1114,10 @@ OC.L10N.register(
"The file is larger than the maximum of %s MB.": "Het bestand is groter dan het maximum van %s MB.",
"The file is not an Excel workbook (.xlsx).": "Het bestand is geen Excel-werkmap (.xlsx).",
"The import failed. The details are in the Nextcloud log.": "De import is mislukt. De details staan in het Nextcloud-logboek.",
- "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.": "De mapping van de import kan niet worden uitgevoerd: OpenRegister ontbreekt of een mappingbestand is ongeldig.",
+ "The import mapping cannot run: OpenRegister is missing, or a mapping pack is invalid or unavailable.": "De mapping van de import kan niet worden uitgevoerd: OpenRegister ontbreekt, of een mappingpakket is ongeldig of niet beschikbaar.",
+ "The import mapping cannot run: the OpenRegister migration pack %s is invalid or cannot be read. The details are in the Nextcloud log.": "De mapping van de import kan niet worden uitgevoerd: het migratiepakket %s in OpenRegister is ongeldig of kan niet worden gelezen. De details staan in het Nextcloud-logboek.",
+ "The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.": "De mapping van de import kan niet worden getoond: OpenRegister ontbreekt, of een mappingpakket is ongeldig of niet beschikbaar.",
+ "OpenRegister's migration pack store is not available, so this import used the mapping that ships with stackiq.": "De opslag voor migratiepakketten van OpenRegister is niet beschikbaar, dus deze import gebruikte de mapping die met stackiq wordt meegeleverd.",
"The workbook has neither of the sheets %s.": "De werkmap bevat geen van de tabbladen %s.",
"Column \"%1$s\": %2$s": "Kolom \"%1$s\": %2$s",
"Optional column \"%s\" not found": "Optionele kolom \"%s\" niet gevonden",
diff --git a/l10n/nl.json b/l10n/nl.json
index 680f6f853..ff4bd963e 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1113,7 +1113,10 @@
"The file is larger than the maximum of %s MB.": "Het bestand is groter dan het maximum van %s MB.",
"The file is not an Excel workbook (.xlsx).": "Het bestand is geen Excel-werkmap (.xlsx).",
"The import failed. The details are in the Nextcloud log.": "De import is mislukt. De details staan in het Nextcloud-logboek.",
- "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.": "De mapping van de import kan niet worden uitgevoerd: OpenRegister ontbreekt of een mappingbestand is ongeldig.",
+ "The import mapping cannot run: OpenRegister is missing, or a mapping pack is invalid or unavailable.": "De mapping van de import kan niet worden uitgevoerd: OpenRegister ontbreekt, of een mappingpakket is ongeldig of niet beschikbaar.",
+ "The import mapping cannot run: the OpenRegister migration pack %s is invalid or cannot be read. The details are in the Nextcloud log.": "De mapping van de import kan niet worden uitgevoerd: het migratiepakket %s in OpenRegister is ongeldig of kan niet worden gelezen. De details staan in het Nextcloud-logboek.",
+ "The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.": "De mapping van de import kan niet worden getoond: OpenRegister ontbreekt, of een mappingpakket is ongeldig of niet beschikbaar.",
+ "OpenRegister's migration pack store is not available, so this import used the mapping that ships with stackiq.": "De opslag voor migratiepakketten van OpenRegister is niet beschikbaar, dus deze import gebruikte de mapping die met stackiq wordt meegeleverd.",
"The workbook has neither of the sheets %s.": "De werkmap bevat geen van de tabbladen %s.",
"Column \"%1$s\": %2$s": "Kolom \"%1$s\": %2$s",
"Optional column \"%s\" not found": "Optionele kolom \"%s\" niet gevonden",
diff --git a/lib/Controller/CmdbImportController.php b/lib/Controller/CmdbImportController.php
index 7d1c00a8f..cc81d612e 100644
--- a/lib/Controller/CmdbImportController.php
+++ b/lib/Controller/CmdbImportController.php
@@ -384,6 +384,8 @@ private function error(string $code, int $status, array $details = []): JSONResp
* @return string
*
* @SuppressWarnings(PHPMD.CyclomaticComplexity) One branch per contract error code.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
*/
private function message(string $code, array $details): string {
$megabytes = (string)round((int)($details['maxBytes'] ?? $this->importService->maxFileBytes()) / 1048576, 1);
@@ -401,7 +403,7 @@ private function message(string $code, array $details): string {
'NO_SOURCE_SHEET' => $this->l10n->t('The workbook has neither of the sheets %s.', [$expected]),
'MISSING_COLUMN' => $this->l10n->t('Sheet "%1$s" has no column "%2$s".', [(string)($details['sheet'] ?? ''), (string)($details['column'] ?? '')]),
'TOO_MANY_ROWS' => $this->l10n->t('Sheet "%1$s" has more than %2$s rows.', [(string)($details['sheet'] ?? ''), (string)($details['limit'] ?? '')]),
- 'MAPPING_UNAVAILABLE' => $this->l10n->t('The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.'),
+ 'MAPPING_UNAVAILABLE' => $this->mappingMessage(details: $details),
'READER_UNAVAILABLE' => $this->l10n->t('The Excel reader is not available: OpenRegister is missing or incomplete.'),
'NOT_CONFIGURED' => $this->l10n->t('Stackiq is not configured: the register or its schemas cannot be found.'),
'OPERATION_NOT_FOUND' => $this->l10n->t('No running CMDB import has this id.'),
@@ -414,6 +416,31 @@ private function message(string $code, array $details): string {
};
}//end message()
+ /**
+ * The message of MAPPING_UNAVAILABLE, naming the OpenRegister migration pack when one is at fault.
+ *
+ * @param array $details The details: `pack` and `packId` for a pack stored in OpenRegister.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function mappingMessage(array $details): string {
+ $pack = ($details['pack'] ?? null);
+ if (is_string($pack) === false || $pack === '') {
+ return $this->l10n->t('The import mapping cannot run: OpenRegister is missing, or a mapping pack is invalid or unavailable.');
+ }
+
+ if (is_int($details['packId'] ?? null) === true) {
+ $pack .= ' (id ' . $details['packId'] . ')';
+ }
+
+ return $this->l10n->t(
+ 'The import mapping cannot run: the OpenRegister migration pack %s is invalid or cannot be read. The details are in the Nextcloud log.',
+ [$pack]
+ );
+ }//end mappingMessage()
+
/**
* The message of FIELD_INVALID, naming the accepted values when the field has a fixed set.
*
diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php
index 13c9fc0ac..3f4c7c39b 100644
--- a/lib/Controller/SettingsController.php
+++ b/lib/Controller/SettingsController.php
@@ -46,6 +46,7 @@
use OCP\IGroupManager;
use OCP\IL10N;
use OCP\IRequest;
+use OCP\IURLGenerator;
use OCP\IUserSession;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
@@ -74,6 +75,13 @@ class SettingsController extends Controller {
*/
private const REASON_MAX_LENGTH = 500;
+ /**
+ * OpenRegister's single-page catch-all route, which serves its migration-pack editor.
+ *
+ * @var string
+ */
+ private const OPENREGISTER_PAGE_ROUTE = 'openregister.dashboard.catchAll';
+
/**
* The OpenRegister object service.
*
@@ -100,6 +108,7 @@ class SettingsController extends Controller {
* @param ConnectionReportService|null $connectionReports Asks integriq to look again after an email settings save.
* @param CmdbImportProfile|null $cmdbImportProfile The CMDB import profile and packs; null builds the shipped one.
* @param IL10N|null $l10n Translations of the CMDB mapping messages.
+ * @param IURLGenerator|null $urlGenerator Builds the link to a CMDB mapping pack's editor in OpenRegister.
*
* @SuppressWarnings(PHPMD.ExcessiveParameterList)
*
@@ -123,6 +132,7 @@ public function __construct(
private readonly ?ConnectionReportService $connectionReports = null,
private readonly ?CmdbImportProfile $cmdbImportProfile = null,
private readonly ?IL10N $l10n = null,
+ private readonly ?IURLGenerator $urlGenerator = null,
) {
parent::__construct(appName: $appName, request: $request);
@@ -3900,23 +3910,29 @@ public function getEolSyncStatus(): JSONResponse {
}//end getEolSyncStatus()
/**
- * The column mapping the CMDB import uses, read-only.
+ * The column mapping the CMDB import uses, read-only, with where each pack is edited.
*
* Loads the import profile and its packs through the loader the import
- * uses (CmdbImportProfile), so the answer is what the next import runs.
- * Success is the flat `{profile, packs}`; a failure uses the import's
- * error envelope, so the section shows it like an import error, with the
- * loader's reason in `details.reason` because the admin who edited a file
- * needs to know which file and why. That reason is the loader's message cut
- * to its first line and REASON_MAX_LENGTH characters: the file, the
- * validator's rule and the configuration value it refused, never a
- * workbook cell or a person.
- *
- * @return JSONResponse `{profile, packs}` (200), 503 MAPPING_UNAVAILABLE, or 500 IMPORT_FAILED.
+ * uses (CmdbImportProfile), so the answer is what the next import runs:
+ * each sheet pack from OpenRegister's migration-pack store when it holds
+ * it, otherwise the shipped file. Success is the flat
+ * `{profile, packs, openRegisterPacks}`; every pack carries its source and
+ * versions and an `editUrl`, the absolute link to its editor in
+ * OpenRegister, or null when it is not stored there. A failure uses the
+ * import's error envelope, so the section shows it like an import error,
+ * with the loader's reason in `details.reason` because the admin who
+ * edited a pack needs to know which pack and why; a pack stored in
+ * OpenRegister is also named in `details.pack` and `details.packId`. That reason is the
+ * loader's message cut to its first line and REASON_MAX_LENGTH characters:
+ * the file or the OpenRegister pack and its id, the broken rule and the
+ * configuration value it refused, never a workbook cell or a person.
+ *
+ * @return JSONResponse `{profile, packs, openRegisterPacks}` (200), 503 MAPPING_UNAVAILABLE, or 500 IMPORT_FAILED.
*
* @auth admin-only the import's configuration; the import writes with RBAC and multitenancy off, so its view keeps that posture.
*
* @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-3
*/
public function getCmdbImportMapping(): JSONResponse {
$profile = ($this->cmdbImportProfile ?? new CmdbImportProfile(container: $this->container));
@@ -3932,8 +3948,8 @@ public function getCmdbImportMapping(): JSONResponse {
[
'success' => false,
'error' => $e->getErrorCode(),
- 'message' => $this->translate(text: 'The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.'),
- 'details' => (object)['reason' => self::boundedReason(message: $e->getMessage())],
+ 'message' => $this->translate(text: 'The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.'),
+ 'details' => (object)array_merge($e->getDetails(), ['reason' => self::boundedReason(message: $e->getMessage())]),
],
$e->getHttpStatus()
);
@@ -3950,9 +3966,46 @@ public function getCmdbImportMapping(): JSONResponse {
);
}//end try
+ foreach ($overview['packs'] as $index => $pack) {
+ $overview['packs'][$index]['editUrl'] = $this->packEditUrl(packId: ($pack['packId'] ?? null));
+ }
+
return new JSONResponse($overview, Http::STATUS_OK);
}//end getCmdbImportMapping()
+ /**
+ * The absolute link to a migration pack's editor in OpenRegister.
+ *
+ * Built from OpenRegister's catch-all page route, so an instance that
+ * needs `/index.php` in its URLs gets it.
+ *
+ * @param int|null $packId The OpenRegister row id, or null for a pack that is not stored there.
+ *
+ * @return string|null Null when the pack is not stored in OpenRegister or the link cannot be built.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-3
+ */
+ private function packEditUrl(?int $packId): ?string {
+ if ($packId === null || $this->urlGenerator === null) {
+ return null;
+ }
+
+ $path = 'migration-packs/' . $packId;
+ try {
+ $url = $this->urlGenerator->linkToRouteAbsolute(self::OPENREGISTER_PAGE_ROUTE, ['path' => $path]);
+ } catch (\Throwable $e) {
+ $this->logger->info('SettingsController: no link to the OpenRegister pack editor', ['exception' => get_class($e)]);
+ return null;
+ }
+
+ // Nextcloud's router answers an unknown route with an empty path, which would link to the instance's front page.
+ if (str_ends_with($url, '/' . $path) === false) {
+ return null;
+ }
+
+ return $url;
+ }//end packEditUrl()
+
/**
* A loader message as the mapping endpoint answers it: its first line, at most REASON_MAX_LENGTH characters.
*
diff --git a/lib/Repair/SeedCmdbImportPacks.php b/lib/Repair/SeedCmdbImportPacks.php
new file mode 100644
index 000000000..3d911e9b4
--- /dev/null
+++ b/lib/Repair/SeedCmdbImportPacks.php
@@ -0,0 +1,146 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-2
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Repair;
+
+use OCA\Stackiq\Service\Cmdb\CmdbImportPackStore;
+use OCA\Stackiq\Service\Cmdb\CmdbImportProfile;
+use OCP\Migration\IOutput;
+use OCP\Migration\IRepairStep;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Seeds and updates the CMDB import packs in OpenRegister's migration-pack store.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-2
+ */
+class SeedCmdbImportPacks implements IRepairStep {
+ /**
+ * Constructor.
+ *
+ * @param CmdbImportProfile $profile Reads and validates the shipped packs.
+ * @param CmdbImportPackStore $store OpenRegister's migration-pack store.
+ * @param LoggerInterface $logger Logger for seed diagnostics.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-2
+ */
+ public function __construct(
+ private readonly CmdbImportProfile $profile,
+ private readonly CmdbImportPackStore $store,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * The name of this repair step.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-2
+ */
+ public function getName(): string {
+ return 'Seed the CMDB import mapping packs into OpenRegister';
+ }//end getName()
+
+ /**
+ * Seed the missing packs and update the unedited ones.
+ *
+ * @param IOutput $output Output interface for status messages.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-2
+ */
+ public function run(IOutput $output): void {
+ if ($this->store->isAvailable() === false) {
+ $output->info('OpenRegister migration pack store not available; CMDB import packs not seeded');
+ return;
+ }
+
+ try {
+ $packs = $this->profile->shippedEditablePacks();
+ } catch (Throwable $e) {
+ $this->logger->warning('[SeedCmdbImportPacks] shipped packs unreadable: ' . $e->getMessage());
+ $output->warning('CMDB import packs not seeded: ' . $e->getMessage());
+ return;
+ }
+
+ foreach ($packs as $shipped) {
+ $this->seed(output: $output, shipped: $shipped);
+ }
+ }//end run()
+
+ /**
+ * Seed or update one pack, and report what happened.
+ *
+ * @param IOutput $output Output interface for status messages.
+ * @param array $shipped The shipped pack.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-2
+ */
+ private function seed(IOutput $output, array $shipped): void {
+ $slug = (string)($shipped['id'] ?? '');
+ try {
+ $stored = $this->store->resolve(shipped: $shipped);
+ } catch (Throwable $e) {
+ $this->logger->warning('[SeedCmdbImportPacks] ' . $slug . ': ' . $e->getMessage());
+ $output->warning('CMDB import pack ' . $slug . ' not seeded: ' . $e->getMessage());
+ return;
+ }
+
+ $where = $slug . ' (id ' . $stored['id'] . ')';
+ if ($stored['outcome'] === CmdbImportPackStore::SEEDED) {
+ $output->info('Seeded CMDB import pack ' . $where);
+ return;
+ }
+
+ if ($stored['outcome'] === CmdbImportPackStore::UPDATED) {
+ $output->info('Updated unedited CMDB import pack ' . $where . ' to version ' . $stored['version']);
+ return;
+ }
+
+ if ($stored['newerShipped'] === true) {
+ $output->warning(
+ 'CMDB import pack ' . $where . ' was edited in OpenRegister and is kept; this release ships version '
+ . (string)($shipped['version'] ?? '') . ', which is not applied'
+ );
+ return;
+ }
+
+ $output->info('CMDB import pack ' . $where . ' already present');
+ }//end seed()
+}//end class
diff --git a/lib/Service/Cmdb/CmdbImportPackMarker.php b/lib/Service/Cmdb/CmdbImportPackMarker.php
new file mode 100644
index 000000000..5d41765f9
--- /dev/null
+++ b/lib/Service/Cmdb/CmdbImportPackMarker.php
@@ -0,0 +1,175 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Cmdb;
+
+/**
+ * Adds, reads and compares the seed marker of a stored pack.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+class CmdbImportPackMarker {
+ /**
+ * The definition key that records what stackiq seeded.
+ */
+ public const KEY = 'x-stackiq';
+
+ /**
+ * The hash stackiq records for a shipped pack: sha256 of its canonical JSON.
+ *
+ * @param array $pack The pack, without the marker.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function hash(array $pack): string {
+ $json = json_encode($this->canonical(value: $pack), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION);
+ return hash('sha256', (string)$json);
+ }//end hash()
+
+ /**
+ * The shipped pack as it is stored: with the marker.
+ *
+ * @param array $shipped The shipped pack.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function seed(array $shipped): array {
+ $definition = $this->strip(definition: $shipped);
+ $definition[self::KEY] = [
+ 'shippedVersion' => (string)($shipped['version'] ?? ''),
+ 'shippedHash' => $this->hash(pack: $definition),
+ ];
+
+ return $definition;
+ }//end seed()
+
+ /**
+ * A stored definition without the marker: the pack the engine runs.
+ *
+ * @param array $definition The stored definition.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function strip(array $definition): array {
+ unset($definition[self::KEY]);
+ return $definition;
+ }//end strip()
+
+ /**
+ * Whether a stored definition differs from what stackiq seeded.
+ *
+ * @param array $definition The stored definition.
+ *
+ * @return bool|null Null when it carries no marker, so nobody knows what was seeded.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function isEdited(array $definition): ?bool {
+ $recorded = $this->recordedHash(definition: $definition);
+ if ($recorded === null) {
+ return null;
+ }
+
+ return $this->hash(pack: $this->strip(definition: $definition)) !== $recorded;
+ }//end isEdited()
+
+ /**
+ * Whether a stored pack nobody edited was seeded from another shipped pack than this one.
+ *
+ * @param array $definition The stored definition.
+ * @param array $shipped The shipped pack.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function isOutdated(array $definition, array $shipped): bool {
+ return $this->isEdited(definition: $definition) === false
+ && $this->recordedHash(definition: $definition) !== $this->hash(pack: $shipped);
+ }//end isOutdated()
+
+ /**
+ * Whether an edited stored pack was seeded from a shipped pack that has changed since.
+ *
+ * @param array $definition The stored definition.
+ * @param array $shipped The shipped pack.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function isNewerShipped(array $definition, array $shipped): bool {
+ return $this->isEdited(definition: $definition) === true
+ && $this->recordedHash(definition: $definition) !== $this->hash(pack: $shipped);
+ }//end isNewerShipped()
+
+ /**
+ * The hash the marker of a stored definition records.
+ *
+ * @param array $definition The stored definition.
+ *
+ * @return string|null
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function recordedHash(array $definition): ?string {
+ $hash = ($definition[self::KEY]['shippedHash'] ?? null);
+ if (is_string($hash) === false || $hash === '') {
+ return null;
+ }
+
+ return $hash;
+ }//end recordedHash()
+
+ /**
+ * A value with every object's keys sorted, recursively.
+ *
+ * @param mixed $value The value.
+ *
+ * @return mixed
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function canonical(mixed $value): mixed {
+ if (is_array($value) === false) {
+ return $value;
+ }
+
+ if (array_is_list($value) === false) {
+ ksort($value, SORT_STRING);
+ }
+
+ return array_map(fn ($item) => $this->canonical(value: $item), $value);
+ }//end canonical()
+}//end class
diff --git a/lib/Service/Cmdb/CmdbImportPackRules.php b/lib/Service/Cmdb/CmdbImportPackRules.php
new file mode 100644
index 000000000..6e9b0e4e7
--- /dev/null
+++ b/lib/Service/Cmdb/CmdbImportPackRules.php
@@ -0,0 +1,377 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Cmdb;
+
+use OCA\Stackiq\Exception\CmdbImportException;
+
+/**
+ * Lists the import rules a pack breaks.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+class CmdbImportPackRules {
+ /**
+ * The fields a pack must map with `required: true`, per target.
+ *
+ * The import keys a module on its APPID and recognises a manufacturer,
+ * a municipality and an owner by name; without them it cannot match.
+ *
+ * @var array>
+ */
+ private const REQUIRED_TARGETS = [
+ 'module' => ['name', 'externalNumber'],
+ 'manufacturer' => ['name'],
+ 'municipality' => ['name'],
+ 'usage' => [],
+ 'businessOwner' => ['name'],
+ ];
+
+ /**
+ * The register schema a target writes into; its properties are the fields a pack may map.
+ *
+ * @var array
+ */
+ private const TARGET_SCHEMAS = [
+ 'module' => 'module',
+ 'manufacturer' => 'organization',
+ 'municipality' => 'organization',
+ 'usage' => 'usage',
+ ];
+
+ /**
+ * Targets that are no schema: the fields such a pack may map.
+ *
+ * The business owner is resolved in Nextcloud Contacts by name, with the role on the contact person.
+ *
+ * @var array>
+ */
+ private const FIXED_FIELDS = ['businessOwner' => ['name', 'role']];
+
+ /**
+ * Fields no pack may write: object metadata, and the keys and relations the import sets itself.
+ *
+ * @var array
+ */
+ private const MANAGED_FIELDS = ['@self', 'id', 'provider', 'consumer', 'module', 'externalKey', 'businessOwner'];
+
+ /**
+ * Further match keys per target: the import finds an organisation by name and type.
+ *
+ * @var array>
+ */
+ private const MATCH_FIELDS = ['manufacturer' => ['type'], 'municipality' => ['type']];
+
+ /**
+ * Defaults a pack must keep as shipped, per target.
+ *
+ * @var array>
+ */
+ private const PINNED_DEFAULTS = [
+ 'manufacturer' => ['type' => 'Supplier'],
+ 'municipality' => ['type' => 'Municipality'],
+ ];
+
+ /**
+ * Property names per schema of the shipped register, once read.
+ *
+ * @var array>|null
+ */
+ private ?array $schemaFields = null;
+
+ /**
+ * Constructor.
+ *
+ * @param string|null $settingsDirectory Directory of the register file and its fragments; null is the shipped one.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function __construct(
+ private ?string $settingsDirectory = null,
+ ) {
+ if ($this->settingsDirectory === null) {
+ $this->settingsDirectory = __DIR__ . '/../../Settings';
+ }
+ }//end __construct()
+
+ /**
+ * The rules a pack breaks, as readable sentences; empty when it follows them all.
+ *
+ * @param string $target The profile target.
+ * @param array $pack The pack, validated by OpenRegister.
+ * @param array $profile The decoded import profile.
+ *
+ * @return array
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the register cannot be read.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function violations(string $target, array $pack, array $profile): array {
+ $never = $profile['neverWritten'][$target] ?? [];
+ if (is_array($never) === false) {
+ $never = [];
+ }
+
+ $forbidden = array_merge(self::MANAGED_FIELDS, (self::MATCH_FIELDS[$target] ?? []), array_map('strval', $never));
+ $allowed = array_values(array_diff($this->targetFields(target: $target), $forbidden));
+
+ return array_values(
+ array_unique(
+ array_merge(
+ $this->requiredViolations(target: $target, pack: $pack, keyColumn: (string)($profile['keyColumn'] ?? 'APPID')),
+ $this->mappingViolations(target: $target, pack: $pack, allowed: $allowed, forbidden: $forbidden),
+ $this->defaultViolations(target: $target, pack: $pack, allowed: $allowed, forbidden: $forbidden)
+ )
+ )
+ );
+ }//end violations()
+
+ /**
+ * The required mappings a pack lacks.
+ *
+ * @param string $target The target.
+ * @param array $pack The pack.
+ * @param string $keyColumn The match key column.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function requiredViolations(string $target, array $pack, string $keyColumn): array {
+ $violations = [];
+ foreach ((self::REQUIRED_TARGETS[$target] ?? []) as $field) {
+ if ($this->hasRequiredMapping(pack: $pack, field: $field, keyColumn: $keyColumn) === true) {
+ continue;
+ }
+
+ if ($field === 'externalNumber') {
+ $violations[] = 'the key column "' . $keyColumn . '" must be mapped to "externalNumber" as required';
+ continue;
+ }
+
+ $violations[] = '"' . $field . '" must be mapped as required';
+ }
+
+ return $violations;
+ }//end requiredViolations()
+
+ /**
+ * Whether a pack maps a field as required; `externalNumber` only from the key column.
+ *
+ * @param array $pack The pack.
+ * @param string $field The field.
+ * @param string $keyColumn The match key column.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function hasRequiredMapping(array $pack, string $field, string $keyColumn): bool {
+ foreach (($pack['fieldMappings'] ?? []) as $mapping) {
+ if (is_array($mapping) === false || ($mapping['target'] ?? null) !== $field || ($mapping['required'] ?? false) !== true) {
+ continue;
+ }
+
+ if ($field !== 'externalNumber' || ($mapping['source'] ?? null) === $keyColumn) {
+ return true;
+ }
+ }
+
+ return false;
+ }//end hasRequiredMapping()
+
+ /**
+ * The mapped targets a pack may not write.
+ *
+ * @param string $target The target.
+ * @param array $pack The pack.
+ * @param array $allowed The fields it may write.
+ * @param array $forbidden The fields the import manages.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function mappingViolations(string $target, array $pack, array $allowed, array $forbidden): array {
+ $violations = [];
+ foreach (($pack['fieldMappings'] ?? []) as $mapping) {
+ $field = (string)($mapping['target'] ?? '');
+ if (in_array($field, $allowed, true) === false) {
+ $violations[] = self::fieldViolation(field: $field, forbidden: in_array($field, $forbidden, true), target: $target);
+ }
+ }
+
+ return $violations;
+ }//end mappingViolations()
+
+ /**
+ * The defaults a pack may not set, and the pinned defaults it changed.
+ *
+ * @param string $target The target.
+ * @param array $pack The pack.
+ * @param array $allowed The fields it may write.
+ * @param array $forbidden The fields the import manages.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function defaultViolations(string $target, array $pack, array $allowed, array $forbidden): array {
+ $defaults = $pack['defaults'] ?? [];
+ if (is_array($defaults) === false) {
+ $defaults = [];
+ }
+
+ $pinned = (self::PINNED_DEFAULTS[$target] ?? []);
+ $violations = [];
+ foreach (array_keys($defaults) as $field) {
+ $field = (string)$field;
+ if (array_key_exists($field, $pinned) === false && in_array($field, $allowed, true) === false) {
+ $violations[] = 'default ' . self::fieldViolation(field: $field, forbidden: in_array($field, $forbidden, true), target: $target);
+ }
+ }
+
+ foreach ($pinned as $field => $value) {
+ if (($defaults[$field] ?? null) !== $value) {
+ $violations[] = 'default "' . $field . '" must stay "' . $value . '"';
+ }
+ }
+
+ return $violations;
+ }//end defaultViolations()
+
+ /**
+ * Why a field may not be written.
+ *
+ * @param string $field The field.
+ * @param bool $forbidden Whether the import manages it.
+ * @param string $target The target.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private static function fieldViolation(string $field, bool $forbidden, string $target): string {
+ if ($forbidden === true) {
+ return '"' . $field . '" is set by the import and cannot be mapped';
+ }
+
+ return '"' . $field . '" is not a field the ' . $target . ' pack may map';
+ }//end fieldViolation()
+
+ /**
+ * The fields a target's pack may map before the forbidden ones are taken out.
+ *
+ * @param string $target The target.
+ *
+ * @return array
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the register cannot be read.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function targetFields(string $target): array {
+ if (isset(self::FIXED_FIELDS[$target]) === true) {
+ return self::FIXED_FIELDS[$target];
+ }
+
+ $schema = (self::TARGET_SCHEMAS[$target] ?? null);
+ if ($schema === null) {
+ return [];
+ }
+
+ return ($this->schemaFields()[$schema] ?? []);
+ }//end targetFields()
+
+ /**
+ * The property names per schema of the register stackiq ships, fragments included.
+ *
+ * @return array>
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the register file cannot be read.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function schemaFields(): array {
+ if ($this->schemaFields !== null) {
+ return $this->schemaFields;
+ }
+
+ $register = $this->decodeJson(file: $this->settingsDirectory . '/softwarecatalogus_register.json');
+ if ($register === null) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'The stackiq register definition cannot be read, so the CMDB import packs cannot be checked'
+ );
+ }
+
+ $fragments = glob($this->settingsDirectory . '/register.d/*.json');
+ if ($fragments === false) {
+ $fragments = [];
+ }
+
+ $fields = [];
+ foreach (array_merge([$register], array_map(fn (string $file): ?array => $this->decodeJson(file: $file), $fragments)) as $decoded) {
+ foreach (($decoded['components']['schemas'] ?? []) as $schema => $definition) {
+ $properties = array_keys((array)($definition['properties'] ?? []));
+ $fields[(string)$schema] = array_values(array_unique(array_merge(($fields[(string)$schema] ?? []), array_map('strval', $properties))));
+ }
+ }
+
+ $this->schemaFields = $fields;
+ return $fields;
+ }//end schemaFields()
+
+ /**
+ * A JSON file decoded to an array, or null when it cannot be read.
+ *
+ * @param string $file The file.
+ *
+ * @return array|null
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function decodeJson(string $file): ?array {
+ $content = false;
+ if (is_readable($file) === true) {
+ $content = file_get_contents($file);
+ }
+
+ if (is_string($content) === false) {
+ return null;
+ }
+
+ $decoded = json_decode($content, true);
+ if (is_array($decoded) === false) {
+ return null;
+ }
+
+ return $decoded;
+ }//end decodeJson()
+}//end class
diff --git a/lib/Service/Cmdb/CmdbImportPackStore.php b/lib/Service/Cmdb/CmdbImportPackStore.php
new file mode 100644
index 000000000..c256e9919
--- /dev/null
+++ b/lib/Service/Cmdb/CmdbImportPackStore.php
@@ -0,0 +1,462 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Cmdb;
+
+use OCA\Stackiq\AppInfo\Application;
+use OCA\Stackiq\Exception\CmdbImportException;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IAppConfig;
+use Psr\Container\ContainerInterface;
+use Throwable;
+
+/**
+ * Seeds, finds and updates the CMDB import packs in OpenRegister.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+class CmdbImportPackStore {
+ /**
+ * OpenRegister's migration-pack service (not a public contract).
+ */
+ public const SERVICE_CLASS = 'OCA\OpenRegister\Service\MigrationPackService';
+
+ /**
+ * App config key: the OpenRegister row id per pack slug, as JSON.
+ */
+ public const CONFIG_KEY = 'cmdb_import_pack_ids';
+
+ /**
+ * The store resolved the pack as it was.
+ */
+ public const FOUND = 'found';
+
+ /**
+ * The store created the pack from the shipped file.
+ */
+ public const SEEDED = 'seeded';
+
+ /**
+ * The store replaced an unedited pack with a newer shipped one.
+ */
+ public const UPDATED = 'updated';
+
+ /**
+ * The methods this store calls on OpenRegister's service.
+ *
+ * @var array
+ */
+ private const SERVICE_METHODS = ['findByPackSlug', 'find', 'create', 'update'];
+
+ /**
+ * OpenRegister's service, once resolved; false when it is not available.
+ *
+ * @var object|false|null
+ */
+ private object|false|null $service = null;
+
+ /**
+ * Reads and writes the seed marker.
+ *
+ * @var CmdbImportPackMarker
+ */
+ private readonly CmdbImportPackMarker $marker;
+
+ /**
+ * Constructor.
+ *
+ * @param ContainerInterface $container Resolves OpenRegister's service, and the app config when none is given.
+ * @param IAppConfig|null $appConfig Keeps the row ids; null resolves it from the container.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function __construct(
+ private readonly ContainerInterface $container,
+ private ?IAppConfig $appConfig = null,
+ ) {
+ $this->marker = new CmdbImportPackMarker();
+ }//end __construct()
+
+ /**
+ * Whether OpenRegister's migration-pack store can be used.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function isAvailable(): bool {
+ return $this->service() !== null;
+ }//end isAvailable()
+
+ /**
+ * The OpenRegister row ids this store resolved before, per pack slug.
+ *
+ * Not empty means the packs were stored in OpenRegister once, so the
+ * import may no longer fall back to the shipped files.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function recordedPackIds(): array {
+ $config = $this->appConfig();
+ if ($config === null) {
+ return [];
+ }
+
+ $decoded = json_decode($config->getValueString(Application::APP_ID, self::CONFIG_KEY, ''), true);
+ if (is_array($decoded) === false) {
+ return [];
+ }
+
+ $ids = [];
+ foreach ($decoded as $slug => $id) {
+ if (is_string($slug) === true && is_int($id) === true && $id > 0) {
+ $ids[$slug] = $id;
+ }
+ }
+
+ return $ids;
+ }//end recordedPackIds()
+
+ /**
+ * Refuse the shipped files once the packs were stored in OpenRegister.
+ *
+ * Called when the store is not available: before any pack was stored
+ * there the import may use the shipped files; afterwards that would
+ * silently drop an administrator's edit.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE naming the packs the store held.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function assertShippedFallbackAllowed(): void {
+ $named = [];
+ foreach ($this->recordedPackIds() as $slug => $id) {
+ $named[] = $slug . ' (id ' . $id . ')';
+ }
+
+ if ($named === []) {
+ return;
+ }
+
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'OpenRegister migration pack store is not available, but it holds the CMDB import packs '
+ . implode(', ', $named) . '; the import does not fall back to the shipped files',
+ details: ['packs' => $this->recordedPackIds()]
+ );
+ }//end assertShippedFallbackAllowed()
+
+ /**
+ * The stored pack for a shipped pack: found, seeded when missing, or updated when unedited and outdated.
+ *
+ * The answer holds the stored definition, the pack the engine runs (the
+ * definition without the marker), whether it was edited (null when it
+ * carries no marker) and whether an edit kept a newer shipped pack out.
+ *
+ * @param array $shipped The shipped pack, already validated.
+ *
+ * @return array id (int), slug, version, definition (stored, with the marker),
+ * pack (without it), edited (bool|null), newerShipped (bool) and outcome.
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the store is missing or fails,
+ * or when the pack was renamed in OpenRegister.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function resolve(array $shipped): array {
+ $service = $this->service();
+ if ($service === null) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'OpenRegister MigrationPackService is not available'
+ );
+ }
+
+ $slug = (string)($shipped['id'] ?? '');
+ $outcome = self::FOUND;
+ $entity = $this->findBySlug(service: $service, slug: $slug);
+ if ($entity === null) {
+ $this->assertNotRenamed(service: $service, slug: $slug);
+ $entity = $this->write(
+ slug: $slug,
+ action: 'seeded',
+ call: fn (): object => $service->create(definition: $this->marker->seed(shipped: $shipped), ownerUid: null, builtin: true)
+ );
+ $outcome = self::SEEDED;
+ } elseif ($this->marker->isOutdated(definition: self::definitionOf(entity: $entity), shipped: $shipped) === true) {
+ $id = (int)$entity->getId();
+ $entity = $this->write(
+ slug: $slug,
+ action: 'updated',
+ call: fn (): object => $service->update(id: $id, definition: $this->marker->seed(shipped: $shipped))
+ );
+ $outcome = self::UPDATED;
+ }
+
+ $id = (int)$entity->getId();
+ $this->record(slug: $slug, id: $id);
+
+ $definition = self::definitionOf(entity: $entity);
+ return [
+ 'id' => $id,
+ 'slug' => $slug,
+ 'version' => (string)($definition['version'] ?? ''),
+ 'definition' => $definition,
+ 'pack' => $this->marker->strip(definition: $definition),
+ 'edited' => $this->marker->isEdited(definition: $definition),
+ 'newerShipped' => $this->marker->isNewerShipped(definition: $definition, shipped: $shipped),
+ 'outcome' => $outcome,
+ ];
+ }//end resolve()
+
+ /**
+ * The decoded definition of a stored pack.
+ *
+ * @param object $entity The MigrationPack entity.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private static function definitionOf(object $entity): array {
+ $definition = $entity->getDefinitionArray();
+ if (is_array($definition) === false) {
+ return [];
+ }
+
+ return $definition;
+ }//end definitionOf()
+
+ /**
+ * Find a stored pack by slug.
+ *
+ * @param object $service OpenRegister's service.
+ * @param string $slug The pack slug.
+ *
+ * @return object|null The entity, or null when no pack has the slug.
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the lookup fails otherwise.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function findBySlug(object $service, string $slug): ?object {
+ try {
+ return $service->findByPackSlug(packSlug: $slug);
+ } catch (DoesNotExistException $e) {
+ return null;
+ } catch (Throwable $e) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'OpenRegister migration pack ' . $slug . ' could not be read: ' . get_class($e),
+ details: ['pack' => $slug],
+ previous: $e
+ );
+ }
+ }//end findBySlug()
+
+ /**
+ * Refuse when the row recorded for a slug still exists under another slug.
+ *
+ * A renamed pack is one the import no longer reads, so seeding a second
+ * one would let the administrator edit a pack that never runs.
+ *
+ * @param object $service OpenRegister's service.
+ * @param string $slug The pack slug.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the pack was renamed or the lookup fails.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function assertNotRenamed(object $service, string $slug): void {
+ $id = ($this->recordedPackIds()[$slug] ?? null);
+ if ($id === null) {
+ return;
+ }
+
+ try {
+ $entity = $service->find(id: $id);
+ } catch (DoesNotExistException $e) {
+ // Deleted: seeding it again restores the shipped pack.
+ return;
+ } catch (Throwable $e) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'OpenRegister migration pack ' . $slug . ' (id ' . $id . ') could not be read: ' . get_class($e),
+ details: ['pack' => $slug, 'packId' => $id],
+ previous: $e
+ );
+ }
+
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'OpenRegister migration pack id ' . $id . ' was renamed from ' . $slug . ' to '
+ . (string)$entity->getPackSlug() . '; the import reads it by the id ' . $slug . ' only, so rename it back',
+ details: ['pack' => $slug, 'packId' => $id]
+ );
+ }//end assertNotRenamed()
+
+ /**
+ * Create or update a stored pack, turning a failure into MAPPING_UNAVAILABLE.
+ *
+ * @param string $slug The pack slug.
+ * @param string $action What the call does, for the message.
+ * @param callable(): object $call The service call.
+ *
+ * @return object The entity.
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the call fails.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function write(string $slug, string $action, callable $call): object {
+ try {
+ return $call();
+ } catch (Throwable $e) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'OpenRegister migration pack ' . $slug . ' could not be ' . $action . ': ' . get_class($e),
+ details: ['pack' => $slug],
+ previous: $e
+ );
+ }
+ }//end write()
+
+ /**
+ * Remember the row id of a slug in the app config, when it changed.
+ *
+ * @param string $slug The pack slug.
+ * @param int $id The row id.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function record(string $slug, int $id): void {
+ $config = $this->appConfig();
+ if ($config === null) {
+ return;
+ }
+
+ $ids = $this->recordedPackIds();
+ if (($ids[$slug] ?? null) === $id) {
+ return;
+ }
+
+ $ids[$slug] = $id;
+ ksort($ids);
+ $config->setValueString(Application::APP_ID, self::CONFIG_KEY, (string)json_encode($ids));
+ }//end record()
+
+ /**
+ * OpenRegister's service, resolved once.
+ *
+ * @return object|null
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function service(): ?object {
+ if ($this->service === null) {
+ $this->service = false;
+ try {
+ if ($this->container->has(self::SERVICE_CLASS) === true) {
+ $service = $this->container->get(self::SERVICE_CLASS);
+ if (is_object($service) === true && self::hasMethods(service: $service) === true) {
+ $this->service = $service;
+ }
+ }
+ } catch (Throwable $e) {
+ // OpenRegister is missing or its service cannot be built: the store is not available.
+ $this->service = false;
+ }
+ }
+
+ if ($this->service === false) {
+ return null;
+ }
+
+ return $this->service;
+ }//end service()
+
+ /**
+ * Whether a resolved service has every method this store calls.
+ *
+ * @param object $service The service.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private static function hasMethods(object $service): bool {
+ foreach (self::SERVICE_METHODS as $method) {
+ if (method_exists($service, $method) === false) {
+ return false;
+ }
+ }
+
+ return true;
+ }//end hasMethods()
+
+ /**
+ * The app config, resolved from the container when it was not given.
+ *
+ * @return IAppConfig|null
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function appConfig(): ?IAppConfig {
+ if ($this->appConfig !== null) {
+ return $this->appConfig;
+ }
+
+ try {
+ if ($this->container->has(IAppConfig::class) === true) {
+ $config = $this->container->get(IAppConfig::class);
+ if ($config instanceof IAppConfig) {
+ $this->appConfig = $config;
+ }
+ }
+ } catch (Throwable $e) {
+ // No app config in this container: nothing is recorded.
+ $this->appConfig = null;
+ }
+
+ return $this->appConfig;
+ }//end appConfig()
+}//end class
diff --git a/lib/Service/Cmdb/CmdbImportProfile.php b/lib/Service/Cmdb/CmdbImportProfile.php
index fb455ac7a..ecd4426e4 100644
--- a/lib/Service/Cmdb/CmdbImportProfile.php
+++ b/lib/Service/Cmdb/CmdbImportProfile.php
@@ -11,10 +11,18 @@
* mean empty, which constants a sheet adds to its rows, and which columns
* may be read at all (the allowlist, design D3).
*
+ * The four sheet packs come from OpenRegister's migration-pack store when it
+ * is available (CmdbImportPackStore), so an administrator's edit there is
+ * what the import runs; the shipped files are the seed. The municipality
+ * pack maps the request options and always comes from its file. A pack from
+ * either source must pass the validator and the import's own rules
+ * (CmdbImportPackRules).
+ *
* `PackDefinitionValidator` is not part of OpenRegister's `Contract`
- * namespace, so it is resolved defensively. When it is missing, or a shipped
- * pack is invalid, loading throws `MAPPING_UNAVAILABLE` (503) before any
- * row is read (REQ-CMDB-005).
+ * namespace, so it is resolved defensively. When it is missing, a pack is
+ * invalid or breaks a rule, or the store that holds the packs is gone,
+ * loading throws `MAPPING_UNAVAILABLE` (503) before any row is read
+ * (REQ-CMDB-005, REQ-CMDB-021).
*
* @category Service
* @package OCA\Stackiq\Service\Cmdb
@@ -24,6 +32,7 @@
* @link https://github.com/ConductionNL/stackiq
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
*
* SPDX-FileCopyrightText: 2026 Conduction B.V.
* SPDX-License-Identifier: EUPL-1.2
@@ -47,6 +56,8 @@
* @SuppressWarnings(PHPMD.TooManyMethods) The same accessors, plus the loader's small private helpers.
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The accessors each guard against a
* malformed profile value; the sum passes the threshold, no single method is complex.
+ * @SuppressWarnings(PHPMD.ExcessiveClassLength) The same accessors with their docblocks, and the
+ * loader that picks each pack's source; the store, the marker and the import rules are classes of their own.
*/
class CmdbImportProfile {
/**
@@ -86,6 +97,16 @@ class CmdbImportProfile {
*/
public const DEFAULT_MAX_REFERENCED_STRING_BYTES = 67108864;
+ /**
+ * The targets whose pack an administrator edits in OpenRegister.
+ *
+ * The municipality pack maps the request options, not a sheet, so it
+ * stays a shipped file.
+ *
+ * @var array
+ */
+ public const EDITABLE_TARGETS = ['module', 'manufacturer', 'usage', 'businessOwner'];
+
/**
* Sources of the municipality pack that come from the request, not from a sheet.
*
@@ -93,6 +114,11 @@ class CmdbImportProfile {
*/
private const OPTION_SOURCES = ['municipalityName'];
+ /**
+ * The separator of a concat mapping when the pack names none (OpenRegister's default).
+ */
+ private const DEFAULT_CONCAT_SEPARATOR = ' ';
+
/**
* The decoded profile, once loaded.
*
@@ -107,65 +133,164 @@ class CmdbImportProfile {
*/
private array $packs = [];
+ /**
+ * Where each loaded pack came from, per target.
+ *
+ * @var array> Per target: source, packId, storedVersion, shippedVersion, edited, newerShipped.
+ */
+ private array $sources = [];
+
+ /**
+ * Whether OpenRegister's pack store was available when the packs were loaded.
+ *
+ * @var bool
+ */
+ private bool $storeAvailable = false;
+
+ /**
+ * The import rules every pack must follow.
+ *
+ * @var CmdbImportPackRules
+ */
+ private readonly CmdbImportPackRules $rules;
+
/**
* Constructor.
*
* @param ContainerInterface $container Resolves OpenRegister's validator.
* @param string|null $directory Directory of the profile and packs; null is the shipped one.
* @param string $profileFile File name of the profile inside the directory.
+ * @param CmdbImportPackStore|null $packStore OpenRegister's pack store; null builds one on the container.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
*/
public function __construct(
private readonly ContainerInterface $container,
private ?string $directory = null,
private readonly string $profileFile = 'topdesk-profile.json',
+ private ?CmdbImportPackStore $packStore = null,
) {
if ($this->directory === null) {
$this->directory = __DIR__ . '/../../Settings/cmdb-import';
}
+
+ $this->rules = new CmdbImportPackRules();
}//end __construct()
/**
* Load the profile and validate every pack it names.
*
+ * Each sheet pack is taken from OpenRegister's pack store when the store
+ * is available, seeded from its shipped file when it is missing there, and
+ * otherwise from the shipped file. Once the packs were stored in
+ * OpenRegister, a store that is gone fails closed: the import never falls
+ * back to the files and silently drops an administrator's edit.
+ *
* @return void
*
- * @throws CmdbImportException MAPPING_UNAVAILABLE when the validator is missing,
- * or the profile or a pack is unreadable or invalid.
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the validator is missing, the profile
+ * or a pack is unreadable, invalid or breaks an import rule, or
+ * the store that holds the packs is not available.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
*/
public function load(): void {
$validator = $this->resolveValidator();
$profile = $this->decodeFile(fileName: $this->profileFile);
+ $store = $this->packStore();
+ $storeAvailable = $store->isAvailable();
+ if ($storeAvailable === false) {
+ $store->assertShippedFallbackAllowed();
+ }
$packs = [];
+ $sources = [];
foreach (self::TARGETS as $target) {
- $fileName = $profile['packs'][$target] ?? null;
- if (is_string($fileName) === false || $fileName === '' || basename($fileName) !== $fileName) {
- throw new CmdbImportException(
- errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
- message: 'CMDB import profile names no pack for target ' . $target
- );
- }
+ $fileName = (string)($profile['packs'][$target] ?? '');
+ $shipped = $this->readShippedPack(profile: $profile, target: $target, validator: $validator);
+ $pack = $shipped;
+ $label = 'CMDB mapping pack ' . $fileName;
+ $details = [];
+ $source = [
+ 'source' => 'file',
+ 'packId' => null,
+ 'storedVersion' => null,
+ 'shippedVersion' => (string)($shipped['version'] ?? ''),
+ 'edited' => null,
+ 'newerShipped' => false,
+ ];
- $pack = $this->decodeFile(fileName: $fileName);
- $errors = $validator->validate($pack);
- if (is_array($errors) === true && $errors !== []) {
- throw new CmdbImportException(
- errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
- message: 'CMDB mapping pack ' . $fileName . ' is invalid: ' . implode('; ', array_map('strval', $errors))
+ if ($storeAvailable === true && in_array($target, self::EDITABLE_TARGETS, true) === true) {
+ $stored = $store->resolve(shipped: $shipped);
+ $pack = $stored['pack'];
+ $label = 'OpenRegister migration pack ' . $stored['slug'] . ' (id ' . $stored['id'] . ')';
+ $details = ['pack' => $stored['slug'], 'packId' => $stored['id']];
+ $this->assertValidPack(validator: $validator, pack: $pack, label: $label, details: $details);
+ $source = array_merge(
+ $source,
+ [
+ 'source' => 'openregister',
+ 'packId' => $stored['id'],
+ 'storedVersion' => $stored['version'],
+ 'edited' => $stored['edited'],
+ 'newerShipped' => $stored['newerShipped'],
+ ]
);
}
+ $this->assertImportRules(target: $target, pack: $pack, profile: $profile, label: $label, details: $details);
$packs[$target] = $pack;
- }
+ $sources[$target] = $source;
+ }//end foreach
$this->profile = $profile;
$this->packs = $packs;
+ $this->sources = $sources;
+ $this->storeAvailable = $storeAvailable;
}//end load()
+ /**
+ * The shipped packs an administrator edits in OpenRegister, validated, per target.
+ *
+ * The seed step stores these; it reads them without touching the store.
+ *
+ * @return array>
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the validator is missing or a file is unreadable or invalid.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-2
+ */
+ public function shippedEditablePacks(): array {
+ $validator = $this->resolveValidator();
+ $profile = $this->decodeFile(fileName: $this->profileFile);
+
+ $packs = [];
+ foreach (self::EDITABLE_TARGETS as $target) {
+ $packs[$target] = $this->readShippedPack(profile: $profile, target: $target, validator: $validator);
+ }
+
+ return $packs;
+ }//end shippedEditablePacks()
+
+ /**
+ * Whether the import uses the shipped files because OpenRegister's pack store is not available.
+ *
+ * Only true while the packs were never stored there; afterwards a missing
+ * store fails the load instead.
+ *
+ * @return bool
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when loading fails.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function usesShippedFallback(): bool {
+ $this->profile();
+ return $this->storeAvailable === false;
+ }//end usesShippedFallback()
+
/**
* The upload limit in bytes, readable without validating the packs.
*
@@ -469,14 +594,40 @@ public function requiredColumns(): array {
}//end requiredColumns()
/**
- * Columns that hold Excel serial dates.
+ * Columns that hold Excel serial dates: the profile's, plus every column a pack maps with a date transform.
+ *
+ * A pack edited in OpenRegister can map a new column to a date; that
+ * column is converted too, so the date transform gets `Y-m-d` and not an
+ * Excel serial number.
*
* @return array
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
*/
public function dateColumns(): array {
- return $this->stringList(key: 'dateColumns');
+ $columns = $this->stringList(key: 'dateColumns');
+ foreach (self::TARGETS as $target) {
+ if ($target === 'municipality') {
+ continue;
+ }
+
+ foreach (($this->pack(target: $target)['fieldMappings'] ?? []) as $mapping) {
+ if (is_array($mapping) === true && ($mapping['transform']['type'] ?? null) === 'date') {
+ $columns[] = (string)($mapping['source'] ?? '');
+ }
+ }
+ }
+
+ $constants = $this->constantColumns();
+ return array_values(
+ array_unique(
+ array_filter(
+ $columns,
+ fn (string $column): bool => $column !== '' && in_array($column, $constants, true) === false
+ )
+ )
+ );
}//end dateColumns()
/**
@@ -573,6 +724,39 @@ public function neverWrittenOnUpdate(string $target): array {
return array_values(array_map('strval', $value));
}//end neverWrittenOnUpdate()
+ /**
+ * The separator of the concat mapping that writes a field, or null when no concat mapping writes it.
+ *
+ * The import drops the empty parts of a concatenated note; it splits on
+ * the separator the pack uses, which an administrator can change.
+ *
+ * @param string $target The profile target.
+ * @param string $field The target field.
+ *
+ * @return string|null
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function concatSeparator(string $target, string $field): ?string {
+ $separator = null;
+ foreach (($this->pack(target: $target)['fieldMappings'] ?? []) as $mapping) {
+ if (is_array($mapping) === false || ($mapping['target'] ?? null) !== $field) {
+ continue;
+ }
+
+ // The last mapping of a field is the one whose value the engine keeps.
+ $separator = null;
+ if (($mapping['transform']['type'] ?? null) === 'concat') {
+ $separator = ($mapping['transform']['separator'] ?? self::DEFAULT_CONCAT_SEPARATOR);
+ if (is_string($separator) === false || $separator === '') {
+ $separator = self::DEFAULT_CONCAT_SEPARATOR;
+ }
+ }
+ }
+
+ return $separator;
+ }//end concatSeparator()
+
/**
* The accepted values of the missingRecords option.
*
@@ -637,14 +821,18 @@ public function referencedColumns(): array {
* (values set on create only, not mapped columns), `createOnlyFields` (mapped
* fields written on create or, on update, only when the stored value is
* empty; a key of `createOnlyDefaults` only when it is also mapped) and `neverWritten` (fields the
- * import never writes on an existing object).
+ * import never writes on an existing object). It also says where it came
+ * from (`source`, `packId`, the stored and the shipped version, whether it
+ * was edited and whether a newer shipped pack was left out because of
+ * that) and whether it is edited in OpenRegister (`editable`).
*
- * @return array{profile: array, packs: array>}
+ * @return array{profile: array, packs: array>, openRegisterPacks: bool}
*
* @throws CmdbImportException MAPPING_UNAVAILABLE when the validator is missing,
* or the profile or a pack is unreadable or invalid.
*
* @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-3
*/
public function mappingOverview(): array {
$profile = $this->profile();
@@ -684,19 +872,23 @@ public function mappingOverview(): array {
$defaults = [];
}
- $packs[] = [
- 'target' => $target,
- 'file' => (string)($profile['packs'][$target] ?? ''),
- 'id' => (string)($pack['id'] ?? ''),
- 'name' => (string)($pack['name'] ?? ''),
- 'version' => (string)($pack['version'] ?? ''),
- 'description' => (string)($pack['description'] ?? ''),
- 'fieldMappings' => $mappings,
- 'defaults' => $defaults,
- 'createOnlyDefaults' => $createOnlyDefaults,
- 'createOnlyFields' => $createOnlyFields,
- 'neverWritten' => $this->neverWrittenOnUpdate(target: $target),
- ];
+ $packs[] = array_merge(
+ [
+ 'target' => $target,
+ 'file' => (string)($profile['packs'][$target] ?? ''),
+ 'id' => (string)($pack['id'] ?? ''),
+ 'name' => (string)($pack['name'] ?? ''),
+ 'version' => (string)($pack['version'] ?? ''),
+ 'description' => (string)($pack['description'] ?? ''),
+ 'fieldMappings' => $mappings,
+ 'defaults' => $defaults,
+ 'createOnlyDefaults' => $createOnlyDefaults,
+ 'createOnlyFields' => $createOnlyFields,
+ 'neverWritten' => $this->neverWrittenOnUpdate(target: $target),
+ ],
+ $this->sources[$target],
+ ['editable' => in_array($target, self::EDITABLE_TARGETS, true)]
+ );
}//end foreach
return [
@@ -717,6 +909,7 @@ public function mappingOverview(): array {
'missingRecords' => $this->missingRecordsModes(),
],
'packs' => $packs,
+ 'openRegisterPacks' => $this->storeAvailable,
];
}//end mappingOverview()
@@ -751,6 +944,105 @@ private function stringList(string $key): array {
return array_values(array_map('strval', $value));
}//end stringList()
+ /**
+ * OpenRegister's pack store, built on the container when none was injected.
+ *
+ * @return CmdbImportPackStore
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function packStore(): CmdbImportPackStore {
+ if ($this->packStore === null) {
+ $this->packStore = new CmdbImportPackStore(container: $this->container);
+ }
+
+ return $this->packStore;
+ }//end packStore()
+
+ /**
+ * Read and validate the shipped pack of a target.
+ *
+ * @param array $profile The decoded profile.
+ * @param string $target The target.
+ * @param object $validator OpenRegister's validator.
+ *
+ * @return array
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the profile names no pack or the file is unreadable or invalid.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function readShippedPack(array $profile, string $target, object $validator): array {
+ $fileName = $profile['packs'][$target] ?? null;
+ if (is_string($fileName) === false || $fileName === '' || basename($fileName) !== $fileName) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'CMDB import profile names no pack for target ' . $target
+ );
+ }
+
+ $pack = $this->decodeFile(fileName: $fileName);
+ $this->assertValidPack(validator: $validator, pack: $pack, label: 'CMDB mapping pack ' . $fileName);
+
+ return $pack;
+ }//end readShippedPack()
+
+ /**
+ * Refuse a pack OpenRegister's validator refuses.
+ *
+ * @param object $validator OpenRegister's validator.
+ * @param array $pack The pack.
+ * @param string $label The pack as the message names it: its file, or its OpenRegister slug and id.
+ * @param array $details The exception details: the OpenRegister pack and its id, for a stored pack.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the pack is invalid.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function assertValidPack(object $validator, array $pack, string $label, array $details = []): void {
+ $errors = $validator->validate($pack);
+ if (is_array($errors) === true && $errors !== []) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: $label . ' is invalid: ' . implode('; ', array_map('strval', $errors)),
+ details: $details
+ );
+ }
+ }//end assertValidPack()
+
+ /**
+ * Refuse a pack that breaks a rule of the import, which the validator does not know.
+ *
+ * The import writes with OpenRegister's RBAC and multitenancy off, so a
+ * pack may only map the fields of its target schema, never the object's
+ * metadata or the keys and relations the import sets itself, and must keep
+ * the mappings the import matches records on (REQ-CMDB-021).
+ *
+ * @param string $target The target.
+ * @param array $pack The validated pack.
+ * @param array $profile The decoded profile.
+ * @param string $label The pack as the message names it.
+ * @param array $details The exception details: the OpenRegister pack and its id, for a stored pack.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the pack breaks a rule, or the register cannot be read.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function assertImportRules(string $target, array $pack, array $profile, string $label, array $details): void {
+ $violations = $this->rules->violations(target: $target, pack: $pack, profile: $profile);
+ if ($violations !== []) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: $label . ' breaks a rule of the CMDB import: ' . implode('; ', $violations),
+ details: $details
+ );
+ }
+ }//end assertImportRules()
+
/**
* Resolve OpenRegister's pack validator.
*
diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php
index 1efc049d7..18ef6a09e 100644
--- a/lib/Service/CmdbExportImportService.php
+++ b/lib/Service/CmdbExportImportService.php
@@ -182,11 +182,6 @@ class CmdbExportImportService {
*/
private const PAGE_SIZE = 500;
- /**
- * Separator of the concat mapping for the internal note.
- */
- private const NOTE_SEPARATOR = ' / ';
-
/**
* The mapping engine of the current run.
*
@@ -442,6 +437,7 @@ private function acquireImportLock(int $register): string {
* @return array The report.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
*/
private function runImport(string $path, array $options, string $startedAt): array {
$workbook = $this->reader->read(path: $path, profile: $this->profile);
@@ -452,6 +448,17 @@ private function runImport(string $path, array $options, string $startedAt): arr
$report = new CmdbImportReport(operationId: $operationId, rowsRead: count($rows));
$report->setMunicipality(uuid: $municipality['uuid'], name: $municipality['name'], created: $municipality['created']);
$report->addImportWarnings(warnings: $this->translateImportWarnings(warnings: $workbook['importWarnings']));
+ if ($this->profile->usesShippedFallback() === true) {
+ $report->addImportWarnings(
+ warnings: [
+ [
+ 'sheet' => '',
+ 'message' => $this->l10n->t('OpenRegister\'s migration pack store is not available, so this import used the mapping that ships with stackiq.'),
+ ],
+ ]
+ );
+ }
+
if ($municipality['created'] === true) {
$report->addImportWarnings(
warnings: [
@@ -1176,15 +1183,17 @@ private function upsertModule(
* @return array{uuid: string, outcome: string}
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
*/
private function upsertUsage(array $data, string $municipalityUuid, string $moduleUuid, ?string $providerUuid, array $owners): array {
- if (isset($data['interneAnnotation']) === true && is_string($data['interneAnnotation']) === true) {
+ $separator = $this->profile->concatSeparator(target: 'usage', field: 'interneAnnotation');
+ if ($separator !== null && isset($data['interneAnnotation']) === true && is_string($data['interneAnnotation']) === true) {
// The concat mapping leaves an empty part for every empty column; drop those.
$parts = array_filter(
- array_map('trim', explode(self::NOTE_SEPARATOR, $data['interneAnnotation'])),
+ array_map('trim', explode($separator, $data['interneAnnotation'])),
fn (string $part): bool => $part !== ''
);
- $data['interneAnnotation'] = implode(self::NOTE_SEPARATOR, $parts);
+ $data['interneAnnotation'] = implode($separator, $parts);
}
$data['consumer'] = $municipalityUuid;
diff --git a/openspec/changes/cmdb-import-mapping-view/design.md b/openspec/changes/cmdb-import-mapping-view/design.md
index e0c8f5871..b632419d9 100644
--- a/openspec/changes/cmdb-import-mapping-view/design.md
+++ b/openspec/changes/cmdb-import-mapping-view/design.md
@@ -2,106 +2,159 @@
## Context
-The CMDB import (archived change `2026-10-05-cmdb-export-import`) maps a TOPdesk export through `lib/Settings/cmdb-import/topdesk-profile.json` and five migration packs, loaded and validated by `CmdbImportProfile` and executed by OpenRegister's `MappingEngine`. The section `CmdbImport.vue` shows the upload form, the progress and the report; its help text names the two sheets from `PROFILE_DEFAULTS` in `src/utils/cmdbImport.js`. Nothing in stackiq or OpenRegister shows the profile or the packs (verified on OpenRegister `beta` ff4dad5c, 2026-10-09: APIs for mappings and migration packs exist, a screen does not).
+The CMDB import (archived change `2026-10-05-cmdb-export-import`) maps a TOPdesk export through `lib/Settings/cmdb-import/topdesk-profile.json` and five migration packs, loaded and validated by `CmdbImportProfile` and executed by OpenRegister's `MappingEngine`. The packs are OpenRegister migration-pack documents (`fieldMappings` with `trim`, `date`, `lookup`, `concat` transforms, `defaults`, `idStrategy`). OpenRegister already stores such documents in `openregister_migration_packs` through `MigrationPackService`, with CRUD at `/api/migration-packs` for admins. On 2026-10-12 it was decided that the mapping lives in OpenRegister: the administrator edits the packs there, and the import runs what is stored.
## Goals / Non-Goals
**Goals**
-- An administrator sees, in the section, exactly the mapping the next import runs.
-- The section's sheet names cannot drift from the profile.
-- Nothing is written; nothing new is parsed.
+- The import runs the pack the administrator saved in OpenRegister, unchanged.
+- An edit is never lost: not overwritten by an update, never silently replaced by the shipped file.
+- An edit cannot make the import write what it must not write.
+- The settings section tells the administrator where each pack comes from and links to its editor.
**Non-Goals**
-- Editing the mapping in a UI (see proposal, Out of Scope).
-- Showing a pack from OpenRegister's migration-pack store; the import does not read that store.
+- The editor screens (OpenRegister's change).
+- Editing the municipality pack or the import profile.
+- A mapping per municipality.
## Architecture Overview
```
-Admin settings, "CMDB import" section (CmdbImport.vue)
- └─ CmdbImportMapping.vue collapsible "Mapping (read-only)", one CnDataTable per pack
- │ GET /api/settings/cmdb-import/mapping (+ requesttoken, via @nextcloud/axios)
- ▼
-SettingsController::getCmdbImportMapping() Nextcloud admins only, CSRF
- └─ CmdbImportProfile::mappingOverview() load() → PackDefinitionValidator → accessors
- lib/Settings/cmdb-import/topdesk-profile.json + topdesk-{module,manufacturer,municipality,usage,business-owner}.json
+Install / upgrade Import or GET /api/settings/cmdb-import/mapping
+ SeedCmdbImportPacks (repair) CmdbImportProfile::load()
+ │ │ for module, manufacturer, usage, businessOwner
+ ▼ ▼
+ CmdbImportPackStore::resolve(shipped) ◄────────┘ municipality: shipped file only
+ │ findByPackSlug → absent: create(builtin, + x-stackiq)
+ │ unedited + outdated: update(shipped + x-stackiq)
+ │ edited: keep (newerShipped)
+ ▼
+ OpenRegister MigrationPackService ── openregister_migration_packs
+ │
+ CmdbImportPackRules (required mappings, field allowlist) + PackDefinitionValidator
+ │
+ MappingEngine::mapRow() (import) SettingsController: + editUrl (IURLGenerator)
```
## Decisions
### D1. The endpoint lives in `SettingsController` and reuses `CmdbImportProfile`
-The route sits with the other `/api/settings/*` reads, and `SettingsController` gets `CmdbImportProfile` and `IL10N` as optional, trailing constructor arguments, so the eight existing tests that build the controller positionally keep working and Nextcloud's container injects both. The method loads through `CmdbImportProfile::load()`, the loader the import uses, so the view can never show a profile the import would refuse.
-
-- **Alternative: `CmdbImportController::mapping()`.** Rejected: the brief and the plan put the read with the settings reads; the profile is injected directly, so the import service is not needed.
-- **Alternative: read the JSON files in the controller.** Rejected: that would bypass the validator and could show a pack the import refuses.
+The route sits with the other `/api/settings/*` reads, and `SettingsController` gets `CmdbImportProfile`, `IL10N` and `IURLGenerator` as optional, trailing constructor arguments, so the existing tests that build the controller positionally keep working and Nextcloud's container injects them. The method loads through `CmdbImportProfile::load()`, the loader the import uses, so the view can never show a mapping the import would refuse.
### D2. Admin-only, CSRF, the import's error envelope
-The method carries no auth attribute (Nextcloud's default: admins with a CSRF token), with `@auth admin-only` and its reason, as `CmdbImportController` does. The settings page is admin-only anyway, so a delegated group never sees the section; the endpoint follows the import's posture so an API caller gets the same answer from both. Success is the flat `{profile, packs}` (ADR-050). Failure is the import's envelope `{success: false, error, message, details}`, so `cmdbImport.js`'s `normaliseError()` and `errorText()` handle it unchanged; `details.reason` carries the loader's message, because an administrator who edited a file needs to know which file and why. The message is cut to its first line and 500 characters; it names the file, the validator's rule and the configuration value it refused, never a workbook cell or a person.
-
-### D3. The transform is shown as stored
+The method carries no auth attribute (Nextcloud's default: admins with a CSRF token), with `@auth admin-only` and its reason, as `CmdbImportController` does. Success is the flat `{profile, packs, openRegisterPacks}` (ADR-050). Failure is the import's envelope `{success: false, error, message, details}`; `details.reason` carries the loader's message cut to its first line and 500 characters, and a pack stored in OpenRegister is also named in `details.pack` and `details.packId`. The import's own 503 names the pack in its `message` from the same details; the full reason goes to the Nextcloud log. The reason names the file or the pack, the broken rule and the configuration value it refused, never a workbook cell or a person.
-`fieldMappings[].transform` is passed through as the pack stores it (`type` plus `map`/`default`, `fields`/`separator`, `value`, `sourceFormat`/`targetFormat`). Re-shaping per type would hide a key the engine reads. The page renders the known keys and lists any other scalar key as it is. `required` is normalised to a boolean.
+### D3. The transform and the write rules are shown as stored
-The write rules the import applies besides the mapping are answered per pack, built from the accessors the import uses (`createOnlyDefaults()`, `createOnlyFields()`, `neverWrittenOnUpdate()`) and the pack's own `defaults`, so the view does not overclaim for a field the import writes only on create or never. A create-only default that is not a mapped field, such as the module `type`, is answered in `createOnlyDefaults` only: `merge()` never reads it on update, so `createOnlyFields` leaves it out. A mapped field with a create default stays in `createOnlyFields`, since `merge()` guards it on update.
+`fieldMappings[].transform` is passed through as the pack stores it. The write rules the import applies besides the mapping are answered per pack (`defaults`, `createOnlyDefaults`, `createOnlyFields`, `neverWritten`), built from the accessors the import uses. A create-only default that is not a mapped field, such as the module `type`, is answered in `createOnlyDefaults` only: `merge()` never reads it on update, so `createOnlyFields` leaves it out. A mapped field with a create default stays in `createOnlyFields`.
### D4. The section takes the sheet names from the endpoint, with the constant as fallback
-`CmdbImportMapping.vue` fetches once on creation and emits `loaded` with the answer; `CmdbImport.vue` computes `sheetNames` from it, or from `PROFILE_DEFAULTS.sheets` until it arrives or when it fails. The constant stays, as the design of cmdb-export-import intended: a fallback, never the source.
+Unchanged from the first version: the help text computes the sheet names from the endpoint's answer, or from `PROFILE_DEFAULTS.sheets` until it arrives or when it fails.
+
+### D5. The packs live in OpenRegister's migration-pack store, found by slug
+
+The four sheet packs are stored as OpenRegister migration packs and found by their slug (`stackiq-topdesk-module`, …). That is the format the packs already have and the engine the import already calls, so what the administrator edits is exactly what runs, with OpenRegister's validator on save (422) and on import (503).
+
+- **Alternative: OpenRegister's Twig mapping entity (`/api/mappings`).** Rejected: lookups, `required` and dates would become Twig templates, validation would no longer be structural, and errors would lose their row and column; the import engine would have to be rewritten.
+- **Alternative: packs as register objects, like Integriq's mappings.** Rejected: a third mapping store next to the one OpenRegister already has, with the validator duplicated in a listener.
+
+The municipality pack maps the import options (`municipalityName`), not a sheet, so it stays a shipped file and is answered with `editable: false`. The import profile stays a shipped file: its limits guard memory and zip bombs, and its sheets, key column and write rules are stackiq semantics.
+
+OpenRegister's pack store has no organisation column: an instance has one TOPdesk mapping, used for every municipality it imports.
+
+### D6. Seeding, and following the release only while unedited
+
+A stored pack carries `x-stackiq: {shippedVersion, shippedHash}`: the version of the shipped pack it was seeded from and the sha256 of that pack's canonical JSON (keys of every object sorted recursively, list order kept, because the order of `fieldMappings` decides which mapping wins a target). OpenRegister's validator ignores keys it does not know, so the marker travels with the definition. A stored definition that, without the marker, still hashes to `shippedHash` is unedited.
+
+`CmdbImportPackStore::resolve()` is the one place that writes: it creates a missing pack as built-in with the marker (never through OpenRegister's import, which upserts and would overwrite an edit), replaces an unedited pack whose marker hash differs from the current shipped pack, and leaves an edited pack alone, reporting `newerShipped`. A pack without a marker (created by hand under the same slug) counts as edited with `edited: null`, and is never replaced. The repair step `SeedCmdbImportPacks` runs it on install and upgrade, after `InitializeSettings`; the loader runs it too, so a pack deleted in OpenRegister is seeded again on the next import, and an instance whose OpenRegister gained the store after stackiq's upgrade is seeded lazily.
+
+Version numbers alone cannot tell an edit from a release: OpenRegister's editor bumps the version on save, independently of stackiq's releases. The hash can.
+
+- **Alternative: seed only when missing, never update.** Rejected: an unedited pack would never get a fix that a release ships.
+- **Alternative: always overwrite on upgrade.** Rejected: it destroys the administrator's edits.
+
+### D7. Fail closed once the packs were stored
+
+The store keeps the row id it resolved per slug in stackiq's app config (`cmdb_import_pack_ids`). Without OpenRegister's store and with no recorded ids, the import uses the shipped files and adds one import warning saying so. With recorded ids, a missing store is 503 `MAPPING_UNAVAILABLE` naming the packs: falling back would silently drop an edit. A slug lookup that misses while the recorded row still exists under another slug is a rename, and is 503 naming the row and both slugs, not a second seeded pack the import would read while the administrator edits the renamed one. A recorded row that was deleted is seeded again. Any other failure of the store is 503 naming the pack and the exception class, never its message.
+
+### D8. The import's rules on every pack
+
+OpenRegister's validator checks structure only, and the import writes with OpenRegister's RBAC and multitenancy off. `CmdbImportPackRules` therefore checks every pack, stored or shipped:
+
+- required mappings with `required: true`: module `name` and `externalNumber` (from the profile's key column), manufacturer, municipality and business-owner `name`;
+- an allowlist per target: the properties of the target schema in the register stackiq ships (fragments included, ADR-037), minus `@self`, `id`, the match keys and the relations the import sets (`externalKey`, `provider`, `consumer`, `module`, `businessOwner`, the organisation `type`) and the profile's `neverWritten`; the business owner may map `name` and `role` only, because it is resolved in Nextcloud Contacts;
+- the same allowlist for `defaults`, and the organisation `type` default pinned (`Supplier`, `Municipality`), because the import finds organisations by name and type.
+
+A broken rule is 503 `MAPPING_UNAVAILABLE` naming the pack, its id and every rule it breaks.
+
+### D9. What a pack controls besides its mappings
+
+Only the profile's date columns used to be converted from Excel serial numbers, so a date transform on a new column would have received `45111`. The date columns are now the profile's plus every column a pack maps with a `date` transform. The cleanup of empty parts in the usage note splits on the separator of the concat mapping that writes `interneAnnotation`, not on a constant. `idColumns`, `emptyValues` and the sheet constants (`Beheer`) stay profile semantics.
-### D5. One component, fetched once, collapsed by default
+### D10. The editor link is built by Nextcloud
-The block is collapsed by default so the import form stays the section's first thing; the fetch happens on creation anyway, because the help text needs the sheet names. The toggle is an `NcButton` with a visible label and `aria-expanded`/`aria-controls`, the pattern of `CollapsibleSection.vue`.
+`editUrl` is `IURLGenerator::linkToRouteAbsolute('openregister.dashboard.catchAll', ['path' => 'migration-packs/'])`, so an instance that needs `/index.php` gets it. Nextcloud's router answers an unknown route with an empty path; a link that does not end in the pack path is answered as null rather than as the instance's front page.
## Declarative-vs-imperative decision (ADR-031)
-- **No new rule.** The change reads the declarative mapping (JSON packs in OpenRegister's format, executed by `MappingEngine`) and shows it; it adds no `x-openregister-*` block and no imperative rule.
-- **Imperative, because it is an endpoint:** one controller method and one accessor that shape the loaded JSON into the answer. That is presentation of configuration, not object lifecycle, aggregation, notification or relation logic.
-- **The mapping itself stays declarative:** a change to a pack changes the view and the import together, without PHP.
+- **The mapping stays declarative:** OpenRegister migration packs, executed by `MappingEngine`; an edit changes the import without PHP.
+- **Imperative, because they guard the import:** the pack resolution, the seed and update policy and the import's rules. They are configuration management and security checks on writes with RBAC off, not object lifecycle, aggregation, notification or relation logic, and no `x-openregister-*` vocabulary covers them.
## Seed Data
-No schema changes. No register fragment, seed object or migration is added or changed; the endpoint reads the six files that ship under `lib/Settings/cmdb-import/`.
+No schema changes and no register fragment. The repair step stores four migration packs in OpenRegister (`stackiq-topdesk-module`, `stackiq-topdesk-manufacturer`, `stackiq-topdesk-usage`, `stackiq-topdesk-business-owner`) from the shipped files, each with the `x-stackiq` marker.
## Nextcloud Integration
- Controller: `SettingsController::getCmdbImportMapping()`; route `settings#getCmdbImportMapping`, `GET /api/settings/cmdb-import/mapping`.
-- Service: `CmdbImportProfile::mappingOverview()`.
-- OCP: `OCP\IL10N`.
-- OpenRegister: `Service\MigrationPack\PackDefinitionValidator`, through the existing guard; absent, the endpoint answers 503 `MAPPING_UNAVAILABLE`.
+- Services: `CmdbImportProfile`, `CmdbImportPackStore`, `CmdbImportPackMarker`, `CmdbImportPackRules`.
+- Repair step: `SeedCmdbImportPacks`, in `` and ``.
+- OCP: `OCP\IL10N`, `OCP\IURLGenerator`, `OCP\IAppConfig`, `OCP\Migration\IRepairStep`, `OCP\AppFramework\Db\DoesNotExistException`.
+- OpenRegister: `Service\MigrationPackService`, `Service\MigrationPack\PackDefinitionValidator`, `Service\MigrationPack\MappingEngine`, resolved defensively; the page route `dashboard#catchAll`.
## Security Considerations
-- Admin-only with CSRF (D2). The answer is configuration that ships with the app: column names, field names, lookup tables, file names. No object, no person.
-- `details.reason` is stackiq's own composed message or the validator's structural messages; `message` is static and translated. An unexpected exception is logged and answered as 500 `IMPORT_FAILED` with a static message, never with `getMessage()`.
-- Every value is rendered as text; nothing goes into `v-html`.
+- Admin-only with CSRF (D2). OpenRegister's pack writes are admin-only too.
+- The import's rules (D8) keep an edited pack from writing object metadata, match keys or relations, or fields outside its schema.
+- An edit can add a column to what the import reads: the read allowlist is every column the profile or a pack names. Only Nextcloud admins can edit packs and run the import.
+- Fail closed (D7): no silent fallback that would drop an administrator's edit.
+- Messages name packs, rules and configuration values, never a workbook cell or a person; a store failure is named by its exception class.
## NL Design System
-Nextcloud and `@conduction/nextcloud-vue` components only (ADR-012): `NcButton` for the toggle, `NcLoadingIcon`, `NcNoteCard` for the error and `CnDataTable` for the tables, the same table the report uses.
+Nextcloud and `@conduction/nextcloud-vue` components only (ADR-012), as in the first version of the block.
## File Structure
```
-appinfo/routes.php (+ settings#getCmdbImportMapping)
-lib/Controller/SettingsController.php (+ getCmdbImportMapping)
-lib/Service/Cmdb/CmdbImportProfile.php (+ mappingOverview)
-src/utils/cmdbImport.js (+ mappingUrl, loadCmdbMapping, mappingSheetNames, packTargetLabel, transformLabel, mappingRows)
-src/views/settings/sections/CmdbImportMapping.vue (new)
-src/views/settings/sections/CmdbImport.vue (mounts the block; sheetNames)
-tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
-tests/Unit/Service/Cmdb/CmdbImportProfileTest.php (+ overview)
-tests/vitest/cmdbImportMapping.spec.js
-tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
+appinfo/info.xml (+ SeedCmdbImportPacks in install and post-migration)
+lib/Repair/SeedCmdbImportPacks.php (new)
+lib/Service/Cmdb/CmdbImportPackStore.php (new)
+lib/Service/Cmdb/CmdbImportPackMarker.php (new)
+lib/Service/Cmdb/CmdbImportPackRules.php (new)
+lib/Service/Cmdb/CmdbImportProfile.php (resolution, sources, dateColumns, concatSeparator, overview)
+lib/Service/CmdbExportImportService.php (fallback warning, separator)
+lib/Controller/SettingsController.php (editUrl, details)
+lib/Controller/CmdbImportController.php (MAPPING_UNAVAILABLE names the pack)
+src/… (the section's mapping block)
+tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php, CmdbImportPackRulesTest.php, CmdbImportProfileTest.php
+tests/Unit/Repair/SeedCmdbImportPacksTest.php
+tests/Unit/Support/FakeMigrationPackService.php
+tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php, CmdbImportControllerTest.php
+tests/Unit/Service/CmdbExportImportServiceTest.php
docs/features/cmdb-import.md
l10n/en.json, en.js, nl.json, nl.js
```
## Testing
-- PHPUnit: the route and its posture (no attribute, `@auth admin-only`, no exemption annotation); the shipped directory answers five packs that match the profile's accessors; a broken pack answers 503 with the reason; a missing validator answers 503; an unexpected error answers 500 and is logged.
-- vitest: the component renders one table per pack from a mocked answer, emits `loaded`, and shows the error and code on a 503.
-- Playwright (written, not run here: no Nextcloud reachable): an admin sees the five tables and the usage row; a non-admin gets 403 and no settings page.
+- PHPUnit against an in-memory `MigrationPackService`: seeding with the marker, an unedited pack following a newer release, an edited pack kept with `newerShipped`, a pack without marker never replaced, a renamed pack and a failing store failing closed, a deleted pack seeded again, the canonical hash.
+- PHPUnit on the loader: a stored pack is what runs, an invalid stored pack and thirteen broken rules are 503 naming the pack and id, no store before a seed uses the files, no store after a seed fails closed, a stored date column is converted, the concat separator is the pack's.
+- PHPUnit on the import: an edit in OpenRegister changes the mapped usage, the separator and a new date column; without a store the report carries the fallback warning.
+- PHPUnit on the endpoint and the repair step: the extended answer, `editUrl` from the page route and null when it cannot be built, 503 naming the pack; seeding four packs, updating and keeping, no store, a failing store, the registration order.
+- Playwright: the section shows the source and the editor link per pack (UI).
diff --git a/openspec/changes/cmdb-import-mapping-view/proposal.md b/openspec/changes/cmdb-import-mapping-view/proposal.md
index e651febe6..7c7925b01 100644
--- a/openspec/changes/cmdb-import-mapping-view/proposal.md
+++ b/openspec/changes/cmdb-import-mapping-view/proposal.md
@@ -7,11 +7,11 @@ depends_on: []
## Summary
-The "CMDB import" section of stackiq's admin settings gets a collapsible, read-only block "Mapping (read-only)" that shows the column mapping the import really uses: the sheets, the key and required columns, and one table per migration pack with the source column, the target field, whether it is required, and the transformation with its lookup values. A new admin endpoint `GET /api/settings/cmdb-import/mapping` loads the import profile and the five packs through the same loader and validator the import uses, so what the administrator sees is what the next import runs. Editing stays in the JSON files on the server; the documentation says where they are and what an administrator can change.
+A Nextcloud administrator can view and edit the column mapping of the CMDB import in OpenRegister. The four sheet packs of the import (module, manufacturer, usage and business owner) move into OpenRegister's existing migration-pack store, where OpenRegister's pack editor shows and changes them. The import reads each pack from that store first and runs exactly what the administrator saved. The packs that ship under `lib/Settings/cmdb-import/` become the seed and the fallback: a repair step stores them in OpenRegister as built-in packs, and replaces a stored pack nobody edited when a later release ships a newer one. An edited pack is never overwritten. A stored pack that is invalid, or that breaks a rule of the import, stops the import with 503 `MAPPING_UNAVAILABLE` naming the pack; it never falls back to the files silently. `GET /api/settings/cmdb-import/mapping` says per pack where it comes from, which versions are stored and shipped, whether it was edited, and links to its editor in OpenRegister. The "CMDB import" section of stackiq's admin settings shows that per pack, with the sheet names the import reads.
## Motivation
-The municipality's application manager (Jira WOO-588, sub-task of WOO-586) wants to see which TOPdesk column lands in which stackiq field, and which export values the import recognises, without reading JSON on the server. The mapping is declarative (REQ-CMDB-005) and OpenRegister has APIs for migration packs, but no screen for them, and stackiq offered no view of its profile or packs either: the section's help text even carried the sheet names as a constant in the page, which could drift from the profile. Options weighed on 2026-10-09: (A) an existing OpenRegister screen, which does not exist; (B) a read-only view in the section; (C) documentation only; (B+) editing through OpenRegister's packs. B was chosen: the question was "make it visible", the change stays in stackiq, and editing a mapping stays a rare, file-based act.
+The municipality's application manager (Jira WOO-588, sub-task of WOO-586) must be able to see and change which TOPdesk column lands in which stackiq field, and which export values the import recognises, without editing JSON on the server. The first version of this change (2026-10-09) only showed the mapping read-only and left editing in the files, which an app update overwrites. On 2026-10-12 it was decided that mapping lives in OpenRegister: the packs are already OpenRegister migration packs, executed by OpenRegister's `MappingEngine` and validated by its `PackDefinitionValidator`, and OpenRegister already stores and serves such packs (`/api/migration-packs`). Storing the import's packs there makes the edited object exactly the object the import runs, and gives the administrator one place for mappings. Integriq is not changed; its mapping screens are the pattern for OpenRegister's editor.
## Capabilities
@@ -21,31 +21,38 @@ None.
### Modified Capabilities
-- `cmdb-export-import`: ADDED REQ-CMDB-020 (the mapping overview endpoint and the read-only block); MODIFIED REQ-CMDB-014 (the section shows the mapping, and its help text takes the sheet names from the endpoint).
+- `cmdb-export-import`: ADDED REQ-CMDB-020 (the mapping endpoint and section, now with source, versions and the editor link), REQ-CMDB-021 (the import takes the sheet packs from OpenRegister's store, with the import's rules and fail-closed answers) and REQ-CMDB-022 (seeding and updating the packs in OpenRegister); MODIFIED REQ-CMDB-005 (where the packs come from, the date columns a pack adds, the pack's own concat separator) and REQ-CMDB-014 (the section shows the mapping summary).
## Affected Projects
-- [ ] Project: `stackiq`: one endpoint in `SettingsController`, one accessor on `CmdbImportProfile`, a `CmdbImportMapping.vue` block in the "CMDB import" section, helpers in `src/utils/cmdbImport.js`, documentation, translations, tests.
+- [ ] Project: `stackiq`: the pack resolution and rules in `lib/Service/Cmdb/`, a repair step, the extended endpoint, the slimmed mapping block in the "CMDB import" section, documentation, translations, tests.
+- [ ] Project: `openregister` (separate change and PR): the migration-pack list and editor pages that the `editUrl` opens, the envelope body on `PUT /api/migration-packs/{id}`, and a builtin pack keeping its slug.
## Scope
### In Scope
-- `GET /api/settings/cmdb-import/mapping`, for Nextcloud admins only and CSRF-protected, answering the profile and the packs as loaded by `CmdbImportProfile`, or 503 `MAPPING_UNAVAILABLE` with the loader's reason when a pack is broken.
-- The collapsible block in `CmdbImport.vue`, with loading and error states, one table per pack and a `data-testid` per table.
-- The sheet names in the section's help text come from the endpoint; the shipped names stay as the fallback.
-- `docs/features/cmdb-import.md`: "Viewing the mapping", and "Adjusting the mapping" updated.
-- PHPUnit, vitest and a Playwright spec.
+- Reading the module, manufacturer, usage and business-owner packs from OpenRegister's migration-pack store by slug, with the shipped file as the seed and, while nothing was ever stored, as the fallback with one import warning.
+- The import's own rules on every pack, stored or shipped: the required mappings, and an allowlist of the fields a pack may write.
+- Failing closed with 503 `MAPPING_UNAVAILABLE` when a stored pack is invalid or breaks a rule, when it was renamed, or when the store is gone after the packs were stored there.
+- The repair step `SeedCmdbImportPacks` on install and upgrade: seeds missing packs as built-in, replaces unedited packs with the shipped pack of the release, keeps edited ones and reports them; the row ids are kept in stackiq's app config.
+- The `x-stackiq` marker in a stored definition: the shipped version and the hash of the shipped pack it was seeded from.
+- Excel date conversion for every column a pack maps with a date transform; the note cleanup on the pack's own concat separator.
+- `GET /api/settings/cmdb-import/mapping` extended per pack with `source`, `packId`, `storedVersion`, `shippedVersion`, `edited`, `newerShipped`, `editable` and `editUrl`, and `openRegisterPacks` at the top level.
+- The section's mapping block: per pack its source, version, a hint when a newer shipped pack was not applied, and an "Edit in OpenRegister" link; the sheet names in the help text come from the endpoint.
+- `docs/features/cmdb-import.md`: viewing and editing the mapping in OpenRegister.
### Out of Scope
-- Editing the mapping in the UI, or through OpenRegister's migration-pack store (B+, rejected for now).
-- New transformations in OpenRegister's mapping engine, or promoting `MappingEngine` to an OpenRegister contract.
-- A sixth pack (technical owner): the functional administrator is not imported (cmdb-export-import, design D2).
+- The editor itself: OpenRegister's change for its migration-pack pages.
+- The municipality pack: it maps the import options, not a sheet, and stays a shipped file that is not editable.
+- The import profile (`topdesk-profile.json`): sheets, key and required columns, limits and write rules stay a shipped file, shown read-only.
+- More than one mapping per instance: OpenRegister's pack store has no organisation scope, so an instance has one TOPdesk mapping for every municipality it imports.
+- Promoting `MappingEngine`, `PackDefinitionValidator` or `MigrationPackService` to an OpenRegister contract.
## Approach
-`CmdbImportProfile::mappingOverview()` builds the answer from the loaded profile and packs through the existing accessors, after `load()` validated every pack. `SettingsController::getCmdbImportMapping()` returns it as a flat 200, or translates `CmdbImportException` into the import's error envelope with the reason in `details.reason`. The section mounts a `CmdbImportMapping` component that fetches the endpoint once, renders the tables with `CnDataTable`, and emits the loaded mapping so the parent can take the sheet names from it. The transform is shown as the pack stores it: type, map or fields or value, and the formats of a date.
+`CmdbImportProfile::load()` reads each shipped pack, validates it, and for the four sheet targets asks `CmdbImportPackStore` for the stored pack. The store resolves OpenRegister's `MigrationPackService` defensively from the container, finds the pack by slug, seeds it when it is missing, and replaces it when its `x-stackiq` marker shows it was not edited and the shipped pack has changed. `CmdbImportPackRules` checks every pack against the import's rules; `CmdbImportPackMarker` computes the canonical hash. The repair step calls the same store. `SettingsController` adds `editUrl` from OpenRegister's page route through `IURLGenerator`.
## New Dependencies
@@ -53,26 +60,33 @@ None.
## Impact
-- **Backend**: `lib/Controller/SettingsController.php` (one method, two optional constructor arguments), `lib/Service/Cmdb/CmdbImportProfile.php` (one method), one route in `appinfo/routes.php`.
-- **Frontend**: `src/views/settings/sections/CmdbImportMapping.vue` (new), `src/views/settings/sections/CmdbImport.vue` (mounts it, takes the sheet names from it), `src/utils/cmdbImport.js` (URL, request, row and label helpers).
-- **Data**: none. The endpoint reads six files that ship with the app and writes nothing.
+- **Backend**: `lib/Service/Cmdb/CmdbImportProfile.php` (resolution, sources, date columns, concat separator), new `CmdbImportPackStore.php`, `CmdbImportPackMarker.php`, `CmdbImportPackRules.php`, new `lib/Repair/SeedCmdbImportPacks.php` and its registration in `appinfo/info.xml`, `lib/Controller/SettingsController.php` (`editUrl`, details), `lib/Controller/CmdbImportController.php` (the message names the pack), `lib/Service/CmdbExportImportService.php` (fallback warning, separator).
+- **Frontend**: the mapping block in the "CMDB import" section.
+- **Data**: four rows in OpenRegister's `openregister_migration_packs`, created by the repair step or the first import; one app config key, `cmdb_import_pack_ids`.
## Cross-Project Dependencies
-- **openregister** (consumed, not changed): `MigrationPack\PackDefinitionValidator`, through the existing guard in `CmdbImportProfile`.
+- **openregister** (consumed): `Service\MigrationPackService` (`findByPackSlug`, `find`, `create`, `update`), `Db\MigrationPack::getDefinitionArray()`, `Service\MigrationPack\PackDefinitionValidator`, `Service\MigrationPack\MappingEngine`, and the page route `dashboard#catchAll` for `/migration-packs/{id}`.
+- **openregister** (needed for editing): the migration-pack pages and the envelope body of OpenRegister's change for WOO-588. Without them stackiq still seeds and reads the packs, and the link opens a page OpenRegister does not have yet.
## Risks
-### Risk 1: The view shows a mapping the import does not use
-**Severity:** Low — **Mitigation:** the endpoint and the import share one loader (`CmdbImportProfile`), one directory and one validator; the unit test reads the shipped directory through the controller and compares it with the profile's own accessors.
+### Risk 1: An edit in OpenRegister writes fields the import must not write
+**Severity:** High — **Mitigation:** the import writes with OpenRegister's RBAC and multitenancy off, so every pack must follow the import's rules (REQ-CMDB-021): only the fields of its target schema, never object metadata, match keys or the relations the import sets, and the required mappings kept. A pack that breaks a rule stops the import with 503 naming the pack.
-### Risk 2: The loader's reason leaks something it should not
-**Severity:** Low — **Mitigation:** the reason is composed by stackiq (file name plus the validator's messages about the pack's structure) or is the fixed text that the validator is missing; it never holds a cell value or a person. The `message` stays static and translated (ADR-050).
+### Risk 2: An administrator's edit is lost or silently ignored
+**Severity:** High — **Mitigation:** the repair step never overwrites an edited pack; a renamed pack, or a store that is gone after the packs were stored, fails closed instead of falling back to the files.
+
+### Risk 3: An edit widens what the import reads
+**Severity:** Medium — **Mitigation:** the read allowlist is every column the profile or a pack names, so an administrator who maps a new column makes the import read it. Only Nextcloud admins can edit packs and run the import; the documentation says that a mapped column is read.
+
+### Risk 4: OpenRegister's internal classes change
+**Severity:** Medium — **Mitigation:** resolved defensively and checked for the methods called; a missing service before any seed falls back to the files with a warning, afterwards fails closed.
## Rollback Strategy
-Additive. Revert the PR to remove the route, the method, the component and the strings; the section then shows its constant sheet names again, as before.
+Revert the PR. The import then reads the shipped files again; the four rows in OpenRegister's pack store stay and are ignored, and can be deleted there. The app config key `cmdb_import_pack_ids` can be deleted with `occ config:app:delete stackiq cmdb_import_pack_ids`.
## Open Questions
-- Should a later change let an administrator override a pack through OpenRegister's migration-pack store (`POST /api/migration-packs/import`), as cmdb-export-import design D2 noted? Not for this change.
+- Does any instance import for more than one municipality? The store holds one mapping per instance; per-municipality packs would need a slug per municipality or a pack chosen in the import options.
diff --git a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
index e1e7f2430..51dcaa9f4 100644
--- a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
@@ -8,43 +8,56 @@
## Purpose
-The CMDB import maps the columns of a TOPdesk export to stackiq fields through an import profile and five migration packs, JSON files under `lib/Settings/cmdb-import/` that OpenRegister's mapping engine executes (REQ-CMDB-005). Until now an administrator could only learn that mapping by reading the JSON on the server. This delta adds a read-only view of it to the "CMDB import" section of the admin settings, served by an endpoint that loads the files through the same loader the import uses, so what the administrator sees is what the next import runs (Jira WOO-588, sub-task of WOO-586).
+The CMDB import maps the columns of a TOPdesk export to stackiq fields through an import profile and five migration packs that OpenRegister's mapping engine executes (REQ-CMDB-005). Until now the packs were JSON files under `lib/Settings/cmdb-import/`, which an administrator could only read on the server and an app update overwrote. This delta moves the four sheet packs into OpenRegister's migration-pack store, where an administrator views and edits them, makes the import run what is stored there, and shows in the "CMDB import" section where each pack comes from with a link to its editor (Jira WOO-588, sub-task of WOO-586; decision 2026-10-12: mapping lives in OpenRegister).
-Nextcloud OCP interfaces used: `OCP\IL10N` (messages). OpenRegister: `Service\MigrationPack\PackDefinitionValidator` (through the existing `CmdbImportProfile`, guarded).
+Nextcloud OCP interfaces used: `OCP\IL10N` (messages), `OCP\IURLGenerator` (the editor link), `OCP\IAppConfig` (the stored pack ids), `OCP\Migration\IRepairStep` (seeding). OpenRegister: `Service\MigrationPackService`, `Service\MigrationPack\PackDefinitionValidator` and `Service\MigrationPack\MappingEngine`, resolved defensively.
## ADDED Requirements
### Requirement: The admin settings SHALL show the mapping the import uses (REQ-CMDB-020)
-`GET /api/settings/cmdb-import/mapping` SHALL be reachable only by Nextcloud admins and SHALL require Nextcloud's CSRF token; like the import routes (REQ-CMDB-001) it SHALL NOT carry `#[AuthorizedAdminSetting]`, `#[NoAdminRequired]` or `#[NoCSRFRequired]`. It SHALL load the import profile and every pack it names through the same loader and validator the import uses, and SHALL answer 200 with a flat envelope of two keys: `profile` (the profile's `id`, `name`, `version`, `sheets` with each sheet's `name`, `constants` and `absentColumns`, `sheetPrecedence`, `keyColumn`, `nameColumn`, `requiredColumns`, `dateColumns`, `idColumns`, `emptyValues`, `externalKeyPrefix`, `missingRecords` and `profileFile`) and `packs`, one entry per target in the order module, manufacturer, municipality, usage, businessOwner, each with `target`, `file`, `id`, `name`, `version`, `description`, `fieldMappings` and the write rules the import applies besides the mapping: `defaults` (the pack's values, field => value), `createOnlyDefaults` (values the import sets on create only, field => value; they are not mapped columns), `createOnlyFields` (mapped fields written on create or, on an existing object, only when the stored value is empty; a key of `createOnlyDefaults` only when it is also a mapped field) and `neverWritten` (fields the import never writes on an existing object); the last three come from the profile's `createOnly` and `neverWritten` through the accessors the import itself uses. Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it: the `type`, and for `lookup` and `bool-map` the `map` and `default`, for `concat` the `fields` and `separator`, for `const` the `value`, for `date` the `sourceFormat` and `targetFormat`. The endpoint SHALL read nothing but those files and SHALL write nothing. When the validator is missing, or the profile or a pack is unreadable or invalid, it SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason`, so the administrator who edited a file learns what is wrong without the Nextcloud log. The "CMDB import" section SHALL show the answer in a collapsible block "Mapping (read-only)": the sheets, the key, name, required and date columns, and one table per pack with the source column, the target field, whether it is required, the transformation and its details. The block SHALL show a loading state until the endpoint answers and the error with its code when it fails, and SHALL offer no editing: the mapping is changed in the files on the server, as the documentation describes.
+`GET /api/settings/cmdb-import/mapping` SHALL be reachable only by Nextcloud admins and SHALL require Nextcloud's CSRF token; like the import routes (REQ-CMDB-001) it SHALL NOT carry `#[AuthorizedAdminSetting]`, `#[NoAdminRequired]` or `#[NoCSRFRequired]`. It SHALL load the import profile and every pack through the same loader, store and validator the import uses (REQ-CMDB-021), and SHALL answer 200 with a flat envelope of three keys:
+
+- `profile`: the profile's `id`, `name`, `version`, `sheets` with each sheet's `name`, `constants` and `absentColumns`, `sheetPrecedence`, `keyColumn`, `nameColumn`, `requiredColumns`, `dateColumns` (the columns the import converts from Excel dates, REQ-CMDB-005), `idColumns`, `emptyValues`, `externalKeyPrefix`, `missingRecords` and `profileFile`;
+- `packs`: one entry per target in the order module, manufacturer, municipality, usage, businessOwner, each with `target`, `file`, `id`, `name`, `version`, `description`, `fieldMappings`, the write rules `defaults`, `createOnlyDefaults`, `createOnlyFields` and `neverWritten`, and where the pack comes from: `source` (`openregister` or `file`), `packId` (the OpenRegister row id, or null), `storedVersion` (or null), `shippedVersion`, `edited` (whether the stored pack differs from what stackiq seeded; null when unknown or for a file), `newerShipped` (an edited pack kept a newer shipped pack out), `editable` (false for the municipality pack) and `editUrl` (the absolute link to the pack's editor in OpenRegister, built by Nextcloud's URL generator so an instance that needs `/index.php` gets it, or null when the pack is not stored there or the link cannot be built);
+- `openRegisterPacks`: whether OpenRegister's migration-pack store is available.
+
+Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it. `createOnlyDefaults` holds values the import sets on create only, `createOnlyFields` the mapped fields written on create or, on an existing object, only when the stored value is empty (a key of `createOnlyDefaults` only when it is also a mapped field), and `neverWritten` the fields the import never writes on an existing object. When the validator is missing, a pack is unreadable, invalid or breaks a rule of the import, or the store that holds the packs is gone, the endpoint SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason` (its first line, at most 500 characters), and for a pack stored in OpenRegister its slug in `details.pack` and its row id in `details.packId`. The "CMDB import" section SHALL show, per pack, its source, its version, a hint when a newer shipped pack was not applied, and a link "Edit in OpenRegister" when the pack has an `editUrl`; it SHALL offer no editing itself.
#### Scenario: An admin reads the mapping the import uses
@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
-- **GIVEN** a Nextcloud admin with the shipped profile and packs
+- **GIVEN** a Nextcloud admin, with OpenRegister's migration-pack store holding the seeded packs
- **WHEN** they request `GET /api/settings/cmdb-import/mapping`
-- **THEN** the endpoint SHALL answer 200 with `profile.sheets` naming "Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB", `profile.keyColumn` `APPID` and `profile.requiredColumns` `APPID` and `Applicatie Naam`
-- **AND** `packs` SHALL hold five entries with targets module, manufacturer, municipality, usage and businessOwner, each with its pack id, name and version
+- **THEN** the endpoint SHALL answer 200 with `profile.sheets` naming "Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB", `profile.keyColumn` `APPID` and `openRegisterPacks` `true`
+- **AND** `packs` SHALL hold five entries with targets module, manufacturer, municipality, usage and businessOwner
+- **AND** the module, manufacturer, usage and businessOwner packs SHALL have `source` `openregister`, a `packId` and an `editUrl` ending in `/apps/openregister/migration-packs/`
+- **AND** the municipality pack SHALL have `source` `file`, `editable` `false` and `editUrl` `null`
- **AND** the usage pack SHALL list a mapping from "Applicatie Status" to `status` with a `lookup` transform whose `map` sends "In productie" to "In production"
-- **AND** the usage pack's `createOnlyFields` SHALL be `interneAnnotation` and `timeClassification`, the module pack's `createOnlyDefaults` SHALL be `type` = `Application` and its `neverWritten` SHALL be `publicationDate` and `depublicationDate`
-#### Scenario: The section shows one table per pack
+#### Scenario: Without OpenRegister's store the shipped packs are shown
+@e2e exclude Removing OpenRegister's store on a shared instance is not done in e2e; tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php asserts the answer.
+
+- **GIVEN** OpenRegister has no migration-pack store and the packs were never stored there
+- **WHEN** a Nextcloud admin requests the mapping
+- **THEN** every pack SHALL have `source` `file` and `editUrl` `null`
+- **AND** `openRegisterPacks` SHALL be `false`
+
+#### Scenario: The section links each pack to its editor
@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
- **GIVEN** a Nextcloud admin on stackiq's admin settings page
-- **WHEN** they open "Mapping (read-only)" in the "CMDB import" section
-- **THEN** the section SHALL show five tables, one per pack, each named after its target and its file
-- **AND** the usage table SHALL have a row for the column "Applicatie Status" with the field `status`, the transformation Lookup and the pair "In productie" to "In production"
-- **AND** the block SHALL have no control that edits a mapping
+- **WHEN** the mapping block of the "CMDB import" section has loaded
+- **THEN** each of the four sheet packs SHALL show its source and version and a link "Edit in OpenRegister" to its `editUrl`
+- **AND** the municipality pack SHALL show no such link
-#### Scenario: A broken pack is reported with its reason
-@e2e exclude Breaking a shipped file on a shared instance is not done in e2e; tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php asserts the 503 and the reason, and tests/vitest/cmdbImportMapping.spec.js asserts the section's error state.
+#### Scenario: A broken stored pack is reported with its name
+@e2e exclude Breaking a stored pack on a shared instance is not done in e2e; tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php asserts the 503, the reason and the pack.
-- **GIVEN** a pack file on the server that OpenRegister's validator refuses
+- **GIVEN** the usage pack in OpenRegister maps a column to `provider`
- **WHEN** a Nextcloud admin requests the mapping
- **THEN** the endpoint SHALL answer 503 with error `MAPPING_UNAVAILABLE`
-- **AND** `details.reason` SHALL name the pack file and the validator's reason, on one line of at most 500 characters
-- **AND** the section SHALL show the error and the code `MAPPING_UNAVAILABLE` instead of the tables
+- **AND** `details.pack` SHALL be `stackiq-topdesk-usage`, `details.packId` its row id, and `details.reason` SHALL name the pack, its id and the broken rule on one line of at most 500 characters
#### Scenario: A user who is not a Nextcloud admin cannot read the mapping
@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
@@ -52,13 +65,135 @@ Nextcloud OCP interfaces used: `OCP\IL10N` (messages). OpenRegister: `Service\Mi
- **GIVEN** a signed-in user who is not a Nextcloud admin
- **WHEN** they request `GET /api/settings/cmdb-import/mapping`
- **THEN** Nextcloud SHALL answer 403
-- **AND** the admin settings page that holds the section SHALL NOT be shown to them
+
+### Requirement: The import SHALL run the mapping packs stored in OpenRegister (REQ-CMDB-021)
+
+For the module, manufacturer, usage and business-owner targets the import SHALL use the pack stored in OpenRegister's migration-pack store under the shipped pack's slug (`stackiq-topdesk-module`, `stackiq-topdesk-manufacturer`, `stackiq-topdesk-usage`, `stackiq-topdesk-business-owner`), without the `x-stackiq` marker (REQ-CMDB-022). When the store is available and such a pack is missing, the import SHALL first seed it from the shipped file. The municipality pack maps the import options, not a sheet; it SHALL always come from its shipped file. An instance SHALL have one mapping, used for every municipality it imports.
+
+Every pack, stored or shipped, SHALL pass OpenRegister's `PackDefinitionValidator` and the import's own rules, or the import SHALL stop with 503 `MAPPING_UNAVAILABLE` before any row is read, naming the pack (its file, or its OpenRegister slug and row id) and every rule it breaks; it SHALL NOT fall back to the shipped file. The rules are:
+
+- the module pack SHALL map `name`, and SHALL map the profile's key column (`APPID`) to `externalNumber`, both with `required: true`; the manufacturer, municipality and business-owner packs SHALL map `name` with `required: true`;
+- a pack SHALL map, and set as a default, only properties of its target schema in the register stackiq ships (module; organisation for manufacturer and municipality; usage), and the business-owner pack only `name` and `role`;
+- no pack SHALL map or default `@self`, `id`, `externalKey`, `provider`, `consumer`, `module`, `businessOwner` or a field the profile lists as never written; the manufacturer and municipality packs SHALL NOT map `type` and SHALL keep the default `type` `Supplier` and `Municipality`.
+
+When OpenRegister's store is not available and no pack was ever stored there, the import SHALL use the shipped files and SHALL add one warning to the report saying so. Once the packs were stored there, a store that is not available SHALL stop the import with 503 `MAPPING_UNAVAILABLE` naming the stored packs. A pack whose stored row now carries another slug SHALL stop the import with 503 naming the row and both slugs; a pack whose row was deleted SHALL be seeded again. The import's 503 message SHALL name the OpenRegister pack and its id when one is at fault.
+
+#### Scenario: An edit in OpenRegister is what the import runs
+@e2e exclude Editing the shared mapping changes every import on the instance; tests/Unit/Service/CmdbExportImportServiceTest.php imports a row against an edited stored usage pack.
+
+- **GIVEN** the usage pack in OpenRegister maps "In productie" to `Planned`
+- **WHEN** a row with "Applicatie Status" `In productie` is imported
+- **THEN** the usage SHALL have `status` `Planned`
+- **AND** the report SHALL carry no warning about the mapping
+
+#### Scenario: A stored pack that writes a field the import manages is refused
+@e2e exclude Breaking the shared mapping is not done in e2e; tests/Unit/Service/Cmdb/CmdbImportProfileTest.php asserts thirteen broken rules.
+
+- **GIVEN** the usage pack in OpenRegister maps "Applicatie Status" to `provider`
+- **WHEN** an import starts
+- **THEN** the import SHALL answer 503 `MAPPING_UNAVAILABLE` before any row is read
+- **AND** the reason SHALL name `stackiq-topdesk-usage`, its row id and `"provider" is set by the import and cannot be mapped`
+
+#### Scenario: A stored module pack without the required name mapping is refused
+@e2e exclude Breaking the shared mapping is not done in e2e; tests/Unit/Service/Cmdb/CmdbImportProfileTest.php asserts it.
+
+- **GIVEN** the module pack in OpenRegister maps "Applicatie Naam" to `name` without `required: true`
+- **WHEN** an import starts
+- **THEN** the import SHALL answer 503 `MAPPING_UNAVAILABLE` naming the pack and `"name" must be mapped as required`
+
+#### Scenario: Without OpenRegister's store, before any seed, the shipped mapping is used with a warning
+@e2e exclude Requires removing OpenRegister's store; tests/Unit/Service/CmdbExportImportServiceTest.php asserts the warning.
+
+- **GIVEN** OpenRegister has no migration-pack store and stackiq never stored a pack there
+- **WHEN** an export is imported
+- **THEN** the rows SHALL be mapped by the shipped packs
+- **AND** the report SHALL carry the warning "OpenRegister's migration pack store is not available, so this import used the mapping that ships with stackiq."
+
+#### Scenario: Without OpenRegister's store, after a seed, the import fails closed
+@e2e exclude Requires removing OpenRegister's store; tests/Unit/Service/Cmdb/CmdbImportProfileTest.php asserts it.
+
+- **GIVEN** stackiq stored the packs in OpenRegister, and OpenRegister's store is no longer available
+- **WHEN** an import starts
+- **THEN** the import SHALL answer 503 `MAPPING_UNAVAILABLE` naming each stored pack and its id
+- **AND** it SHALL NOT use the shipped files
+
+#### Scenario: A renamed pack is refused, a deleted one is seeded again
+@e2e exclude Renaming or deleting the shared pack is not done in e2e; tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php asserts both.
+
+- **GIVEN** the usage pack's row in OpenRegister was renamed to `my-usage`
+- **WHEN** an import starts
+- **THEN** the import SHALL answer 503 `MAPPING_UNAVAILABLE` naming the row, `stackiq-topdesk-usage` and `my-usage`, and SHALL NOT seed a second pack
+- **AND** when the row was deleted instead, the import SHALL seed the shipped usage pack again and use it
+
+### Requirement: Stackiq SHALL seed its mapping packs into OpenRegister and keep unedited ones current (REQ-CMDB-022)
+
+On install and on every upgrade, after its register is initialised, stackiq SHALL create each of the four sheet packs that is missing from OpenRegister's migration-pack store as a built-in pack, from the shipped file, never through OpenRegister's import (which overwrites). Each stored definition SHALL carry `x-stackiq` with `shippedVersion` and `shippedHash`: the version of the shipped pack and the sha256 of its canonical JSON (every object's keys sorted, recursively; list order kept). A stored pack whose definition without `x-stackiq` still hashes to its `shippedHash` is unedited: when the shipped pack of the release differs, stackiq SHALL replace it with the shipped pack and a new marker. An edited pack, or one without marker, SHALL NOT be changed; for an edited pack whose marker differs from the shipped pack, the upgrade SHALL warn that the shipped version was not applied, and the mapping endpoint SHALL answer `newerShipped` `true`. Stackiq SHALL record the row id of every pack it stored or found in its app config. Seeding SHALL never throw: without OpenRegister's store, or when the store fails, it SHALL report and leave the instance as it was.
+
+#### Scenario: A fresh install seeds the four sheet packs
+@e2e exclude Install-time behaviour; tests/Unit/Repair/SeedCmdbImportPacksTest.php asserts it.
+
+- **GIVEN** OpenRegister's migration-pack store holds no stackiq pack
+- **WHEN** stackiq is installed
+- **THEN** the store SHALL hold `stackiq-topdesk-module`, `stackiq-topdesk-manufacturer`, `stackiq-topdesk-usage` and `stackiq-topdesk-business-owner` as built-in packs with the `x-stackiq` marker
+- **AND** no municipality pack SHALL be stored
+- **AND** running the step again SHALL write nothing
+
+#### Scenario: An upgrade updates an unedited pack and keeps an edited one
+@e2e exclude Upgrade-time behaviour; tests/Unit/Repair/SeedCmdbImportPacksTest.php and tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php assert it.
+
+- **GIVEN** the stored usage pack is unedited and was seeded from version 1.9.0, and the stored business-owner pack was edited by an administrator
+- **WHEN** stackiq is upgraded to a release that ships both packs at version 2.0.0
+- **THEN** the usage pack SHALL be the shipped 2.0.0 pack with a new marker
+- **AND** the business-owner pack SHALL keep the administrator's edit, and the upgrade SHALL warn that version 2.0.0 was not applied
## MODIFIED Requirements
+### Requirement: Field mapping SHALL be declarative and executed by OpenRegister's mapping engine (REQ-CMDB-005)
+
+The service SHALL map each normalised row with OpenRegister's `MigrationPack\MappingEngine::mapRow()`, once per target pack: module, manufacturer, municipality, usage, business owner. The packs and the import profile SHALL ship as JSON under `lib/Settings/cmdb-import/`; the module, manufacturer, usage and business-owner packs SHALL be taken from OpenRegister's migration-pack store when it holds them (REQ-CMDB-021), the shipped files being their seed. Each pack SHALL pass OpenRegister's `PackDefinitionValidator` and the import's rules when the import starts; an invalid pack, or a missing `MappingEngine`, SHALL stop the import with 503 `MAPPING_UNAVAILABLE` before any row is read. Before mapping, the service SHALL convert the cells of the date columns from Excel serial numbers to `Y-m-d`: the profile's date columns and every column a pack maps with a `date` transform. It SHALL turn numeric id cells into strings without a decimal part, SHALL read a value the profile lists as empty for its column (`NB` in "BNN Classificatie"; dates, "End-of-Life Functioneel" included, are kept as the file has them) as empty, and SHALL add the constants of the row's sheet (`Beheer` = `Beheer geregeld: nee` or `ja`). A mapping error on a mapping marked `required` in the module pack SHALL skip the row. In the manufacturer and owner packs it SHALL mean the row has no manufacturer or no such owner, without a warning. A mapping error on any other mapping SHALL drop only that field and add a row warning naming the column and the value. The empty parts of the usage's internal note SHALL be dropped on the separator of the concat mapping that writes it. The reader SHALL keep only the columns that the profile or a pack references, and SHALL discard every other cell when it reads the row.
+
+(Previously: the packs were read from the shipped files only, only the profile's date columns were converted, and the note was split on a fixed ` / `.)
+
+#### Scenario: Excel serial dates are converted before mapping
+@e2e exclude Pure transformation; tests/Unit/Service/Cmdb/CmdbRowNormaliserTest.php asserts the conversions below.
+
+- **GIVEN** the "Onbeh" row of the anonymised export with "Datum" = `45111.380322627316` and "Referentie datum wijziging" = `46232.552113113423`, and the "Beheerde" row with "End-of-Life Functioneel" = `53359`
+- **WHEN** the rows are normalised
+- **THEN** "Datum" SHALL be `2023-07-04`, "Referentie datum wijziging" SHALL be `2026-07-29`, and "End-of-Life Functioneel" SHALL be `2046-02-01`
+- **AND** "APPID" `1234` SHALL be the string `"1234"`
+- **AND** "BNN Classificatie" `NB` SHALL be empty
+- **AND** "End-of-Life Functioneel" `49675` SHALL be `2036-01-01`
+
+#### Scenario: Changing a pack changes the mapping without code
+@e2e exclude Configuration behaviour; tests/Unit/Service/CmdbExportImportServiceTest.php imports against an alternate module pack and against an edited stored usage pack.
+
+- **GIVEN** the usage pack in OpenRegister is edited to add a mapping from "In productie sinds" to `startDateInProduction` with a `date` transform, and its note separator is changed to ` | `
+- **WHEN** an export is imported whose row has "In productie sinds" = `45111` and "Cluster" = `B10`
+- **THEN** the usage SHALL have `startDateInProduction` = `2023-07-04`
+- **AND** its `interneAnnotation` SHALL be `Beheer geregeld: ja | B10`
+- **AND** no PHP code SHALL have changed
+
+#### Scenario: An unknown status value drops only that field
+@e2e exclude Mapping behaviour; tests/Unit/Service/CmdbExportImportServiceTest.php asserts the row outcome and warning.
+
+- **GIVEN** a row whose "Applicatie Status" is `Onbekende status`, which the usage pack's lookup does not contain
+- **WHEN** the row is imported
+- **THEN** the module and the usage SHALL be saved without a status from the export
+- **AND** the row's report entry SHALL carry a warning naming column "Applicatie Status" and value `Onbekende status`
+
+#### Scenario: The classifications map to the stackiq fields
+@e2e exclude Mapping behaviour; tests/Unit/Service/CmdbExportImportServiceTest.php imports the fixture and asserts the fields.
+
+- **GIVEN** the "Beheerde" row of the anonymised export with "Applicatiesoort" `Saas`, "BNN Classificatie" `BBN2`, "Classificatie" `Tolereren` and "End-of-Life Functioneel" `53359`
+- **WHEN** it is imported
+- **THEN** the module SHALL have `applicationType` = `Saas`, `cloudDienstverleningsmodel` = `["SaaS"]` and `bbnLevel` = `BBN2`
+- **AND** the usage SHALL have `timeClassification` = `Tolerate` and `startDateOutPhased` = `2046-02-01`
+- **AND** the "Onbeh" row's "Applicatiesoort" `Webapplicatie`, which is not a hosting model, SHALL be stored as `applicationType` and SHALL leave `cloudDienstverleningsmodel` empty without a warning
+- **AND** "BNN Classificatie" `1`, `2`, `2+` SHALL be `BBN1`, `BBN2`, `BBN2+`
+
### Requirement: The admin settings SHALL offer a CMDB import section (REQ-CMDB-014)
-Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary and a report table that can be filtered by outcome. The section SHALL also show the mapping the import uses in a collapsible block "Mapping (read-only)" (REQ-CMDB-020), and the sheet names in its help text SHALL come from that mapping, with the shipped names as the fallback until the mapping has loaded or when it cannot be loaded. Every control SHALL have a visible label, and every string SHALL be translatable.
+Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary and a report table that can be filtered by outcome. The section SHALL also show the mapping the import uses (REQ-CMDB-020): per pack its source, its version and a link to its editor in OpenRegister. The sheet names in its help text SHALL come from that mapping, with the shipped names as the fallback until the mapping has loaded or when it cannot be loaded. Every control SHALL have a visible label, and every string SHALL be translatable.
(Previously: the section had no mapping block, and the sheet names in its help text were a constant in the page, which could drift from the profile on the server.)
@@ -81,20 +216,24 @@ Stackiq's admin settings page SHALL show a section "CMDB import", rendered by th
## Non-Functional Requirements
-- **Performance:** the endpoint reads six small JSON files and validates five packs; it SHALL answer within the time an admin settings page loads and SHALL be called once per page view.
-- **Security:** admin-only with CSRF, as the import routes. The answer holds column names, field names and lookup tables from files that ship with the app; no object data and no person data. The loader's reason in `details.reason` is its message's first line, at most 500 characters: the file, the validator's rule and the configuration value it refused, never a cell value.
-- **Accessibility:** Target WCAG 2.2 AA. The block's toggle is an `NcButton` with a visible label and `aria-expanded` (SC 4.1.2; gate `button-name`), the content it controls is referenced with `aria-controls`, and each table has header cells (SC 1.3.1; gate `table-headers`). New in 2.2: 2.4.11 Focus Not Obscured applies (the tables are in the page flow, nothing sticky covers them); 2.5.7 Dragging Movements does not apply; 2.5.8 Target Size applies to the toggle (Nextcloud default); 3.2.6 Consistent Help does not apply (no help mechanism added); 3.3.7 Redundant Entry does not apply (nothing is entered); 3.3.8 Accessible Authentication does not apply.
-- **Internationalization:** Dutch and English MUST be supported (ADR-005) for the block, the table headers, the target and transformation names and the error.
+- **Performance:** loading the mapping reads seven small JSON files and the register definition, and makes at most one OpenRegister lookup per sheet pack; it SHALL answer within the time an admin settings page loads. Writes happen only to seed or update a pack.
+- **Security:** admin-only with CSRF, as the import routes; OpenRegister's pack writes are admin-only. The import writes with OpenRegister's RBAC and multitenancy off, so every pack SHALL follow the import's rules (REQ-CMDB-021). An edit that maps a new column makes the import read that column. The answer and the reasons hold column names, field names, lookup tables, pack names and ids; no object data and no person data. A store failure is named by its exception class, never its message.
+- **Reliability:** an edit is never overwritten by an upgrade and never silently replaced by the shipped files (REQ-CMDB-021, REQ-CMDB-022).
+- **Accessibility:** Target WCAG 2.2 AA. The mapping block's link has a visible, descriptive label (SC 2.4.4, 4.1.2; gate `link-name`); a status hint is text, not colour alone (SC 1.4.1). New in 2.2: 2.4.11 Focus Not Obscured applies (the block is in the page flow); 2.5.7 Dragging Movements does not apply; 2.5.8 Target Size applies to the link (Nextcloud default); 3.2.6 Consistent Help does not apply; 3.3.7 Redundant Entry does not apply (nothing is entered); 3.3.8 Accessible Authentication does not apply.
+- **Internationalization:** Dutch and English MUST be supported (ADR-005) for the block, the messages and the import warning.
## Acceptance Criteria
-- [ ] A Nextcloud admin opens "Mapping (read-only)" in the "CMDB import" section and sees five tables, one per pack, with the columns of the shipped packs.
-- [ ] The usage table shows "Applicatie Status" mapped to `status` with its lookup values.
-- [ ] With a pack file broken on the server, the block shows `MAPPING_UNAVAILABLE` with the reason and the import refuses to run with the same code.
+- [ ] After install, OpenRegister lists the four `stackiq-topdesk-*` packs as built-in packs.
+- [ ] A Nextcloud admin edits a lookup value of the usage pack in OpenRegister, and the next CMDB import uses it.
+- [ ] A pack edited to map a field the import manages makes the import answer 503 naming the pack; the mapping block shows the same.
+- [ ] Deleting the stored pack in OpenRegister makes the next import seed the shipped pack again.
+- [ ] The "CMDB import" section shows per pack its source and an "Edit in OpenRegister" link that opens the pack's editor.
- [ ] A signed-in user who is not a Nextcloud admin gets 403 from the endpoint.
-- [ ] The help text under the file control names the sheets from the endpoint.
## Notes
-- Editing stays file-based (choice B, 2026-10-09): OpenRegister has APIs for migration packs but no screen, so a stackiq editor would have been a larger change for a mapping that changes rarely. A pack edited on the server is overwritten by the next app update; lasting changes go to the app itself.
-- The transform is passed through as the pack stores it, not re-shaped per type, so a transform key the engine reads is never hidden from the administrator.
+- Decision 2026-10-12: the mapping lives in OpenRegister. The packs use OpenRegister's existing migration-pack store, not its Twig mapping entity: the packs are already migration packs, executed by `MappingEngine` with structural validation and row and column errors.
+- One mapping per instance: OpenRegister's pack store has no organisation scope. An instance that imports for more than one municipality shares the mapping between them.
+- `MigrationPackService`, `PackDefinitionValidator` and `MappingEngine` are not part of OpenRegister's `Contract` namespace; stackiq resolves them defensively.
+- Deleting a stored pack means "back to the shipped pack": the next import or upgrade seeds it again.
diff --git a/openspec/changes/cmdb-import-mapping-view/tasks.md b/openspec/changes/cmdb-import-mapping-view/tasks.md
index 36fe883c7..b36e6dc94 100644
--- a/openspec/changes/cmdb-import-mapping-view/tasks.md
+++ b/openspec/changes/cmdb-import-mapping-view/tasks.md
@@ -4,50 +4,65 @@ Spec: `openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.m
## Implementation Tasks
-### Task 1: Mapping overview on the profile loader
-- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020)
-- **files**: `lib/Service/Cmdb/CmdbImportProfile.php`, `tests/Unit/Service/Cmdb/CmdbImportProfileTest.php`
+### Task 1: The import reads the sheet packs from OpenRegister's migration-pack store
+- **spec_ref**: `SPEC#requirement-the-import-shall-run-the-mapping-packs-stored-in-openregister-req-cmdb-021` (cmdb-export-import#REQ-CMDB-021) and `SPEC#requirement-field-mapping-shall-be-declarative-and-executed-by-openregisters-mapping-engine-req-cmdb-005` (cmdb-export-import#REQ-CMDB-005)
+- **files**: `lib/Service/Cmdb/CmdbImportProfile.php`, `lib/Service/Cmdb/CmdbImportPackStore.php`, `lib/Service/Cmdb/CmdbImportPackMarker.php`, `lib/Service/Cmdb/CmdbImportPackRules.php`, `lib/Service/CmdbExportImportService.php`, `lib/Controller/CmdbImportController.php`, `tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php`, `tests/Unit/Service/Cmdb/CmdbImportPackRulesTest.php`, `tests/Unit/Service/Cmdb/CmdbImportProfileTest.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php`, `tests/Unit/Controller/CmdbImportControllerTest.php`, `tests/Unit/Support/FakeMigrationPackService.php`
- **acceptance_criteria**:
- - GIVEN the shipped directory WHEN `mappingOverview()` is called THEN it loads and validates every pack first and answers `profile` and `packs` with five entries in TARGETS order, each with target, file, id, name, version, description, fieldMappings and the write rules (defaults, createOnlyDefaults, createOnlyFields, neverWritten)
- - GIVEN a field mapping WHEN it is shaped THEN `required` is a boolean and `transform` is the stored array
- - GIVEN a broken pack WHEN `mappingOverview()` is called THEN it throws `CmdbImportException` MAPPING_UNAVAILABLE with the file in its message
+ - GIVEN OpenRegister's store holds an edited module pack WHEN the profile loads THEN the module pack is the stored one without its marker, and the missing sheet packs are seeded; the municipality pack is the shipped file
+ - GIVEN an unedited stored pack seeded from an older release WHEN the profile loads THEN it is replaced by the shipped pack; GIVEN an edited one THEN it is kept and reported `newerShipped`
+ - GIVEN a stored pack the validator refuses, or that breaks a rule of the import WHEN the profile loads THEN 503 `MAPPING_UNAVAILABLE` names the pack and its id in the message and the details
+ - GIVEN no store and no recorded pack ids THEN the shipped files are used and the import report carries one warning; GIVEN no store and recorded ids THEN 503 naming the packs
+ - GIVEN a renamed stored pack THEN 503 naming the row and both slugs; GIVEN a deleted one THEN it is seeded again
+ - GIVEN a stored pack that maps a new column with a date transform WHEN a row is imported THEN that column is converted from an Excel date; the note cleanup uses the pack's concat separator
- [x] Implement
- [x] Test
-### Task 2: Endpoint `GET /api/settings/cmdb-import/mapping`
-- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020)
-- **files**: `lib/Controller/SettingsController.php`, `appinfo/routes.php`, `tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php`
+### Task 2: Seed and update the packs in OpenRegister on install and upgrade
+- **spec_ref**: `SPEC#requirement-stackiq-shall-seed-its-mapping-packs-into-openregister-and-keep-unedited-ones-current-req-cmdb-022` (cmdb-export-import#REQ-CMDB-022)
+- **files**: `lib/Repair/SeedCmdbImportPacks.php`, `appinfo/info.xml`, `tests/Unit/Repair/SeedCmdbImportPacksTest.php`
- **acceptance_criteria**:
- - GIVEN the route WHEN its method is reflected THEN it carries no attribute, declares `@auth admin-only` with a reason and no `AuthorizedAdminSetting`, `NoAdminRequired`, `NoCSRFRequired` or `PublicPage` annotation
- - GIVEN the shipped directory WHEN an admin calls it THEN 200 with the flat `{profile, packs}` and five packs
- - GIVEN a broken pack THEN 503 `MAPPING_UNAVAILABLE` with the reason in `details.reason`; GIVEN an unexpected error THEN 500 `IMPORT_FAILED`, logged, with a static message
+ - GIVEN an empty store WHEN the step runs THEN the four sheet packs are created as built-in packs with the `x-stackiq` marker and their ids are recorded; a second run writes nothing
+ - GIVEN an unedited pack from an older release THEN it is updated; GIVEN an edited one THEN it is kept and the step warns that the shipped version was not applied
+ - GIVEN no store, or a failing store THEN the step reports and never throws
+ - GIVEN `appinfo/info.xml` THEN the step runs after `InitializeSettings` in `` and ``
- [x] Implement
- [x] Test
-### Task 3: The "Mapping (read-only)" block and the sheet names
-- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020) and `SPEC#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014` (cmdb-export-import#REQ-CMDB-014)
-- **files**: `src/views/settings/sections/CmdbImportMapping.vue`, `src/views/settings/sections/CmdbImport.vue`, `src/utils/cmdbImport.js`, `tests/vitest/cmdbImportMapping.spec.js`
+### Task 3: The mapping endpoint says where each pack comes from and links to its editor
+- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020)
+- **files**: `lib/Service/Cmdb/CmdbImportProfile.php`, `lib/Controller/SettingsController.php`, `appinfo/routes.php`, `tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php`, `tests/Unit/Service/Cmdb/CmdbImportProfileTest.php`
- **acceptance_criteria**:
- - GIVEN the endpoint's answer WHEN the block is expanded THEN one table per pack renders with `data-testid="cmdb-import-mapping-"`, the usage table holding "Applicatie Status" → `status` with the lookup pairs
- - GIVEN a 503 WHEN the block loads THEN it shows the error and the code and no table
- - GIVEN the answer WHEN the section renders its help text THEN the sheet names are the endpoint's; before and on failure they are `PROFILE_DEFAULTS.sheets`
+ - GIVEN the route WHEN its method is reflected THEN it carries no attribute, declares `@auth admin-only` with a reason and no exemption annotation
+ - GIVEN the shipped files and no store THEN 200 with `{profile, packs, openRegisterPacks: false}`, every pack `source: file`, `editUrl: null`
+ - GIVEN the store THEN the four sheet packs answer `source: openregister`, `packId`, `storedVersion`, `shippedVersion`, `edited`, `newerShipped`, `editable: true` and `editUrl` built from OpenRegister's page route; the municipality pack answers `editable: false`
+ - GIVEN a broken stored pack THEN 503 with `details.reason`, `details.pack` and `details.packId`
- [x] Implement
- [x] Test
-### Task 4: Translations and documentation
+### Task 4: The section's mapping block shows the source and the editor link per pack
+- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014` (cmdb-export-import#REQ-CMDB-014) and `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020)
+- **files**: `src/views/settings/sections/CmdbImportMapping.vue`, `src/views/settings/sections/CmdbImport.vue`, `src/utils/cmdbImport.js`, `tests/vitest/cmdbImportMapping.spec.js`
+- **acceptance_criteria**:
+ - GIVEN the endpoint's answer WHEN the block renders THEN each pack shows its source, its version, a hint when `newerShipped`, and an "Edit in OpenRegister" link to `editUrl` when there is one
+ - GIVEN a 503 THEN the block shows the error, the code and the reason
+ - GIVEN the answer THEN the help text names the endpoint's sheets; before and on failure `PROFILE_DEFAULTS.sheets`
+- [ ] Implement
+- [ ] Test
+
+### Task 5: Translations and documentation
- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020)
- **files**: `l10n/en.json`, `l10n/nl.json`, `l10n/en.js`, `l10n/nl.js`, `docs/features/cmdb-import.md`
- **acceptance_criteria**:
- GIVEN the new strings WHEN `npm run test:l10n` and `npm run check:l10n-js` run THEN both pass, with Dutch for every string
- - GIVEN the docs WHEN an administrator reads "Viewing the mapping" and "Adjusting the mapping" THEN they know where the block is, what it shows, which file to change for which column and that an app update overwrites a changed file
-- [x] Implement
+ - GIVEN the docs WHEN an administrator reads "Viewing and editing the mapping" THEN they know where to edit a pack, what the import refuses, what happens on an app update and how to go back to the shipped pack
+- [x] Implement (backend strings and documentation; the section's strings come with Task 4)
- [x] Test
-### Task 5: End-to-end coverage
+### Task 6: End-to-end coverage
- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020)
- **files**: `tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts`
- **acceptance_criteria**:
- - GIVEN an admin on the settings page WHEN they expand the block THEN five tables are visible and the usage row for "Applicatie Status" is shown
- - GIVEN a signed-in non-admin WHEN they call the endpoint THEN 403, and the admin settings page is not shown to them
-- [x] Implement
-- [ ] Test (written, not run: no Nextcloud was reachable in this build)
+ - GIVEN an admin on the settings page WHEN the mapping has loaded THEN every sheet pack shows its source and an "Edit in OpenRegister" link
+ - GIVEN a signed-in non-admin WHEN they call the endpoint THEN 403
+- [ ] Implement
+- [ ] Test
diff --git a/openspec/specs/cmdb-export-import/spec.md b/openspec/specs/cmdb-export-import/spec.md
index 8818d842b..d285d7409 100644
--- a/openspec/specs/cmdb-export-import/spec.md
+++ b/openspec/specs/cmdb-export-import/spec.md
@@ -10,7 +10,7 @@ built_by: openspec/changes/archive/2026-10-05-cmdb-export-import
**Scope**: stackiq
**OpenSpec changes**:
- [cmdb-export-import](../../changes/archive/2026-10-05-cmdb-export-import/) _(archived 2026-10-05)_ — admin uploads a TOPdesk CMDB export (xlsx); stackiq upserts modules, vendor organisations, usages and owner contact persons for one municipality from the two CMDB sheets, matched on APPID, mapped by OpenRegister migration packs (kind: code)
-- [cmdb-import-mapping-view](../../changes/cmdb-import-mapping-view/) — the "CMDB import" section shows, read-only, the mapping the import uses, served by `GET /api/settings/cmdb-import/mapping` through the import's own loader; editing stays file-based (kind: code)
+- [cmdb-import-mapping-view](../../changes/cmdb-import-mapping-view/) — the four sheet packs are seeded into OpenRegister's migration-pack store, where an administrator edits them, and the import runs what is stored there (shipped files as seed and fallback, fail-closed on an invalid pack); the "CMDB import" section shows per pack its source and a link to its editor, served by `GET /api/settings/cmdb-import/mapping` (kind: code)
## Purpose
diff --git a/tests/Unit/Controller/CmdbImportControllerTest.php b/tests/Unit/Controller/CmdbImportControllerTest.php
index a27b7cfbf..b18e7d8f5 100644
--- a/tests/Unit/Controller/CmdbImportControllerTest.php
+++ b/tests/Unit/Controller/CmdbImportControllerTest.php
@@ -396,6 +396,7 @@ public function testAMunicipalityIsRequired(): void {
public static function serviceErrors(): array {
return [
'mapping' => [CmdbImportException::MAPPING_UNAVAILABLE, 503, []],
+ 'stored mapping pack' => [CmdbImportException::MAPPING_UNAVAILABLE, 503, ['pack' => 'stackiq-topdesk-usage', 'packId' => 43]],
'reader' => [CmdbImportException::READER_UNAVAILABLE, 503, []],
'config' => [CmdbImportException::NOT_CONFIGURED, 503, []],
'no sheet' => [CmdbImportException::NO_SOURCE_SHEET, 422, ['expected' => ['Onbeh Applicaties CMDB', 'Beheerde Applicaties CMDB']]],
@@ -442,6 +443,48 @@ public function testEngineCodesHaveTheirOwnMessage(string $code, string $message
$this->assertSame($message, $response->getData()['message']);
}//end testEngineCodesHaveTheirOwnMessage()
+ /**
+ * MAPPING_UNAVAILABLE names the OpenRegister migration pack at fault, with its id when known.
+ *
+ * @return array, string}>
+ */
+ public static function mappingMessages(): array {
+ return [
+ 'no pack' => [[], 'The import mapping cannot run: OpenRegister is missing, or a mapping pack is invalid or unavailable.'],
+ 'stored pack' => [
+ ['pack' => 'stackiq-topdesk-usage', 'packId' => 43],
+ 'The import mapping cannot run: the OpenRegister migration pack stackiq-topdesk-usage (id 43) is invalid or cannot be read. The details are in the Nextcloud log.',
+ ],
+ 'pack without id' => [
+ ['pack' => 'stackiq-topdesk-usage'],
+ 'The import mapping cannot run: the OpenRegister migration pack stackiq-topdesk-usage is invalid or cannot be read. The details are in the Nextcloud log.',
+ ],
+ ];
+ }//end mappingMessages()
+
+ /**
+ * The MAPPING_UNAVAILABLE message names the pack from the details.
+ *
+ * @param array $details The exception details.
+ * @param string $message The expected message.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ #[DataProvider('mappingMessages')]
+ public function testTheMappingMessageNamesThePack(array $details, string $message): void {
+ $service = $this->service();
+ $service->method('import')->willThrowException(
+ new CmdbImportException(errorCode: CmdbImportException::MAPPING_UNAVAILABLE, message: 'internal', details: $details)
+ );
+
+ $response = $this->controller(file: $this->file(path: $this->upload()), params: ['municipalityName' => 'Gemeente Voorbeeldstad'], service: $service)->import();
+
+ $this->assertSame(503, $response->getStatus());
+ $this->assertSame($message, $response->getData()['message']);
+ }//end testTheMappingMessageNamesThePack()
+
/**
* A service exception becomes its contract response.
*
diff --git a/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php b/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
index 69bdc844c..7f84ca17e 100644
--- a/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
+++ b/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
@@ -23,6 +23,7 @@
namespace OCA\Stackiq\Tests\Unit\Controller;
require_once __DIR__ . '/../Support/CmdbTestSupport.php';
+require_once __DIR__ . '/../Support/FakeMigrationPackService.php';
use OCA\Stackiq\Controller\SettingsController;
use OCA\Stackiq\Exception\CmdbImportException;
@@ -33,6 +34,7 @@
use OCA\Stackiq\Service\ProgressTracker;
use OCA\Stackiq\Service\SettingsService;
use OCA\Stackiq\Tests\Unit\Support\CmdbTestSupport;
+use OCA\Stackiq\Tests\Unit\Support\FakeMigrationPackService;
use OCP\App\IAppManager;
use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
@@ -42,6 +44,7 @@
use OCP\IGroupManager;
use OCP\IL10N;
use OCP\IRequest;
+use OCP\IURLGenerator;
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
@@ -113,10 +116,16 @@ private function copyOfShippedDirectory(): string {
* @param CmdbImportProfile|null $profile The profile; null lets the controller build the shipped one.
* @param LoggerInterface|MockObject|null $logger The logger.
* @param IL10N|null $l10n The translator; null answers the English text.
+ * @param IURLGenerator|null $urlGenerator The URL generator; null builds no edit links.
*
* @return SettingsController
*/
- private function controller(?CmdbImportProfile $profile, LoggerInterface|MockObject|null $logger = null, ?IL10N $l10n = null): SettingsController {
+ private function controller(
+ ?CmdbImportProfile $profile,
+ LoggerInterface|MockObject|null $logger = null,
+ ?IL10N $l10n = null,
+ ?IURLGenerator $urlGenerator = null
+ ): SettingsController {
$request = $this->createMock(IRequest::class);
$request->method('getParams')->willReturn([]);
@@ -136,7 +145,8 @@ private function controller(?CmdbImportProfile $profile, LoggerInterface|MockObj
($logger ?? $this->createMock(LoggerInterface::class)),
null,
$profile,
- $l10n
+ $l10n,
+ $urlGenerator
);
}//end controller()
@@ -197,7 +207,8 @@ public function testTheShippedMappingIsAnsweredFlat(): void {
$data = $response->getData();
$this->assertSame(200, $response->getStatus());
- $this->assertSame(['profile', 'packs'], array_keys($data), 'flat envelope, no success or message key');
+ $this->assertSame(['profile', 'packs', 'openRegisterPacks'], array_keys($data), 'flat envelope, no success or message key');
+ $this->assertFalse($data['openRegisterPacks']);
$this->assertSame(['Onbeh Applicaties CMDB', 'Beheerde Applicaties CMDB'], array_column($data['profile']['sheets'], 'name'));
$this->assertSame(['Beheer' => 'Beheer geregeld: nee'], $data['profile']['sheets'][0]['constants']);
@@ -225,6 +236,8 @@ public function testTheShippedMappingIsAnsweredFlat(): void {
// What is shown is what runs: every pack lists as many mappings as the loader hands the engine.
foreach ($data['packs'] as $pack) {
+ $this->assertSame('file', $pack['source'], $pack['target']);
+ $this->assertNull($pack['editUrl'], $pack['target'] . ': a file is not edited in OpenRegister');
$this->assertNotSame('', $pack['name'], $pack['target']);
$this->assertNotSame('', $pack['version'], $pack['target']);
$this->assertCount(count($profile->pack(target: $pack['target'])['fieldMappings']), $pack['fieldMappings'], $pack['target']);
@@ -293,7 +306,7 @@ public function testABrokenPackIs503WithTheReason(): void {
$this->assertSame(503, $response->getStatus());
$this->assertFalse($data['success']);
$this->assertSame('MAPPING_UNAVAILABLE', $data['error']);
- $this->assertSame('NL The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.', $data['message']);
+ $this->assertSame('NL The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.', $data['message']);
$this->assertStringContainsString('topdesk-usage.json', $data['details']->reason);
$this->assertStringContainsString('uppercase', $data['details']->reason, 'the validator names the unknown transform');
}//end testABrokenPackIs503WithTheReason()
@@ -313,7 +326,7 @@ public function testAMissingValidatorIs503(): void {
$this->assertSame(503, $response->getStatus());
$this->assertSame('MAPPING_UNAVAILABLE', $data['error']);
- $this->assertSame('The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.', $data['message']);
+ $this->assertSame('The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.', $data['message']);
$this->assertSame('OpenRegister PackDefinitionValidator is not available', $data['details']->reason);
}//end testAMissingValidatorIs503()
@@ -348,6 +361,114 @@ public function mappingOverview(): array {
$this->assertSame(500, mb_strlen($reason));
}//end testTheReasonIsOneBoundedLine()
+ /**
+ * A pack stored in OpenRegister answers its source, versions and the absolute link to its editor.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-3
+ */
+ public function testAStoredPackLinksToItsEditorInOpenRegister(): void {
+ CmdbTestSupport::loadMigrationPack();
+ $service = new FakeMigrationPackService();
+ $config = [];
+ $profile = new CmdbImportProfile(
+ container: $service->container(test: $this, appConfig: FakeMigrationPackService::appConfig(test: $this, values: $config))
+ );
+ $routes = [];
+ $urls = $this->createMock(IURLGenerator::class);
+ $urls->method('linkToRouteAbsolute')->willReturnCallback(
+ function (string $route, array $parameters) use (&$routes): string {
+ $routes[] = $route;
+ return 'https://cloud.example/index.php/apps/openregister/' . $parameters['path'];
+ }
+ );
+
+ $data = $this->controller(profile: $profile, urlGenerator: $urls)->getCmdbImportMapping()->getData();
+ $byTarget = array_column($data['packs'], null, 'target');
+
+ $this->assertTrue($data['openRegisterPacks']);
+ foreach (CmdbImportProfile::EDITABLE_TARGETS as $target) {
+ $pack = $byTarget[$target];
+ $this->assertSame('openregister', $pack['source'], $target);
+ $this->assertIsInt($pack['packId'], $target);
+ $this->assertSame(
+ 'https://cloud.example/index.php/apps/openregister/migration-packs/' . $pack['packId'],
+ $pack['editUrl'],
+ $target
+ );
+ $this->assertTrue($pack['editable'], $target);
+ $this->assertFalse($pack['edited'], $target . ' is as seeded');
+ $this->assertFalse($pack['newerShipped'], $target);
+ $this->assertSame($pack['shippedVersion'], $pack['storedVersion'], $target);
+ }
+
+ $this->assertSame(['openregister.dashboard.catchAll'], array_values(array_unique($routes)), "OpenRegister's page route");
+ $this->assertSame('file', $byTarget['municipality']['source']);
+ $this->assertNull($byTarget['municipality']['editUrl']);
+ $this->assertFalse($byTarget['municipality']['editable']);
+ $this->assertSame(
+ ['target', 'file', 'id', 'name', 'version', 'description', 'fieldMappings', 'defaults', 'createOnlyDefaults', 'createOnlyFields', 'neverWritten', 'source', 'packId', 'storedVersion', 'shippedVersion', 'edited', 'newerShipped', 'editable', 'editUrl'],
+ array_keys($byTarget['module']),
+ 'the keys the settings section reads'
+ );
+ }//end testAStoredPackLinksToItsEditorInOpenRegister()
+
+ /**
+ * When OpenRegister's route cannot be built the link is null, never the instance's front page.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-3
+ */
+ public function testAnUnknownRouteGivesNoLink(): void {
+ CmdbTestSupport::loadMigrationPack();
+ $service = new FakeMigrationPackService();
+ $config = [];
+ $profile = new CmdbImportProfile(
+ container: $service->container(test: $this, appConfig: FakeMigrationPackService::appConfig(test: $this, values: $config))
+ );
+ $urls = $this->createMock(IURLGenerator::class);
+ $urls->method('linkToRouteAbsolute')->willReturn('https://cloud.example/');
+
+ $data = $this->controller(profile: $profile, urlGenerator: $urls)->getCmdbImportMapping()->getData();
+
+ $this->assertSame([null], array_values(array_unique(array_column($data['packs'], 'editUrl'))));
+
+ $throwing = $this->createMock(IURLGenerator::class);
+ $throwing->method('linkToRouteAbsolute')->willThrowException(new RuntimeException('no router'));
+ $data = $this->controller(profile: $profile, urlGenerator: $throwing)->getCmdbImportMapping()->getData();
+ $this->assertSame([null], array_values(array_unique(array_column($data['packs'], 'editUrl'))));
+ }//end testAnUnknownRouteGivesNoLink()
+
+ /**
+ * A stored pack that breaks a rule answers 503 naming the OpenRegister pack and its id.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-3
+ */
+ public function testABrokenStoredPackIs503NamingThePack(): void {
+ CmdbTestSupport::loadMigrationPack();
+ $service = new FakeMigrationPackService();
+ $usage = json_decode((string)file_get_contents(CmdbTestSupport::appRoot() . '/lib/Settings/cmdb-import/topdesk-usage.json'), true);
+ $usage['fieldMappings'][0]['target'] = 'provider';
+ $id = $service->put(definition: $usage);
+ $config = [];
+ $profile = new CmdbImportProfile(
+ container: $service->container(test: $this, appConfig: FakeMigrationPackService::appConfig(test: $this, values: $config))
+ );
+
+ $response = $this->controller(profile: $profile)->getCmdbImportMapping();
+
+ $this->assertSame(503, $response->getStatus());
+ $this->assertSame('MAPPING_UNAVAILABLE', $response->getData()['error']);
+ $this->assertStringStartsWith('OpenRegister migration pack stackiq-topdesk-usage (id ' . $id . ')', $response->getData()['details']->reason);
+ $this->assertStringContainsString('"provider" is set by the import', $response->getData()['details']->reason);
+ $this->assertSame('stackiq-topdesk-usage', $response->getData()['details']->pack);
+ $this->assertSame($id, $response->getData()['details']->packId);
+ }//end testABrokenStoredPackIs503NamingThePack()
+
/**
* An unexpected error is 500 IMPORT_FAILED with a static message, and the exception is logged.
*
diff --git a/tests/Unit/Repair/SeedCmdbImportPacksTest.php b/tests/Unit/Repair/SeedCmdbImportPacksTest.php
new file mode 100644
index 000000000..e792f054b
--- /dev/null
+++ b/tests/Unit/Repair/SeedCmdbImportPacksTest.php
@@ -0,0 +1,212 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-2
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Tests\Unit\Repair;
+
+require_once __DIR__ . '/../Support/CmdbTestSupport.php';
+require_once __DIR__ . '/../Support/FakeMigrationPackService.php';
+
+use OCA\Stackiq\Repair\SeedCmdbImportPacks;
+use OCA\Stackiq\Service\Cmdb\CmdbImportPackMarker;
+use OCA\Stackiq\Service\Cmdb\CmdbImportPackStore;
+use OCA\Stackiq\Service\Cmdb\CmdbImportProfile;
+use OCA\Stackiq\Tests\Unit\Support\CmdbTestSupport;
+use OCA\Stackiq\Tests\Unit\Support\FakeMigrationPackService;
+use OCP\Migration\IOutput;
+use OCP\Migration\IRepairStep;
+use PHPUnit\Framework\TestCase;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+
+/**
+ * SeedCmdbImportPacks against an in-memory MigrationPackService.
+ */
+class SeedCmdbImportPacksTest extends TestCase {
+ /**
+ * What the step wrote to its output, as "info: …" and "warning: …".
+ *
+ * @var array
+ */
+ private array $lines = [];
+
+ /**
+ * An output that records its lines.
+ *
+ * @return IOutput
+ */
+ private function recordingOutput(): IOutput {
+ $this->lines = [];
+ $output = $this->createMock(IOutput::class);
+ $output->method('info')->willReturnCallback(function (string $message): void {
+ $this->lines[] = 'info: ' . $message;
+ });
+ $output->method('warning')->willReturnCallback(function (string $message): void {
+ $this->lines[] = 'warning: ' . $message;
+ });
+
+ return $output;
+ }//end output()
+
+ /**
+ * The step on the fake service.
+ *
+ * @param FakeMigrationPackService $service The fake service.
+ * @param array $config The app config values.
+ * @param LoggerInterface|null $logger The logger.
+ *
+ * @return SeedCmdbImportPacks
+ */
+ private function step(FakeMigrationPackService $service, array &$config, ?LoggerInterface $logger = null): SeedCmdbImportPacks {
+ $container = $service->container(test: $this, appConfig: FakeMigrationPackService::appConfig(test: $this, values: $config));
+ return new SeedCmdbImportPacks(
+ profile: new CmdbImportProfile(container: $container),
+ store: new CmdbImportPackStore(container: $container),
+ logger: ($logger ?? $this->createMock(LoggerInterface::class))
+ );
+ }//end step()
+
+ /**
+ * The step is a repair step and names itself.
+ *
+ * @return void
+ */
+ public function testItIsARepairStepWithAName(): void {
+ $config = [];
+ $step = $this->step(service: new FakeMigrationPackService(), config: $config);
+
+ $this->assertInstanceOf(IRepairStep::class, $step);
+ $this->assertSame('Seed the CMDB import mapping packs into OpenRegister', $step->getName());
+ }//end testItIsARepairStepWithAName()
+
+ /**
+ * A fresh store gets the four sheet packs as built-in packs, and their ids are recorded; the municipality pack never.
+ *
+ * @return void
+ */
+ public function testTheSheetPacksAreSeeded(): void {
+ CmdbTestSupport::loadMigrationPack();
+ $service = new FakeMigrationPackService();
+ $config = [];
+
+ $this->step(service: $service, config: $config)->run($this->recordingOutput());
+
+ $this->assertSame(
+ ['create stackiq-topdesk-module', 'create stackiq-topdesk-manufacturer', 'create stackiq-topdesk-usage', 'create stackiq-topdesk-business-owner'],
+ $service->writes
+ );
+ $this->assertSame([true], array_values(array_unique(array_column($service->rows, 'builtin'))));
+ $this->assertCount(4, json_decode($config[CmdbImportPackStore::CONFIG_KEY], true));
+ $this->assertCount(4, preg_grep('/^info: Seeded CMDB import pack stackiq-topdesk-/', $this->lines));
+
+ $this->step(service: $service, config: $config)->run($this->recordingOutput());
+ $this->assertCount(4, $service->writes, 'a second run writes nothing');
+ $this->assertCount(4, preg_grep('/already present$/', $this->lines));
+ }//end testTheSheetPacksAreSeeded()
+
+ /**
+ * An unedited pack from an older release is updated; an edited one is kept with a warning.
+ *
+ * @return void
+ */
+ public function testUneditedPacksAreUpdatedAndEditedOnesKept(): void {
+ CmdbTestSupport::loadMigrationPack();
+ $service = new FakeMigrationPackService();
+ $older = json_decode((string)file_get_contents(CmdbTestSupport::appRoot() . '/lib/Settings/cmdb-import/topdesk-usage.json'), true);
+ $older['version'] = '1.9.0';
+ unset($older['fieldMappings'][0]['transform']['map']['Besteld']);
+ $usageId = $service->put(definition: (new CmdbImportPackMarker())->seed(shipped: $older));
+
+ $olderOwner = json_decode((string)file_get_contents(CmdbTestSupport::appRoot() . '/lib/Settings/cmdb-import/topdesk-business-owner.json'), true);
+ $olderOwner['version'] = '1.9.0';
+ $edited = (new CmdbImportPackMarker())->seed(shipped: $olderOwner);
+ $edited['fieldMappings'][1]['source'] = 'Functie eigenaar';
+ $ownerId = $service->put(definition: $edited);
+ $config = [];
+
+ $this->step(service: $service, config: $config)->run($this->recordingOutput());
+
+ $this->assertContains('update ' . $usageId, $service->writes);
+ $this->assertNotContains('update ' . $ownerId, $service->writes);
+ $this->assertSame('Functie eigenaar', $service->definition(id: $ownerId)['fieldMappings'][1]['source']);
+ $this->assertContains(
+ 'info: Updated unedited CMDB import pack stackiq-topdesk-usage (id ' . $usageId . ') to version 2.0.0',
+ $this->lines
+ );
+ $this->assertContains(
+ 'warning: CMDB import pack stackiq-topdesk-business-owner (id ' . $ownerId . ') was edited in OpenRegister and is kept; '
+ . 'this release ships version 2.0.0, which is not applied',
+ $this->lines
+ );
+ }//end testUneditedPacksAreUpdatedAndEditedOnesKept()
+
+ /**
+ * Without OpenRegister's store the step does nothing and does not throw.
+ *
+ * @return void
+ */
+ public function testWithoutTheStoreNothingIsSeeded(): void {
+ $container = $this->createMock(ContainerInterface::class);
+ $container->method('has')->willReturn(false);
+ $step = new SeedCmdbImportPacks(
+ profile: new CmdbImportProfile(container: $container),
+ store: new CmdbImportPackStore(container: $container),
+ logger: $this->createMock(LoggerInterface::class)
+ );
+
+ $step->run($this->recordingOutput());
+
+ $this->assertSame(['info: OpenRegister migration pack store not available; CMDB import packs not seeded'], $this->lines);
+ }//end testWithoutTheStoreNothingIsSeeded()
+
+ /**
+ * A failing store is reported per pack and logged, never thrown, so an install is not aborted.
+ *
+ * @return void
+ */
+ public function testAFailingStoreIsReportedNotThrown(): void {
+ CmdbTestSupport::loadMigrationPack();
+ $service = new FakeMigrationPackService();
+ $service->failure = new RuntimeException('database gone');
+ $config = [];
+ $logger = $this->createMock(LoggerInterface::class);
+ $logger->expects($this->exactly(4))->method('warning');
+
+ $this->step(service: $service, config: $config, logger: $logger)->run($this->recordingOutput());
+
+ $this->assertCount(4, preg_grep('/^warning: CMDB import pack stackiq-topdesk-[a-z-]+ not seeded: /', $this->lines));
+ $this->assertSame([], $service->writes);
+ }//end testAFailingStoreIsReportedNotThrown()
+
+ /**
+ * The step is registered after InitializeSettings, on install and on upgrade.
+ *
+ * @return void
+ */
+ public function testItIsRegisteredOnInstallAndUpgrade(): void {
+ $info = simplexml_load_file(__DIR__ . '/../../../appinfo/info.xml');
+ foreach (['install', 'post-migration'] as $block) {
+ $steps = array_map('strval', iterator_to_array($info->{'repair-steps'}->{$block}->step, false));
+ $seed = array_search(SeedCmdbImportPacks::class, $steps, true);
+ $this->assertIsInt($seed, $block);
+ $this->assertGreaterThan(array_search('OCA\Stackiq\Repair\InitializeSettings', $steps, true), $seed, $block);
+ }
+ }//end testItIsRegisteredOnInstallAndUpgrade()
+}//end class
diff --git a/tests/Unit/Service/Cmdb/CmdbImportPackRulesTest.php b/tests/Unit/Service/Cmdb/CmdbImportPackRulesTest.php
new file mode 100644
index 000000000..5c039f807
--- /dev/null
+++ b/tests/Unit/Service/Cmdb/CmdbImportPackRulesTest.php
@@ -0,0 +1,103 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Tests\Unit\Service\Cmdb;
+
+require_once __DIR__ . '/../../Support/CmdbTestSupport.php';
+
+use OCA\Stackiq\Exception\CmdbImportException;
+use OCA\Stackiq\Service\Cmdb\CmdbImportPackRules;
+use OCA\Stackiq\Tests\Unit\Support\CmdbTestSupport;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * CmdbImportPackRules.
+ */
+class CmdbImportPackRulesTest extends TestCase {
+ /**
+ * A shipped file, decoded.
+ *
+ * @param string $file The file name.
+ *
+ * @return array
+ */
+ private function shipped(string $file): array {
+ return json_decode((string)file_get_contents(CmdbTestSupport::appRoot() . '/lib/Settings/cmdb-import/' . $file), true);
+ }//end shipped()
+
+ /**
+ * Every shipped pack follows the rules, including the module fields only a register fragment declares.
+ *
+ * @return void
+ */
+ public function testTheShippedPacksFollowTheRules(): void {
+ $rules = new CmdbImportPackRules();
+ $profile = $this->shipped(file: 'topdesk-profile.json');
+
+ foreach ($profile['packs'] as $target => $file) {
+ $this->assertSame([], $rules->violations(target: $target, pack: $this->shipped(file: $file), profile: $profile), $target);
+ }
+ }//end testTheShippedPacksFollowTheRules()
+
+ /**
+ * Several broken rules are all named, once each.
+ *
+ * @return void
+ */
+ public function testEveryBrokenRuleIsNamedOnce(): void {
+ $profile = $this->shipped(file: 'topdesk-profile.json');
+ $pack = $this->shipped(file: 'topdesk-manufacturer.json');
+ $pack['fieldMappings'][0]['required'] = false;
+ $pack['fieldMappings'][] = ['source' => 'Leverancier', 'target' => 'provider'];
+ $pack['fieldMappings'][] = ['source' => 'Hostingpartij', 'target' => 'provider'];
+ unset($pack['defaults']['type']);
+
+ $this->assertSame(
+ [
+ '"name" must be mapped as required',
+ '"provider" is set by the import and cannot be mapped',
+ 'default "type" must stay "Supplier"',
+ ],
+ (new CmdbImportPackRules())->violations(target: 'manufacturer', pack: $pack, profile: $profile)
+ );
+ }//end testEveryBrokenRuleIsNamedOnce()
+
+ /**
+ * Without the register definition no pack can be checked, so the import fails closed.
+ *
+ * @return void
+ */
+ public function testAnUnreadableRegisterIsMappingUnavailable(): void {
+ $rules = new CmdbImportPackRules(settingsDirectory: '/nonexistent');
+
+ try {
+ $rules->violations(target: 'usage', pack: $this->shipped(file: 'topdesk-usage.json'), profile: []);
+ $this->fail('MAPPING_UNAVAILABLE expected');
+ } catch (CmdbImportException $e) {
+ $this->assertSame('MAPPING_UNAVAILABLE', $e->getErrorCode());
+ $this->assertSame('The stackiq register definition cannot be read, so the CMDB import packs cannot be checked', $e->getMessage());
+ }
+ }//end testAnUnreadableRegisterIsMappingUnavailable()
+}//end class
diff --git a/tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php b/tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php
new file mode 100644
index 000000000..ac32ce3f1
--- /dev/null
+++ b/tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php
@@ -0,0 +1,296 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Tests\Unit\Service\Cmdb;
+
+require_once __DIR__ . '/../../Support/CmdbTestSupport.php';
+require_once __DIR__ . '/../../Support/FakeMigrationPackService.php';
+
+use OCA\Stackiq\Exception\CmdbImportException;
+use OCA\Stackiq\Service\Cmdb\CmdbImportPackMarker;
+use OCA\Stackiq\Service\Cmdb\CmdbImportPackStore;
+use OCA\Stackiq\Tests\Unit\Support\CmdbTestSupport;
+use OCA\Stackiq\Tests\Unit\Support\FakeMigrationPackService;
+use PHPUnit\Framework\TestCase;
+use Psr\Container\ContainerInterface;
+use RuntimeException;
+
+/**
+ * CmdbImportPackStore against an in-memory MigrationPackService.
+ */
+class CmdbImportPackStoreTest extends TestCase {
+ /**
+ * The fake OpenRegister service.
+ *
+ * @var FakeMigrationPackService
+ */
+ private FakeMigrationPackService $service;
+
+ /**
+ * The app config values the store wrote.
+ *
+ * @var array
+ */
+ private array $config = [];
+
+ /**
+ * A fresh service and config per test.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ parent::setUp();
+ $this->service = new FakeMigrationPackService();
+ $this->config = [];
+ }//end setUp()
+
+ /**
+ * The store on the fake service, with an app config.
+ *
+ * @return CmdbImportPackStore
+ */
+ private function store(): CmdbImportPackStore {
+ return new CmdbImportPackStore(
+ container: $this->service->container(test: $this, appConfig: FakeMigrationPackService::appConfig(test: $this, values: $this->config))
+ );
+ }//end store()
+
+ /**
+ * The shipped usage pack.
+ *
+ * @return array
+ */
+ private function shippedUsage(): array {
+ return json_decode((string)file_get_contents(CmdbTestSupport::appRoot() . '/lib/Settings/cmdb-import/topdesk-usage.json'), true);
+ }//end shippedUsage()
+
+ /**
+ * The shipped usage pack as a later release would ship it: one more status value, a newer version.
+ *
+ * @return array
+ */
+ private function newerUsage(): array {
+ $pack = $this->shippedUsage();
+ $pack['version'] = '2.1.0';
+ $pack['fieldMappings'][0]['transform']['map']['Gepland'] = 'Planned';
+ return $pack;
+ }//end newerUsage()
+
+ /**
+ * Without OpenRegister's service the store is not available and refuses to resolve.
+ *
+ * @return void
+ */
+ public function testWithoutTheServiceTheStoreIsNotAvailable(): void {
+ $container = $this->createMock(ContainerInterface::class);
+ $container->method('has')->willReturn(false);
+ $store = new CmdbImportPackStore(container: $container);
+
+ $this->assertFalse($store->isAvailable());
+ $this->assertSame([], $store->recordedPackIds(), 'no app config: nothing recorded');
+
+ $this->expectException(CmdbImportException::class);
+ $this->expectExceptionMessage('OpenRegister MigrationPackService is not available');
+ $store->resolve(shipped: $this->shippedUsage());
+ }//end testWithoutTheServiceTheStoreIsNotAvailable()
+
+ /**
+ * A missing pack is created as a built-in pack with the marker, and its row id is recorded.
+ *
+ * @return void
+ */
+ public function testAMissingPackIsSeededWithTheMarker(): void {
+ $shipped = $this->shippedUsage();
+
+ $stored = $this->store()->resolve(shipped: $shipped);
+
+ $this->assertSame(CmdbImportPackStore::SEEDED, $stored['outcome']);
+ $this->assertSame(['create stackiq-topdesk-usage'], $this->service->writes);
+ $this->assertTrue($this->service->rows[$stored['id']]['builtin']);
+ $definition = $this->service->definition(id: $stored['id']);
+ $this->assertSame(
+ ['shippedVersion' => '2.0.0', 'shippedHash' => (new CmdbImportPackMarker())->hash(pack: $shipped)],
+ $definition[CmdbImportPackMarker::KEY]
+ );
+ $this->assertSame($shipped, (new CmdbImportPackMarker())->strip(definition: $definition), 'the pack itself is stored as shipped');
+ $this->assertFalse((new CmdbImportPackMarker())->isEdited(definition: $definition));
+ $this->assertSame(['stackiq-topdesk-usage' => $stored['id']], json_decode($this->config[CmdbImportPackStore::CONFIG_KEY], true));
+ $this->assertSame(['stackiq-topdesk-usage' => $stored['id']], $this->store()->recordedPackIds());
+ }//end testAMissingPackIsSeededWithTheMarker()
+
+ /**
+ * An unedited pack that matches the shipped one is found and not written.
+ *
+ * @return void
+ */
+ public function testAnUneditedCurrentPackIsLeftAlone(): void {
+ $shipped = $this->shippedUsage();
+ $id = $this->service->put(definition: (new CmdbImportPackMarker())->seed(shipped: $shipped));
+
+ $stored = $this->store()->resolve(shipped: $shipped);
+
+ $this->assertSame(CmdbImportPackStore::FOUND, $stored['outcome']);
+ $this->assertSame($id, $stored['id']);
+ $this->assertSame([], $this->service->writes);
+ }//end testAnUneditedCurrentPackIsLeftAlone()
+
+ /**
+ * An unedited pack seeded from an older release is replaced by the newer shipped pack.
+ *
+ * @return void
+ */
+ public function testAnUneditedPackFollowsANewerShippedPack(): void {
+ $id = $this->service->put(definition: (new CmdbImportPackMarker())->seed(shipped: $this->shippedUsage()));
+ $newer = $this->newerUsage();
+
+ $stored = $this->store()->resolve(shipped: $newer);
+
+ $this->assertSame(CmdbImportPackStore::UPDATED, $stored['outcome']);
+ $this->assertSame(['update ' . $id], $this->service->writes);
+ $this->assertSame('2.1.0', $stored['version']);
+ $definition = $this->service->definition(id: $id);
+ $this->assertSame('Planned', $definition['fieldMappings'][0]['transform']['map']['Gepland']);
+ $this->assertSame('2.1.0', $definition[CmdbImportPackMarker::KEY]['shippedVersion']);
+ $this->assertFalse((new CmdbImportPackMarker())->isEdited(definition: $definition));
+ $this->assertFalse((new CmdbImportPackMarker())->isNewerShipped(definition: $definition, shipped: $newer));
+ }//end testAnUneditedPackFollowsANewerShippedPack()
+
+ /**
+ * An edited pack is kept when a newer pack ships, and reported as such.
+ *
+ * @return void
+ */
+ public function testAnEditedPackIsKeptAndReportsTheNewerShippedPack(): void {
+ $edited = (new CmdbImportPackMarker())->seed(shipped: $this->shippedUsage());
+ $edited['fieldMappings'][0]['transform']['map']['In beheer'] = 'In production';
+ $edited['version'] = '2.0.1';
+ $id = $this->service->put(definition: $edited);
+ $newer = $this->newerUsage();
+
+ $stored = $this->store()->resolve(shipped: $newer);
+
+ $this->assertSame(CmdbImportPackStore::FOUND, $stored['outcome']);
+ $this->assertSame([], $this->service->writes, 'an edit is never overwritten');
+ $this->assertSame('2.0.1', $stored['version']);
+ $this->assertTrue((new CmdbImportPackMarker())->isEdited(definition: $stored['definition']));
+ $this->assertTrue((new CmdbImportPackMarker())->isNewerShipped(definition: $stored['definition'], shipped: $newer));
+ $this->assertFalse(
+ (new CmdbImportPackMarker())->isNewerShipped(definition: $stored['definition'], shipped: $this->shippedUsage()),
+ 'edited, but seeded from the pack that still ships'
+ );
+ $this->assertSame('In production', $this->service->definition(id: $id)['fieldMappings'][0]['transform']['map']['In beheer']);
+ }//end testAnEditedPackIsKeptAndReportsTheNewerShippedPack()
+
+ /**
+ * A stored pack without the marker is never updated: nobody knows what was seeded.
+ *
+ * @return void
+ */
+ public function testAPackWithoutTheMarkerIsNeverUpdated(): void {
+ $this->service->put(definition: $this->shippedUsage());
+ $newer = $this->newerUsage();
+
+ $stored = $this->store()->resolve(shipped: $newer);
+
+ $this->assertSame(CmdbImportPackStore::FOUND, $stored['outcome']);
+ $this->assertSame([], $this->service->writes);
+ $this->assertNull((new CmdbImportPackMarker())->isEdited(definition: $stored['definition']));
+ $this->assertFalse((new CmdbImportPackMarker())->isNewerShipped(definition: $stored['definition'], shipped: $newer));
+ }//end testAPackWithoutTheMarkerIsNeverUpdated()
+
+ /**
+ * A pack whose recorded row now carries another slug is refused, naming the row and both slugs.
+ *
+ * @return void
+ */
+ public function testARenamedPackFailsClosed(): void {
+ $renamed = $this->shippedUsage();
+ $renamed['id'] = 'my-usage';
+ $id = $this->service->put(definition: $renamed);
+ $this->config[CmdbImportPackStore::CONFIG_KEY] = (string)json_encode(['stackiq-topdesk-usage' => $id]);
+
+ try {
+ $this->store()->resolve(shipped: $this->shippedUsage());
+ $this->fail('MAPPING_UNAVAILABLE expected');
+ } catch (CmdbImportException $e) {
+ $this->assertSame('MAPPING_UNAVAILABLE', $e->getErrorCode());
+ $this->assertSame(503, $e->getHttpStatus());
+ $this->assertStringContainsString('id ' . $id, $e->getMessage());
+ $this->assertStringContainsString('stackiq-topdesk-usage', $e->getMessage());
+ $this->assertStringContainsString('my-usage', $e->getMessage());
+ $this->assertSame(['pack' => 'stackiq-topdesk-usage', 'packId' => $id], $e->getDetails());
+ }
+
+ $this->assertSame([], $this->service->writes, 'no second pack under the old slug');
+ }//end testARenamedPackFailsClosed()
+
+ /**
+ * A pack whose recorded row was deleted is seeded again, and the new row id is recorded.
+ *
+ * @return void
+ */
+ public function testADeletedPackIsSeededAgain(): void {
+ $this->config[CmdbImportPackStore::CONFIG_KEY] = (string)json_encode(['stackiq-topdesk-usage' => 7]);
+
+ $stored = $this->store()->resolve(shipped: $this->shippedUsage());
+
+ $this->assertSame(CmdbImportPackStore::SEEDED, $stored['outcome']);
+ $this->assertNotSame(7, $stored['id']);
+ $this->assertSame(['stackiq-topdesk-usage' => $stored['id']], json_decode($this->config[CmdbImportPackStore::CONFIG_KEY], true));
+ }//end testADeletedPackIsSeededAgain()
+
+ /**
+ * A store that fails on a lookup is MAPPING_UNAVAILABLE naming the pack, never a fallback.
+ *
+ * @return void
+ */
+ public function testAFailingStoreIsMappingUnavailable(): void {
+ $this->service->failure = new RuntimeException('database gone');
+
+ try {
+ $this->store()->resolve(shipped: $this->shippedUsage());
+ $this->fail('MAPPING_UNAVAILABLE expected');
+ } catch (CmdbImportException $e) {
+ $this->assertSame('MAPPING_UNAVAILABLE', $e->getErrorCode());
+ $this->assertStringContainsString('stackiq-topdesk-usage', $e->getMessage());
+ $this->assertStringContainsString('RuntimeException', $e->getMessage());
+ $this->assertStringNotContainsString('database gone', $e->getMessage(), 'the class, not the message');
+ }
+ }//end testAFailingStoreIsMappingUnavailable()
+
+ /**
+ * The hash ignores the order of an object's keys but not the order of the field mappings.
+ *
+ * @return void
+ */
+ public function testTheHashIsCanonical(): void {
+ $pack = $this->shippedUsage();
+ $reordered = array_reverse($pack, true);
+ $reordered['fieldMappings'][0]['transform'] = array_reverse($reordered['fieldMappings'][0]['transform'], true);
+ $swapped = $pack;
+ $swapped['fieldMappings'] = array_reverse($pack['fieldMappings']);
+
+ $this->assertSame((new CmdbImportPackMarker())->hash(pack: $pack), (new CmdbImportPackMarker())->hash(pack: $reordered));
+ $this->assertNotSame((new CmdbImportPackMarker())->hash(pack: $pack), (new CmdbImportPackMarker())->hash(pack: $swapped));
+ $this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', (new CmdbImportPackMarker())->hash(pack: $pack));
+ }//end testTheHashIsCanonical()
+}//end class
diff --git a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php
index 74fdbe784..f144495bf 100644
--- a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php
+++ b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php
@@ -21,12 +21,16 @@
namespace OCA\Stackiq\Tests\Unit\Service\Cmdb;
require_once __DIR__ . '/../../Support/CmdbTestSupport.php';
+require_once __DIR__ . '/../../Support/FakeMigrationPackService.php';
use OCA\OpenRegister\Service\MigrationPack\MappingEngine;
use OCA\OpenRegister\Service\MigrationPack\PackDefinitionValidator;
use OCA\Stackiq\Exception\CmdbImportException;
+use OCA\Stackiq\Service\Cmdb\CmdbImportPackMarker;
+use OCA\Stackiq\Service\Cmdb\CmdbImportPackStore;
use OCA\Stackiq\Service\Cmdb\CmdbImportProfile;
use OCA\Stackiq\Tests\Unit\Support\CmdbTestSupport;
+use OCA\Stackiq\Tests\Unit\Support\FakeMigrationPackService;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
@@ -356,7 +360,8 @@ public function testTheOverviewShowsWhatTheImportRuns(): void {
$overview = $profile->mappingOverview();
- $this->assertSame(['profile', 'packs'], array_keys($overview));
+ $this->assertSame(['profile', 'packs', 'openRegisterPacks'], array_keys($overview));
+ $this->assertFalse($overview['openRegisterPacks'], 'no OpenRegister pack store in this container');
$this->assertSame('topdesk-cmdb', $overview['profile']['id']);
$this->assertSame($profile->sheets(), $overview['profile']['sheets']);
$this->assertSame(['Beheerde Applicaties CMDB', 'Onbeh Applicaties CMDB'], $overview['profile']['sheetPrecedence']);
@@ -378,6 +383,14 @@ public function testTheOverviewShowsWhatTheImportRuns(): void {
$this->assertSame($profile->createOnlyDefaults(target: $pack['target']), $pack['createOnlyDefaults'], $pack['target']);
$this->assertSame([], array_intersect($pack['createOnlyFields'], array_diff(array_keys($pack['createOnlyDefaults']), array_column($pack['fieldMappings'], 'target'))), $pack['target']);
$this->assertSame($profile->neverWrittenOnUpdate(target: $pack['target']), $pack['neverWritten'], $pack['target']);
+
+ $this->assertSame('file', $pack['source'], $pack['target']);
+ $this->assertNull($pack['packId'], $pack['target']);
+ $this->assertNull($pack['storedVersion'], $pack['target']);
+ $this->assertSame($shipped['version'], $pack['shippedVersion'], $pack['target']);
+ $this->assertNull($pack['edited'], $pack['target']);
+ $this->assertFalse($pack['newerShipped'], $pack['target']);
+ $this->assertSame($pack['target'] !== 'municipality', $pack['editable'], $pack['target']);
}
$directory = $this->copyOfShippedDirectory();
@@ -393,6 +406,328 @@ public function testTheOverviewShowsWhatTheImportRuns(): void {
}
}//end testTheOverviewShowsWhatTheImportRuns()
+ /**
+ * A shipped pack, decoded.
+ *
+ * @param string $file The file name.
+ *
+ * @return array
+ */
+ private function shipped(string $file): array {
+ return json_decode((string)file_get_contents(CmdbTestSupport::appRoot() . '/lib/Settings/cmdb-import/' . $file), true);
+ }//end shipped()
+
+ /**
+ * A profile whose container holds the fake OpenRegister service and an app config.
+ *
+ * @param FakeMigrationPackService $service The fake service.
+ * @param array $config The app config values.
+ *
+ * @return CmdbImportProfile
+ */
+ private function storedProfile(FakeMigrationPackService $service, array &$config): CmdbImportProfile {
+ return new CmdbImportProfile(
+ container: $service->container(test: $this, appConfig: FakeMigrationPackService::appConfig(test: $this, values: $config))
+ );
+ }//end storedProfile()
+
+ /**
+ * The import runs the pack an administrator edited in OpenRegister; missing packs are seeded, the municipality stays a file.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function testAStoredPackIsWhatTheImportRuns(): void {
+ CmdbTestSupport::loadMigrationPack();
+ $service = new FakeMigrationPackService();
+ $module = (new CmdbImportPackMarker())->seed(shipped: $this->shipped(file: 'topdesk-module.json'));
+ $module['fieldMappings'][7]['transform']['map']['Cloud'] = ['SaaS'];
+ $module['version'] = '2.0.1';
+ $moduleId = $service->put(definition: $module);
+ $config = [];
+
+ $profile = $this->storedProfile(service: $service, config: $config);
+ $overview = $profile->mappingOverview();
+
+ $this->assertSame(['SaaS'], $profile->pack(target: 'module')['fieldMappings'][7]['transform']['map']['Cloud']);
+ $this->assertArrayNotHasKey(CmdbImportPackMarker::KEY, $profile->pack(target: 'module'), 'the engine gets the pack without the marker');
+ $this->assertSame(
+ ['create stackiq-topdesk-manufacturer', 'create stackiq-topdesk-usage', 'create stackiq-topdesk-business-owner'],
+ $service->writes,
+ 'the missing sheet packs are seeded; the municipality pack never is'
+ );
+ $this->assertTrue($overview['openRegisterPacks']);
+ $this->assertFalse($profile->usesShippedFallback());
+
+ $byTarget = array_column($overview['packs'], null, 'target');
+ $this->assertSame('openregister', $byTarget['module']['source']);
+ $this->assertSame($moduleId, $byTarget['module']['packId']);
+ $this->assertSame('2.0.1', $byTarget['module']['storedVersion']);
+ $this->assertSame('2.0.1', $byTarget['module']['version']);
+ $this->assertSame('2.0.0', $byTarget['module']['shippedVersion']);
+ $this->assertTrue($byTarget['module']['edited']);
+ $this->assertFalse($byTarget['module']['newerShipped'], 'seeded from the pack that still ships');
+ $this->assertTrue($byTarget['module']['editable']);
+ $this->assertSame('openregister', $byTarget['usage']['source']);
+ $this->assertFalse($byTarget['usage']['edited']);
+ $this->assertSame('file', $byTarget['municipality']['source']);
+ $this->assertNull($byTarget['municipality']['packId']);
+ $this->assertFalse($byTarget['municipality']['editable']);
+
+ $recorded = json_decode($config[CmdbImportPackStore::CONFIG_KEY], true);
+ $this->assertSame(['stackiq-topdesk-business-owner', 'stackiq-topdesk-manufacturer', 'stackiq-topdesk-module', 'stackiq-topdesk-usage'], array_keys($recorded));
+ $this->assertSame($moduleId, $recorded['stackiq-topdesk-module']);
+ }//end testAStoredPackIsWhatTheImportRuns()
+
+ /**
+ * An unedited stored pack from an older release follows the shipped pack; an edited one is kept and reported.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function testUneditedPacksFollowTheReleaseAndEditedOnesAreReported(): void {
+ CmdbTestSupport::loadMigrationPack();
+ $service = new FakeMigrationPackService();
+ $olderUsage = $this->shipped(file: 'topdesk-usage.json');
+ $olderUsage['version'] = '1.9.0';
+ unset($olderUsage['fieldMappings'][0]['transform']['map']['Besteld']);
+ $usageId = $service->put(definition: (new CmdbImportPackMarker())->seed(shipped: $olderUsage));
+
+ $olderModule = $this->shipped(file: 'topdesk-module.json');
+ $olderModule['version'] = '1.9.0';
+ $editedModule = (new CmdbImportPackMarker())->seed(shipped: $olderModule);
+ $editedModule['fieldMappings'][0]['transform'] = ['type' => 'trim'];
+ $editedModule['description'] = 'Edited by the administrator';
+ $moduleId = $service->put(definition: $editedModule);
+ $config = [];
+
+ $profile = $this->storedProfile(service: $service, config: $config);
+ $byTarget = array_column($profile->mappingOverview()['packs'], null, 'target');
+
+ $this->assertContains('update ' . $usageId, $service->writes);
+ $this->assertNotContains('update ' . $moduleId, $service->writes);
+ $this->assertSame('Acquisition', $profile->pack(target: 'usage')['fieldMappings'][0]['transform']['map']['Besteld']);
+ $this->assertFalse($byTarget['usage']['edited']);
+ $this->assertFalse($byTarget['usage']['newerShipped']);
+ $this->assertSame('2.0.0', $byTarget['usage']['storedVersion']);
+
+ $this->assertSame('Edited by the administrator', $profile->pack(target: 'module')['description']);
+ $this->assertTrue($byTarget['module']['edited']);
+ $this->assertTrue($byTarget['module']['newerShipped']);
+ $this->assertSame('1.9.0', $byTarget['module']['storedVersion']);
+ $this->assertSame('2.0.0', $byTarget['module']['shippedVersion']);
+ }//end testUneditedPacksFollowTheReleaseAndEditedOnesAreReported()
+
+ /**
+ * A stored pack OpenRegister's validator refuses stops the import, naming the pack and its id.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function testAnInvalidStoredPackIsMappingUnavailable(): void {
+ CmdbTestSupport::loadMigrationPack();
+ $service = new FakeMigrationPackService();
+ $usage = (new CmdbImportPackMarker())->seed(shipped: $this->shipped(file: 'topdesk-usage.json'));
+ $usage['fieldMappings'][0]['transform'] = ['type' => 'uppercase'];
+ $id = $service->put(definition: $usage);
+ $config = [];
+
+ try {
+ $this->storedProfile(service: $service, config: $config)->load();
+ $this->fail('MAPPING_UNAVAILABLE expected');
+ } catch (CmdbImportException $e) {
+ $this->assertSame('MAPPING_UNAVAILABLE', $e->getErrorCode());
+ $this->assertSame(503, $e->getHttpStatus());
+ $this->assertStringStartsWith('OpenRegister migration pack stackiq-topdesk-usage (id ' . $id . ') is invalid: ', $e->getMessage());
+ $this->assertStringContainsString('uppercase', $e->getMessage());
+ $this->assertSame(['pack' => 'stackiq-topdesk-usage', 'packId' => $id], $e->getDetails());
+ }
+ }//end testAnInvalidStoredPackIsMappingUnavailable()
+
+ /**
+ * Edits a stored pack may not make, and the rule each one breaks.
+ *
+ * @return array
+ */
+ public static function brokenRules(): array {
+ $target = static fn (int $index, string $field): callable => static function (array $pack) use ($index, $field): array {
+ $pack['fieldMappings'][$index]['target'] = $field;
+ return $pack;
+ };
+
+ return [
+ 'module name no longer required' => ['topdesk-module.json', static function (array $pack): array {
+ $pack['fieldMappings'][0]['required'] = false;
+ return $pack;
+ }, '"name" must be mapped as required'],
+ 'externalNumber from another column' => ['topdesk-module.json', static function (array $pack): array {
+ $pack['fieldMappings'][1]['source'] = 'Applicatie Code';
+ return $pack;
+ }, 'the key column "APPID" must be mapped to "externalNumber" as required'],
+ 'the object metadata' => ['topdesk-module.json', $target(2, '@self'), '"@self" is set by the import and cannot be mapped'],
+ 'the match key' => ['topdesk-module.json', $target(2, 'externalKey'), '"externalKey" is set by the import'],
+ 'a field never written' => ['topdesk-module.json', $target(2, 'publicationDate'), '"publicationDate" is set by the import'],
+ 'a field the schema lacks' => ['topdesk-module.json', $target(2, 'secretNotes'), '"secretNotes" is not a field the module pack may map'],
+ 'the usage provider' => ['topdesk-usage.json', $target(0, 'provider'), '"provider" is set by the import'],
+ 'the usage consumer' => ['topdesk-usage.json', $target(1, 'consumer'), '"consumer" is set by the import'],
+ 'the usage business owner' => ['topdesk-usage.json', $target(1, 'businessOwner'), '"businessOwner" is set by the import'],
+ 'a default the import sets' => ['topdesk-usage.json', static function (array $pack): array {
+ $pack['defaults'] = ['module' => 'x'];
+ return $pack;
+ }, 'default "module" is set by the import'],
+ 'the manufacturer type mapped' => ['topdesk-manufacturer.json', $target(0, 'type'), '"type" is set by the import'],
+ 'the manufacturer type default changed' => ['topdesk-manufacturer.json', static function (array $pack): array {
+ $pack['defaults']['type'] = 'Municipality';
+ return $pack;
+ }, 'default "type" must stay "Supplier"'],
+ 'an owner field that is no contact field' => ['topdesk-business-owner.json', $target(1, 'email'), '"email" is not a field the businessOwner pack may map'],
+ ];
+ }//end brokenRules()
+
+ /**
+ * A stored pack that breaks a rule of the import stops it, naming the pack, its id and the rule.
+ *
+ * @param string $file The shipped file the stored pack starts from.
+ * @param callable $edit The edit.
+ * @param string $rule The text of the broken rule.
+ *
+ * @return void
+ *
+ * @dataProvider brokenRules
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function testAStoredPackThatBreaksAnImportRuleIsRefused(string $file, callable $edit, string $rule): void {
+ CmdbTestSupport::loadMigrationPack();
+ $service = new FakeMigrationPackService();
+ $pack = $edit((new CmdbImportPackMarker())->seed(shipped: $this->shipped(file: $file)));
+ $id = $service->put(definition: $pack);
+ $config = [];
+
+ try {
+ $this->storedProfile(service: $service, config: $config)->load();
+ $this->fail('MAPPING_UNAVAILABLE expected');
+ } catch (CmdbImportException $e) {
+ $this->assertSame('MAPPING_UNAVAILABLE', $e->getErrorCode());
+ $this->assertStringStartsWith('OpenRegister migration pack ' . $pack['id'] . ' (id ' . $id . ') breaks a rule of the CMDB import: ', $e->getMessage());
+ $this->assertStringContainsString($rule, $e->getMessage());
+ $this->assertSame(['pack' => $pack['id'], 'packId' => $id], $e->getDetails());
+ }
+ }//end testAStoredPackThatBreaksAnImportRuleIsRefused()
+
+ /**
+ * Without OpenRegister's store, and no pack ever stored there, the import uses the shipped files and says so.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function testWithoutTheStoreBeforeASeedTheShippedFilesAreUsed(): void {
+ CmdbTestSupport::loadMigrationPack();
+ $config = [];
+ $container = $this->createMock(ContainerInterface::class);
+ $appConfig = FakeMigrationPackService::appConfig(test: $this, values: $config);
+ $container->method('has')->willReturnCallback(fn (string $id): bool => $id === \OCP\IAppConfig::class);
+ $container->method('get')->willReturn($appConfig);
+
+ $profile = new CmdbImportProfile(container: $container);
+ $overview = $profile->mappingOverview();
+
+ $this->assertTrue($profile->usesShippedFallback());
+ $this->assertFalse($overview['openRegisterPacks']);
+ $this->assertSame(['file'], array_values(array_unique(array_column($overview['packs'], 'source'))));
+ $this->assertSame($this->shipped(file: 'topdesk-module.json'), $profile->pack(target: 'module'));
+ }//end testWithoutTheStoreBeforeASeedTheShippedFilesAreUsed()
+
+ /**
+ * Once the packs were stored in OpenRegister, a store that is gone fails closed, naming the packs.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function testWithoutTheStoreAfterASeedTheImportFailsClosed(): void {
+ CmdbTestSupport::loadMigrationPack();
+ $config = [CmdbImportPackStore::CONFIG_KEY => (string)json_encode(['stackiq-topdesk-module' => 41, 'stackiq-topdesk-usage' => 43])];
+ $container = $this->createMock(ContainerInterface::class);
+ $appConfig = FakeMigrationPackService::appConfig(test: $this, values: $config);
+ $container->method('has')->willReturnCallback(fn (string $id): bool => $id === \OCP\IAppConfig::class);
+ $container->method('get')->willReturn($appConfig);
+
+ try {
+ (new CmdbImportProfile(container: $container))->load();
+ $this->fail('MAPPING_UNAVAILABLE expected');
+ } catch (CmdbImportException $e) {
+ $this->assertSame('MAPPING_UNAVAILABLE', $e->getErrorCode());
+ $this->assertStringContainsString('OpenRegister migration pack store is not available', $e->getMessage());
+ $this->assertStringContainsString('stackiq-topdesk-module (id 41)', $e->getMessage());
+ $this->assertStringContainsString('stackiq-topdesk-usage (id 43)', $e->getMessage());
+ $this->assertSame(['packs' => ['stackiq-topdesk-module' => 41, 'stackiq-topdesk-usage' => 43]], $e->getDetails());
+ }
+ }//end testWithoutTheStoreAfterASeedTheImportFailsClosed()
+
+ /**
+ * A column a stored pack maps with a date transform is converted from an Excel date, and is read.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function testADateColumnOfAStoredPackIsConverted(): void {
+ CmdbTestSupport::loadMigrationPack();
+ $service = new FakeMigrationPackService();
+ $usage = (new CmdbImportPackMarker())->seed(shipped: $this->shipped(file: 'topdesk-usage.json'));
+ $usage['fieldMappings'][] = [
+ 'source' => 'In productie sinds',
+ 'target' => 'startDateInProduction',
+ 'transform' => ['type' => 'date', 'sourceFormat' => '!Y-m-d', 'targetFormat' => 'Y-m-d'],
+ ];
+ $service->put(definition: $usage);
+ $config = [];
+
+ $profile = $this->storedProfile(service: $service, config: $config);
+
+ $this->assertSame(
+ ['Datum', 'Referentie datum wijziging', 'End-of-Life Functioneel', 'In productie sinds'],
+ $profile->dateColumns()
+ );
+ $this->assertContains('In productie sinds', $profile->referencedColumns(), 'a mapped column is read');
+ $this->assertSame(
+ ['Datum', 'Referentie datum wijziging', 'End-of-Life Functioneel'],
+ (new CmdbImportProfile(container: $this->emptyContainer()))->dateColumns(),
+ 'the shipped packs add no column the profile does not list'
+ );
+ }//end testADateColumnOfAStoredPackIsConverted()
+
+ /**
+ * The separator the import drops empty note parts on is the pack's own.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function testTheConcatSeparatorIsThePacks(): void {
+ CmdbTestSupport::loadMigrationPack();
+ $shipped = new CmdbImportProfile(container: $this->emptyContainer());
+ $this->assertSame(' / ', $shipped->concatSeparator(target: 'usage', field: 'interneAnnotation'));
+ $this->assertNull($shipped->concatSeparator(target: 'usage', field: 'status'), 'no concat mapping writes it');
+
+ $service = new FakeMigrationPackService();
+ $usage = (new CmdbImportPackMarker())->seed(shipped: $this->shipped(file: 'topdesk-usage.json'));
+ $usage['fieldMappings'][3]['transform']['separator'] = ' | ';
+ $service->put(definition: $usage);
+ $config = [];
+ $this->assertSame(' | ', $this->storedProfile(service: $service, config: $config)->concatSeparator(target: 'usage', field: 'interneAnnotation'));
+
+ unset($usage['fieldMappings'][3]['transform']['separator']);
+ $service = new FakeMigrationPackService();
+ $service->put(definition: $usage);
+ $this->assertSame(' ', $this->storedProfile(service: $service, config: $config)->concatSeparator(target: 'usage', field: 'interneAnnotation'), "OpenRegister's default");
+ }//end testTheConcatSeparatorIsThePacks()
+
/**
* The upload limit is readable without OpenRegister.
*
diff --git a/tests/Unit/Service/CmdbExportImportServiceTest.php b/tests/Unit/Service/CmdbExportImportServiceTest.php
index d81417ea7..31c23aa10 100644
--- a/tests/Unit/Service/CmdbExportImportServiceTest.php
+++ b/tests/Unit/Service/CmdbExportImportServiceTest.php
@@ -32,10 +32,13 @@
namespace OCA\Stackiq\Tests\Unit\Service;
require_once __DIR__ . '/../Support/CmdbTestSupport.php';
+require_once __DIR__ . '/../Support/FakeMigrationPackService.php';
use OCA\OpenRegister\Contract\ObjectEntityInterface;
use OCA\OpenRegister\Contract\ObjectServiceInterface;
use OCA\Stackiq\Exception\CmdbImportException;
+use OCA\Stackiq\Service\Cmdb\CmdbImportPackMarker;
+use OCA\Stackiq\Service\Cmdb\CmdbImportPackStore;
use OCA\Stackiq\Service\Cmdb\CmdbImportProfile;
use OCA\Stackiq\Service\Cmdb\CmdbRowNormaliser;
use OCA\Stackiq\Service\Cmdb\CmdbWorkbookReader;
@@ -44,6 +47,7 @@
use OCA\Stackiq\Service\SettingsService;
use OCA\Stackiq\Service\StackiqContactSyncService;
use OCA\Stackiq\Tests\Unit\Support\CmdbTestSupport;
+use OCA\Stackiq\Tests\Unit\Support\FakeMigrationPackService;
use OCP\IAppConfig;
use OCP\ICache;
use OCP\ICacheFactory;
@@ -710,16 +714,26 @@ public function read(string $path, CmdbImportProfile $profile): array {
* @param CmdbWorkbookReader|null $reader The reader; null is the real one.
* @param string|null $profileDir A profile directory other than the shipped one.
* @param array $config The voorzieningen config.
+ * @param FakeMigrationPackService|false|null $packs OpenRegister's pack store: null is an empty one, false none at all.
*
* @return CmdbExportImportService
*/
- private function service(?CmdbWorkbookReader $reader = null, ?string $profileDir = null, array $config = ['register' => '20']): CmdbExportImportService {
+ private function service(
+ ?CmdbWorkbookReader $reader = null,
+ ?string $profileDir = null,
+ array $config = ['register' => '20'],
+ FakeMigrationPackService|false|null $packs = null
+ ): CmdbExportImportService {
$objectService = $this->objectService();
$schemaMapper = $this->schemaMapper();
+ if ($packs === null) {
+ $packs = new FakeMigrationPackService();
+ }
+
$container = $this->createMock(ContainerInterface::class);
- $container->method('has')->willReturn(false);
+ $container->method('has')->willReturnCallback(fn (string $id): bool => $packs !== false && $id === CmdbImportPackStore::SERVICE_CLASS);
$container->method('get')->willReturnCallback(
- function (string $id) use ($objectService, $schemaMapper) {
+ function (string $id) use ($objectService, $schemaMapper, $packs) {
if ($id === ObjectServiceInterface::class) {
return $objectService;
}
@@ -728,6 +742,10 @@ function (string $id) use ($objectService, $schemaMapper) {
return $schemaMapper;
}
+ if ($packs !== false && $id === CmdbImportPackStore::SERVICE_CLASS) {
+ return $packs;
+ }
+
throw new RuntimeException('not in this container: ' . $id);
}
);
@@ -970,6 +988,67 @@ public function testAnAmbiguousMunicipalityNameIsRefused(): void {
$this->assertSame([], $this->saves);
}//end testAnAmbiguousMunicipalityNameIsRefused()
+ /**
+ * Without OpenRegister's pack store, and nothing stored there before, the import runs the shipped mapping and says so once.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function testWithoutThePackStoreTheShippedMappingIsUsedWithAWarning(): void {
+ $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Rotterdam', type: 'Municipality');
+
+ $report = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')]), packs: false)
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1']);
+
+ $this->assertSame(1, $report['summary']['created']);
+ $this->assertSame(
+ [['sheet' => '', 'message' => "OpenRegister's migration pack store is not available, so this import used the mapping that ships with stackiq."]],
+ $report['importWarnings']
+ );
+ $this->assertSame('In production', $this->objects(self::USAGE)[0]['status']);
+ }//end testWithoutThePackStoreTheShippedMappingIsUsedWithAWarning()
+
+ /**
+ * A pack edited in OpenRegister is what the import runs: its lookup, its concat separator and a new date column.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function testAPackEditedInOpenRegisterIsWhatTheImportRuns(): void {
+ $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Rotterdam', type: 'Municipality');
+ $packs = new FakeMigrationPackService();
+ $usage = (new CmdbImportPackMarker())->seed(
+ shipped: json_decode((string)file_get_contents(CmdbTestSupport::appRoot() . '/lib/Settings/cmdb-import/topdesk-usage.json'), true)
+ );
+ $usage['fieldMappings'][0]['transform']['map']['In productie'] = 'Planned';
+ $usage['fieldMappings'][3]['transform']['separator'] = ' | ';
+ $usage['fieldMappings'][] = [
+ 'source' => 'In productie sinds',
+ 'target' => 'startDateInProduction',
+ 'transform' => ['type' => 'date', 'sourceFormat' => '!Y-m-d', 'targetFormat' => 'Y-m-d'],
+ ];
+ $packs->put(definition: $usage);
+
+ $report = $this->service(
+ reader: $this->rowsReader(rows: [$this->row(appId: '1', cells: ['Cluster' => 'B10', 'In productie sinds' => 45111])]),
+ packs: $packs
+ )->import(path: '', options: ['municipalityUuid' => 'muni-1']);
+
+ $this->assertSame([], $report['importWarnings'], 'the packs came from OpenRegister');
+ $this->assertSame([], $report['rows'][0]['warnings']);
+ $stored = $this->objects(self::USAGE)[0];
+ $this->assertSame('Planned', $stored['status'], 'the edited lookup');
+ $this->assertSame('Beheer geregeld: ja | B10', $stored['interneAnnotation'], 'empty parts dropped on the edited separator');
+ $this->assertSame('2023-07-04', $stored['startDateInProduction'], 'the new column was converted from an Excel date');
+ $this->assertSame(
+ ['create stackiq-topdesk-module', 'create stackiq-topdesk-manufacturer', 'create stackiq-topdesk-business-owner'],
+ $packs->writes,
+ 'the other sheet packs were seeded on the first import'
+ );
+ }//end testAPackEditedInOpenRegisterIsWhatTheImportRuns()
+
/**
* A name that matches no municipality creates it, and the report warns about that.
*
diff --git a/tests/Unit/Support/FakeMigrationPackService.php b/tests/Unit/Support/FakeMigrationPackService.php
new file mode 100644
index 000000000..4dea695be
--- /dev/null
+++ b/tests/Unit/Support/FakeMigrationPackService.php
@@ -0,0 +1,286 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Tests\Unit\Support;
+
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IAppConfig;
+use PHPUnit\Framework\TestCase;
+use Psr\Container\ContainerInterface;
+use RuntimeException;
+use Throwable;
+
+/**
+ * Migration packs in memory.
+ */
+final class FakeMigrationPackService {
+ /**
+ * Stored rows by id: slug, definition JSON and builtin flag.
+ *
+ * @var array
+ */
+ public array $rows = [];
+
+ /**
+ * Every write, as "create " or "update ".
+ *
+ * @var array
+ */
+ public array $writes = [];
+
+ /**
+ * When set, every lookup throws it.
+ *
+ * @var Throwable|null
+ */
+ public ?Throwable $failure = null;
+
+ /**
+ * The next row id.
+ *
+ * @var int
+ */
+ private int $nextId = 40;
+
+ /**
+ * Store a row directly, as an administrator's edit or an older seed would have.
+ *
+ * @param array $definition The definition.
+ * @param int|null $id The row id; null takes the next one.
+ *
+ * @return int The row id.
+ */
+ public function put(array $definition, ?int $id = null): int {
+ $id = ($id ?? $this->nextId++);
+ $this->rows[$id] = ['slug' => (string)$definition['id'], 'definition' => (string)json_encode($definition), 'builtin' => false];
+ return $id;
+ }//end put()
+
+ /**
+ * The stored definition of a row.
+ *
+ * @param int $id The row id.
+ *
+ * @return array
+ */
+ public function definition(int $id): array {
+ return json_decode($this->rows[$id]['definition'], true);
+ }//end definition()
+
+ /**
+ * Find a pack by slug.
+ *
+ * @param string $packSlug The slug.
+ *
+ * @return object
+ *
+ * @throws DoesNotExistException When no row has the slug.
+ */
+ public function findByPackSlug(string $packSlug): object {
+ $this->failIfAsked();
+ foreach ($this->rows as $id => $row) {
+ if ($row['slug'] === $packSlug) {
+ return $this->entity(id: $id);
+ }
+ }
+
+ throw new DoesNotExistException('No migration pack ' . $packSlug);
+ }//end findByPackSlug()
+
+ /**
+ * Find a pack by id.
+ *
+ * @param int $id The row id.
+ *
+ * @return object
+ *
+ * @throws DoesNotExistException When no row has the id.
+ */
+ public function find(int $id): object {
+ $this->failIfAsked();
+ if (isset($this->rows[$id]) === false) {
+ throw new DoesNotExistException('No migration pack ' . $id);
+ }
+
+ return $this->entity(id: $id);
+ }//end find()
+
+ /**
+ * Create a pack.
+ *
+ * @param array $definition The definition.
+ * @param string|null $ownerUid The owner.
+ * @param bool $builtin Whether it is built in.
+ *
+ * @return object
+ *
+ * @throws RuntimeException When the slug exists.
+ */
+ public function create(array $definition, ?string $ownerUid = null, bool $builtin = false): object {
+ foreach ($this->rows as $row) {
+ if ($row['slug'] === $definition['id']) {
+ throw new RuntimeException('A migration pack with id "' . $definition['id'] . '" already exists');
+ }
+ }
+
+ $id = $this->put(definition: $definition);
+ $this->rows[$id]['builtin'] = $builtin;
+ $this->writes[] = 'create ' . $definition['id'];
+ return $this->entity(id: $id);
+ }//end create()
+
+ /**
+ * Replace a pack's definition.
+ *
+ * @param int $id The row id.
+ * @param array $definition The definition.
+ *
+ * @return object
+ */
+ public function update(int $id, array $definition): object {
+ $this->rows[$id]['slug'] = (string)$definition['id'];
+ $this->rows[$id]['definition'] = (string)json_encode($definition);
+ $this->writes[] = 'update ' . $id;
+ return $this->entity(id: $id);
+ }//end update()
+
+ /**
+ * A container that hands out this service and, when given, an app config.
+ *
+ * @param TestCase $test The test, to build the mock with.
+ * @param IAppConfig|null $appConfig The app config, or null for none.
+ *
+ * @return ContainerInterface
+ */
+ public function container(TestCase $test, ?IAppConfig $appConfig = null): ContainerInterface {
+ $builder = new \ReflectionMethod(TestCase::class, 'createMock');
+ $container = $builder->invoke($test, ContainerInterface::class);
+ $known = ['OCA\OpenRegister\Service\MigrationPackService' => $this];
+ if ($appConfig !== null) {
+ $known[IAppConfig::class] = $appConfig;
+ }
+
+ $container->method('has')->willReturnCallback(fn (string $id): bool => isset($known[$id]));
+ $container->method('get')->willReturnCallback(
+ function (string $id) use ($known) {
+ if (isset($known[$id]) === true) {
+ return $known[$id];
+ }
+
+ throw new RuntimeException('not in this container: ' . $id);
+ }
+ );
+
+ return $container;
+ }//end container()
+
+ /**
+ * An app config backed by an array, for the store's row ids.
+ *
+ * @param TestCase $test The test, to build the mock with.
+ * @param array $values Initial values by key.
+ *
+ * @return IAppConfig
+ */
+ public static function appConfig(TestCase $test, array &$values): IAppConfig {
+ $builder = new \ReflectionMethod(TestCase::class, 'createMock');
+ $config = $builder->invoke($test, IAppConfig::class);
+ $config->method('getValueString')->willReturnCallback(
+ function (string $app, string $key, string $default = '') use (&$values): string {
+ return ($values[$key] ?? $default);
+ }
+ );
+ $config->method('setValueString')->willReturnCallback(
+ function (string $app, string $key, string $value) use (&$values): bool {
+ $values[$key] = $value;
+ return true;
+ }
+ );
+
+ return $config;
+ }//end appConfig()
+
+ /**
+ * Throw the configured failure.
+ *
+ * @return void
+ *
+ * @throws Throwable The configured failure.
+ */
+ private function failIfAsked(): void {
+ if ($this->failure !== null) {
+ throw $this->failure;
+ }
+ }//end failIfAsked()
+
+ /**
+ * The entity of a row, with the getters the store calls.
+ *
+ * @param int $id The row id.
+ *
+ * @return object
+ */
+ private function entity(int $id): object {
+ $row = $this->rows[$id];
+ return new class($id, $row['slug'], $row['definition']) {
+ /**
+ * Constructor.
+ *
+ * @param int $id The row id.
+ * @param string $slug The slug.
+ * @param string $definition The definition JSON.
+ */
+ public function __construct(private int $id, private string $slug, private string $definition) {
+ }
+
+ /**
+ * The row id.
+ *
+ * @return int
+ */
+ public function getId(): int {
+ return $this->id;
+ }
+
+ /**
+ * The slug.
+ *
+ * @return string
+ */
+ public function getPackSlug(): string {
+ return $this->slug;
+ }
+
+ /**
+ * The decoded definition.
+ *
+ * @return array
+ */
+ public function getDefinitionArray(): array {
+ return json_decode($this->definition, true);
+ }
+ };
+ }//end entity()
+}//end class
From 9450c8497177ea285758d1c60fada2a1aa9b756c Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 18:52:16 +0200
Subject: [PATCH 061/124] feat(cmdb-import): show where each import pack comes
from and link to its editor in OpenRegister
The Mapping block of the CMDB import section no longer lists each pack's
columns. Per pack it now shows one line: what the pack's rows become,
whether the import takes it from OpenRegister's pack store or from the
shipped file, the stored and shipped versions, an Edited marker, a hint
when a newer shipped version was not applied because the pack was edited,
and an Edit in OpenRegister link (http(s) or server-relative addresses
only). The municipality pack says it maps the import options and is not
edited there. When OpenRegister's pack store is not available, the block
says the shipped mapping is used.
The transform helpers the tables used are removed, the unused strings
are dropped from l10n, and the vitest and jest specs cover the new view.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
l10n/en.js | 35 +-
l10n/en.json | 35 +-
l10n/nl.js | 35 +-
l10n/nl.json | 35 +-
src/utils/cmdbImport.js | 169 ++------
.../settings/sections/CmdbImport.spec.js | 3 +
src/views/settings/sections/CmdbImport.vue | 2 +-
.../settings/sections/CmdbImportMapping.vue | 229 ++++++-----
tests/vitest/cmdbImportMapping.spec.js | 366 +++++++++++-------
9 files changed, 469 insertions(+), 440 deletions(-)
diff --git a/l10n/en.js b/l10n/en.js
index 60a730e51..571939b25 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1137,35 +1137,28 @@ OC.L10N.register(
"Supplier organisation (Vendor)": "Supplier organisation (Vendor)",
"Municipality (from the import options)": "Municipality (from the import options)",
"Business owner (contact person)": "Business owner (contact person)",
- "As is": "As is",
- "Trim": "Trim",
- "Date": "Date",
- "Lookup": "Lookup",
- "Yes/no lookup": "Yes/no lookup",
- "Join": "Join",
- "Constant": "Constant",
- "Any other value: {value}": "Any other value: {value}",
- "Joined with the columns {columns}, separated by \"{separator}\"": "Joined with the columns {columns}, separated by \"{separator}\"",
- "Value: {value}": "Value: {value}",
- "Read as {source}, stored as {target}": "Read as {source}, stored as {target}",
- "Mapping (read-only)": "Mapping (read-only)",
- "The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.": "The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.",
"Loading the mapping…": "Loading the mapping…",
"The mapping could not be loaded.": "The mapping could not be loaded.",
- "{file}: {name}, version {version}": "{file}: {name}, version {version}",
- "This pack maps no columns": "This pack maps no columns",
- "Yes": "Yes",
- "No": "No",
"Sheets read": "Sheets read",
"Match column": "Match column",
"Name column": "Name column",
"Required columns": "Required columns",
"Date columns": "Date columns",
- "Column in the export": "Column in the export",
- "Field": "Field",
- "Transformation": "Transformation",
"The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.",
- "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log."
+ "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log.",
+ "Mapping": "Mapping",
+ "The mapping the next import runs: the sheets it reads and, for each kind of record, the pack that says which column of the export goes to which field.": "The mapping the next import runs: the sheets it reads and, for each kind of record, the pack that says which column of the export goes to which field.",
+ "The packs are kept in OpenRegister, where you can view and edit them. A pack deleted there is restored from the shipped file at the next import.": "The packs are kept in OpenRegister, where you can view and edit them. A pack deleted there is restored from the shipped file at the next import.",
+ "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.": "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.",
+ "OpenRegister": "OpenRegister",
+ "Shipped file": "Shipped file",
+ "Version {version}": "Version {version}",
+ "Version {version} (shipped: {shipped})": "Version {version} (shipped: {shipped})",
+ "Edited": "Edited",
+ "Edit in OpenRegister": "Edit in OpenRegister",
+ "Edit in OpenRegister: {target} (opens in a new tab)": "Edit in OpenRegister: {target} (opens in a new tab)",
+ "This pack maps the import options, not a column of the export, so it is not edited in OpenRegister.": "This pack maps the import options, not a column of the export, so it is not edited in OpenRegister.",
+ "Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost.": "Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index 5e5a32434..9e3614a50 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1136,34 +1136,27 @@
"Supplier organisation (Vendor)": "Supplier organisation (Vendor)",
"Municipality (from the import options)": "Municipality (from the import options)",
"Business owner (contact person)": "Business owner (contact person)",
- "As is": "As is",
- "Trim": "Trim",
- "Date": "Date",
- "Lookup": "Lookup",
- "Yes/no lookup": "Yes/no lookup",
- "Join": "Join",
- "Constant": "Constant",
- "Any other value: {value}": "Any other value: {value}",
- "Joined with the columns {columns}, separated by \"{separator}\"": "Joined with the columns {columns}, separated by \"{separator}\"",
- "Value: {value}": "Value: {value}",
- "Read as {source}, stored as {target}": "Read as {source}, stored as {target}",
- "Mapping (read-only)": "Mapping (read-only)",
- "The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.": "The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.",
"Loading the mapping…": "Loading the mapping…",
"The mapping could not be loaded.": "The mapping could not be loaded.",
- "{file}: {name}, version {version}": "{file}: {name}, version {version}",
- "This pack maps no columns": "This pack maps no columns",
- "Yes": "Yes",
- "No": "No",
"Sheets read": "Sheets read",
"Match column": "Match column",
"Name column": "Name column",
"Required columns": "Required columns",
"Date columns": "Date columns",
- "Column in the export": "Column in the export",
- "Field": "Field",
- "Transformation": "Transformation",
"The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.",
- "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log."
+ "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log.",
+ "Mapping": "Mapping",
+ "The mapping the next import runs: the sheets it reads and, for each kind of record, the pack that says which column of the export goes to which field.": "The mapping the next import runs: the sheets it reads and, for each kind of record, the pack that says which column of the export goes to which field.",
+ "The packs are kept in OpenRegister, where you can view and edit them. A pack deleted there is restored from the shipped file at the next import.": "The packs are kept in OpenRegister, where you can view and edit them. A pack deleted there is restored from the shipped file at the next import.",
+ "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.": "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.",
+ "OpenRegister": "OpenRegister",
+ "Shipped file": "Shipped file",
+ "Version {version}": "Version {version}",
+ "Version {version} (shipped: {shipped})": "Version {version} (shipped: {shipped})",
+ "Edited": "Edited",
+ "Edit in OpenRegister": "Edit in OpenRegister",
+ "Edit in OpenRegister: {target} (opens in a new tab)": "Edit in OpenRegister: {target} (opens in a new tab)",
+ "This pack maps the import options, not a column of the export, so it is not edited in OpenRegister.": "This pack maps the import options, not a column of the export, so it is not edited in OpenRegister.",
+ "Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost.": "Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost."
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index e51e4af38..e3cb1682f 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1205,35 +1205,28 @@ OC.L10N.register(
"Supplier organisation (Vendor)": "Leveranciersorganisatie (Vendor)",
"Municipality (from the import options)": "Gemeente (uit de importopties)",
"Business owner (contact person)": "Business owner (contactpersoon)",
- "As is": "Ongewijzigd",
- "Trim": "Spaties weghalen",
- "Date": "Datum",
- "Lookup": "Opzoektabel",
- "Yes/no lookup": "Ja/nee-opzoektabel",
- "Join": "Samenvoegen",
- "Constant": "Vaste waarde",
- "Any other value: {value}": "Elke andere waarde: {value}",
- "Joined with the columns {columns}, separated by \"{separator}\"": "Samengevoegd met de kolommen {columns}, gescheiden door \"{separator}\"",
- "Value: {value}": "Waarde: {value}",
- "Read as {source}, stored as {target}": "Gelezen als {source}, opgeslagen als {target}",
- "Mapping (read-only)": "Mapping (alleen-lezen)",
- "The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.": "De mapping die de volgende import uitvoert: de tabbladen die hij leest en, per pack, welke kolom van de export naar welk veld gaat. Hij wordt gelezen uit de bestanden onder lib/Settings/cmdb-import van de app op de server; een bestand dat daar is aangepast wordt bij de volgende app-update overschreven.",
"Loading the mapping…": "Mapping laden…",
"The mapping could not be loaded.": "De mapping kon niet worden geladen.",
- "{file}: {name}, version {version}": "{file}: {name}, versie {version}",
- "This pack maps no columns": "Dit pack koppelt geen kolommen",
- "Yes": "Ja",
- "No": "Nee",
"Sheets read": "Gelezen tabbladen",
"Match column": "Koppelkolom",
"Name column": "Naamkolom",
"Required columns": "Verplichte kolommen",
"Date columns": "Datumkolommen",
- "Column in the export": "Kolom in de export",
- "Field": "Veld",
- "Transformation": "Transformatie",
"The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "De mapping van de import kan niet worden getoond: OpenRegister ontbreekt of een mappingbestand is ongeldig.",
- "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek."
+ "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek.",
+ "Mapping": "Mapping",
+ "The mapping the next import runs: the sheets it reads and, for each kind of record, the pack that says which column of the export goes to which field.": "De mapping die de volgende import uitvoert: de tabbladen die hij leest en, per soort gegeven, het pack dat bepaalt welke kolom van de export naar welk veld gaat.",
+ "The packs are kept in OpenRegister, where you can view and edit them. A pack deleted there is restored from the shipped file at the next import.": "De packs staan in OpenRegister, waar ze kunnen worden bekeken en bewerkt. Een pack dat daar is verwijderd, wordt bij de volgende import hersteld uit het meegeleverde bestand.",
+ "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.": "De packopslag van OpenRegister is niet beschikbaar, dus de import gebruikt de mapping die met stackiq wordt meegeleverd. Werk OpenRegister bij of schakel het in om de mapping daar te bekijken en te bewerken.",
+ "OpenRegister": "OpenRegister",
+ "Shipped file": "Meegeleverd bestand",
+ "Version {version}": "Versie {version}",
+ "Version {version} (shipped: {shipped})": "Versie {version} (meegeleverd: {shipped})",
+ "Edited": "Bewerkt",
+ "Edit in OpenRegister": "Bewerken in OpenRegister",
+ "Edit in OpenRegister: {target} (opens in a new tab)": "Bewerken in OpenRegister: {target} (opent in een nieuw tabblad)",
+ "This pack maps the import options, not a column of the export, so it is not edited in OpenRegister.": "Dit pack koppelt de importopties, geen kolom van de export, en wordt daarom niet in OpenRegister bewerkt.",
+ "Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost.": "Stackiq levert een nieuwere versie van dit pack mee ({version}). Die is niet toegepast, omdat het pack in OpenRegister is bewerkt. Verwijder het pack daar om vanaf de volgende import de meegeleverde versie te gebruiken; de wijzigingen gaan dan verloren."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index 6ab4bbf98..58b39d6d4 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1204,34 +1204,27 @@
"Supplier organisation (Vendor)": "Leveranciersorganisatie (Vendor)",
"Municipality (from the import options)": "Gemeente (uit de importopties)",
"Business owner (contact person)": "Business owner (contactpersoon)",
- "As is": "Ongewijzigd",
- "Trim": "Spaties weghalen",
- "Date": "Datum",
- "Lookup": "Opzoektabel",
- "Yes/no lookup": "Ja/nee-opzoektabel",
- "Join": "Samenvoegen",
- "Constant": "Vaste waarde",
- "Any other value: {value}": "Elke andere waarde: {value}",
- "Joined with the columns {columns}, separated by \"{separator}\"": "Samengevoegd met de kolommen {columns}, gescheiden door \"{separator}\"",
- "Value: {value}": "Waarde: {value}",
- "Read as {source}, stored as {target}": "Gelezen als {source}, opgeslagen als {target}",
- "Mapping (read-only)": "Mapping (alleen-lezen)",
- "The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.": "De mapping die de volgende import uitvoert: de tabbladen die hij leest en, per pack, welke kolom van de export naar welk veld gaat. Hij wordt gelezen uit de bestanden onder lib/Settings/cmdb-import van de app op de server; een bestand dat daar is aangepast wordt bij de volgende app-update overschreven.",
"Loading the mapping…": "Mapping laden…",
"The mapping could not be loaded.": "De mapping kon niet worden geladen.",
- "{file}: {name}, version {version}": "{file}: {name}, versie {version}",
- "This pack maps no columns": "Dit pack koppelt geen kolommen",
- "Yes": "Ja",
- "No": "Nee",
"Sheets read": "Gelezen tabbladen",
"Match column": "Koppelkolom",
"Name column": "Naamkolom",
"Required columns": "Verplichte kolommen",
"Date columns": "Datumkolommen",
- "Column in the export": "Kolom in de export",
- "Field": "Veld",
- "Transformation": "Transformatie",
"The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "De mapping van de import kan niet worden getoond: OpenRegister ontbreekt of een mappingbestand is ongeldig.",
- "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek."
+ "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek.",
+ "Mapping": "Mapping",
+ "The mapping the next import runs: the sheets it reads and, for each kind of record, the pack that says which column of the export goes to which field.": "De mapping die de volgende import uitvoert: de tabbladen die hij leest en, per soort gegeven, het pack dat bepaalt welke kolom van de export naar welk veld gaat.",
+ "The packs are kept in OpenRegister, where you can view and edit them. A pack deleted there is restored from the shipped file at the next import.": "De packs staan in OpenRegister, waar ze kunnen worden bekeken en bewerkt. Een pack dat daar is verwijderd, wordt bij de volgende import hersteld uit het meegeleverde bestand.",
+ "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.": "De packopslag van OpenRegister is niet beschikbaar, dus de import gebruikt de mapping die met stackiq wordt meegeleverd. Werk OpenRegister bij of schakel het in om de mapping daar te bekijken en te bewerken.",
+ "OpenRegister": "OpenRegister",
+ "Shipped file": "Meegeleverd bestand",
+ "Version {version}": "Versie {version}",
+ "Version {version} (shipped: {shipped})": "Versie {version} (meegeleverd: {shipped})",
+ "Edited": "Bewerkt",
+ "Edit in OpenRegister": "Bewerken in OpenRegister",
+ "Edit in OpenRegister: {target} (opens in a new tab)": "Bewerken in OpenRegister: {target} (opent in een nieuw tabblad)",
+ "This pack maps the import options, not a column of the export, so it is not edited in OpenRegister.": "Dit pack koppelt de importopties, geen kolom van de export, en wordt daarom niet in OpenRegister bewerkt.",
+ "Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost.": "Stackiq levert een nieuwere versie van dit pack mee ({version}). Die is niet toegepast, omdat het pack in OpenRegister is bewerkt. Verwijder het pack daar om vanaf de volgende import de meegeleverde versie te gebruiken; de wijzigingen gaan dan verloren."
}
}
diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js
index 4646c5a53..228d5a1fe 100644
--- a/src/utils/cmdbImport.js
+++ b/src/utils/cmdbImport.js
@@ -1052,157 +1052,66 @@ export function packTargetLabel(target) {
}
/**
- * The words for a transformation type of OpenRegister's mapping engine.
+ * The words for where the import takes a pack from: OpenRegister's pack
+ * store, or the file shipped with stackiq.
*
- * @param {string|null|undefined} type The transform type, or nothing when the value is copied as is
- * @return {string} The translated name, or the type itself when it is unknown
+ * @param {string} source The pack's `source` from the endpoint (`openregister` or `file`)
+ * @return {string} The translated name, or the source itself when it is unknown
* @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
*/
-export function transformLabel(type) {
- switch (type) {
- case undefined:
- case null:
- case '':
- return t('stackiq', 'As is')
- case 'trim':
- return t('stackiq', 'Trim')
- case 'date':
- return t('stackiq', 'Date')
- case 'lookup':
- return t('stackiq', 'Lookup')
- case 'bool-map':
- return t('stackiq', 'Yes/no lookup')
- case 'concat':
- return t('stackiq', 'Join')
- case 'const':
- return t('stackiq', 'Constant')
+export function packSourceLabel(source) {
+ switch (source) {
+ case 'openregister':
+ return t('stackiq', 'OpenRegister')
+ case 'file':
+ return t('stackiq', 'Shipped file')
default:
- return String(type)
+ return String(source ?? '')
}
}
/**
- * One text per value, for the details column: a JSON array is shown as its
- * members, a null as a dash, anything else as a string.
+ * The version of a pack as the import runs it: the stored version for a
+ * pack from OpenRegister, with the shipped version next to it when the two
+ * differ, and the shipped version for a pack read from the file.
*
- * @param {unknown} value The stored value
- * @return {string} The text
- */
-function valueText(value) {
- if (value === null || value === undefined) {
- return '—'
- }
- if (Array.isArray(value)) {
- return value.map((member) => valueText(member)).join(', ')
- }
- if (typeof value === 'object') {
- return JSON.stringify(value)
- }
- return String(value)
-}
-
-/**
- * The details of a transformation, as lines: the pairs of a lookup, the
- * extra columns of a join, the value of a constant, the formats of a date.
- * A key the page does not know is listed as it is, so nothing the engine
- * reads is hidden.
- *
- * @param {object|null} transform The transform as the pack stores it
- * @return {Array} The lines, empty for a plain trim
+ * @param {object} pack One entry of the endpoint's `packs`
+ * @return {string} The text, empty when the pack names no version
* @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
*/
-export function transformDetails(transform) {
- if (!transform || typeof transform !== 'object') {
- return []
- }
- const lines = []
- const known = new Set(['type'])
- if (transform.map && typeof transform.map === 'object') {
- known.add('map')
- for (const [from, to] of Object.entries(transform.map)) {
- lines.push(`${from} → ${valueText(to)}`)
- }
- }
- if (Object.hasOwn(transform, 'default')) {
- known.add('default')
- lines.push(
- t(
- 'stackiq',
- 'Any other value: {value}',
- { value: valueText(transform.default) },
- AS_TEXT,
- ),
- )
- }
- if (Array.isArray(transform.fields)) {
- known.add('fields')
- known.add('separator')
- lines.push(
- t(
+export function packVersionText(pack) {
+ const shipped = String(pack?.shippedVersion ?? pack?.version ?? '')
+ const stored = String(pack?.storedVersion ?? '')
+ if (pack?.source === 'openregister' && stored !== '') {
+ if (shipped !== '' && shipped !== stored) {
+ return t(
'stackiq',
- 'Joined with the columns {columns}, separated by "{separator}"',
- {
- columns: transform.fields
- .map((field) => String(field))
- .join(', '),
- separator: String(transform.separator ?? ''),
- },
+ 'Version {version} (shipped: {shipped})',
+ { version: stored, shipped },
AS_TEXT,
- ),
- )
- }
- if (Object.hasOwn(transform, 'value')) {
- known.add('value')
- lines.push(
- t(
- 'stackiq',
- 'Value: {value}',
- { value: valueText(transform.value) },
- AS_TEXT,
- ),
- )
- }
- if (transform.sourceFormat || transform.targetFormat) {
- known.add('sourceFormat')
- known.add('targetFormat')
- lines.push(
- t(
- 'stackiq',
- 'Read as {source}, stored as {target}',
- {
- source: String(transform.sourceFormat ?? '—'),
- target: String(transform.targetFormat ?? 'Y-m-d'),
- },
- AS_TEXT,
- ),
- )
- }
- for (const [key, value] of Object.entries(transform)) {
- if (!known.has(key)) {
- lines.push(`${key}: ${valueText(value)}`)
+ )
}
+ return t('stackiq', 'Version {version}', { version: stored }, AS_TEXT)
}
- return lines
+ if (shipped === '') {
+ return ''
+ }
+ return t('stackiq', 'Version {version}', { version: shipped }, AS_TEXT)
}
/**
- * The rows of one pack's table: one per field mapping, in the pack's order.
+ * The address of OpenRegister's editor for a pack, when the page may link
+ * to it: the pack is editable and the server gave an http(s) or
+ * server-relative address. Anything else (a `javascript:` address, a
+ * protocol-relative one, no address at all) gives no link.
*
* @param {object} pack One entry of the endpoint's `packs`
- * @return {Array<{key: string, source: string, target: string, required: boolean, transform: string, details: Array}>} The rows
+ * @return {string|null} The address, or null for no link
* @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
*/
-export function mappingRows(pack) {
- const mappings = pack?.fieldMappings
- if (!Array.isArray(mappings)) {
- return []
+export function packEditUrl(pack) {
+ if (pack?.editable !== true || typeof pack.editUrl !== 'string') {
+ return null
}
- return mappings.map((mapping, index) => ({
- key: `${mapping?.source ?? ''}:${mapping?.target ?? ''}:${index}`,
- source: String(mapping?.source ?? ''),
- target: String(mapping?.target ?? ''),
- required: Boolean(mapping?.required),
- transform: transformLabel(mapping?.transform?.type),
- details: transformDetails(mapping?.transform),
- }))
+ return /^(https?:\/\/|\/(?!\/))/i.test(pack.editUrl) ? pack.editUrl : null
}
diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js
index d0598b407..b1b0b884a 100644
--- a/src/views/settings/sections/CmdbImport.spec.js
+++ b/src/views/settings/sections/CmdbImport.spec.js
@@ -61,6 +61,9 @@ jest.mock('vue-material-design-icons/ChevronDown.vue', () => ({
jest.mock('vue-material-design-icons/ChevronUp.vue', () => ({
render: () => null,
}))
+jest.mock('vue-material-design-icons/OpenInNew.vue', () => ({
+ render: () => null,
+}))
jest.mock('../../../components/AlwaysVisibleSection.vue', () => ({
render: () => null,
}))
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index 8b5a6ff62..294062392 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -362,7 +362,7 @@
-
+
diff --git a/src/views/settings/sections/CmdbImportMapping.vue b/src/views/settings/sections/CmdbImportMapping.vue
index 127226a9a..77861847f 100644
--- a/src/views/settings/sections/CmdbImportMapping.vue
+++ b/src/views/settings/sections/CmdbImportMapping.vue
@@ -15,7 +15,7 @@
- {{ t('stackiq', 'Mapping (read-only)') }}
+ {{ t('stackiq', 'Mapping') }}
+
+ {{
+ t(
+ 'stackiq',
+ 'The packs are kept in OpenRegister, where you can view and edit them. A pack deleted there is restored from the shipped file at the next import.',
)
}}
@@ -39,7 +49,7 @@
{{ t('stackiq', 'Loading the mapping…') }}
-
+
+
+
+ {{
+ t(
+ 'stackiq',
+ "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.",
+ )
+ }}
+
+ {{
+ t(
+ 'stackiq',
+ 'This pack maps the import options, not a column of the export, so it is not edited in OpenRegister.',
+ )
+ }}
+
+
{{
- row.required
- ? t('stackiq', 'Yes')
- : t('stackiq', 'No')
+ t(
+ 'stackiq',
+ 'Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost.',
+ { version: String(pack.shippedVersion ?? '') },
+ asText,
+ )
}}
-
-
-
-
- {{ line }}
-
-
- —
-
-
-
+
+
+
+
+
diff --git a/src/customComponents.js b/src/customComponents.js
index f6ec30d97..da401ac5e 100644
--- a/src/customComponents.js
+++ b/src/customComponents.js
@@ -23,6 +23,7 @@ import OrganisatieCard from './components/cards/OrganisatieCard.vue'
import ApplicationContractsPanel from './components/contracts/ApplicationContractsPanel.vue'
import ContractApprovalPanel from './components/contracts/ContractApprovalPanel.vue'
import ContractSeatsPanel from './components/contracts/ContractSeatsPanel.vue'
+import ContactPersonName from './components/organisations/ContactPersonName.vue'
import OrganisationMergePanel from './components/organisations/OrganisationMergePanel.vue'
import UsageRiskSignals from './components/portfolio/UsageRiskSignals.vue'
import ReviewsPanel from './components/reviews/ReviewsPanel.vue'
@@ -107,6 +108,11 @@ export default {
// and the vulnerabilities, which no built-in widget does.
UsageRiskSignals,
+ // The name of a contact person on its detail page (landscape-usage-registration):
+ // the name lives in Nextcloud Contacts, read through /api/contact-names,
+ // which no built-in widget does.
+ ContactPersonName,
+
// AI Act evidence per tag on the AI system page (landscape-ai-system-inventory):
// it reads the object's files and their tags, which no built-in widget lists per tag.
AiActChecklist,
diff --git a/src/manifest.json b/src/manifest.json
index 22b321684..ec9a337cf 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -423,7 +423,7 @@
{ "id": "org-diensten", "type": "object-list", "title": "Services", "icon": "HandshakeOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "catalogService", "filter": { "provider": "@objectId" }, "columns": [ { "key": "type", "label": "Type" } ], "limit": 25, "emptyText": "No services registered for this organisation yet" } },
{ "id": "org-modules", "type": "object-list", "title": "Applications", "icon": "Package", "content": { "register": "@resolve:voorzieningen_register", "schema": "module", "filter": { "provider": "@objectId" }, "columns": [ { "key": "name", "label": "Name" }, { "key": "licentietype", "label": "License type" }, { "key": "bbnLevel", "label": "BBN level" } ], "limit": 25, "rowRoute": "ModuleDetail", "viewAllRoute": "Modules", "viewAllQuery": { "provider": "@objectId" }, "emptyText": "No applications registered for this organisation yet" } },
{ "id": "org-usages", "type": "object-list", "title": "Applications in use", "icon": "OfficeBuilding", "content": { "register": "@resolve:voorzieningen_register", "schema": "usage", "filter": { "consumer": "@objectId" }, "columns": [ { "key": "module", "label": "Application", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } }, { "key": "moduleVersion", "label": "Version", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "moduleVersion", "labelField": "version" } }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "GebruikDetail", "viewAllRoute": "Gebruik", "viewAllQuery": { "consumer": "@objectId" }, "allowCreate": false, "emptyText": "No applications in use recorded for this organisation yet" } },
- { "id": "org-contactpersonen", "type": "object-list", "title": "Contact persons", "icon": "AccountGroup", "content": { "register": "@resolve:voorzieningen_register", "schema": "contactPerson", "filter": { "organization": "@objectId" }, "columns": [ { "key": "role", "label": "Function" }, { "key": "roles", "label": "Roles" } ], "limit": 25, "rowRoute": "ContactpersoonDetail", "emptyText": "No contact persons linked to this organisation yet" } }
+ { "id": "org-contactpersonen", "type": "object-list", "title": "Contact persons", "icon": "AccountGroup", "content": { "register": "@resolve:voorzieningen_register", "schema": "contactPerson", "filter": { "organization": "@objectId" }, "columns": [ { "key": "id", "label": "Name", "widget": "contactName" }, { "key": "role", "label": "Function" }, { "key": "roles", "label": "Roles" } ], "limit": 25, "rowRoute": "ContactpersoonDetail", "emptyText": "No contact persons linked to this organisation yet" } }
],
"layout": [
{ "id": "1", "widgetId": "org-data", "gridX": 0, "gridY": 0, "gridWidth": 8, "gridHeight": 5 },
@@ -486,6 +486,9 @@
{ "id": "1", "widgetId": "cp-data", "gridX": 0, "gridY": 0, "gridWidth": 8, "gridHeight": 4 },
{ "id": "2", "widgetId": "cp-related", "gridX": 8, "gridY": 0, "gridWidth": 4, "gridHeight": 4 }
],
+ "bodyWidgets": [
+ { "id": "cp-name", "component": "ContactPersonName", "props": { "objectId": "@objectId" }, "placement": "before-body", "colSpan": 12 }
+ ],
"sidebar": {
"enabled": true,
"showMetadata": true,
From cb48f556b109ca3ede6f80fab4d2a7da6ef285b7 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 18:38:51 +0200
Subject: [PATCH 074/124] fix(usage): keep the risk signal labels clear of
their values
A page-wide style gives every dt a fixed width of 130px, aligned to the
end, so "Known vulnerabilities" and "End of support" ran into their
values. The labels now take their own width and start-align.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
src/components/portfolio/UsageRiskSignals.vue | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/components/portfolio/UsageRiskSignals.vue b/src/components/portfolio/UsageRiskSignals.vue
index de23c8c9e..bad7e81ed 100644
--- a/src/components/portfolio/UsageRiskSignals.vue
+++ b/src/components/portfolio/UsageRiskSignals.vue
@@ -224,6 +224,10 @@ export default {
}
.usage-risk-signals__list dt {
+ /* Undo the page-wide fixed width and right alignment of a dt, which made
+ a long label run into its value. */
+ width: auto;
+ text-align: start;
font-weight: bold;
}
From ae31ac3867e9af47d02fba8219d185e712fc1a33 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 18:44:13 +0200
Subject: [PATCH 075/124] docs: scope the archive-reconciliation change to the
reader slice and document the reader's bounds
Closes the review findings:
- "design D8 describes UI code not in this PR": D8 now says the UI code lands in #1287.
- tasks.md ticked Implement/Test for Tasks 3-7 whose code is not in this PR: unticked, with a status note that they land in #1286 and #1287.
- CmdbWorkbookReader sheet count always includes the archive sheet: documented in the read() docblock (limit / 3 is deliberate).
- appIds are raw cell values: documented as raw, untrimmed and un-normalised; no consumer exists in this PR, so no code change.
Class sweep: docs/features/cmdb-import.md described the referenced-string bound as "once per CMDB sheet"; it now names the archive sheet too.
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 2 +-
lib/Service/Cmdb/CmdbWorkbookReader.php | 10 ++++++++++
.../design.md | 2 ++
.../tasks.md | 20 ++++++++++---------
4 files changed, 24 insertions(+), 10 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index b9d505abc..32550932b 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -326,7 +326,7 @@ every import:
| `maxUncompressedBytes` | `52428800` (50 MB) | the size of the workbook once unpacked, checked before a sheet is parsed |
| `maxPartBytes` | `10485760` (10 MB) | the size of one part the import parses once unpacked, such as a source sheet or the shared-strings table, checked before a sheet is parsed; a sheet the import does not read may be larger, and is read as an empty sheet |
| `maxSharedStrings` | `200000` | the number of different texts in the workbook's shared-strings table, each formatted piece of a text with mixed formatting counted as one more, also in text written in a cell itself, counted before a sheet is parsed |
-| `maxReferencedStringBytes` | `67108864` (64 MB) | the shared text all cells of the workbook reference together, counted once per cell and once per CMDB sheet before a sheet is parsed; a long text that many cells repeat counts as often as it is repeated |
+| `maxReferencedStringBytes` | `67108864` (64 MB) | the shared text all cells of the workbook reference together, counted once per cell and once per sheet the import reads (the two CMDB sheets and the archive sheet, also when the workbook has no archive sheet) before a sheet is parsed; a long text that many cells repeat counts as often as it is repeated |
The section's help text shows the defaults; when the server refuses a file,
the message shows the limit the server applied. A larger file also has to
diff --git a/lib/Service/Cmdb/CmdbWorkbookReader.php b/lib/Service/Cmdb/CmdbWorkbookReader.php
index 4723f33fa..3ba065a4d 100644
--- a/lib/Service/Cmdb/CmdbWorkbookReader.php
+++ b/lib/Service/Cmdb/CmdbWorkbookReader.php
@@ -160,6 +160,16 @@ public function isAvailable(): bool {
* and its row number is listed in `archive.uncachedRows`: the sheet then
* lists an application the reader could not see, so a caller must not
* treat the sheet as complete.
+ * The `appIds` are the raw key-column values as read, in sheet order:
+ * not trimmed, not normalised and not de-duplicated, and an empty cell
+ * is an empty entry. Matching them to the stored applications, and any
+ * normalisation that needs, is the consumer's job.
+ * The referenced-string budget (`maxReferencedStringBytes`) is divided
+ * over every sheet in the profile's `readSheetNames()`, so the archive
+ * sheet always counts as one of them, also in a workbook that has no
+ * archive sheet: each sheet's share is limit / sheet count (limit / 3 for
+ * the Topdesk profile). This is deliberate: the bound does not depend on
+ * what the workbook contains.
*
* @param string $path The xlsx file, already checked by assertXlsx().
* @param CmdbImportProfile $profile The import profile.
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/design.md b/openspec/changes/cmdb-import-archive-reconciliation/design.md
index 51daa0580..53fc943f8 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/design.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/design.md
@@ -115,6 +115,8 @@ Progress: the operation gets the phase `reconciling` (added to `ProgressTracker:
### D8. Stackiq UI
+> This section records the decision. The UI code it describes (the quick filters, the `_ids` override and the `src/icons.js` entry) is not part of the reader slice in this PR; it lands in the UI follow-up PR (#1287).
+
The Applications (`Modules`, a `FacetedCatalogIndexView` that forwards `quickFilters` to `CnIndexPage`) and Applications in use (`Gebruik`) pages get the quick filter `{"label": "Archived", "filter": {"_archived": "true"}}`. `useSelfFetchList` spreads the active tab's filter into the fetch parameters as they are, so `_archived=true` reaches OpenRegister and the list shows the archive alone. The facet narrowing's `_ids` would compose with it (AND) and hide archived applications the facets do not know; the Archived tab of the Applications page therefore overrides `_ids` with an empty value, so it lists the whole archive and ignores the facets and the search. The icon `ArchiveOutline` is registered in `src/icons.js`. `@conduction/nextcloud-vue` 2.65 renders no `@self.archived` on a detail page or in the sidebar metadata, so the detail pages show no archive state yet; that is a library follow-up, not a stackiq widget.
### D9. How the import reads the state of a stored object
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
index 1ff7af0d9..17bee62a1 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
@@ -4,6 +4,8 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp
## Implementation Tasks
+Status in this PR (the archive-sheet reader slice): Tasks 1 and 2 are implemented here. Tasks 3-7 are unticked because their code lands in the follow-up PRs (reconciliation in #1286, UI in #1287).
+
### Task 1: Schema opt-in and the import profile
- **spec_ref**: `SPEC#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017`, `SPEC#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012`
- **files**: `lib/Settings/register.d/topdesk-cmdb-import.json`, `lib/Settings/cmdb-import/topdesk-profile.json`, `lib/Service/Cmdb/CmdbImportProfile.php`, `tests/Unit/Settings/TopdeskCmdbFragmentTest.php`, `tests/Unit/Settings/PublicationFieldRulesTest.php`, `tests/Unit/Service/Cmdb/CmdbImportProfileTest.php`
@@ -35,8 +37,8 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp
- GIVEN OpenRegister's search results (no archive marker; no soft-deleted row, `_includeDeleted` or not) THEN the state comes from the lens that found the object and from the trash listing, read once per import (design D9)
- GIVEN `updateExisting` false THEN the row is skipped as `exists` and nothing is revived
- GIVEN a deleted and a working module (and usage) sharing one import key THEN the working one is updated and nothing is restored
-- [x] Implement
-- [x] Test
+- [ ] Implement
+- [ ] Test
### Task 4: Reconciliation after the rows
- **spec_ref**: `SPEC#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015`
@@ -49,8 +51,8 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp
- GIVEN a cancel during the last row THEN nothing is archived or deleted; GIVEN a cancel during the reconciliation THEN it stops before the next application and the report is cancelled
- GIVEN the archive of one application throws THEN that application is a `failed` row and the others are reconciled; GIVEN no ArchiveHandler THEN 503 `ARCHIVE_UNAVAILABLE` before reading
- GIVEN the step runs THEN the operation is in phase `reconciling` and its progress advances per application
-- [x] Implement
-- [x] Test
+- [ ] Implement
+- [ ] Test
### Task 5: Controller option
- **spec_ref**: `SPEC#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012`
@@ -58,8 +60,8 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp
- **acceptance_criteria**:
- GIVEN no `missingRecords` THEN the service receives `archive`; GIVEN `keep` or `archive` THEN it is passed through; GIVEN `remove` or `mark` THEN 422 `MISSING_RECORDS_UNSUPPORTED` with `details.accepted` `["keep", "archive"]` and no import
- GIVEN `ARCHIVE_UNAVAILABLE` from the service THEN 503 with its own translated message
-- [x] Implement
-- [x] Test
+- [ ] Implement
+- [ ] Test
### Task 6: Settings section, report words and the archive quick filter
- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014`, `SPEC#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017`
@@ -69,8 +71,8 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp
- GIVEN a report with the new outcomes THEN the summary shows their tiles, the outcome filter offers them, and a reconciliation row shows no row number
- GIVEN the Applications and Applications in use pages THEN a quick filter "Archived" sends `_archived=true`
- Every new string is in `l10n/en.json` and `l10n/nl.json`, and the generated `l10n/*.js` are current
-- [x] Implement
-- [x] Test
+- [ ] Implement
+- [ ] Test
### Task 7: Fixture, docs and e2e
- **spec_ref**: `SPEC#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015`
@@ -79,7 +81,7 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp
- GIVEN `build-fixtures.py` THEN it derives `topdesk-archived-applications.xlsx`: APPID 1234 on the archive sheet only, APPID 2 on no sheet, both CMDB sheets present and empty; the hygiene test passes on it
- GIVEN the e2e THEN after the first import, importing the variant archives 1234 and soft-deletes 2, the Applications page lists 1234 under "Archived", and importing the original again reports 1234 `unarchived` and 2 `restored` with the object counts back to the first import's
- GIVEN the docs THEN "Repeat imports" describes archive, soft delete, the archive sheet, the option and the return of an application, and the error table has `ARCHIVE_UNAVAILABLE` and the new meaning of `MISSING_RECORDS_UNSUPPORTED`
-- [x] Implement
+- [ ] Implement
- [ ] Test
- Status: PHPUnit covers the fixture round trip (testTheArchivedApplicationsFixtureArchivesDeletesAndBringsBack); the Playwright test is written and not run yet, because no Nextcloud was reachable from the worktree.
From 8dfa03a1a1cb56ca90b2087f6ed982db6ef99742 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 18:49:30 +0200
Subject: [PATCH 076/124] feat(cmdb-import): shorten the help of the
missing-applications choice
The help under "Applications missing from the export" was a paragraph of
seven lines. It is now one sentence; the full rules, including when
applications are only archived, moved to the section's information
panel.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
l10n/en.js | 3 +-
l10n/en.json | 3 +-
l10n/nl.js | 3 +-
l10n/nl.json | 3 +-
.../specs/cmdb-export-import/spec.md | 2 +-
.../settings/sections/CmdbImport.spec.js | 73 +++++++++++++------
src/views/settings/sections/CmdbImport.vue | 20 +++--
7 files changed, 74 insertions(+), 33 deletions(-)
diff --git a/l10n/en.js b/l10n/en.js
index 613cb76f2..fa52e1cda 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1153,7 +1153,8 @@ OC.L10N.register(
"{count} unarchived": "{count} unarchived",
"{count} deleted": "{count} deleted",
"{count} restored": "{count} restored",
- "Applications: {changes}.": "Applications: {changes}."
+ "Applications: {changes}.": "Applications: {changes}.",
+ "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index 29d312f53..a26643b77 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1152,6 +1152,7 @@
"{count} unarchived": "{count} unarchived",
"{count} deleted": "{count} deleted",
"{count} restored": "{count} restored",
- "Applications: {changes}.": "Applications: {changes}."
+ "Applications: {changes}.": "Applications: {changes}.",
+ "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when."
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index 1dc8b83d9..d98a24b44 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1221,7 +1221,8 @@ OC.L10N.register(
"{count} unarchived": "{count} uit het archief gehaald",
"{count} deleted": "{count} verwijderd",
"{count} restored": "{count} teruggezet",
- "Applications: {changes}.": "Applicaties: {changes}."
+ "Applications: {changes}.": "Applicaties: {changes}.",
+ "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applicaties die niet meer in de export staan, worden gearchiveerd of naar de prullenbak verplaatst. De (i)-knop bovenaan legt uit wanneer."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index 7963d0ebc..30c99f4e9 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1220,6 +1220,7 @@
"{count} unarchived": "{count} uit het archief gehaald",
"{count} deleted": "{count} verwijderd",
"{count} restored": "{count} teruggezet",
- "Applications: {changes}.": "Applicaties: {changes}."
+ "Applications: {changes}.": "Applicaties: {changes}.",
+ "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applicaties die niet meer in de export staan, worden gearchiveerd of naar de prullenbak verplaatst. De (i)-knop bovenaan legt uit wanneer."
}
}
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
index f6a842422..c65412f52 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
@@ -165,7 +165,7 @@ The import SHALL accept `missingRecords` with the values `keep` and `archive`; `
### Requirement: The admin settings SHALL offer a CMDB import section (REQ-CMDB-014)
-Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, choose what happens to applications missing from the export (archive, the default, or keep) with help text that says what each does, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary, including the archived, unarchived, deleted and restored counts when they are not zero, and a report table that can be filtered by outcome, including the four new ones; a reconciliation row SHALL show no row number. Every control SHALL have a visible label, and every string SHALL be translatable.
+Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, choose what happens to applications missing from the export (archive, the default, or keep) with a one-sentence help text that says what each does and the details in the section's information panel, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary, including the archived, unarchived, deleted and restored counts when they are not zero, and a report table that can be filtered by outcome, including the four new ones; a reconciliation row SHALL show no row number. Every control SHALL have a visible label, and every string SHALL be translatable.
(Previously: the section had no choice for missing records; the request always sent `keep`.)
diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js
index 5f43536cc..f30570388 100644
--- a/src/views/settings/sections/CmdbImport.spec.js
+++ b/src/views/settings/sections/CmdbImport.spec.js
@@ -10,6 +10,7 @@
import axios from '@nextcloud/axios'
import { flushPromises, mount, shallowMount } from '@vue/test-utils'
+import { h } from 'vue'
import CmdbImport from './CmdbImport.vue'
// Virtual: the package's `exports` field declares only the `import` condition, which jest does not resolve.
@@ -308,13 +309,19 @@ describe('CmdbImport cancelling', () => {
await wrapper.setData({
importing: true,
operationId: 'cmdb-12345678',
- progress: { phase: 'reconciling', processed_items: 400, total_items: 810 },
+ progress: {
+ phase: 'reconciling',
+ processed_items: 400,
+ total_items: 810,
+ },
})
await wrapper.vm.cancelImport()
await flushPromises()
- const status = wrapper.find('[data-testid="cmdb-import-cancel-status"]').text()
+ const status = wrapper
+ .find('[data-testid="cmdb-import-cancel-status"]')
+ .text()
expect(status).toContain('before the next row')
expect(status).toContain('before the next application')
})
@@ -323,7 +330,11 @@ describe('CmdbImport cancelling', () => {
const wrapper = await mountFull()
await wrapper.setData({
- report: { cancelled: true, summary: { rowsRead: 400, archived: 3 }, rows: [] },
+ report: {
+ cancelled: true,
+ summary: { rowsRead: 400, archived: 3 },
+ rows: [],
+ },
})
expect(wrapper.find('[data-testid="cmdb-import-cancelled"]').text()).toMatch(
@@ -366,7 +377,13 @@ describe('CmdbImport finished announcement', () => {
report: {
cancelled: true,
municipality: { name: 'Gemeente Bergen' },
- summary: { rowsRead: 5, archived: 2, unarchived: 3, deleted: 4, restored: 5 },
+ summary: {
+ rowsRead: 5,
+ archived: 2,
+ unarchived: 3,
+ deleted: 4,
+ restored: 5,
+ },
rows: [],
},
})
@@ -435,15 +452,30 @@ describe('CmdbImport the choice for missing applications, as the admin makes it'
expect(wrapper.vm.missingRecords).toBe('archive')
})
- it('says in the help when applications are only archived, never deleted', async () => {
+ it('says in short what happens, and in the info panel when applications are only archived', async () => {
const wrapper = await mountFull()
const help = wrapper.get('#cmdb-import-missing-records-help').text()
-
- expect(help).toContain('is missing')
- expect(help).toContain('has no APPID column')
- expect(help).toContain('an APPID without a cached value')
- expect(help).toContain('only archived')
+ expect(help).toContain('archived or moved to the trash')
+
+ // The info panel is the section's `info-content` slot; render it.
+ const panel = mount(CmdbImport, {
+ global: {
+ stubs: {
+ AlwaysVisibleSection: {
+ render() {
+ return h('div', [this.$slots['info-content']?.()])
+ },
+ },
+ },
+ },
+ })
+ await flushPromises()
+ const text = panel.text()
+ expect(text).toContain('is missing')
+ expect(text).toContain('has no APPID column')
+ expect(text).toContain('an APPID without a cached value')
+ expect(text).toContain('only archived')
})
it('disables both radios while an import runs', async () => {
@@ -480,10 +512,7 @@ describe('CmdbImport report rows of archived and deleted applications', () => {
await wrapper.setData({
report: {
summary: { rowsRead: 0, archived: 1, deleted: 1 },
- rows: [
- reconciliationRow('archived'),
- reconciliationRow('deleted'),
- ],
+ rows: [reconciliationRow('archived'), reconciliationRow('deleted')],
},
})
@@ -491,11 +520,13 @@ describe('CmdbImport report rows of archived and deleted applications', () => {
expect(rows).toHaveLength(2)
const [archived, deleted] = rows
expect(
- archived.find('[data-testid="cmdb-import-module-link"]').attributes('href'),
+ archived
+ .find('[data-testid="cmdb-import-module-link"]')
+ .attributes('href'),
).toBe('/apps/stackiq/modules/module-uuid-archived')
- expect(deleted.find('[data-testid="cmdb-import-module-link"]').exists()).toBe(
- false,
- )
+ expect(
+ deleted.find('[data-testid="cmdb-import-module-link"]').exists(),
+ ).toBe(false)
// The name of the deleted application is still shown, as text.
expect(deleted.text()).toContain('Naamtest')
})
@@ -524,9 +555,9 @@ describe('CmdbImport report rows of archived and deleted applications', () => {
})
const rows = wrapper.findAll('[data-testid="table-row"]')
- expect(rows.map((row) => row.get('[data-testid="table-row-number"]').text())).toEqual(
- ['', '', '2'],
- )
+ expect(
+ rows.map((row) => row.get('[data-testid="table-row-number"]').text()),
+ ).toEqual(['', '', '2'])
expect(
rows[0].find('[data-testid="cmdb-import-module-link"]').exists(),
).toBe(true)
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index 88eaf5d75..801bc9b36 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -172,7 +172,7 @@
{{
t(
'stackiq',
- 'An application of this municipality that is on neither CMDB sheet any more is archived when the sheet "Gearchiveerde Applicaties" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.',
+ 'Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.',
)
}}
@@ -449,6 +449,15 @@
}}
+
{{ t('stackiq', 'Applications missing from the export') }}
+
+ {{
+ t(
+ 'stackiq',
+ 'An application of this municipality that is on neither CMDB sheet any more is archived when the sheet "Gearchiveerde Applicaties" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.',
+ )
+ }}
+
@@ -718,8 +727,7 @@ export default {
const parts = [
[
'archived',
- (count) =>
- t('stackiq', '{count} archived', { count }, AS_TEXT),
+ (count) => t('stackiq', '{count} archived', { count }, AS_TEXT),
],
[
'unarchived',
@@ -728,13 +736,11 @@ export default {
],
[
'deleted',
- (count) =>
- t('stackiq', '{count} deleted', { count }, AS_TEXT),
+ (count) => t('stackiq', '{count} deleted', { count }, AS_TEXT),
],
[
'restored',
- (count) =>
- t('stackiq', '{count} restored', { count }, AS_TEXT),
+ (count) => t('stackiq', '{count} restored', { count }, AS_TEXT),
],
]
.map(([key, text]) => [Number(summary[key]) || 0, text])
From dca821194f2f61b1c769bcfc8e782bfe803b9550 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 18:58:44 +0200
Subject: [PATCH 077/124] fix(cmdb-import): never soft-delete when a CMDB-sheet
APPID is unreadable; usages before modules
Closes the review findings:
- "Unreadable CMDB-sheet APPID still soft-deletes": a CMDB-sheet row whose APPID is a formula without a cached value is skipped as empty, so its application looked absent. The run now notes such rows per sheet and, with any, archives instead of soft-deleting and warns naming the CMDB sheet and the rows (reusing the archive-sheet warning). New test testAnUncachedCmdbRowArchivesInsteadOfDeleting.
- "Extras written after the module; no failing-extra test": itemsOf() now orders every usage (own and extra) before every module, as the docblock promises. New test testAFailingExtraUsageFailsTheApplicationBeforeItsModule.
- archiveServices() swallowed the container exception: it is now logged (exception class only) and chained as previous.
- trash() read for missingRecords=keep: kept on purpose (matching is mode independent, a hand-deleted application must be restored, not duplicated); the reason is now in its docblock.
Class sweep: the rule is updated in the service docblocks, openapi.json (missingRecords description), and the change's spec.md (requirement and new scenario), design.md (D3 text, D5 table, risks) and proposal.md. docs/features/cmdb-import.md does not describe archive reconciliation yet, so it holds no copy of the rule.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/CmdbExportImportService.php | 81 ++++++++++++++++---
openapi.json | 2 +-
.../design.md | 6 +-
.../proposal.md | 4 +-
.../specs/cmdb-export-import/spec.md | 10 ++-
.../Service/CmdbExportImportServiceTest.php | 76 +++++++++++++++++
6 files changed, 162 insertions(+), 17 deletions(-)
diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php
index 54c9e4b86..4c236d9ac 100644
--- a/lib/Service/CmdbExportImportService.php
+++ b/lib/Service/CmdbExportImportService.php
@@ -297,6 +297,13 @@ class CmdbExportImportService {
*/
private array $winningSheets = [];
+ /**
+ * The CMDB-sheet rows of this run whose key column the reader could not see (a formula without a cached value).
+ *
+ * @var array> Sheet name => row numbers.
+ */
+ private array $unreadableKeyRows = [];
+
/**
* OpenRegister's archive handler and object mapper, once resolved.
*
@@ -549,6 +556,7 @@ private function runImport(string $path, array $options, string $startedAt): arr
$report->setMunicipality(uuid: $municipality['uuid'], name: $municipality['name'], created: $municipality['created']);
$report->addImportWarnings(warnings: $this->translateImportWarnings(warnings: $workbook['importWarnings']));
$archive = $this->archiveSheet(workbook: $workbook);
+ $this->unreadableKeyRows = $this->unreadableKeyRows(rows: $rows);
$this->addRunWarnings(report: $report, municipality: $municipality, archive: $archive, missingRecords: $missingRecords);
$this->progressTracker->startOperation(
@@ -625,7 +633,10 @@ private function runImport(string $path, array $options, string $startedAt): arr
}//end runImport()
/**
- * Add the run's own warnings: an archive sheet that is missing, has no key column or has unreadable keys, and a created municipality.
+ * Add the run's own warnings.
+ *
+ * An archive sheet that is missing, has no key column or has unreadable keys, a CMDB sheet with
+ * unreadable keys, and a created municipality.
*
* @param CmdbImportReport $report The report.
* @param array{uuid: string, name: string, created: bool} $municipality The consumer, as resolveMunicipality() found it.
@@ -642,6 +653,13 @@ private function addRunWarnings(CmdbImportReport $report, array $municipality, a
$report->addImportWarnings(warnings: [['sheet' => $archive['sheet'], 'message' => $this->archiveSheetWarning(archive: $archive)]]);
}
+ if ($missingRecords === 'archive') {
+ foreach ($this->unreadableKeyRows as $sheet => $rowNumbers) {
+ $cmdbSheet = ['sheet' => $sheet, 'keyColumnMissing' => false, 'uncachedRows' => $rowNumbers];
+ $report->addImportWarnings(warnings: [['sheet' => $sheet, 'message' => $this->archiveSheetWarning(archive: $cmdbSheet)]]);
+ }
+ }
+
if ($municipality['created'] === true) {
$report->addImportWarnings(
warnings: [
@@ -657,6 +675,32 @@ private function addRunWarnings(CmdbImportReport $report, array $municipality, a
}
}//end addRunWarnings()
+ /**
+ * The CMDB-sheet rows whose key column is a formula without a cached value, per sheet.
+ *
+ * Such a row reads as empty and is skipped, so its application may be
+ * missing from `winningSheets` although it is on a CMDB sheet. A run with
+ * one is not authoritative for a deletion, as with an archive sheet that
+ * has an unreadable key.
+ *
+ * @param array}> $rows The reader rows.
+ *
+ * @return array> Sheet name => row numbers.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ private function unreadableKeyRows(array $rows): array {
+ $key = $this->profile->keyColumn();
+ $found = [];
+ foreach ($rows as $row) {
+ if (in_array($key, ($row['uncached'] ?? []), true) === true) {
+ $found[$row['sheet']][] = $row['row'];
+ }
+ }
+
+ return $found;
+ }//end unreadableKeyRows()
+
/**
* Process one row in its own error boundary and add its outcome.
*
@@ -1968,7 +2012,9 @@ private function archiveSheet(array $workbook): array {
/**
* The report warning of an archiving import whose archive sheet is absent or has no key column, or has unreadable keys.
*
- * @param array{sheet: string, keyColumnMissing: bool, uncachedRows: array} $archive The archive sheet.
+ * Also the warning of a CMDB sheet with unreadable keys: it is handed the sheet's name and rows in the archive sheet's shape.
+ *
+ * @param array{sheet: string, keyColumnMissing: bool, uncachedRows: array} $archive The archive sheet, or a CMDB sheet.
*
* @return string
*
@@ -2021,10 +2067,13 @@ private function archiveServices(): array {
return $this->archiveServices;
}
+ $previous = null;
try {
$handler = $this->container->get(static::ARCHIVE_HANDLER_CLASS);
$mapper = $this->container->get(static::OBJECT_MAPPER_CLASS);
} catch (Throwable $e) {
+ $this->logger->warning('CmdbExportImportService: OpenRegister archive services could not be resolved', ['exception' => get_class($e)]);
+ $previous = $e;
$handler = null;
$mapper = null;
}
@@ -2035,7 +2084,8 @@ private function archiveServices(): array {
) {
throw new CmdbImportException(
errorCode: CmdbImportException::ARCHIVE_UNAVAILABLE,
- message: 'OpenRegister ArchiveHandler or MagicMapper is not available'
+ message: 'OpenRegister ArchiveHandler or MagicMapper is not available',
+ previous: $previous
);
}
@@ -2149,6 +2199,11 @@ private function restore(string $uuid): mixed {
* nothing in it is matched, and the log says so. A listing that throws is
* not cached, so the next row reads it again.
*
+ * It is read for `keep` as well: matching is not tied to the mode (REQ-CMDB-016),
+ * and an application a person soft-deleted by hand must be restored, not
+ * created a second time. The listing cannot be narrowed, so the cost is one
+ * scan per run, paid on the first row no working or archived object matches.
+ *
* @return array{module: array, usage: array} Schema key => uuid => entity.
*
* @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
@@ -2545,8 +2600,9 @@ private function reconcileApplication(array $application, array $archive, string
if ($onArchiveSheet === true) {
$entry['sheet'] = $archive['sheet'];
$reason = self::REASON_PREFIX . 'on sheet "' . $archive['sheet'] . '" (' . $operationId . ')';
- } elseif ($archive['present'] === true && $archive['uncachedRows'] === []) {
- // A sheet with an unreadable key may list this application, so it is not authoritative for a deletion.
+ } elseif ($archive['present'] === true && $archive['uncachedRows'] === [] && $this->unreadableKeyRows === []) {
+ // A sheet with an unreadable key (the archive sheet or a CMDB sheet) may list this application, so the
+ // workbook is not authoritative for a deletion.
$target = CmdbImportReport::DELETED;
}
@@ -2573,7 +2629,10 @@ private function reconcileApplication(array $application, array $archive, string
}//end reconcileApplication()
/**
- * What one application brings to the state the export asks for: its usage and module, then the other usages and modules of its key.
+ * What one application brings to the state the export asks for: its usage and the other usages of its key, then its module and the other modules.
+ *
+ * Usages come before modules, so a failure part way leaves the
+ * municipality's view right and a module at worst visible.
*
* Only the application's own objects are restored from the trash, and only
* when `restoreDeleted` says so; the extras never are.
@@ -2588,16 +2647,18 @@ private function reconcileApplication(array $application, array $archive, string
private static function itemsOf(array $application, bool $restoreDeleted): array {
$items = [];
foreach (['usage', 'module'] as $schemaKey) {
+ // Every usage is written before any module, whoever holds it.
$items[] = [
'schemaKey' => $schemaKey,
'uuid' => (string)$application[$schemaKey]->getUuid(),
'state' => $application['states'][$schemaKey],
'restore' => $restoreDeleted,
];
- }
-
- foreach (($application['extras'] ?? []) as $extra) {
- $items[] = $extra + ['restore' => false];
+ foreach (($application['extras'] ?? []) as $extra) {
+ if ($extra['schemaKey'] === $schemaKey) {
+ $items[] = $extra + ['restore' => false];
+ }
+ }
}
return $items;
diff --git a/openapi.json b/openapi.json
index 4d4bb058b..f456fc209 100644
--- a/openapi.json
+++ b/openapi.json
@@ -66,7 +66,7 @@
"archive"
],
"default": "archive",
- "description": "What happens to the municipality's applications whose APPID is no longer on either CMDB sheet. archive: after the last row, archive module and usage when the sheet \"Gearchiveerde Applicaties\" lists the APPID, soft-delete them when no sheet does, and archive only when the workbook has no archive sheet, the sheet has no APPID column, or an APPID in it has no cached value; an archived or deleted application that returns is unarchived or restored. keep: leave them as they are. mark and remove are reserved and refused with 422 MISSING_RECORDS_UNSUPPORTED"
+ "description": "What happens to the municipality's applications whose APPID is no longer on either CMDB sheet. archive: after the last row, archive module and usage when the sheet \"Gearchiveerde Applicaties\" lists the APPID, soft-delete them when no sheet does, and archive only when the workbook has no archive sheet, the sheet has no APPID column, an APPID in it has no cached value, or an APPID on a CMDB sheet has no cached value (that application may be the one the reader could not see); an archived or deleted application that returns is unarchived or restored. keep: leave them as they are. mark and remove are reserved and refused with 422 MISSING_RECORDS_UNSUPPORTED"
},
"operationId": {
"type": "string",
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/design.md b/openspec/changes/cmdb-import-archive-reconciliation/design.md
index a1bbbe62e..1fc7058ee 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/design.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/design.md
@@ -71,7 +71,7 @@ With `updateExisting=false`, a matched row is skipped as `exists` before anythin
The profile names the sheet (`archiveSheet.name`: "Gearchiveerde Applicaties"). The reader lists the workbook's sheets, and when the archive sheet is present it is loaded with the CMDB sheets under the same bounds: it counts as a read sheet for `CmdbPartReferences` (an oversized archive sheet is refused, not blanked) and for the referenced-string budget, its row span is bounded like a source sheet, and `TOO_MANY_ROWS` names it. Only its APPID column is resolved and read; the allowlist does not grow. The reader returns `archive: {present, keyColumnMissing, appIds, uncachedRows}` next to `rows`.
- Sheet absent, or present without an APPID column: `present` false (with `keyColumnMissing`), and the service adds a report warning. The reconciliation then archives every missing application and soft-deletes nothing: a workbook saved without the sheet must not empty the trash-bound set by accident.
-- A row whose APPID is a formula without a cached value reads as empty, as on the CMDB sheets (REQ-CMDB-002), so the sheet would silently list one application fewer. The reader therefore lists such rows (their numbers) in `uncachedRows`, and the service treats the sheet like an absent one for the decision between archive and delete: it adds a warning naming the sheet and the rows, archives every missing application and soft-deletes nothing. A tool that saves formulas without cached values must not turn archived applications into trash.
+- A row whose APPID is a formula without a cached value reads as empty, as on the CMDB sheets (REQ-CMDB-002), so the sheet would silently list one application fewer. The reader therefore lists such rows (their numbers) in `uncachedRows`, and the service treats the sheet like an absent one for the decision between archive and delete: it adds a warning naming the sheet and the rows, archives every missing application and soft-deletes nothing. A tool that saves formulas without cached values must not turn archived applications into trash. The same holds for a CMDB sheet: a row whose APPID has no cached value is skipped, so that application may be on a CMDB sheet and still look absent. The service notes such rows (`unreadableKeyRows`, per sheet) and, with any of them, archives instead of soft-deleting and warns naming the CMDB sheet and the rows, as for the archive sheet.
- The archive sheet's other columns are not read; `Applicatie Status` there is always `Verwijderd` in the municipality's export and adds nothing.
### D4. Matching widens to archived and deleted objects, and revives what it matches
@@ -94,7 +94,7 @@ Per application, with `processed` = every APPID match key on the CMDB sheets (wh
| on the archive sheet | deleted | restore, then archive | `archived` |
| nowhere, sheet present | working or archived | soft-delete usage, then module | `deleted` |
| nowhere, sheet present | deleted | nothing | — |
-| nowhere, sheet absent or incomplete (no key column, or `uncachedRows` not empty) | working | archive usage, then module | `archived` |
+| nowhere, sheet absent or incomplete (no key column, `uncachedRows` not empty, or a CMDB-sheet row with an unreadable APPID) | working | archive usage, then module | `archived` |
| nowhere, sheet absent or incomplete | archived or deleted | nothing | — |
The table decides on one entry per key. When a municipality has several usages and modules under the same import key (D4 says earlier imports created exactly such duplicates, a module deleted by hand next to the one a later import created), the scope keeps the entry the import would match: the best module state wins (working, then archived, then deleted), the best usage state breaks a tie, the first listed breaks the rest. The other usages of the key follow the application, so a stray usage stays visible nowhere: a working or archived usage of a stale duplicate, a second usage of the same module, and a working or archived duplicate module are archived or soft-deleted as the key asks, once, in the same error boundary and the same report row (usages first, then modules). What is in the trash is left as it is: a stale trashed duplicate is not restored into the archive when the key is on the archive sheet, and is not touched when it is on no sheet.
@@ -196,7 +196,7 @@ No seed objects change. The fragment's three seed modules stay as they are; the
## Risks / Trade-offs
- [The default archives on the first re-import after the upgrade] → The section shows the choice with help text; the report lists every archived and deleted application; nothing is hard-deleted.
-- [A workbook saved without the archive sheet, or whose archive sheet has no APPID column or an APPID without a cached value] → archive only, never delete, plus a warning (D3).
+- [A workbook saved without the archive sheet, or whose archive sheet has no APPID column or an APPID without a cached value, or a CMDB sheet with an APPID without a cached value] → archive only, never delete, plus a warning (D3).
- [A failure between the usage and the module] → the usage is written first, so the municipality's view is right and the module is at worst still visible (D5).
- [Reviving before writing adds calls per returning row] → only for rows whose match is archived or deleted; a row whose module is working costs the same searches as before, a new row one more search (the archive lens).
- [The trash listing scans every magic table of the instance] → read once per import, not per row (D9).
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/proposal.md b/openspec/changes/cmdb-import-archive-reconciliation/proposal.md
index dd4017f30..d9685e710 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/proposal.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/proposal.md
@@ -34,7 +34,7 @@ None.
### In Scope
- The sheet "Gearchiveerde Applicaties" (columns Applicatiecomponent, APPID, Applicatie Code, Applicatie Naam, Roepnaam, Applicatie Status, Bron, Datum Interface, Datum wijziging) is read for its APPID column only, when it is present.
-- After every row of a completed import: the municipality's usages, with their modules, are compared with the APPIDs on the CMDB sheets and on the archive sheet. Missing and on the archive sheet: module and usage archived. Missing everywhere: module and usage soft-deleted. Without the archive sheet, or when it has no APPID column or an APPID the reader cannot see (a formula without a cached value): archived only, with a warning in the report.
+- After every row of a completed import: the municipality's usages, with their modules, are compared with the APPIDs on the CMDB sheets and on the archive sheet. Missing and on the archive sheet: module and usage archived. Missing everywhere: module and usage soft-deleted. Without the archive sheet, or when it has no APPID column or an APPID the reader cannot see (a formula without a cached value, on the archive sheet or on a CMDB sheet): archived only, with a warning in the report.
- A returning APPID: an archived module or usage is unarchived, a soft-deleted one is restored from the trash, and the row is then updated as before.
- `missingRecords`: `keep` leaves everything as it is (the behaviour so far), `archive` reconciles and is the default; `remove` stays reserved and is refused.
- The settings section offers the choice with help text; the report shows the new outcomes in its summary and rows.
@@ -78,7 +78,7 @@ None. `ArchiveHandler` and `MagicMapper` come from OpenRegister, which stackiq a
**Severity:** Medium — **Mitigation:** only the municipality's own usages and the modules they point at are in scope, never a supplier's or another municipality's module; nothing is hard-deleted; the choice is visible in the section with help text, and the report lists every archived and deleted application. A cancelled or failed import never reconciles.
### Risk 2: A workbook without a usable archive sheet soft-deletes everything that moved there
-**Severity:** Medium — **Mitigation:** without the sheet, without its APPID column, or with an APPID the reader cannot see (a formula without a cached value), missing applications are archived, never soft-deleted, and the report warns, naming the sheet (and the rows).
+**Severity:** Medium — **Mitigation:** without the sheet, without its APPID column, or with an APPID the reader cannot see on it or on a CMDB sheet (a formula without a cached value), missing applications are archived, never soft-deleted, and the report warns, naming the sheet (and the rows).
### Risk 3: The archive services are OpenRegister internals
**Severity:** Low — **Mitigation:** resolved through the container behind a guard; when missing, an import with `missingRecords=archive` is refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read, and `keep` still works.
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
index d03c04ee7..7f781f895 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
@@ -16,7 +16,7 @@ OpenRegister: `OCA\OpenRegister\Contract\ObjectServiceInterface` (`searchObjects
### Requirement: An application missing from the CMDB sheets SHALL be archived when the archive sheet lists it and soft-deleted when no sheet does (REQ-CMDB-015)
-With `missingRecords` `archive`, after every row of an import that was not cancelled and did not fail, the import SHALL reconcile, under the register lock it already holds, every `usage` whose `consumer` is the imported municipality together with the `module` it points at, including archived and soft-deleted ones, and only those whose module carries this municipality's import key (`topdesk::`). It SHALL read the APPID column of the sheet "Gearchiveerde Applicaties" when the workbook has it, under the same size and row bounds as the CMDB sheets and without reading any other column of it. For an application whose APPID is on neither CMDB sheet: when the archive sheet lists the APPID, the import SHALL archive the usage and then the module through OpenRegister's archive state with a reason that names the import, restoring a soft-deleted one from the trash first; when no sheet lists it, the import SHALL soft-delete the usage and then the module through OpenRegister's trash. When the workbook has no archive sheet, or the sheet has no APPID column, or a row of the sheet holds an APPID formula without a cached value (read as empty, as REQ-CMDB-002 has it for the CMDB sheets, and listed by the reader in `archive.uncachedRows`), the import SHALL archive and SHALL NOT soft-delete, and SHALL add a warning to the report; for an uncached row the warning SHALL name the sheet and the row numbers. When a municipality has more than one module or usage under the same import key (a module deleted by hand next to the one a later import created), the import SHALL reconcile as the application the one it would match, a working one before an archived one and an archived one before one in the trash, and SHALL bring every other usage of that key, and every other module of it that is not in the trash, to the same state, so no stray usage stays visible; it SHALL leave what is in the trash as it is, so a stale duplicate is never restored into the archive. An application that is already in the state the export asks for SHALL be left alone and not reported. The import SHALL NOT touch contact persons, organisations, modules that carry another municipality's import key or no import key, or usages of another organisation. Each application SHALL be reconciled in its own error boundary: a failure SHALL be a `failed` report row naming the step and the exception class, and the other applications SHALL still be reconciled. The reconciliation SHALL report its progress as the phase `reconciling` of the import's operation. A cancel SHALL also be honoured after the last row and before each application of the reconciliation: the import SHALL stop there, SHALL keep what it archived or deleted so far, and SHALL report the run as cancelled. The report SHALL count `archived` and `deleted` in its summary and SHALL list each as a row with the APPID, the application name and both uuids, never person data. When OpenRegister's archive handler or object mapper is not available, an import with `missingRecords` `archive` SHALL be refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read.
+With `missingRecords` `archive`, after every row of an import that was not cancelled and did not fail, the import SHALL reconcile, under the register lock it already holds, every `usage` whose `consumer` is the imported municipality together with the `module` it points at, including archived and soft-deleted ones, and only those whose module carries this municipality's import key (`topdesk::`). It SHALL read the APPID column of the sheet "Gearchiveerde Applicaties" when the workbook has it, under the same size and row bounds as the CMDB sheets and without reading any other column of it. For an application whose APPID is on neither CMDB sheet: when the archive sheet lists the APPID, the import SHALL archive the usage and then the module through OpenRegister's archive state with a reason that names the import, restoring a soft-deleted one from the trash first; when no sheet lists it, the import SHALL soft-delete the usage and then the module through OpenRegister's trash. When the workbook has no archive sheet, or the sheet has no APPID column, or a row of the sheet holds an APPID formula without a cached value (read as empty, as REQ-CMDB-002 has it for the CMDB sheets, and listed by the reader in `archive.uncachedRows`), the import SHALL archive and SHALL NOT soft-delete, and SHALL add a warning to the report; for an uncached row the warning SHALL name the sheet and the row numbers. The same holds when a row of a CMDB sheet holds an APPID formula without a cached value (the row is skipped as empty, so its application may look absent): the import SHALL archive and SHALL NOT soft-delete, and the warning SHALL name that CMDB sheet and the rows. When a municipality has more than one module or usage under the same import key (a module deleted by hand next to the one a later import created), the import SHALL reconcile as the application the one it would match, a working one before an archived one and an archived one before one in the trash, and SHALL bring every other usage of that key, and every other module of it that is not in the trash, to the same state, so no stray usage stays visible; it SHALL leave what is in the trash as it is, so a stale duplicate is never restored into the archive. An application that is already in the state the export asks for SHALL be left alone and not reported. The import SHALL NOT touch contact persons, organisations, modules that carry another municipality's import key or no import key, or usages of another organisation. Each application SHALL be reconciled in its own error boundary: a failure SHALL be a `failed` report row naming the step and the exception class, and the other applications SHALL still be reconciled. The reconciliation SHALL report its progress as the phase `reconciling` of the import's operation. A cancel SHALL also be honoured after the last row and before each application of the reconciliation: the import SHALL stop there, SHALL keep what it archived or deleted so far, and SHALL report the run as cancelled. The report SHALL count `archived` and `deleted` in its summary and SHALL list each as a row with the APPID, the application name and both uuids, never person data. When OpenRegister's archive handler or object mapper is not available, an import with `missingRecords` `archive` SHALL be refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read.
#### Scenario: An application that moved to the archive sheet is archived
@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts
@@ -53,6 +53,14 @@ With `missingRecords` `archive`, after every row of an import that was not cance
- **THEN** the module and the usage of `7` SHALL be archived and SHALL NOT be deleted
- **AND** the report SHALL carry a warning naming the sheet "Gearchiveerde Applicaties" and the row
+#### Scenario: A CMDB row whose APPID has no cached value never turns an archive into a deletion
+@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testAnUncachedCmdbRowArchivesInsteadOfDeleting asserts the archive state, no delete, and the warning naming the CMDB sheet and the row.
+
+- **GIVEN** the module with APPID `8` and its usage were imported, `8` is on no readable row, and a row of a CMDB sheet holds its APPID in a formula that carries no cached value
+- **WHEN** the workbook is imported with `archive` and a readable archive sheet that does not list `8`
+- **THEN** the module and the usage of `8` SHALL be archived and SHALL NOT be deleted
+- **AND** the report SHALL carry a warning naming the CMDB sheet and the row
+
#### Scenario: Keeping missing records, a cancelled import and another municipality leave everything as it is
@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testRecordsMissingFromTheExportStayWhenKept, testACancelledImportDoesNotReconcile and testAnotherMunicipalitysUsagesAreNeverReconciled assert no archive, no delete and no reconciliation row.
diff --git a/tests/Unit/Service/CmdbExportImportServiceTest.php b/tests/Unit/Service/CmdbExportImportServiceTest.php
index 6fb99267f..c476b5b1f 100644
--- a/tests/Unit/Service/CmdbExportImportServiceTest.php
+++ b/tests/Unit/Service/CmdbExportImportServiceTest.php
@@ -2000,6 +2000,82 @@ public function testAnUncachedArchiveRowArchivesInsteadOfDeleting(): void {
$this->assertSame([], $kept['importWarnings']);
}//end testAnUncachedArchiveRowArchivesInsteadOfDeleting()
+ /**
+ * A CMDB-sheet row whose APPID the reader could not see is skipped as empty, so its application looks absent:
+ * the import archives what it would have deleted, and warns naming the CMDB sheet and the row.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function testAnUncachedCmdbRowArchivesInsteadOfDeleting(): void {
+ $this->importOneSevenAndEight();
+ [$seven, $eight] = [$this->moduleOf(appId: '7'), $this->moduleOf(appId: '8')];
+
+ // APPID 7 is on the readable archive sheet; APPID 8 is on no readable sheet, and row 5 of a CMDB sheet has an APPID formula without a cached value.
+ $report = $this->service(
+ reader: $this->rowsReader(
+ rows: [$this->row(appId: '1'), $this->row(appId: '', cells: ['APPID' => null], row: 5, uncached: ['APPID'])],
+ archived: ['7']
+ )
+ )->import(path: '', options: ['municipalityUuid' => 'muni-1']);
+
+ $this->assertNotContains('delete', array_column($this->transitions, 0), 'nothing is soft-deleted');
+ $this->assertSame([true, false], $this->stateOf(schema: self::MODULE, uuid: $seven));
+ $this->assertSame([true, false], $this->stateOf(schema: self::MODULE, uuid: $eight), 'APPID 8 may be the unreadable row, so it is archived');
+ $this->assertSame([true, false], $this->stateOf(schema: self::USAGE, uuid: $this->usageOf(moduleUuid: $eight)));
+ $this->assertSame([2, 0], [$report['summary']['archived'], $report['summary']['deleted']]);
+ $this->assertSame(
+ [['sheet' => 'Beheerde Applicaties CMDB', 'message' => 'Sheet "Beheerde Applicaties CMDB" has an APPID without a cached value in row 5, so applications missing from the export are archived, not deleted.']],
+ $report['importWarnings']
+ );
+
+ // Keeping missing records needs no warning, and without the unreadable row the same workbook deletes.
+ $this->transitions = [];
+ $this->states = array_replace_recursive($this->states, [self::MODULE => [$eight => ['archived' => null]], self::USAGE => [$this->usageOf(moduleUuid: $eight) => ['archived' => null]]]);
+ $kept = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1'), $this->row(appId: '', cells: ['APPID' => null], row: 5, uncached: ['APPID'])], archived: ['7']))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1', 'missingRecords' => 'keep']);
+ $this->assertSame([], $kept['importWarnings']);
+ $this->assertSame([], $this->transitions);
+
+ $readable = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: ['7']))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1']);
+ $this->assertSame([false, true], $this->stateOf(schema: self::MODULE, uuid: $eight), 'a readable workbook still soft-deletes what is on no sheet');
+ $this->assertSame([], $readable['importWarnings']);
+ }//end testAnUncachedCmdbRowArchivesInsteadOfDeleting()
+
+ /**
+ * An extra usage of a key that cannot be archived fails the application's row; its module, written after every usage, stays
+ * as it was, and the other applications are still reconciled.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function testAFailingExtraUsageFailsTheApplicationBeforeItsModule(): void {
+ $this->importOneSevenAndEight();
+ [$seven, $eight] = [$this->moduleOf(appId: '7'), $this->moduleOf(appId: '8')];
+ $sevenUsage = $this->usageOf(moduleUuid: $seven);
+ $this->store[self::USAGE]['usage-second'] = array_merge($this->store[self::USAGE][$sevenUsage], ['id' => 'usage-second']);
+ $this->beforeTransition = static function (string $verb, string $uuid): void {
+ if ($uuid === 'usage-second') {
+ throw new RuntimeException('the extra usage cannot be archived');
+ }
+ };
+
+ $report = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: ['7']))
+ ->import(path: '', options: ['municipalityUuid' => 'muni-1']);
+
+ $rows = array_values(array_filter($report['rows'], static fn (array $row): bool => $row['row'] === 0));
+ $this->assertSame(['failed', 'deleted'], array_column($rows, 'outcome'));
+ $this->assertSame(['step "archive" failed (RuntimeException)'], $rows[0]['reasons']);
+ $this->assertSame([true, false], $this->stateOf(schema: self::USAGE, uuid: $sevenUsage), 'the application\'s own usage went first');
+ $this->assertSame([false, false], $this->stateOf(schema: self::USAGE, uuid: 'usage-second'), 'the failing extra is unchanged');
+ $this->assertSame([false, false], $this->stateOf(schema: self::MODULE, uuid: $seven), 'the module comes after every usage, so it was not reached');
+ $this->assertSame([false, true], $this->stateOf(schema: self::MODULE, uuid: $eight), 'the next application is still reconciled');
+ $this->assertSame(1, $report['summary']['failed']);
+ }//end testAFailingExtraUsageFailsTheApplicationBeforeItsModule()
+
/**
* A cancelled import reconciles nothing.
*
From 18bd2224df881d3ed09c706d92f67ebc426de752 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 18:58:44 +0200
Subject: [PATCH 078/124] docs(openspec): tick the reconciliation tasks that
this PR implements
Closes the merge artefact from the base fix, which unticked Tasks 3-7 and said their code lands in #1286/#1287:
- Tasks 3, 4 and 5 (matching, reconciliation, controller option) are implemented and tested here: Implement and Test ticked again.
- Tasks 6 and 7 stay unticked: the UI is #1287, and the docs and the e2e run of task 7 are not done.
- The status line now says what is true for this PR.
- Task 4's acceptance criterion also names the CMDB-sheet unreadable APPID case.
Co-Authored-By: Claude Opus 5.5
---
.../cmdb-import-archive-reconciliation/tasks.md | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
index 9e1e85772..b75a1fb11 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
@@ -4,7 +4,7 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp
## Implementation Tasks
-Status in this PR (the archive-sheet reader slice): Tasks 1 and 2 are implemented here. Tasks 3-7 are unticked because their code lands in the follow-up PRs (reconciliation in #1286, UI in #1287).
+Status in this PR (the reconciliation slice, on top of the reader slice): Tasks 1-5 are implemented here (the reader's tasks 1-2 come from the base PR). Tasks 6-7 stay unticked: the UI lands in #1287, and the docs and the e2e run of task 7 are not done yet.
### Task 1: Schema opt-in and the import profile
- **spec_ref**: `SPEC#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017`, `SPEC#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012`
@@ -37,23 +37,23 @@ Status in this PR (the archive-sheet reader slice): Tasks 1 and 2 are implemente
- GIVEN OpenRegister's search results (no archive marker; no soft-deleted row, `_includeDeleted` or not) THEN the state comes from the lens that found the object and from the trash listing, read once per import (design D9)
- GIVEN `updateExisting` false THEN the row is skipped as `exists` and nothing is revived
- GIVEN a deleted and a working module (and usage) sharing one import key THEN the working one is updated and nothing is restored
-- [ ] Implement
-- [ ] Test
+- [x] Implement
+- [x] Test
### Task 4: Reconciliation after the rows
- **spec_ref**: `SPEC#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015`
- **files**: `lib/Service/CmdbExportImportService.php`, `lib/Service/ProgressTracker.php`, `lib/Exception/CmdbImportException.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php`
- **acceptance_criteria**:
- GIVEN APPIDs 1 and 7 imported WHEN an export with row 1 and 7 on the archive sheet is imported THEN module and usage of 7 are archived with a reason, the report row is `archived`, and the owner contact person is untouched
- - GIVEN APPID 7 on no sheet THEN module and usage are soft-deleted and the row is `deleted`; GIVEN the archive sheet absent, without an APPID column, or with an APPID that has no cached value THEN they are archived instead and the report warns
+ - GIVEN APPID 7 on no sheet THEN module and usage are soft-deleted and the row is `deleted`; GIVEN the archive sheet absent, without an APPID column, or with an APPID that has no cached value, or a CMDB-sheet row whose APPID has no cached value THEN they are archived instead and the report warns
- GIVEN APPID 7 soft-deleted and listed on the archive sheet THEN module and usage are restored and archived; GIVEN it already archived or, when on no sheet, already deleted THEN nothing is written
- GIVEN a working pair and a trashed pair under the same import key and the key on the archive sheet THEN the working pair is archived, nothing is restored and the trashed pair stays deleted; GIVEN a further working usage of the key (of the trashed module, or a second one of the working module) THEN it is archived or deleted with the application
- GIVEN `missingRecords` `keep`, a cancelled import, or a usage of another municipality THEN nothing is archived or deleted
- GIVEN a cancel during the last row THEN nothing is archived or deleted; GIVEN a cancel during the reconciliation THEN it stops before the next application and the report is cancelled
- GIVEN the archive of one application throws THEN that application is a `failed` row and the others are reconciled; GIVEN no ArchiveHandler THEN 503 `ARCHIVE_UNAVAILABLE` before reading
- GIVEN the step runs THEN the operation is in phase `reconciling` and its progress advances per application
-- [ ] Implement
-- [ ] Test
+- [x] Implement
+- [x] Test
### Task 5: Controller option
- **spec_ref**: `SPEC#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012`
@@ -61,8 +61,8 @@ Status in this PR (the archive-sheet reader slice): Tasks 1 and 2 are implemente
- **acceptance_criteria**:
- GIVEN no `missingRecords` THEN the service receives `archive`; GIVEN `keep` or `archive` THEN it is passed through; GIVEN `remove` or `mark` THEN 422 `MISSING_RECORDS_UNSUPPORTED` with `details.accepted` `["keep", "archive"]` and no import
- GIVEN `ARCHIVE_UNAVAILABLE` from the service THEN 503 with its own translated message
-- [ ] Implement
-- [ ] Test
+- [x] Implement
+- [x] Test
### Task 6: Settings section, report words and the archive quick filter
- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014`, `SPEC#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017`
From 3d8b7891239fa5b582baa4691587baa8ffe104ad Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 19:02:21 +0200
Subject: [PATCH 079/124] fix(cmdb-import): view the mapping without writing,
and never overwrite an edit made in between
- Viewing the mapping (the read-only GET and any lazy load) now reads the
store only: a pack it does not hold yet is shown from its shipped file,
and nothing is seeded, updated or recorded. The import run and the seed
step still seed and update (CmdbImportProfile::load() vs loadForView()).
- Updating an unedited pack passes the read pack's updatedAt to
OpenRegister; when an administrator saved it in between, OpenRegister
refuses with PackChangedException and the store keeps that edit.
- The store counts as available only when OpenRegister's pack controller
keeps every write method admin-only and CSRF-protected (no opening
attribute or annotation), checked by reflection in the new
CmdbImportPackStoreProbe, next to the slug-lock marker class.
- The test fake follows OpenRegister's service: the updatedAt check, the
slug lock, DoesNotExist on update, PackSlugExistsException on create,
and the real exception hierarchy.
- Tests pin that the endpoint without an injected profile uses the
import's loader, and that a profile without a usage pack is refused.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
lib/Service/Cmdb/CmdbImportPackStore.php | 203 +++++++++++------
lib/Service/Cmdb/CmdbImportPackStoreProbe.php | 205 ++++++++++++++++++
lib/Service/Cmdb/CmdbImportProfile.php | 51 ++++-
...ettingsControllerCmdbImportMappingTest.php | 54 ++++-
.../Service/Cmdb/CmdbImportPackStoreTest.php | 95 ++++++++
.../Service/Cmdb/CmdbImportProfileTest.php | 58 +++++
.../Unit/Support/FakeMigrationPackService.php | 119 ++++++++--
.../AdminOpenedMigrationPacksController.php | 68 ++++++
.../BuiltinPackSlugLockedException.php | 6 +-
.../CsrfOpenedMigrationPacksController.php | 70 ++++++
.../InvalidPackDefinitionException.php | 27 +++
.../MigrationPackRefusedException.php | 29 +++
.../OpenRegister/MigrationPacksController.php | 71 ++++++
.../OpenRegister/PackChangedException.php | 27 +++
.../OpenRegister/PackSlugExistsException.php | 27 +++
15 files changed, 1017 insertions(+), 93 deletions(-)
create mode 100644 lib/Service/Cmdb/CmdbImportPackStoreProbe.php
create mode 100644 tests/Unit/Support/OpenRegister/AdminOpenedMigrationPacksController.php
create mode 100644 tests/Unit/Support/OpenRegister/CsrfOpenedMigrationPacksController.php
create mode 100644 tests/Unit/Support/OpenRegister/InvalidPackDefinitionException.php
create mode 100644 tests/Unit/Support/OpenRegister/MigrationPackRefusedException.php
create mode 100644 tests/Unit/Support/OpenRegister/MigrationPacksController.php
create mode 100644 tests/Unit/Support/OpenRegister/PackChangedException.php
create mode 100644 tests/Unit/Support/OpenRegister/PackSlugExistsException.php
diff --git a/lib/Service/Cmdb/CmdbImportPackStore.php b/lib/Service/Cmdb/CmdbImportPackStore.php
index 472226aee..7963d5981 100644
--- a/lib/Service/Cmdb/CmdbImportPackStore.php
+++ b/lib/Service/Cmdb/CmdbImportPackStore.php
@@ -76,13 +76,6 @@ class CmdbImportPackStore {
*/
public const UPDATED = 'updated';
- /**
- * The methods this store calls on OpenRegister's service.
- *
- * @var array
- */
- private const SERVICE_METHODS = ['findByPackSlug', 'find', 'create', 'update'];
-
/**
* A class only an OpenRegister with the hardened pack store has.
*
@@ -92,11 +85,22 @@ class CmdbImportPackStore {
public const HARDENED_CLASS = 'OCA\OpenRegister\Exception\BuiltinPackSlugLockedException';
/**
- * OpenRegister's service, once resolved; false when it is not available.
+ * OpenRegister's controller for the pack routes; its write methods must be admin-only and CSRF-protected.
+ */
+ public const CONTROLLER_CLASS = 'OCA\OpenRegister\Controller\MigrationPacksController';
+
+ /**
+ * The exception OpenRegister throws when a pack was saved after the caller read it.
+ */
+ public const PACK_CHANGED_CLASS = 'OCA\OpenRegister\Exception\PackChangedException';
+
+
+ /**
+ * Decides whether OpenRegister's store may be used, and hands out its service.
*
- * @var object|false|null
+ * @var CmdbImportPackStoreProbe
*/
- private object|false|null $service = null;
+ private readonly CmdbImportPackStoreProbe $probe;
/**
* Reads and writes the seed marker.
@@ -111,6 +115,7 @@ class CmdbImportPackStore {
* @param ContainerInterface $container Resolves OpenRegister's service, and the app config when none is given.
* @param IAppConfig|null $appConfig Keeps the row ids; null resolves it from the container.
* @param string $hardenedClass The class that marks an OpenRegister with the hardened pack store.
+ * @param string $controllerClass The controller whose write methods must be guarded.
*
* @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
*/
@@ -118,14 +123,23 @@ public function __construct(
private readonly ContainerInterface $container,
private ?IAppConfig $appConfig = null,
private readonly string $hardenedClass = self::HARDENED_CLASS,
+ private readonly string $controllerClass = self::CONTROLLER_CLASS,
) {
$this->marker = new CmdbImportPackMarker();
+ $this->probe = new CmdbImportPackStoreProbe(
+ container: $this->container,
+ serviceClass: self::SERVICE_CLASS,
+ hardenedClass: $this->hardenedClass,
+ controllerClass: $this->controllerClass
+ );
}//end __construct()
/**
* Whether OpenRegister's migration-pack store can be used.
*
- * Only an OpenRegister with the hardened pack store counts (HARDENED_CLASS).
+ * Only an OpenRegister with the hardened pack store counts: it has
+ * HARDENED_CLASS, and no write method of its pack controller carries an
+ * attribute or annotation that skips the CSRF check or admits non-admins.
*
* @return bool
*
@@ -197,6 +211,33 @@ public function assertShippedFallbackAllowed(): void {
);
}//end assertShippedFallbackAllowed()
+ /**
+ * The stored pack for a shipped pack, read only: nothing is created, updated or recorded.
+ *
+ * For a view of the mapping. The import run and the seed step use resolve(),
+ * which writes.
+ *
+ * @param array $shipped The shipped pack, already validated.
+ *
+ * @return array|null As resolve(), with outcome found; null when the store holds no such pack.
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the store is missing or fails,
+ * or when the pack was renamed in OpenRegister.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function find(array $shipped): ?array {
+ $service = $this->requireService();
+ $slug = (string)($shipped['id'] ?? '');
+ $entity = $this->findBySlug(service: $service, slug: $slug);
+ if ($entity === null) {
+ $this->assertNotRenamed(service: $service, slug: $slug);
+ return null;
+ }
+
+ return $this->describe(entity: $entity, slug: $slug, shipped: $shipped, outcome: self::FOUND);
+ }//end find()
+
/**
* The stored pack for a shipped pack: found, seeded when missing, or updated when unedited and outdated.
*
@@ -217,14 +258,7 @@ public function assertShippedFallbackAllowed(): void {
* @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
*/
public function resolve(array $shipped): array {
- $service = $this->service();
- if ($service === null) {
- throw new CmdbImportException(
- errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
- message: 'OpenRegister MigrationPackService is not available'
- );
- }
-
+ $service = $this->requireService();
$slug = (string)($shipped['id'] ?? '');
$outcome = self::FOUND;
$entity = $this->findBySlug(service: $service, slug: $slug);
@@ -245,21 +279,71 @@ public function resolve(array $shipped): array {
}
}
} elseif ($this->marker->isOutdated(definition: self::definitionOf(entity: $entity), shipped: $shipped) === true) {
- $id = (int)$entity->getId();
- $entity = $this->write(
+ [$entity, $outcome] = $this->updateUnedited(service: $service, entity: $entity, slug: $slug, shipped: $shipped);
+ }
+
+ $this->record(slug: $slug, id: (int)$entity->getId());
+ return $this->describe(entity: $entity, slug: $slug, shipped: $shipped, outcome: $outcome);
+ }//end resolve()
+
+ /**
+ * Replace an unedited stored pack with the newer shipped one, unless someone saved it since it was read.
+ *
+ * The update carries the read pack's `updatedAt`, so an administrator's
+ * edit saved in between is refused by OpenRegister instead of overwritten;
+ * that edit is then read back and kept.
+ *
+ * @param object $service OpenRegister's service.
+ * @param object $entity The stored pack as read.
+ * @param string $slug The pack slug.
+ * @param array $shipped The shipped pack.
+ *
+ * @return array{0: object, 1: string} The stored entity and the outcome (updated, or found when an edit came in between).
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the update fails otherwise.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function updateUnedited(object $service, object $entity, string $slug, array $shipped): array {
+ $id = (int)$entity->getId();
+ $readAt = $entity->getUpdatedAt()?->getTimestamp();
+ try {
+ $updated = $this->write(
slug: $slug,
action: 'updated',
- call: fn (): object => $service->update(id: $id, definition: $this->marker->seed(shipped: $shipped))
+ call: fn (): object => $service->update(id: $id, definition: $this->marker->seed(shipped: $shipped), expectedUpdatedAt: $readAt)
);
- $outcome = self::UPDATED;
- }
+ return [$updated, self::UPDATED];
+ } catch (CmdbImportException $e) {
+ $current = null;
+ if (is_a($e->getPrevious(), self::PACK_CHANGED_CLASS) === true) {
+ $current = $this->findBySlug(service: $service, slug: $slug);
+ }
- $id = (int)$entity->getId();
- $this->record(slug: $slug, id: $id);
+ if ($current === null) {
+ throw $e;
+ }
+ return [$current, self::FOUND];
+ }
+ }//end updateUnedited()
+
+ /**
+ * What resolve() and find() answer for a stored pack.
+ *
+ * @param object $entity The MigrationPack entity.
+ * @param string $slug The pack slug.
+ * @param array $shipped The shipped pack.
+ * @param string $outcome found, seeded or updated.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function describe(object $entity, string $slug, array $shipped, string $outcome): array {
$definition = self::definitionOf(entity: $entity);
return [
- 'id' => $id,
+ 'id' => (int)$entity->getId(),
'slug' => $slug,
'version' => (string)($definition['version'] ?? ''),
'definition' => $definition,
@@ -268,7 +352,28 @@ public function resolve(array $shipped): array {
'newerShipped' => $this->marker->isNewerShipped(definition: $definition, shipped: $shipped),
'outcome' => $outcome,
];
- }//end resolve()
+ }//end describe()
+
+ /**
+ * OpenRegister's service, or a refusal when the store is not available.
+ *
+ * @return object
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the store is not available.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function requireService(): object {
+ $service = $this->service();
+ if ($service === null) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'OpenRegister MigrationPackService is not available'
+ );
+ }
+
+ return $service;
+ }//end requireService()
/**
* The decoded definition of a stored pack.
@@ -411,54 +516,16 @@ private function record(string $slug, int $id): void {
}//end record()
/**
- * OpenRegister's service, resolved once; null on an OpenRegister without the hardened pack store.
+ * OpenRegister's service; null on an OpenRegister without the hardened pack store.
*
* @return object|null
*
* @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
*/
private function service(): ?object {
- if ($this->service === null) {
- $this->service = false;
- try {
- if (class_exists($this->hardenedClass) === true && $this->container->has(self::SERVICE_CLASS) === true) {
- $service = $this->container->get(self::SERVICE_CLASS);
- if (is_object($service) === true && self::hasMethods(service: $service) === true) {
- $this->service = $service;
- }
- }
- } catch (Throwable $e) {
- // OpenRegister is missing or its service cannot be built: the store is not available.
- $this->service = false;
- }
- }
-
- if ($this->service === false) {
- return null;
- }
-
- return $this->service;
+ return $this->probe->service();
}//end service()
- /**
- * Whether a resolved service has every method this store calls.
- *
- * @param object $service The service.
- *
- * @return bool
- *
- * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
- */
- private static function hasMethods(object $service): bool {
- foreach (self::SERVICE_METHODS as $method) {
- if (method_exists($service, $method) === false) {
- return false;
- }
- }
-
- return true;
- }//end hasMethods()
-
/**
* The app config, resolved from the container when it was not given.
*
diff --git a/lib/Service/Cmdb/CmdbImportPackStoreProbe.php b/lib/Service/Cmdb/CmdbImportPackStoreProbe.php
new file mode 100644
index 000000000..4c41b8b66
--- /dev/null
+++ b/lib/Service/Cmdb/CmdbImportPackStoreProbe.php
@@ -0,0 +1,205 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Cmdb;
+
+use Psr\Container\ContainerInterface;
+use ReflectionAttribute;
+use ReflectionClass;
+use ReflectionMethod;
+use Throwable;
+
+/**
+ * Resolves OpenRegister's pack service when the store may be used.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+final class CmdbImportPackStoreProbe {
+ /**
+ * The methods the store calls on OpenRegister's service.
+ *
+ * @var array
+ */
+ private const SERVICE_METHODS = ['findByPackSlug', 'find', 'create', 'update'];
+
+ /**
+ * The controller methods that write packs.
+ *
+ * @var array
+ */
+ private const WRITE_METHODS = ['create', 'update', 'destroy', 'import', 'preview'];
+
+ /**
+ * The attributes and annotations that would open a write route to non-admins or skip its CSRF check.
+ *
+ * @var array
+ */
+ private const OPENING_MARKERS = ['NoCSRFRequired', 'NoAdminRequired', 'PublicPage', 'AuthorizedAdminSetting'];
+
+ /**
+ * The service, once resolved; false when the store may not be used.
+ *
+ * @var object|false|null
+ */
+ private object|false|null $service = null;
+
+ /**
+ * Constructor.
+ *
+ * @param ContainerInterface $container Resolves OpenRegister's service.
+ * @param string $serviceClass OpenRegister's pack service.
+ * @param string $hardenedClass The class that marks an OpenRegister with the hardened pack store.
+ * @param string $controllerClass The controller whose write methods must be guarded.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function __construct(
+ private readonly ContainerInterface $container,
+ private readonly string $serviceClass,
+ private readonly string $hardenedClass,
+ private readonly string $controllerClass,
+ ) {
+ }//end __construct()
+
+ /**
+ * OpenRegister's service, resolved once; null when the store may not be used.
+ *
+ * @return object|null
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function service(): ?object {
+ if ($this->service === null) {
+ $this->service = false;
+ try {
+ $this->service = $this->resolve();
+ } catch (Throwable $e) {
+ // OpenRegister is missing or its service cannot be built: the store is not available.
+ $this->service = false;
+ }
+ }
+
+ if ($this->service === false) {
+ return null;
+ }
+
+ return $this->service;
+ }//end service()
+
+ /**
+ * The service when every condition holds, false otherwise.
+ *
+ * @return object|false
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function resolve(): object|false {
+ if (class_exists($this->hardenedClass) === false
+ || self::writesAreGuarded(controllerClass: $this->controllerClass) === false
+ || $this->container->has($this->serviceClass) === false
+ ) {
+ return false;
+ }
+
+ $service = $this->container->get($this->serviceClass);
+ if (is_object($service) === false || self::hasMethods(service: $service) === false) {
+ return false;
+ }
+
+ return $service;
+ }//end resolve()
+
+ /**
+ * Whether every pack write method of the controller is admin-only and CSRF-protected.
+ *
+ * Nextcloud opens a route with an attribute or a docblock annotation, so
+ * a method that carries none of the opening markers keeps the default:
+ * admins only, with a CSRF token.
+ *
+ * @param string $controllerClass The controller class.
+ *
+ * @return bool False when the class or a write method is missing, or a write method is opened.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private static function writesAreGuarded(string $controllerClass): bool {
+ if (class_exists($controllerClass) === false) {
+ return false;
+ }
+
+ $class = new ReflectionClass($controllerClass);
+ foreach (self::WRITE_METHODS as $name) {
+ if ($class->hasMethod($name) === false || self::isOpened(method: $class->getMethod($name)) === true) {
+ return false;
+ }
+ }
+
+ return true;
+ }//end writesAreGuarded()
+
+ /**
+ * Whether a controller method carries an attribute or annotation that opens its route.
+ *
+ * @param ReflectionMethod $method The method.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private static function isOpened(ReflectionMethod $method): bool {
+ $attributes = array_map(
+ static fn (ReflectionAttribute $attribute): string => substr((string)strrchr('\\' . $attribute->getName(), '\\'), 1),
+ $method->getAttributes()
+ );
+ $doc = (string)$method->getDocComment();
+ foreach (self::OPENING_MARKERS as $marker) {
+ if (in_array($marker, $attributes, true) === true || str_contains($doc, '@' . $marker) === true) {
+ return true;
+ }
+ }
+
+ return false;
+ }//end isOpened()
+
+ /**
+ * Whether a resolved service has every method the store calls.
+ *
+ * @param object $service The service.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private static function hasMethods(object $service): bool {
+ foreach (self::SERVICE_METHODS as $method) {
+ if (method_exists($service, $method) === false) {
+ return false;
+ }
+ }
+
+ return true;
+ }//end hasMethods()
+}//end class
diff --git a/lib/Service/Cmdb/CmdbImportProfile.php b/lib/Service/Cmdb/CmdbImportProfile.php
index 5b3b98273..c945cedfe 100644
--- a/lib/Service/Cmdb/CmdbImportProfile.php
+++ b/lib/Service/Cmdb/CmdbImportProfile.php
@@ -187,6 +187,9 @@ public function __construct(
* OpenRegister, a store that is gone fails closed: the import never falls
* back to the files and silently drops an administrator's edit.
*
+ * The import run and the seed step load this way; viewing the mapping
+ * uses loadForView(), which writes nothing.
+ *
* @return void
*
* @throws CmdbImportException MAPPING_UNAVAILABLE when the validator is missing, the profile
@@ -197,6 +200,39 @@ public function __construct(
* @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
*/
public function load(): void {
+ $this->loadPacks(storedPack: fn (CmdbImportPackStore $store, array $shipped): ?array => $store->resolve(shipped: $shipped));
+ }//end load()
+
+ /**
+ * Load the profile and validate every pack it names, reading the store only.
+ *
+ * A pack the store does not hold yet is taken from its shipped file, and
+ * nothing is seeded, updated or recorded: viewing the mapping changes
+ * neither OpenRegister nor stackiq's app config. The next import run
+ * seeds and updates as load() does.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE as load().
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+ public function loadForView(): void {
+ $this->loadPacks(storedPack: fn (CmdbImportPackStore $store, array $shipped): ?array => $store->find(shipped: $shipped));
+ }//end loadForView()
+
+ /**
+ * Load the profile and the packs, taking each editable pack from the store as the callback answers it.
+ *
+ * @param callable $storedPack (store, shipped pack) => the stored pack, or null to use the shipped file.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE as load().
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function loadPacks(callable $storedPack): void {
$validator = $this->resolveValidator();
$profile = $this->decodeFile(fileName: $this->profileFile);
$store = $this->packStore();
@@ -222,8 +258,12 @@ public function load(): void {
'newerShipped' => false,
];
+ $stored = null;
if ($storeAvailable === true && in_array($target, self::EDITABLE_TARGETS, true) === true) {
- $stored = $store->resolve(shipped: $shipped);
+ $stored = $storedPack($store, $shipped);
+ }
+
+ if ($stored !== null) {
$pack = $stored['pack'];
$label = 'OpenRegister migration pack ' . $stored['slug'] . ' (id ' . $stored['id'] . ')';
$details = ['pack' => $stored['slug'], 'packId' => $stored['id']];
@@ -249,7 +289,7 @@ public function load(): void {
$this->packs = $packs;
$this->sources = $sources;
$this->storeAvailable = $storeAvailable;
- }//end load()
+ }//end loadPacks()
/**
* The shipped packs an administrator edits in OpenRegister, validated, per target.
@@ -918,7 +958,10 @@ public function mappingOverview(): array {
}//end mappingOverview()
/**
- * The loaded profile.
+ * The loaded profile; loaded read only when nothing loaded it yet.
+ *
+ * The import run calls load() itself before it reads a row, so a lazy
+ * load is a read: the limits, the sheet names or the mapping view.
*
* @return array
*
@@ -926,7 +969,7 @@ public function mappingOverview(): array {
*/
private function profile(): array {
if ($this->profile === null) {
- $this->load();
+ $this->loadForView();
}
return ($this->profile ?? []);
diff --git a/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php b/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
index fa6062427..46433b1a3 100644
--- a/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
+++ b/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
@@ -362,6 +362,53 @@ public function mappingOverview(): array {
$this->assertSame(500, mb_strlen($reason));
}//end testTheReasonIsOneBoundedLine()
+ /**
+ * Without an injected profile the endpoint builds the loader the import uses: the shipped profile and packs.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+ public function testWithoutAProfileTheEndpointUsesTheImportsLoader(): void {
+ CmdbTestSupport::loadMigrationPack();
+
+ $data = $this->controller(profile: null)->getCmdbImportMapping()->getData();
+ $expected = (new CmdbImportProfile(container: $this->emptyContainer()))->mappingOverview();
+
+ $this->assertSame($expected['profile'], $data['profile']);
+ $this->assertSame($expected['openRegisterPacks'], $data['openRegisterPacks']);
+ foreach ($data['packs'] as $index => $pack) {
+ unset($pack['editUrl']);
+ $this->assertSame($expected['packs'][$index], $pack, $pack['target']);
+ }
+ }//end testWithoutAProfileTheEndpointUsesTheImportsLoader()
+
+ /**
+ * The read-only endpoint writes nothing: on an empty store no pack is seeded and no row id is recorded.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+ public function testTheEndpointWritesNothingToOpenRegisterOrTheAppConfig(): void {
+ CmdbTestSupport::loadMigrationPack();
+ $service = new FakeMigrationPackService();
+ $appConfig = $this->createMock(IAppConfig::class);
+ $appConfig->method('getValueString')->willReturn('');
+ $appConfig->expects($this->never())->method('setValueString');
+
+ $data = $this->controller(profile: new CmdbImportProfile(container: $service->container(test: $this, appConfig: $appConfig)))
+ ->getCmdbImportMapping()
+ ->getData();
+
+ $this->assertSame([], $service->writes);
+ $this->assertTrue($data['openRegisterPacks']);
+ foreach ($data['packs'] as $pack) {
+ $this->assertSame('file', $pack['source'], $pack['target']);
+ $this->assertNull($pack['editUrl'], $pack['target']);
+ }
+ }//end testTheEndpointWritesNothingToOpenRegisterOrTheAppConfig()
+
/**
* A pack stored in OpenRegister answers its source, versions and the absolute link to its editor.
*
@@ -373,9 +420,10 @@ public function testAStoredPackLinksToItsEditorInOpenRegister(): void {
CmdbTestSupport::loadMigrationPack();
$service = new FakeMigrationPackService();
$config = [];
- $profile = new CmdbImportProfile(
- container: $service->container(test: $this, appConfig: FakeMigrationPackService::appConfig(test: $this, values: $config))
- );
+ $container = $service->container(test: $this, appConfig: FakeMigrationPackService::appConfig(test: $this, values: $config));
+ // The seed step (or an import run) stores the packs; the page then reads them.
+ (new CmdbImportProfile(container: $container))->load();
+ $profile = new CmdbImportProfile(container: $container);
$routes = [];
$urls = $this->createMock(IURLGenerator::class);
$urls->method('linkToRouteAbsolute')->willReturnCallback(
diff --git a/tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php b/tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php
index d71814be1..aeea3e91d 100644
--- a/tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php
+++ b/tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php
@@ -360,6 +360,101 @@ public function testAFailingStoreIsMappingUnavailable(): void {
}
}//end testAFailingStoreIsMappingUnavailable()
+ /**
+ * An administrator who saves the pack between the store's read and its update keeps the edit.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function testAnEditSavedBetweenTheReadAndTheUpdateIsKept(): void {
+ $seeded = (new CmdbImportPackMarker())->seed(shipped: $this->shippedUsage());
+ $id = $this->service->put(definition: $seeded);
+ $edited = $seeded;
+ $edited['description'] = 'Edited by the administrator';
+ $this->service->beforeUpdate = function (int $updateId) use ($edited): void {
+ $this->service->beforeUpdate = null;
+ $this->service->edit(id: $updateId, definition: $edited);
+ };
+
+ $stored = $this->store()->resolve(shipped: $this->newerUsage());
+
+ $this->assertSame(CmdbImportPackStore::FOUND, $stored['outcome']);
+ $this->assertSame([], $this->service->writes, 'the update was refused, not applied');
+ $this->assertSame('Edited by the administrator', $this->service->definition(id: $id)['description']);
+ $this->assertSame('Edited by the administrator', $stored['pack']['description']);
+ $this->assertTrue($stored['edited']);
+ $this->assertTrue($stored['newerShipped']);
+ }//end testAnEditSavedBetweenTheReadAndTheUpdateIsKept()
+
+ /**
+ * A pack deleted between the read and the update cannot be re-read, so the import stops with MAPPING_UNAVAILABLE.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function testAPackDeletedBetweenTheReadAndTheUpdateIsMappingUnavailable(): void {
+ $this->service->put(definition: (new CmdbImportPackMarker())->seed(shipped: $this->shippedUsage()));
+ $this->service->beforeUpdate = function (int $updateId): void {
+ unset($this->service->rows[$updateId]);
+ };
+
+ try {
+ $this->store()->resolve(shipped: $this->newerUsage());
+ $this->fail('MAPPING_UNAVAILABLE expected');
+ } catch (CmdbImportException $e) {
+ $this->assertSame('MAPPING_UNAVAILABLE', $e->getErrorCode());
+ $this->assertStringContainsString('could not be updated: OCP\\AppFramework\\Db\\DoesNotExistException', $e->getMessage());
+ }
+ }//end testAPackDeletedBetweenTheReadAndTheUpdateIsMappingUnavailable()
+
+ /**
+ * The fake refuses an update read at another time, as OpenRegister does; without a time it overwrites.
+ *
+ * @return void
+ */
+ public function testTheFakeRefusesAStaleUpdateAsOpenRegisterDoes(): void {
+ $id = $this->service->put(definition: $this->shippedUsage());
+ $readAt = $this->service->find(id: $id)->getUpdatedAt()->getTimestamp();
+ $this->service->edit(id: $id, definition: $this->newerUsage());
+
+ try {
+ $this->service->update(id: $id, definition: $this->shippedUsage(), expectedUpdatedAt: $readAt);
+ $this->fail('PackChangedException expected');
+ } catch (\OCA\OpenRegister\Exception\PackChangedException $e) {
+ $this->assertInstanceOf(\InvalidArgumentException::class, $e, 'the same parent as OpenRegister\'s');
+ }
+
+ $this->service->update(id: $id, definition: $this->shippedUsage());
+ $this->assertSame(['update ' . $id], $this->service->writes);
+ }//end testTheFakeRefusesAStaleUpdateAsOpenRegisterDoes()
+
+ /**
+ * An OpenRegister whose pack controller opens a write route is not the hardened store.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ public function testAnOpenedWriteRouteMakesTheStoreUnavailable(): void {
+ $support = __DIR__ . '/../../Support/OpenRegister/';
+ require_once $support . 'CsrfOpenedMigrationPacksController.php';
+ require_once $support . 'AdminOpenedMigrationPacksController.php';
+ $container = $this->service->container(test: $this, appConfig: FakeMigrationPackService::appConfig(test: $this, values: $this->config));
+
+ foreach (['CsrfOpenedMigrationPacksController', 'AdminOpenedMigrationPacksController', 'NoSuchController'] as $class) {
+ $store = new CmdbImportPackStore(
+ container: $container,
+ controllerClass: 'OCA\\Stackiq\\Tests\\Unit\\Support\\OpenRegister\\' . $class
+ );
+ $this->assertFalse($store->isAvailable(), $class);
+ }
+
+ $this->assertSame('OCA\\OpenRegister\\Controller\\MigrationPacksController', CmdbImportPackStore::CONTROLLER_CLASS);
+ $this->assertTrue($this->store()->isAvailable(), 'the guarded controller');
+ }//end testAnOpenedWriteRouteMakesTheStoreUnavailable()
+
/**
* The hash ignores the order of an object's keys but not the order of the field mappings.
*
diff --git a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php
index 27543c5fd..d3235f2c9 100644
--- a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php
+++ b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php
@@ -406,6 +406,29 @@ public function testTheOverviewShowsWhatTheImportRuns(): void {
}
}//end testTheOverviewShowsWhatTheImportRuns()
+ /**
+ * A profile that names no pack for a target cannot be run, so the mapping is unavailable.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+ public function testAProfileWithoutAUsagePackIsMappingUnavailable(): void {
+ CmdbTestSupport::loadMigrationPack();
+ $directory = $this->copyOfShippedDirectory();
+ $profile = json_decode((string)file_get_contents($directory . '/topdesk-profile.json'), true);
+ unset($profile['packs']['usage']);
+ file_put_contents($directory . '/topdesk-profile.json', json_encode($profile));
+ try {
+ (new CmdbImportProfile(container: $this->emptyContainer(), directory: $directory))->mappingOverview();
+ $this->fail('MAPPING_UNAVAILABLE expected');
+ } catch (CmdbImportException $e) {
+ $this->assertSame('MAPPING_UNAVAILABLE', $e->getErrorCode());
+ } finally {
+ $this->remove(directory: $directory);
+ }
+ }//end testAProfileWithoutAUsagePackIsMappingUnavailable()
+
/**
* A shipped pack, decoded.
*
@@ -448,6 +471,8 @@ public function testAStoredPackIsWhatTheImportRuns(): void {
$config = [];
$profile = $this->storedProfile(service: $service, config: $config);
+ // The import run loads the packs with writes; the overview then shows what it ran.
+ $profile->load();
$overview = $profile->mappingOverview();
$this->assertSame(['SaaS'], $profile->pack(target: 'module')['fieldMappings'][7]['transform']['map']['Cloud']);
@@ -504,6 +529,7 @@ public function testUneditedPacksFollowTheReleaseAndEditedOnesAreReported(): voi
$config = [];
$profile = $this->storedProfile(service: $service, config: $config);
+ $profile->load();
$byTarget = array_column($profile->mappingOverview()['packs'], null, 'target');
$this->assertContains('update ' . $usageId, $service->writes);
@@ -520,6 +546,38 @@ public function testUneditedPacksFollowTheReleaseAndEditedOnesAreReported(): voi
$this->assertSame('1.0.0', $byTarget['module']['shippedVersion']);
}//end testUneditedPacksFollowTheReleaseAndEditedOnesAreReported()
+ /**
+ * Viewing the mapping writes nothing: an outdated unedited pack is shown as stored, a missing one from its file.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+ public function testTheMappingOverviewOnlyReadsTheStore(): void {
+ CmdbTestSupport::loadMigrationPack();
+ $service = new FakeMigrationPackService();
+ $olderUsage = $this->shipped(file: 'topdesk-usage.json');
+ $olderUsage['version'] = '0.9.0';
+ unset($olderUsage['fieldMappings'][0]['transform']['map']['Besteld']);
+ $usageId = $service->put(definition: (new CmdbImportPackMarker())->seed(shipped: $olderUsage));
+ $config = [];
+
+ $profile = $this->storedProfile(service: $service, config: $config);
+ $byTarget = array_column($profile->mappingOverview()['packs'], null, 'target');
+
+ $this->assertSame([], $service->writes, 'nothing is seeded or updated');
+ $this->assertSame([], $config, 'no row id is recorded');
+ $this->assertSame('openregister', $byTarget['usage']['source']);
+ $this->assertSame($usageId, $byTarget['usage']['packId']);
+ $this->assertSame('0.9.0', $byTarget['usage']['storedVersion'], 'the stored pack, not yet updated');
+ $this->assertSame('file', $byTarget['module']['source'], 'not stored yet: shown from the shipped file');
+ $this->assertNull($byTarget['module']['packId']);
+
+ $profile->load();
+ $this->assertContains('update ' . $usageId, $service->writes, 'the import run still updates it');
+ $this->assertContains('create stackiq-topdesk-module', $service->writes, 'and seeds the missing pack');
+ }//end testTheMappingOverviewOnlyReadsTheStore()
+
/**
* A stored pack OpenRegister's validator refuses stops the import, naming the pack and its id.
*
diff --git a/tests/Unit/Support/FakeMigrationPackService.php b/tests/Unit/Support/FakeMigrationPackService.php
index 4078ab362..31450fd91 100644
--- a/tests/Unit/Support/FakeMigrationPackService.php
+++ b/tests/Unit/Support/FakeMigrationPackService.php
@@ -4,9 +4,13 @@
* An in-memory stand-in for OpenRegister's MigrationPackService.
*
* It has the four methods CmdbImportPackStore calls, with OpenRegister's
- * parameter names (the store calls them by name), and answers an unknown
- * slug or id with DoesNotExistException as OpenRegister's mapper does.
- * Definitions are stored as JSON, as OpenRegister stores them.
+ * parameter names (the store calls them by name), and answers as
+ * OpenRegister's MigrationPackService does (openregister#4602): an unknown
+ * slug or id with DoesNotExistException, a taken slug on create with
+ * PackSlugExistsException, an update read at another time than the pack's
+ * `updatedAt` with PackChangedException, and a changed slug of a built-in
+ * pack with BuiltinPackSlugLockedException. Definitions are stored as JSON,
+ * as OpenRegister stores them; every write moves `updatedAt` on.
*
* @category Test
* @package OCA\Stackiq\Tests\Unit\Support
@@ -25,6 +29,10 @@
namespace OCA\Stackiq\Tests\Unit\Support;
+use DateTime;
+use OCA\OpenRegister\Exception\BuiltinPackSlugLockedException;
+use OCA\OpenRegister\Exception\PackChangedException;
+use OCA\OpenRegister\Exception\PackSlugExistsException;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IAppConfig;
use PHPUnit\Framework\TestCase;
@@ -37,9 +45,9 @@
*/
final class FakeMigrationPackService {
/**
- * Stored rows by id: slug, definition JSON and builtin flag.
+ * Stored rows by id: slug, definition JSON, builtin flag and the Unix time of the last write.
*
- * @var array
+ * @var array
*/
public array $rows = [];
@@ -64,6 +72,20 @@ final class FakeMigrationPackService {
*/
public ?\Closure $beforeCreate = null;
+ /**
+ * When set, called at the start of every update, as an administrator saving in between would act.
+ *
+ * @var \Closure|null
+ */
+ public ?\Closure $beforeUpdate = null;
+
+ /**
+ * The clock: the Unix time the next write records.
+ *
+ * @var int
+ */
+ private int $clock = 1760000000;
+
/**
* The next row id.
*
@@ -88,10 +110,23 @@ public function __construct() {
*/
public function put(array $definition, ?int $id = null): int {
$id = ($id ?? $this->nextId++);
- $this->rows[$id] = ['slug' => (string)$definition['id'], 'definition' => (string)json_encode($definition), 'builtin' => false];
+ $this->rows[$id] = ['slug' => (string)$definition['id'], 'definition' => (string)json_encode($definition), 'builtin' => false, 'updatedAt' => $this->clock++];
return $id;
}//end put()
+ /**
+ * Save a row's definition as an administrator would in OpenRegister's editor: a new `updatedAt`, not logged as a store write.
+ *
+ * @param int $id The row id.
+ * @param array $definition The definition.
+ *
+ * @return void
+ */
+ public function edit(int $id, array $definition): void {
+ $this->rows[$id]['definition'] = (string)json_encode($definition);
+ $this->rows[$id]['updatedAt'] = $this->clock++;
+ }//end edit()
+
/**
* The stored definition of a row.
*
@@ -150,7 +185,7 @@ public function find(int $id): object {
*
* @return object
*
- * @throws RuntimeException When the slug exists.
+ * @throws PackSlugExistsException When the slug exists.
*/
public function create(array $definition, ?string $ownerUid = null, bool $builtin = false): object {
if ($this->beforeCreate !== null) {
@@ -159,7 +194,7 @@ public function create(array $definition, ?string $ownerUid = null, bool $builti
foreach ($this->rows as $row) {
if ($row['slug'] === $definition['id']) {
- throw new RuntimeException('A migration pack with id "' . $definition['id'] . '" already exists');
+ throw new PackSlugExistsException('A migration pack with id "' . $definition['id'] . '" already exists');
}
}
@@ -174,12 +209,34 @@ public function create(array $definition, ?string $ownerUid = null, bool $builti
*
* @param int $id The row id.
* @param array $definition The definition.
+ * @param int|null $expectedUpdatedAt The `updatedAt` the caller read, as a Unix time, or null for no check.
*
* @return object
+ *
+ * @throws DoesNotExistException When no row has the id.
+ * @throws PackChangedException When the pack was saved at another time than `$expectedUpdatedAt`.
+ * @throws BuiltinPackSlugLockedException When the slug of a built-in pack changes.
*/
- public function update(int $id, array $definition): object {
+ public function update(int $id, array $definition, ?int $expectedUpdatedAt = null): object {
+ if ($this->beforeUpdate !== null) {
+ ($this->beforeUpdate)($id, $definition);
+ }
+
+ if (isset($this->rows[$id]) === false) {
+ throw new DoesNotExistException('No migration pack ' . $id);
+ }
+
+ if ($expectedUpdatedAt !== null && $this->rows[$id]['updatedAt'] !== $expectedUpdatedAt) {
+ throw new PackChangedException('Migration pack ' . $id . ' was saved after it was read');
+ }
+
+ if ($this->rows[$id]['builtin'] === true && $this->rows[$id]['slug'] !== (string)$definition['id']) {
+ throw new BuiltinPackSlugLockedException('The slug of built-in pack ' . $id . ' cannot change');
+ }
+
$this->rows[$id]['slug'] = (string)$definition['id'];
$this->rows[$id]['definition'] = (string)json_encode($definition);
+ $this->rows[$id]['updatedAt'] = $this->clock++;
$this->writes[] = 'update ' . $id;
return $this->entity(id: $id);
}//end update()
@@ -215,13 +272,21 @@ function (string $id) use ($known) {
}//end container()
/**
- * Make the class that marks OpenRegister's hardened pack store exist, as on such an OpenRegister.
+ * Make the classes of OpenRegister's hardened pack store exist, as on such an OpenRegister.
*
* @return void
*/
public static function loadHardenedOpenRegister(): void {
- if (class_exists('OCA\OpenRegister\Exception\BuiltinPackSlugLockedException') === false) {
- require_once __DIR__ . '/OpenRegister/BuiltinPackSlugLockedException.php';
+ $files = [
+ 'MigrationPackRefusedException',
+ 'InvalidPackDefinitionException',
+ 'BuiltinPackSlugLockedException',
+ 'PackSlugExistsException',
+ 'PackChangedException',
+ 'MigrationPacksController',
+ ];
+ foreach ($files as $file) {
+ require_once __DIR__ . '/OpenRegister/' . $file . '.php';
}
}//end loadHardenedOpenRegister()
@@ -273,15 +338,41 @@ private function failIfAsked(): void {
*/
private function entity(int $id): object {
$row = $this->rows[$id];
- return new class($id, $row['slug'], $row['definition']) {
+ return new class($id, $row['slug'], $row['definition'], $row['builtin'], $row['updatedAt']) {
/**
* Constructor.
*
* @param int $id The row id.
* @param string $slug The slug.
* @param string $definition The definition JSON.
+ * @param bool $builtin Whether the pack is built in.
+ * @param int $updatedAt The Unix time of the last write.
+ */
+ public function __construct(
+ private int $id,
+ private string $slug,
+ private string $definition,
+ private bool $builtin,
+ private int $updatedAt,
+ ) {
+ }
+
+ /**
+ * Whether the pack is built in.
+ *
+ * @return bool
+ */
+ public function getBuiltin(): bool {
+ return $this->builtin;
+ }
+
+ /**
+ * When the pack was last written.
+ *
+ * @return DateTime
*/
- public function __construct(private int $id, private string $slug, private string $definition) {
+ public function getUpdatedAt(): DateTime {
+ return (new DateTime())->setTimestamp($this->updatedAt);
}
/**
diff --git a/tests/Unit/Support/OpenRegister/AdminOpenedMigrationPacksController.php b/tests/Unit/Support/OpenRegister/AdminOpenedMigrationPacksController.php
new file mode 100644
index 000000000..7306baf61
--- /dev/null
+++ b/tests/Unit/Support/OpenRegister/AdminOpenedMigrationPacksController.php
@@ -0,0 +1,68 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Tests\Unit\Support\OpenRegister;
+
+/**
+ * A pack controller whose import admits non-admins with a docblock annotation.
+ */
+class AdminOpenedMigrationPacksController {
+
+ /**
+ * The create route.
+ *
+ * @return void
+ */
+ public function create(): void {
+ }//end create()
+
+ /**
+ * The update route.
+ *
+ * @return void
+ */
+ public function update(): void {
+ }//end update()
+
+ /**
+ * The destroy route.
+ *
+ * @return void
+ */
+ public function destroy(): void {
+ }//end destroy()
+
+ /**
+ * The import route.
+ *
+ * @NoAdminRequired
+ * @return void
+ */
+ public function import(): void {
+ }//end import()
+
+ /**
+ * The preview route.
+ *
+ * @return void
+ */
+ public function preview(): void {
+ }//end preview()
+}//end class
diff --git a/tests/Unit/Support/OpenRegister/BuiltinPackSlugLockedException.php b/tests/Unit/Support/OpenRegister/BuiltinPackSlugLockedException.php
index 249edd8f4..314077e13 100644
--- a/tests/Unit/Support/OpenRegister/BuiltinPackSlugLockedException.php
+++ b/tests/Unit/Support/OpenRegister/BuiltinPackSlugLockedException.php
@@ -25,10 +25,8 @@
namespace OCA\OpenRegister\Exception;
-use RuntimeException;
-
/**
- * Thrown by OpenRegister when a built-in pack would be renamed.
+ * A built-in pack's slug cannot change.
*/
-class BuiltinPackSlugLockedException extends RuntimeException {
+class BuiltinPackSlugLockedException extends InvalidPackDefinitionException {
}//end class
diff --git a/tests/Unit/Support/OpenRegister/CsrfOpenedMigrationPacksController.php b/tests/Unit/Support/OpenRegister/CsrfOpenedMigrationPacksController.php
new file mode 100644
index 000000000..54d189bd9
--- /dev/null
+++ b/tests/Unit/Support/OpenRegister/CsrfOpenedMigrationPacksController.php
@@ -0,0 +1,70 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Tests\Unit\Support\OpenRegister;
+
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+
+/**
+ * A pack controller whose update skips the CSRF check with an attribute, as OpenRegister's development branch does.
+ */
+class CsrfOpenedMigrationPacksController {
+
+ /**
+ * The create route.
+ *
+ * @return void
+ */
+ public function create(): void {
+ }//end create()
+
+ /**
+ * The update route.
+ *
+ * @return void
+ */
+ #[NoCSRFRequired]
+ public function update(): void {
+ }//end update()
+
+ /**
+ * The destroy route.
+ *
+ * @return void
+ */
+ public function destroy(): void {
+ }//end destroy()
+
+ /**
+ * The import route.
+ *
+ * @return void
+ */
+ public function import(): void {
+ }//end import()
+
+ /**
+ * The preview route.
+ *
+ * @return void
+ */
+ public function preview(): void {
+ }//end preview()
+}//end class
diff --git a/tests/Unit/Support/OpenRegister/InvalidPackDefinitionException.php b/tests/Unit/Support/OpenRegister/InvalidPackDefinitionException.php
new file mode 100644
index 000000000..52d8d5b4d
--- /dev/null
+++ b/tests/Unit/Support/OpenRegister/InvalidPackDefinitionException.php
@@ -0,0 +1,27 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Exception;
+
+/**
+ * A definition OpenRegister refuses.
+ */
+class InvalidPackDefinitionException extends MigrationPackRefusedException {
+}//end class
diff --git a/tests/Unit/Support/OpenRegister/MigrationPackRefusedException.php b/tests/Unit/Support/OpenRegister/MigrationPackRefusedException.php
new file mode 100644
index 000000000..b8f2f88e7
--- /dev/null
+++ b/tests/Unit/Support/OpenRegister/MigrationPackRefusedException.php
@@ -0,0 +1,29 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Exception;
+
+use InvalidArgumentException;
+
+/**
+ * A refused pack write.
+ */
+abstract class MigrationPackRefusedException extends InvalidArgumentException {
+}//end class
diff --git a/tests/Unit/Support/OpenRegister/MigrationPacksController.php b/tests/Unit/Support/OpenRegister/MigrationPacksController.php
new file mode 100644
index 000000000..736f63713
--- /dev/null
+++ b/tests/Unit/Support/OpenRegister/MigrationPacksController.php
@@ -0,0 +1,71 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Controller;
+
+/**
+ * The pack routes.
+ */
+class MigrationPacksController {
+
+ /**
+ * Create a pack.
+ *
+ * @return void
+ */
+ public function create(): void {
+ }//end create()
+
+ /**
+ * Update a pack.
+ *
+ * @return void
+ */
+ public function update(): void {
+ }//end update()
+
+ /**
+ * Delete a pack.
+ *
+ * @return void
+ */
+ public function destroy(): void {
+ }//end destroy()
+
+ /**
+ * Import a pack.
+ *
+ * @return void
+ */
+ public function import(): void {
+ }//end import()
+
+ /**
+ * Preview a pack.
+ *
+ * @return void
+ */
+ public function preview(): void {
+ }//end preview()
+}//end class
diff --git a/tests/Unit/Support/OpenRegister/PackChangedException.php b/tests/Unit/Support/OpenRegister/PackChangedException.php
new file mode 100644
index 000000000..b6d3de009
--- /dev/null
+++ b/tests/Unit/Support/OpenRegister/PackChangedException.php
@@ -0,0 +1,27 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Exception;
+
+/**
+ * The pack was saved after the caller read it.
+ */
+class PackChangedException extends MigrationPackRefusedException {
+}//end class
diff --git a/tests/Unit/Support/OpenRegister/PackSlugExistsException.php b/tests/Unit/Support/OpenRegister/PackSlugExistsException.php
new file mode 100644
index 000000000..ef9b04171
--- /dev/null
+++ b/tests/Unit/Support/OpenRegister/PackSlugExistsException.php
@@ -0,0 +1,27 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Exception;
+
+/**
+ * Another pack has the slug.
+ */
+class PackSlugExistsException extends InvalidPackDefinitionException {
+}//end class
From 8f0792946641087d93106c795f6cdfcfddd8d74b Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 19:02:21 +0200
Subject: [PATCH 080/124] fix(cmdb-import): translate both mapping endpoint
errors with literal t() calls
The 503 and 500 messages went through a wrapper that called t() with a
variable, which Nextcloud's extraction cannot see, and the 500 message
had no translation. Both are now literal `->t('...')` calls, and the 500
message has its English and Dutch entries.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
l10n/en.js | 3 ++-
l10n/en.json | 3 ++-
l10n/nl.js | 3 ++-
l10n/nl.json | 3 ++-
lib/Controller/SettingsController.php | 30 +++++++--------------------
5 files changed, 16 insertions(+), 26 deletions(-)
diff --git a/l10n/en.js b/l10n/en.js
index afd93e55c..0c34d605b 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1135,7 +1135,8 @@ OC.L10N.register(
"The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.",
"In development since": "In development since",
- "In use since": "In use since"
+ "In use since": "In use since",
+ "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index 349882f8f..0ed57538e 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1134,6 +1134,7 @@
"The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.",
"In development since": "In development since",
- "In use since": "In use since"
+ "In use since": "In use since",
+ "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log."
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index 2cf474396..3f2953500 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1203,7 +1203,8 @@ OC.L10N.register(
"The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.",
"In development since": "In ontwikkeling sinds",
- "In use since": "In gebruik sinds"
+ "In use since": "In gebruik sinds",
+ "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index ff4bd963e..ca9c80731 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1202,6 +1202,7 @@
"The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.",
"In development since": "In ontwikkeling sinds",
- "In use since": "In gebruik sinds"
+ "In use since": "In gebruik sinds",
+ "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek."
}
}
diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php
index 0451e1718..f4faa4bf7 100644
--- a/lib/Controller/SettingsController.php
+++ b/lib/Controller/SettingsController.php
@@ -3954,7 +3954,10 @@ public function getCmdbImportMapping(): JSONResponse {
[
'success' => false,
'error' => $e->getErrorCode(),
- 'message' => $this->translate(text: 'The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.'),
+ 'message' => (
+ $this->l10n?->t('The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.')
+ ?? 'The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.'
+ ),
'details' => (object)$details,
],
$e->getHttpStatus()
@@ -3965,7 +3968,10 @@ public function getCmdbImportMapping(): JSONResponse {
[
'success' => false,
'error' => 'IMPORT_FAILED',
- 'message' => $this->translate(text: 'The import mapping could not be read. The details are in the Nextcloud log.'),
+ 'message' => (
+ $this->l10n?->t('The import mapping could not be read. The details are in the Nextcloud log.')
+ ?? 'The import mapping could not be read. The details are in the Nextcloud log.'
+ ),
'details' => (object)[],
],
Http::STATUS_INTERNAL_SERVER_ERROR
@@ -4029,24 +4035,4 @@ private static function boundedReason(string $message): string {
return mb_substr($firstLine, 0, self::REASON_MAX_LENGTH);
}//end boundedReason()
-
- /**
- * Translate a message, or hand it back as it is when no translator was injected.
- *
- * IL10N is an optional constructor argument (cmdb-import-mapping-view, design D1),
- * so a test that builds the controller without it gets the English text.
- *
- * @param string $text The English text.
- *
- * @return string
- *
- * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
- */
- private function translate(string $text): string {
- if ($this->l10n === null) {
- return $text;
- }
-
- return $this->l10n->t($text);
- }//end translate()
}//end class
From 27a2110891ec39922e7e3c02a37e794e50bb3579 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 19:05:06 +0200
Subject: [PATCH 081/124] fix(cmdb-import): see a fully empty CMDB row with an
uncached APPID; describe what a failed application leaves
Closes the pre-push findings:
- "Fully-empty CMDB row with an uncached APPID stays invisible": the reader dropped a CMDB row whose only content is an APPID formula without a cached value before the service could see it, so its application could still be soft-deleted. read() now returns `uncachedKeyRows` (source sheet => dropped row numbers, collected only when the key column is the uncached one), and the service merges them with the rows that carry `uncached` into the same unreadable-key bookkeeping, so the run archives instead of deleting and warns. New tests: testAnEmptyCmdbRowWithAnUncachedKeyIsListed (reader) and testAFullyEmptyCmdbRowWithAnUncachedKeyArchivesInsteadOfDeleting (service).
- "Failure-branch docblock overstates": the itemsOf() and reconcileApplication() docblocks and design.md D5 and Risks now state the order (own usage, extra usages, module, extra modules) and what a failure leaves: a failing extra usage leaves the own usage changed, that extra visible and the module unchanged, and the row only says failed.
Class sweep: other callers of readRows() are the archive sheet (unchanged, no key filter) and readSourceRows(); the read() docblock names the new key. The spec and proposal speak of "a row of a CMDB sheet" in general and need no change.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Cmdb/CmdbWorkbookReader.php | 59 ++++++++++++++++---
lib/Service/CmdbExportImportService.php | 32 ++++++++--
.../design.md | 4 +-
.../Service/Cmdb/CmdbWorkbookReaderTest.php | 28 +++++++++
.../Service/CmdbExportImportServiceTest.php | 32 +++++++++-
5 files changed, 136 insertions(+), 19 deletions(-)
diff --git a/lib/Service/Cmdb/CmdbWorkbookReader.php b/lib/Service/Cmdb/CmdbWorkbookReader.php
index 3ba065a4d..4b80393b8 100644
--- a/lib/Service/Cmdb/CmdbWorkbookReader.php
+++ b/lib/Service/Cmdb/CmdbWorkbookReader.php
@@ -175,8 +175,11 @@ public function isAvailable(): bool {
* @param CmdbImportProfile $profile The import profile.
*
* @return array `rows` (list of {sheet, row, cells, uncached}), `importWarnings`
- * (list of {sheet, message}), `date1904` (bool) and `archive`
- * ({sheet, present, keyColumnMissing, appIds, uncachedRows}).
+ * (list of {sheet, message}), `date1904` (bool), `archive`
+ * ({sheet, present, keyColumnMissing, appIds, uncachedRows}) and `uncachedKeyRows`
+ * (source sheet => numbers of the rows dropped as empty because their key is a
+ * formula without a cached value; a row with other content is in `rows`, its
+ * `uncached` lists the key).
*
* @throws CmdbImportException WORKBOOK_TOO_LARGE, READER_UNAVAILABLE, NOT_XLSX, NO_SOURCE_SHEET,
* MISSING_COLUMN or TOO_MANY_ROWS.
@@ -369,7 +372,15 @@ private function readBounded(string $path, CmdbImportProfile $profile, object $s
$spreadsheet = $this->load(path: $path, sheetNames: $loaded, filter: new CmdbReadFilter(lastRow: $lastRow, columns: $letters));
try {
- $rows = $this->readSourceRows(spreadsheet: $spreadsheet, sheetNames: $present, columnsPerSheet: $resolved['columns'], limit: $limit);
+ $uncachedKeyRows = [];
+ $rows = $this->readSourceRows(
+ spreadsheet: $spreadsheet,
+ sheetNames: $present,
+ columnsPerSheet: $resolved['columns'],
+ limit: $limit,
+ keyColumn: $profile->keyColumn(),
+ uncachedKeyRows: $uncachedKeyRows
+ );
if ($archiveColumns !== []) {
$unseen = [];
$archiveRows = $this->readRows(
@@ -389,7 +400,13 @@ private function readBounded(string $path, CmdbImportProfile $profile, object $s
$spreadsheet->disconnectWorksheets();
}
- return ['rows' => $rows, 'importWarnings' => $resolved['warnings'], 'date1904' => $date1904, 'archive' => $archive];
+ return [
+ 'rows' => $rows,
+ 'importWarnings' => $resolved['warnings'],
+ 'date1904' => $date1904,
+ 'archive' => $archive,
+ 'uncachedKeyRows' => $uncachedKeyRows,
+ ];
}//end readBounded()
/**
@@ -434,20 +451,38 @@ private function sourceSheets(string $path, CmdbImportProfile $profile): array {
* @param array $sheetNames The source sheets the workbook has.
* @param array> $columnsPerSheet Sheet name => column letter => column name.
* @param int $limit The row limit per sheet.
+ * @param string $keyColumn The key column's name.
+ * @param array> $uncachedKeyRows Filled, per sheet, with the number of each row that was
+ * dropped as empty only because its key is a formula
+ * without a cached value.
*
* @return array> See read().
*
* @throws CmdbImportException TOO_MANY_ROWS.
*/
- private function readSourceRows(object $spreadsheet, array $sheetNames, array $columnsPerSheet, int $limit): array {
+ private function readSourceRows(
+ object $spreadsheet,
+ array $sheetNames,
+ array $columnsPerSheet,
+ int $limit,
+ string $keyColumn,
+ array &$uncachedKeyRows,
+ ): array {
$rows = [];
foreach ($sheetNames as $sheetName) {
+ $unseen = [];
$sheetRows = $this->readRows(
worksheet: $spreadsheet->getSheetByName($sheetName),
columns: $columnsPerSheet[$sheetName],
sheetName: $sheetName,
- limit: $limit
+ limit: $limit,
+ unseen: $unseen,
+ keyColumn: $keyColumn
);
+ if ($unseen !== []) {
+ $uncachedKeyRows[$sheetName] = array_values((array)$unseen);
+ }
+
array_push($rows, ...$sheetRows);
}
@@ -732,12 +767,20 @@ private function resolveColumns(object $worksheet, array $referenced): array {
* @param int $limit The maximum number of non-empty rows.
* @param array|null $unseen Filled with the number of each row that read as empty only because
* a formula in it has no cached value; null to not collect them.
+ * @param string|null $keyColumn When set, only a row whose uncached columns include this one is collected.
*
* @return array, uncached: array}>
*
* @throws CmdbImportException TOO_MANY_ROWS.
*/
- private function readRows(object $worksheet, array $columns, string $sheetName, int $limit, ?array &$unseen = null): array {
+ private function readRows(
+ object $worksheet,
+ array $columns,
+ string $sheetName,
+ int $limit,
+ ?array &$unseen = null,
+ ?string $keyColumn = null,
+ ): array {
$rows = [];
$lastRow = (int)$worksheet->getHighestDataRow();
for ($rowNumber = 2; $rowNumber <= $lastRow; $rowNumber++) {
@@ -747,7 +790,7 @@ private function readRows(object $worksheet, array $columns, string $sheetName,
rowNumber: $rowNumber
);
if ($empty === true) {
- if ($uncached !== [] && $unseen !== null) {
+ if ($uncached !== [] && $unseen !== null && ($keyColumn === null || in_array($keyColumn, $uncached, true) === true)) {
$unseen[] = $rowNumber;
}
diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php
index 4c236d9ac..1cce85f47 100644
--- a/lib/Service/CmdbExportImportService.php
+++ b/lib/Service/CmdbExportImportService.php
@@ -556,7 +556,7 @@ private function runImport(string $path, array $options, string $startedAt): arr
$report->setMunicipality(uuid: $municipality['uuid'], name: $municipality['name'], created: $municipality['created']);
$report->addImportWarnings(warnings: $this->translateImportWarnings(warnings: $workbook['importWarnings']));
$archive = $this->archiveSheet(workbook: $workbook);
- $this->unreadableKeyRows = $this->unreadableKeyRows(rows: $rows);
+ $this->unreadableKeyRows = $this->unreadableKeyRows(rows: $rows, droppedRows: (array)($workbook['uncachedKeyRows'] ?? []));
$this->addRunWarnings(report: $report, municipality: $municipality, archive: $archive, missingRecords: $missingRecords);
$this->progressTracker->startOperation(
@@ -684,20 +684,31 @@ private function addRunWarnings(CmdbImportReport $report, array $municipality, a
* has an unreadable key.
*
* @param array}> $rows The reader rows.
+ * @param array> $droppedRows The rows the reader dropped as empty because their key
+ * has no cached value (they are not in `$rows`), per sheet.
*
* @return array> Sheet name => row numbers.
*
* @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
*/
- private function unreadableKeyRows(array $rows): array {
+ private function unreadableKeyRows(array $rows, array $droppedRows): array {
$key = $this->profile->keyColumn();
$found = [];
+ foreach ($droppedRows as $sheet => $rowNumbers) {
+ $found[(string)$sheet] = array_map('intval', array_values((array)$rowNumbers));
+ }
+
foreach ($rows as $row) {
if (in_array($key, ($row['uncached'] ?? []), true) === true) {
$found[$row['sheet']][] = $row['row'];
}
}
+ foreach ($found as $sheet => $rowNumbers) {
+ sort($rowNumbers);
+ $found[$sheet] = $rowNumbers;
+ }
+
return $found;
}//end unreadableKeyRows()
@@ -2572,8 +2583,15 @@ private function modulesInChunk(array $chunk, array $coordinates): array {
/**
* Archive or soft-delete one application that left the CMDB sheets, usage first, in its own error boundary.
*
- * The usage is written before the module, so a failure between the two
- * leaves the municipality's view right and the module at worst visible.
+ * The writes go in this order: the application's own usage, the other
+ * usages of its key, its module, the other modules. A failure stops the
+ * application there and reports one `failed` row (the step and the
+ * exception class, no list of what was already written). What it leaves:
+ * a failing own usage leaves everything as it was; a failing extra usage
+ * leaves the own usage in the new state, that extra and the module as
+ * they were (so the extra stays visible and the module stays working);
+ * a failing module leaves every usage in the new state and the module
+ * as it was. The next import reconciles the application again.
* An application already in the state the export asks for is not reported.
*
* @param ScopeEntry $application The application.
@@ -2631,8 +2649,10 @@ private function reconcileApplication(array $application, array $archive, string
/**
* What one application brings to the state the export asks for: its usage and the other usages of its key, then its module and the other modules.
*
- * Usages come before modules, so a failure part way leaves the
- * municipality's view right and a module at worst visible.
+ * Own usage, extra usages, own module, extra modules. Usages come before
+ * modules, so a failure leaves usages in the new state and a module at
+ * worst unchanged; it does not leave a module changed under a usage that
+ * still shows. An extra usage that fails does hold back the module.
*
* Only the application's own objects are restored from the trash, and only
* when `restoreDeleted` says so; the extras never are.
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/design.md b/openspec/changes/cmdb-import-archive-reconciliation/design.md
index 1fc7058ee..5329cc153 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/design.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/design.md
@@ -99,7 +99,7 @@ Per application, with `processed` = every APPID match key on the CMDB sheets (wh
The table decides on one entry per key. When a municipality has several usages and modules under the same import key (D4 says earlier imports created exactly such duplicates, a module deleted by hand next to the one a later import created), the scope keeps the entry the import would match: the best module state wins (working, then archived, then deleted), the best usage state breaks a tie, the first listed breaks the rest. The other usages of the key follow the application, so a stray usage stays visible nowhere: a working or archived usage of a stale duplicate, a second usage of the same module, and a working or archived duplicate module are archived or soft-deleted as the key asks, once, in the same error boundary and the same report row (usages first, then modules). What is in the trash is left as it is: a stale trashed duplicate is not restored into the archive when the key is on the archive sheet, and is not touched when it is on no sheet.
-The usage is written before the module, so a failure between the two leaves the municipality's usage in the new state and the module visible, never the reverse. A module another municipality still uses is archived or deleted all the same when this municipality's usage says so: the module carries this municipality's import key, so it is this municipality's record (REQ-CMDB-006 allows a shared module only when the other organisation's usage was added by hand). The archive reason is `cmdb-import: on sheet "Gearchiveerde Applicaties"` or `cmdb-import: not in the export`, plus the operation id, so the audit trail says why.
+The writes go in this order: the application's own usage, the other usages of its key, its module, the other modules. A failure stops that application and gives one `failed` report row (step and exception class only). A failing own usage leaves everything as it was; a failing extra usage leaves the own usage in the new state and that extra and the module as they were (the extra stays visible, the module stays working); a failing module leaves every usage in the new state and the module as it was. Never is a module changed while a usage of the key still shows. The next import reconciles the application again. A module another municipality still uses is archived or deleted all the same when this municipality's usage says so: the module carries this municipality's import key, so it is this municipality's record (REQ-CMDB-006 allows a shared module only when the other organisation's usage was added by hand). The archive reason is `cmdb-import: on sheet "Gearchiveerde Applicaties"` or `cmdb-import: not in the export`, plus the operation id, so the audit trail says why.
Each application runs in its own boundary: a failure is a `failed` row naming the step (`archive`, `restore`, `delete`) and the exception class, as row failures do, and the other applications continue. Contact persons are never touched.
@@ -197,7 +197,7 @@ No seed objects change. The fragment's three seed modules stay as they are; the
- [The default archives on the first re-import after the upgrade] → The section shows the choice with help text; the report lists every archived and deleted application; nothing is hard-deleted.
- [A workbook saved without the archive sheet, or whose archive sheet has no APPID column or an APPID without a cached value, or a CMDB sheet with an APPID without a cached value] → archive only, never delete, plus a warning (D3).
-- [A failure between the usage and the module] → the usage is written first, so the municipality's view is right and the module is at worst still visible (D5).
+- [A failure part way through an application] → usages are written before modules, so a module is never changed under a usage that still shows; but a failing extra usage leaves the own usage changed, that extra visible and the module unchanged, and the row only says `failed`. The next import retries (D5).
- [Reviving before writing adds calls per returning row] → only for rows whose match is archived or deleted; a row whose module is working costs the same searches as before, a new row one more search (the archive lens).
- [The trash listing scans every magic table of the instance] → read once per import, not per row (D9).
- [OpenRegister internals change shape] → guarded resolution, 503 before the file is read (D7).
diff --git a/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php b/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php
index b4d561809..fa5754636 100644
--- a/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php
+++ b/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php
@@ -1055,6 +1055,34 @@ public function testAnUncachedArchiveKeyIsListedNotDropped(): void {
$this->assertSame([2, 5], $archive['uncachedRows'], 'the rows whose APPID formula has no cached value');
}//end testAnUncachedArchiveKeyIsListedNotDropped()
+ /**
+ * A CMDB row whose only content is an APPID formula without a cached value is dropped as empty, but its row
+ * number is listed per sheet, so a caller knows the sheet holds an application it could not see.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function testAnEmptyCmdbRowWithAnUncachedKeyIsListed(): void {
+ $this->requireSpreadsheet();
+ $path = CmdbTestSupport::buildWorkbook(
+ sheets: [
+ 'Beheerde Applicaties CMDB' => [['APPID', 'Applicatie Naam'], [1, 'Een'], [['f' => 'Invoer!A2']], [2, 'Twee']],
+ 'Onbeh Applicaties CMDB' => [['APPID', 'Applicatie Naam'], [3, 'Drie']],
+ ]
+ );
+
+ try {
+ $result = (new CmdbWorkbookReader())->read(path: $path, profile: $this->profile());
+ } finally {
+ unlink($path);
+ }
+
+ $beheerd = array_filter($result['rows'], static fn (array $row): bool => $row['sheet'] === 'Beheerde Applicaties CMDB');
+ $this->assertSame([2, 4], array_column($beheerd, 'row'), 'the empty row is not a row');
+ $this->assertSame(['Beheerde Applicaties CMDB' => [3]], $result['uncachedKeyRows'], 'the dropped row is listed, only on its sheet');
+ }//end testAnEmptyCmdbRowWithAnUncachedKeyIsListed()
+
/**
* An archive sheet whose last used row lies beyond twice the row limit stops before any sheet is loaded.
*
diff --git a/tests/Unit/Service/CmdbExportImportServiceTest.php b/tests/Unit/Service/CmdbExportImportServiceTest.php
index c476b5b1f..2cf2e04c8 100644
--- a/tests/Unit/Service/CmdbExportImportServiceTest.php
+++ b/tests/Unit/Service/CmdbExportImportServiceTest.php
@@ -994,19 +994,21 @@ function (&$value): void {
*
* @return CmdbWorkbookReader
*/
- private function rowsReader(array $rows, ?array $archived = [], array $uncachedRows = []): CmdbWorkbookReader {
- return new class($rows, $archived, $uncachedRows) extends CmdbWorkbookReader {
+ private function rowsReader(array $rows, ?array $archived = [], array $uncachedRows = [], array $uncachedKeyRows = []): CmdbWorkbookReader {
+ return new class($rows, $archived, $uncachedRows, $uncachedKeyRows) extends CmdbWorkbookReader {
/**
* Constructor.
*
* @param array> $rows The rows.
* @param array|null $archived The archive sheet's APPIDs, or null.
* @param array $uncachedRows The archive sheet's rows without a cached APPID.
+ * @param array> $uncachedKeyRows The CMDB rows dropped as empty, per sheet.
*/
public function __construct(
private array $rows,
private ?array $archived,
private array $uncachedRows,
+ private array $uncachedKeyRows = [],
) {
}
@@ -1020,7 +1022,7 @@ public function __construct(
*/
public function read(string $path, CmdbImportProfile $profile): array {
$archive = ['sheet' => 'Gearchiveerde Applicaties', 'present' => ($this->archived !== null), 'keyColumnMissing' => false, 'appIds' => ($this->archived ?? []), 'uncachedRows' => $this->uncachedRows];
- return ['rows' => $this->rows, 'importWarnings' => [], 'date1904' => false, 'archive' => $archive];
+ return ['rows' => $this->rows, 'importWarnings' => [], 'date1904' => false, 'archive' => $archive, 'uncachedKeyRows' => $this->uncachedKeyRows];
}
};
}//end rowsReader()
@@ -2044,6 +2046,30 @@ public function testAnUncachedCmdbRowArchivesInsteadOfDeleting(): void {
$this->assertSame([], $readable['importWarnings']);
}//end testAnUncachedCmdbRowArchivesInsteadOfDeleting()
+ /**
+ * A CMDB row whose only content is an uncached APPID is dropped by the reader before the service sees a row, and
+ * is handed over as a list of rows: the import still archives instead of deleting, and warns.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
+ */
+ public function testAFullyEmptyCmdbRowWithAnUncachedKeyArchivesInsteadOfDeleting(): void {
+ $this->importOneSevenAndEight();
+ $eight = $this->moduleOf(appId: '8');
+
+ $report = $this->service(
+ reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: ['7'], uncachedKeyRows: ['Beheerde Applicaties CMDB' => [6]])
+ )->import(path: '', options: ['municipalityUuid' => 'muni-1']);
+
+ $this->assertNotContains('delete', array_column($this->transitions, 0));
+ $this->assertSame([true, false], $this->stateOf(schema: self::MODULE, uuid: $eight));
+ $this->assertSame(
+ [['sheet' => 'Beheerde Applicaties CMDB', 'message' => 'Sheet "Beheerde Applicaties CMDB" has an APPID without a cached value in row 6, so applications missing from the export are archived, not deleted.']],
+ $report['importWarnings']
+ );
+ }//end testAFullyEmptyCmdbRowWithAnUncachedKeyArchivesInsteadOfDeleting()
+
/**
* An extra usage of a key that cannot be archived fails the application's row; its module, written after every usage, stays
* as it was, and the other applications are still reconciled.
From 3c81e239f934de40cb0fbbee37d77b0b7686a976 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 19:07:28 +0200
Subject: [PATCH 082/124] docs(cmdb-import): describe only what this part
ships, and what editing needs
- The docs and the spec delta of this part describe the endpoint, the
import and the seed step, and no longer the settings section, which
arrives with the UI part (#1289, Task 4). The REQ-CMDB-014 change moves
there with its scenarios.
- "Viewing and editing the mapping" opens with what editing needs: an
OpenRegister with the migration-pack editor (openregister#4602), and
what an administrator gets without it. The claims that the import always
runs what OpenRegister holds are qualified, and the stale "fixed list of
plain fields" now says any plain field of the target schema.
- REQ-CMDB-020 says reading the mapping writes nothing; the deleted-pack
scenario and its e2e test now expect the next import, not a read, to
seed the pack again, and the first e2e test checks a read stores nothing.
- The edit e2e test names the "Beheerde" usage as the evidence and the
"Onbeh" one as unchanged by the edit.
- Task 6 is split into the API coverage (done here) and the section's
coverage (Task 7); Task 8 records that archiving returns the canonical
spec's status to done.
- CHANGELOG: the migration pack store and the repair step.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
CHANGELOG.md | 7 ++
docs/features/cmdb-import.md | 80 +++++++++++--------
.../cmdb-import-mapping-view/design.md | 4 +-
.../cmdb-import-mapping-view/proposal.md | 2 +-
.../specs/cmdb-export-import/spec.md | 29 ++-----
.../changes/cmdb-import-mapping-view/tasks.md | 25 ++++--
openspec/specs/cmdb-export-import/spec.md | 2 +-
.../spec-coverage/cmdb-import-packs.spec.ts | 59 +++++++++++---
8 files changed, 132 insertions(+), 76 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87505881a..3de8ca873 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,13 @@
sheets becomes or updates a module, its vendor, a usage for the municipality
and a contact person for its owner; a repeat import matches on APPID, so it
updates instead of duplicating. See `docs/features/cmdb-import.md`.
+- CMDB import: on an OpenRegister with the migration-pack editor
+ (openregister#4602), the import runs the mapping packs kept in
+ OpenRegister's migration pack store, where an administrator edits them.
+ The `SeedCmdbImportPacks` repair step stores them on install and upgrade;
+ a pack nobody edited follows newer releases, an edited one is kept.
+ Viewing the mapping writes nothing. An older OpenRegister keeps the packs
+ that ship with stackiq.
### Changed
- EOL feed: the register and schema slugs the feature reads from are corrected.
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index fa101274f..ed2b17e16 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -150,8 +150,9 @@ date stored there, including one set by hand.
### Columns and where they go
-This is the mapping as it ships; an administrator can change it in
-OpenRegister (see [Viewing and editing the mapping](#viewing-and-editing-the-mapping)).
+This is the mapping as it ships. On an OpenRegister with the migration-pack
+editor an administrator can change it there (see
+[Viewing and editing the mapping](#viewing-and-editing-the-mapping)).
| Column | Goes to | Rule |
|---|---|---|
@@ -339,10 +340,22 @@ server is overwritten by the next app update.
## Viewing and editing the mapping
+**What you need.** Editing the mapping needs an OpenRegister with the
+migration-pack editor, whose pack writes are admin-only and need a CSRF
+token (added in [openregister#4602](https://github.com/ConductionNL/openregister/pull/4602);
+the first OpenRegister release that contains it). On an older OpenRegister
+nothing is stored in OpenRegister and there is nothing to edit: the import
+runs the packs that ship with stackiq, and the import result says so.
+Changing those shipped files on the server does not last, because the next
+app update replaces them. Once the packs have been stored in OpenRegister,
+the import does not fall back to the shipped copies: when OpenRegister's
+pack store is gone, it refuses with `MAPPING_UNAVAILABLE` until OpenRegister
+is back.
+
The mapping from export columns to stackiq fields is not in code. It is a
set of mapping packs that OpenRegister's mapping engine runs, one per kind of
-record the import writes. Four of them are kept in OpenRegister, where you
-can view and change them:
+record the import writes. With the editor, four of them are kept in
+OpenRegister, where you can view and change them:
| Pack (in OpenRegister) | What it maps |
|---|---|
@@ -351,11 +364,14 @@ can view and change them:
| `stackiq-topdesk-usage` | a row to the usage (status and TIME lookups, phase-out date, internal note) |
| `stackiq-topdesk-business-owner` | the owner columns |
-Stackiq puts these four packs into OpenRegister when it is installed or
-updated. The **CMDB import** section shows, per pack, where the import takes
-it from (OpenRegister, or the copy that ships with stackiq), its version, and
-an **Edit in OpenRegister** link that opens the pack. The import always runs
-what is saved in OpenRegister.
+When OpenRegister has the editor, stackiq puts these four packs into
+OpenRegister when it is installed or updated, and an import stores any that
+is missing. From then on the import runs what is saved in OpenRegister. The
+mapping endpoint (`GET /index.php/apps/stackiq/api/settings/cmdb-import/mapping`,
+Nextcloud admins only) answers, per pack, where the import takes it from
+(OpenRegister, or the copy that ships with stackiq), its version and the
+link to its editor in OpenRegister. Reading it changes nothing: it does not
+store or update a pack.
Each pack has a list of field mappings, one per column: the column name in
the export, the field it goes to, whether it is required, and a
@@ -374,27 +390,27 @@ rules, so it checks every pack before it reads a row. It refuses a pack, with
- OpenRegister finds the pack invalid;
- the application name, the APPID (to the source number), the vendor name or
the owner name is no longer mapped, or no longer marked required;
-- a mapping or a default writes a field the pack may not write. Each pack
- may write only a fixed list of plain fields: the fields the shipped pack
- maps, the licence type of an application, and the start dates of a usage's
- phases. Refused are the fields the import sets itself (the import key, the
- provider, the consumer, the application of a usage, the business owner, the
- type of an organisation: a Supplier stays a Supplier), every link to
- another record, the publication dates, and the fields only an
- administrator or the catalogue sets, such as an organisation's status and
- who registered it. The status and registration defaults of the vendor and
- municipality packs stay as shipped.
-
-The section shows the same reason with an **Edit in OpenRegister** link to
-the pack, so you can correct it there and try again.
+- a mapping or a default writes a field the pack may not write. A pack may
+ write any plain field of the record it writes (the application, the
+ organisation or the usage), as the register defines it; the owner pack
+ writes the name and the role only. Refused are the fields the import sets
+ itself (the import key, the provider, the consumer, the application of a
+ usage, the business owner, the type of an organisation: a Supplier stays a
+ Supplier), every link to another record, files, the publication dates, and
+ the fields only an administrator or the catalogue sets, such as an
+ organisation's status and who registered it. The status and registration
+ defaults of the vendor and municipality packs stay as shipped.
+
+The mapping endpoint answers the same reason, with the link to the pack's
+editor in `details.editUrl`, so you can correct it there and try again.
**What a pack does not change.** Which sheets are read, the APPID and
"Applicatie Naam" columns, the values that mean empty (`NB`), the constant
each sheet adds (`Beheer geregeld: ja` or `nee`), the limits, and which fields
are written only on create or never on an existing record are part of the app
-(`lib/Settings/cmdb-import/topdesk-profile.json`), and the section shows them
-read-only. The municipality is mapped from the name you type in the section,
-not from the export; that pack is not edited in OpenRegister either.
+(`lib/Settings/cmdb-import/topdesk-profile.json`) and cannot be changed. The
+municipality is mapped from the name you choose for the import, not from the
+export; that pack is not stored in OpenRegister.
**Reading more columns.** The import reads only the columns the mapping
names. When you map a new column in a pack, the import reads that column from
@@ -405,18 +421,12 @@ import on this Nextcloud, whatever the municipality.
**App updates.** A pack you have not changed follows the app: when an update
ships a newer version, the stored pack is replaced by it. A pack you changed
-is never overwritten. When an update ships a newer version of a pack you
-changed, the section says so; compare the two and copy what you need into
-your pack in OpenRegister.
+is never overwritten, also not when you save it while an import is updating
+it. When an update ships a newer version of a pack you changed, the mapping
+endpoint reports it (`newerShipped`); compare the two and copy what you need
+into your pack in OpenRegister.
**Back to the shipped pack.** Delete the pack in OpenRegister. The next
import, or the next app update, puts the shipped pack back. Do not rename a
pack: the import finds it by its id (such as `stackiq-topdesk-usage`), and
refuses to run while a pack it stored has another id.
-
-**Without OpenRegister's pack store.** On an OpenRegister version without
-migration packs, or one whose pack store does not yet protect its writes
-with a CSRF token and lock the id of a built-in pack, the import uses the
-packs that ship with stackiq and the result says so. Once the packs have been stored in OpenRegister, the import
-does not fall back to those copies: when OpenRegister's pack store is gone,
-it refuses with `MAPPING_UNAVAILABLE` until OpenRegister is back.
diff --git a/openspec/changes/cmdb-import-mapping-view/design.md b/openspec/changes/cmdb-import-mapping-view/design.md
index 21f19580e..8aef9796c 100644
--- a/openspec/changes/cmdb-import-mapping-view/design.md
+++ b/openspec/changes/cmdb-import-mapping-view/design.md
@@ -71,7 +71,7 @@ OpenRegister's pack store has no organisation column: an instance has one TOPdes
A stored pack carries `x-stackiq: {shippedVersion, shippedHash}`: the version of the shipped pack it was seeded from and the sha256 of that pack's canonical JSON (keys of every object sorted recursively, list order kept, because the order of `fieldMappings` decides which mapping wins a target). OpenRegister's validator ignores keys it does not know, so the marker travels with the definition. A stored definition that, without the marker, still hashes to `shippedHash` is unedited.
-`CmdbImportPackStore::resolve()` is the one place that writes: it creates a missing pack as built-in with the marker (never through OpenRegister's import, which upserts and would overwrite an edit), replaces an unedited pack whose marker hash differs from the current shipped pack, and leaves an edited pack alone, reporting `newerShipped`. A pack without a marker (created by hand under the same slug) counts as edited with `edited: null`, and is never replaced. The repair step `SeedCmdbImportPacks` runs it on install and upgrade, after `InitializeSettings`; the loader runs it too, so a pack deleted in OpenRegister is seeded again on the next import, and an instance whose OpenRegister gained the store after stackiq's upgrade is seeded lazily.
+`CmdbImportPackStore::resolve()` is the one place that writes: it creates a missing pack as built-in with the marker (never through OpenRegister's import, which upserts and would overwrite an edit), replaces an unedited pack whose marker hash differs from the current shipped pack, and leaves an edited pack alone, reporting `newerShipped`. A pack without a marker (created by hand under the same slug) counts as edited with `edited: null`, and is never replaced. The repair step `SeedCmdbImportPacks` runs it on install and upgrade, after `InitializeSettings`; the import's `CmdbImportProfile::load()` runs it too, so a pack deleted in OpenRegister is seeded again on the next import, and an instance whose OpenRegister gained the store after stackiq's upgrade is seeded by its next import. Viewing the mapping uses `loadForView()`, which calls `CmdbImportPackStore::find()`: it only reads, and a pack the store does not hold yet is shown from its shipped file. The update of an unedited pack passes the read pack's `updatedAt`, so OpenRegister refuses it (`PackChangedException`) when an administrator saved the pack in between; the store then keeps that edit. `CmdbImportPackStoreProbe` decides whether the store may be used at all: the slug-lock class exists and no write method of OpenRegister's pack controller carries an attribute or annotation that skips the CSRF check or admits non-admins.
Version numbers alone cannot tell an edit from a release: OpenRegister's editor bumps the version on save, independently of stackiq's releases. The hash can.
@@ -163,5 +163,5 @@ l10n/en.json, en.js, nl.json, nl.js
- PHPUnit on the import: an edit in OpenRegister changes the mapped usage, the separator and a new date column; without a store the report carries the fallback warning.
- PHPUnit on the endpoint and the repair step: the extended answer, `editUrl` from the page route and null when it cannot be built, 503 naming the pack with the link to its editor; seeding four packs, updating and keeping, no store, a failing store, the registration order.
- Playwright, through the API (`cmdb-import-packs.spec.ts`): the four packs stored as built-in packs with the marker, the mapping with source, `packId` and `editUrl`, a broken stored pack refused by the mapping and by the import, a module pack without its required name refused, an edit in OpenRegister run by the import, a deleted pack seeded again, the 403 for a non-admin. Each test that edits a pack puts back the definition it read.
-- Playwright, on the settings page: the section shows the source and the editor link per pack (UI).
+- Playwright, on the settings page (Task 7, with the section in Task 4): the section shows the source and the editor link per pack.
- Install, upgrade and a missing or older store are not driven in e2e: they need another OpenRegister or a reinstall. The requirements state them, and the PHPUnit tests above prove them.
diff --git a/openspec/changes/cmdb-import-mapping-view/proposal.md b/openspec/changes/cmdb-import-mapping-view/proposal.md
index 5a12aa5f3..f8172ddaf 100644
--- a/openspec/changes/cmdb-import-mapping-view/proposal.md
+++ b/openspec/changes/cmdb-import-mapping-view/proposal.md
@@ -39,7 +39,7 @@ None.
- The `x-stackiq` marker in a stored definition: the shipped version and the hash of the shipped pack it was seeded from.
- Excel date conversion for every column a pack maps with a date transform; the note cleanup on the pack's own concat separator.
- `GET /api/settings/cmdb-import/mapping` extended per pack with `source`, `packId`, `storedVersion`, `shippedVersion`, `edited`, `newerShipped`, `editable` and `editUrl`, and `openRegisterPacks` at the top level.
-- The section's mapping block: per pack its source, version, a hint when a newer shipped pack was not applied, and an "Edit in OpenRegister" link; the sheet names in the help text come from the endpoint.
+- The section's mapping block: per pack its source, version, a hint when a newer shipped pack was not applied, and a link to its editor in OpenRegister (Task 4); the sheet names in the help text come from the endpoint.
- `docs/features/cmdb-import.md`: viewing and editing the mapping in OpenRegister.
### Out of Scope
diff --git a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
index 793c3bdfa..991044edf 100644
--- a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
@@ -22,7 +22,7 @@ Nextcloud OCP interfaces used: `OCP\IL10N` (messages), `OCP\IURLGenerator` (the
- `packs`: one entry per target in the order module, manufacturer, municipality, usage, businessOwner, each with `target`, `file`, `id`, `name`, `version`, `description`, `fieldMappings`, the write rules `defaults`, `createOnlyDefaults`, `createOnlyFields` and `neverWritten`, and where the pack comes from: `source` (`openregister` or `file`), `packId` (the OpenRegister row id, or null), `storedVersion` (or null), `shippedVersion`, `edited` (whether the stored pack differs from what stackiq seeded; null when unknown or for a file), `newerShipped` (an edited pack kept a newer shipped pack out), `editable` (false for the municipality pack) and `editUrl` (the absolute link to the pack's editor in OpenRegister, built by Nextcloud's URL generator so an instance that needs `/index.php` gets it, or null when the pack is not stored there or the link cannot be built);
- `openRegisterPacks`: whether OpenRegister's migration-pack store is available.
-Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it. `createOnlyDefaults` holds values the import sets on create only, `createOnlyFields` the mapped fields written on create or, on an existing object, only when the stored value is empty (a key of `createOnlyDefaults` only when it is also a mapped field), and `neverWritten` the fields the import never writes on an existing object. When the validator is missing, a pack is unreadable, invalid or breaks a rule of the import, or the store that holds the packs is gone, the endpoint SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason` (its first line, at most 500 characters), and for a pack stored in OpenRegister its slug in `details.pack`, its row id in `details.packId` and the link to its editor in `details.editUrl` (null when the link cannot be built). The "CMDB import" section SHALL show, per pack, its source, its version, a hint when a newer shipped pack was not applied, and a link "Edit in OpenRegister" when the pack has an `editUrl`; when the endpoint answers 503 for a stored pack, the error SHALL carry the same link from `details.editUrl`. It SHALL offer no editing itself.
+Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it. `createOnlyDefaults` holds values the import sets on create only, `createOnlyFields` the mapped fields written on create or, on an existing object, only when the stored value is empty (a key of `createOnlyDefaults` only when it is also a mapped field), and `neverWritten` the fields the import never writes on an existing object. When the validator is missing, a pack is unreadable, invalid or breaks a rule of the import, or the store that holds the packs is gone, the endpoint SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason` (its first line, at most 500 characters), and for a pack stored in OpenRegister its slug in `details.pack`, its row id in `details.packId` and the link to its editor in `details.editUrl` (null when the link cannot be built). Reading the mapping SHALL change nothing: it SHALL NOT seed, update or delete a pack in OpenRegister and SHALL NOT write stackiq's app config; a pack the store does not hold yet SHALL be answered from its shipped file (`source` `file`), and an unedited stored pack that a newer shipped pack would replace SHALL be answered as stored until the next import run or upgrade replaces it.
#### Scenario: An admin reads the mapping the import uses
@e2e tests/e2e/spec-coverage/cmdb-import-packs.spec.ts
@@ -92,8 +92,10 @@ OpenRegister's store SHALL count as available only when its pack writes require
- **GIVEN** an administrator deleted the business-owner pack in OpenRegister
- **WHEN** a Nextcloud admin requests the mapping
-- **THEN** the business-owner pack SHALL have `source` `openregister`, a new `packId` and `edited` `false`
-- **AND** OpenRegister SHALL hold `stackiq-topdesk-business-owner` again as a built-in pack
+- **THEN** the business-owner pack SHALL have `source` `file`, and OpenRegister SHALL still hold no `stackiq-topdesk-business-owner`
+- **WHEN** the next import runs
+- **THEN** OpenRegister SHALL hold `stackiq-topdesk-business-owner` again as a built-in pack
+- **AND** the mapping SHALL show it with `source` `openregister`, a new `packId` and `edited` `false`
### Requirement: Stackiq SHALL seed its mapping packs into OpenRegister and keep unedited ones current (REQ-CMDB-022)
@@ -152,27 +154,12 @@ The service SHALL map each normalised row with OpenRegister's `MigrationPack\Map
- **AND** the "Onbeh" row's "Applicatiesoort" `Webapplicatie`, which is not a hosting model, SHALL be stored as `applicationType` and SHALL leave `cloudDienstverleningsmodel` empty without a warning
- **AND** "BNN Classificatie" `1`, `2`, `2+` SHALL be `BBN1`, `BBN2`, `BBN2+`
-### Requirement: The admin settings SHALL offer a CMDB import section (REQ-CMDB-014)
-
-Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary and a report table that can be filtered by outcome. The section SHALL also show the mapping the import uses (REQ-CMDB-020): per pack its source, its version and a link to its editor in OpenRegister. The sheet names in its help text SHALL come from that mapping, with the shipped names as the fallback until the mapping has loaded or when it cannot be loaded. Every control SHALL have a visible label, and every string SHALL be translatable.
-
-(Previously: the section had no mapping block, and the sheet names in its help text were a constant in the page, which could drift from the profile on the server.)
-
-#### Scenario: The admin runs an import from the settings page
-@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts
-
-- **GIVEN** a Nextcloud admin on stackiq's admin settings page
-- **WHEN** they choose "Gemeente Voorbeeldstad", choose the anonymised export and press "Import"
-- **THEN** a progress bar SHALL appear while the import runs
-- **AND** afterwards the summary and the report table SHALL be shown
-- **AND** filtering the table on `created` SHALL show the two imported rows
-
## Non-Functional Requirements
-- **Performance:** loading the mapping reads seven small JSON files and the register definition, and makes at most one OpenRegister lookup per sheet pack; it SHALL answer within the time an admin settings page loads. Writes happen only to seed or update a pack.
+- **Performance:** loading the mapping reads seven small JSON files and the register definition, and makes at most one OpenRegister lookup per sheet pack; it SHALL answer within the time an admin settings page loads. Reading the mapping writes nothing; only the import run and the seed step seed or update a pack.
- **Security:** admin-only with CSRF, as the import routes; OpenRegister's pack writes are admin-only with CSRF, and stackiq uses no store without those guards. The import writes with OpenRegister's RBAC and multitenancy off, so every pack SHALL follow the import's rules (REQ-CMDB-021). An edit that maps a new column makes the import read that column. The answer and the reasons hold column names, field names, lookup tables, pack names and ids; no object data and no person data. A store failure is named by its exception class, never its message.
- **Reliability:** an edit is never overwritten by an upgrade and never silently replaced by the shipped files (REQ-CMDB-021, REQ-CMDB-022).
-- **Accessibility:** Target WCAG 2.2 AA. The mapping block's link has a visible, descriptive label (SC 2.4.4, 4.1.2; gate `link-name`); a status hint is text, not colour alone (SC 1.4.1). New in 2.2: 2.4.11 Focus Not Obscured applies (the block is in the page flow); 2.5.7 Dragging Movements does not apply; 2.5.8 Target Size applies to the link (Nextcloud default); 3.2.6 Consistent Help does not apply; 3.3.7 Redundant Entry does not apply (nothing is entered); 3.3.8 Accessible Authentication does not apply.
+- **Accessibility:** not applicable to this part: the endpoint, the import rules and the seed step have no user interface. The section that shows the mapping is part of Task 4 and targets WCAG 2.2 AA.
- **Internationalization:** Dutch and English MUST be supported (ADR-005) for the block, the messages and the import warning.
## Acceptance Criteria
@@ -181,7 +168,7 @@ Stackiq's admin settings page SHALL show a section "CMDB import", rendered by th
- [ ] A Nextcloud admin edits a lookup value of the usage pack in OpenRegister, and the next CMDB import uses it.
- [ ] A pack edited to map a field the import manages makes the import answer 503 naming the pack; the mapping block shows the same.
- [ ] Deleting the stored pack in OpenRegister makes the next import seed the shipped pack again.
-- [ ] The "CMDB import" section shows per pack its source and an "Edit in OpenRegister" link that opens the pack's editor.
+- [ ] The "CMDB import" section shows per pack its source and a link that opens the pack's editor (Task 4).
- [ ] A signed-in user who is not a Nextcloud admin gets 403 from the endpoint.
## Notes
diff --git a/openspec/changes/cmdb-import-mapping-view/tasks.md b/openspec/changes/cmdb-import-mapping-view/tasks.md
index 9c6df1ed4..67e261fd8 100644
--- a/openspec/changes/cmdb-import-mapping-view/tasks.md
+++ b/openspec/changes/cmdb-import-mapping-view/tasks.md
@@ -43,7 +43,7 @@ Spec: `openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.m
- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014` (cmdb-export-import#REQ-CMDB-014) and `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020)
- **files**: `src/views/settings/sections/CmdbImportMapping.vue`, `src/views/settings/sections/CmdbImport.vue`, `src/utils/cmdbImport.js`, `tests/vitest/cmdbImportMapping.spec.js`
- **acceptance_criteria**:
- - GIVEN the endpoint's answer WHEN the block renders THEN each pack shows its source, its version, a hint when `newerShipped`, and an "Edit in OpenRegister" link to `editUrl` when there is one
+ - GIVEN the endpoint's answer WHEN the block renders THEN each pack shows its source, its version, a hint when `newerShipped`, and a link to its editor (`editUrl`) when there is one
- GIVEN a 503 THEN the block shows the error, the code and the reason
- GIVEN the answer THEN the help text names the endpoint's sheets; before and on failure `PROFILE_DEFAULTS.sheets`
- [ ] Implement
@@ -58,16 +58,31 @@ Spec: `openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.m
- [x] Implement (backend strings and documentation; the section's strings come with Task 4)
- [x] Test
-### Task 6: End-to-end coverage
+### Task 6: End-to-end coverage of the endpoint, the import and the seed
- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020), `SPEC#requirement-the-import-shall-run-the-mapping-packs-stored-in-openregister-req-cmdb-021` (cmdb-export-import#REQ-CMDB-021) and `SPEC#requirement-stackiq-shall-seed-its-mapping-packs-into-openregister-and-keep-unedited-ones-current-req-cmdb-022` (cmdb-export-import#REQ-CMDB-022)
-- **files**: `tests/e2e/spec-coverage/cmdb-import-packs.spec.ts`, `tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts`
+- **files**: `tests/e2e/spec-coverage/cmdb-import-packs.spec.ts`
- **acceptance_criteria**:
- GIVEN the rig with the hardened OpenRegister WHEN an admin lists OpenRegister's packs THEN the four sheet packs are built-in packs with the marker, and no municipality pack is stored
- GIVEN an admin WHEN they read the mapping THEN every sheet pack answers `source: openregister`, its `packId` and an `editUrl` to its editor; the municipality pack `source: file` and no link
- GIVEN the usage pack edited to map a column to `provider` THEN the mapping and the import answer 503 naming the pack, and nothing is written; GIVEN the module pack's name no longer required THEN the same with the rule named
- GIVEN an edited lookup in the usage pack WHEN the export is imported THEN the usages carry the edited value
- - GIVEN the business-owner pack deleted in OpenRegister WHEN the mapping is read THEN it is seeded again
+ - GIVEN the business-owner pack deleted in OpenRegister WHEN the mapping is read THEN it is shown from the shipped file and not stored; WHEN the next import runs THEN it is seeded again
- GIVEN a signed-in non-admin WHEN they call the endpoint THEN 403
- - GIVEN an admin on the settings page WHEN the mapping has loaded THEN every sheet pack shows its source and an "Edit in OpenRegister" link
+- [x] Implement
+- [x] Test
+
+### Task 7: End-to-end coverage of the section
+- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014` (cmdb-export-import#REQ-CMDB-014)
+- **files**: `tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts`
+- **acceptance_criteria**:
+ - GIVEN an admin on the settings page WHEN the mapping has loaded THEN every sheet pack shows its source and a link to its editor
+- [ ] Implement
+- [ ] Test
+
+### Task 8: Archive
+- **spec_ref**: `SPEC#requirement-the-import-shall-run-the-mapping-packs-stored-in-openregister-req-cmdb-021` (cmdb-export-import#REQ-CMDB-021)
+- **files**: `openspec/specs/cmdb-export-import/spec.md`
+- **acceptance_criteria**:
+ - GIVEN the change is archived WHEN this was the last open change of `cmdb-export-import` THEN the canonical spec's front matter `status:` and its `**Status**` line read `done` again, and the change's entry links the archive
- [ ] Implement
- [ ] Test
diff --git a/openspec/specs/cmdb-export-import/spec.md b/openspec/specs/cmdb-export-import/spec.md
index d285d7409..796158845 100644
--- a/openspec/specs/cmdb-export-import/spec.md
+++ b/openspec/specs/cmdb-export-import/spec.md
@@ -10,7 +10,7 @@ built_by: openspec/changes/archive/2026-10-05-cmdb-export-import
**Scope**: stackiq
**OpenSpec changes**:
- [cmdb-export-import](../../changes/archive/2026-10-05-cmdb-export-import/) _(archived 2026-10-05)_ — admin uploads a TOPdesk CMDB export (xlsx); stackiq upserts modules, vendor organisations, usages and owner contact persons for one municipality from the two CMDB sheets, matched on APPID, mapped by OpenRegister migration packs (kind: code)
-- [cmdb-import-mapping-view](../../changes/cmdb-import-mapping-view/) — the four sheet packs are seeded into OpenRegister's migration-pack store, where an administrator edits them, and the import runs what is stored there (shipped files as seed and fallback, fail-closed on an invalid pack); the "CMDB import" section shows per pack its source and a link to its editor, served by `GET /api/settings/cmdb-import/mapping` (kind: code)
+- [cmdb-import-mapping-view](../../changes/cmdb-import-mapping-view/) — the four sheet packs are seeded into OpenRegister's migration-pack store, where an administrator edits them, and the import runs what is stored there (shipped files as seed and fallback, fail-closed on an invalid pack); `GET /api/settings/cmdb-import/mapping` answers, without writing, per pack its source and a link to its editor (kind: code)
## Purpose
diff --git a/tests/e2e/spec-coverage/cmdb-import-packs.spec.ts b/tests/e2e/spec-coverage/cmdb-import-packs.spec.ts
index 6fd9f8cda..ae9bb5ab1 100644
--- a/tests/e2e/spec-coverage/cmdb-import-packs.spec.ts
+++ b/tests/e2e/spec-coverage/cmdb-import-packs.spec.ts
@@ -246,8 +246,18 @@ test.describe.serial('CMDB import packs in OpenRegister', () => {
test('the four sheet packs are stored in OpenRegister as built-in packs', async () => {
const ctx = await newApiContext()
try {
- // The mapping endpoint seeds a pack that is still missing.
+ // Reading the mapping stores nothing; the seed step (install or
+ // upgrade) or an import run stores a pack that is still missing.
+ const before = await storedPacks(ctx)
expect((await readMapping(ctx)).status()).toBe(200)
+ expect(
+ await storedPacks(ctx),
+ 'reading the mapping writes nothing',
+ ).toEqual(before)
+ if (Object.keys(before).length < STORED_SLUGS.length) {
+ const answer = await importExport(ctx)
+ expect(answer.status(), await answer.text()).toBe(200)
+ }
const packs = await storedPacks(ctx)
expect(Object.keys(packs).sort()).toEqual([...STORED_SLUGS].sort())
@@ -427,10 +437,24 @@ test.describe.serial('CMDB import packs in OpenRegister', () => {
const usages = await usagesOfMunicipality(ctx)
expect(usages).toHaveLength(2)
- // The "Beheerde" row is "In productie" in the export.
- for (const written of usages) {
- expect(written.status).toBe('Planned')
- }
+ const fromSheet = (constant: string) =>
+ usages.find((usage) =>
+ String(usage.interneAnnotation ?? '').startsWith(
+ constant,
+ ),
+ )
+ // The "Beheerde" row is "In productie" in the export: only the
+ // edit makes it Planned. This is the evidence.
+ expect(
+ fromSheet('Beheer geregeld: ja')?.status,
+ 'the usage of the "Beheerde" row',
+ ).toBe('Planned')
+ // The "Onbeh" row is "In voorraad", which the shipped lookup
+ // already maps to Planned: unchanged by the edit, no evidence.
+ expect(
+ fromSheet('Beheer geregeld: nee')?.status,
+ 'the usage of the "Onbeh" row',
+ ).toBe('Planned')
},
)
@@ -447,7 +471,7 @@ test.describe.serial('CMDB import packs in OpenRegister', () => {
})
// @e2e cmdb-export-import::a-deleted-pack-is-seeded-again
- test('a pack deleted in OpenRegister is seeded again', async () => {
+ test('a pack deleted in OpenRegister is seeded again by the next import', async () => {
const ctx = await newApiContext()
try {
const before = (await storedPacks(ctx))['stackiq-topdesk-business-owner']
@@ -455,11 +479,24 @@ test.describe.serial('CMDB import packs in OpenRegister', () => {
const deleted = await ctx.delete(`${PACKS_PATH}/${before.id}`)
expect(deleted.status(), await deleted.text()).toBe(204)
- const res = await readMapping(ctx)
- expect(res.status(), await res.text()).toBe(200)
- const owner = ((await res.json()).packs as MappingPack[]).find(
- (pack) => pack.target === 'businessOwner',
- ) as MappingPack
+ const ownerIn = async () =>
+ (
+ (await (await readMapping(ctx)).json()).packs as MappingPack[]
+ ).find((pack) => pack.target === 'businessOwner') as MappingPack
+ const shown = await ownerIn()
+ expect(
+ shown.source,
+ 'until the next import, from the shipped file',
+ ).toBe('file')
+ expect(
+ (await storedPacks(ctx))['stackiq-topdesk-business-owner'],
+ 'reading the mapping does not seed it',
+ ).toBeUndefined()
+
+ const answer = await importExport(ctx)
+ expect(answer.status(), await answer.text()).toBe(200)
+
+ const owner = await ownerIn()
expect(owner.source).toBe('openregister')
expect(owner.packId).not.toBe(before.id)
expect(owner.edited).toBe(false)
From c55a96d8810cd12c7e804406e0a81537649ea92b Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 19:07:37 +0200
Subject: [PATCH 083/124] test(cmdb-import): wait on a rendered list, not
networkidle, and fail loudly on a bad lens read
Closes "e2e waits for networkidle, which never settles on Nextcloud
(gate-58)": the archive test now waits for a working application row to be
visible before asserting that the archived application is absent, so the
negative assertion cannot pass on a list that has not rendered. No other
networkidle wait remains in the touched spec (git grep over tests/e2e shows
only comments explaining why it is avoided).
Closes thread 4248187506 (e2e cleanup comment): the header and the helper
doc no longer claim that an interrupted run leaves nothing behind or that
_includeDeleted adds the trash (a soft-deleted row cannot be listed, design
D9); objectsInLens now fails the test on a non-OK response instead of
returning an empty list that made cleanup skip silently.
Co-Authored-By: Claude Opus 5.5
---
tests/e2e/spec-coverage/cmdb-import.spec.ts | 31 +++++++++++++++------
1 file changed, 22 insertions(+), 9 deletions(-)
diff --git a/tests/e2e/spec-coverage/cmdb-import.spec.ts b/tests/e2e/spec-coverage/cmdb-import.spec.ts
index c0bd8d3e3..e86047e41 100644
--- a/tests/e2e/spec-coverage/cmdb-import.spec.ts
+++ b/tests/e2e/spec-coverage/cmdb-import.spec.ts
@@ -33,8 +33,10 @@
* moved APPID 1234 to "Gearchiveerde Applicaties" and dropped APPID 2 archives
* the one and moves the other to the trash, the Applications page lists 1234
* under "Archived", and the original export brings both back. The cleanup
- * looks through OpenRegister's archive and trash lenses too, so an
- * interrupted run leaves nothing archived behind.
+ * looks through OpenRegister's archive lens too, so an interrupted run
+ * leaves no archived application of the run's municipality behind; a
+ * soft-deleted one cannot be listed and stays in the trash until its purge
+ * date (design.md, D9).
*/
import type { APIRequestContext, Locator, Page, Response } from '@playwright/test'
@@ -140,7 +142,8 @@ async function objectsOfMunicipality(
/**
* The objects of a schema that mention a municipality, through OpenRegister's
* archive and trash lenses: `_archived` `true` lists the archive alone, `any`
- * the archive and the working set; `_includeDeleted` adds the trash.
+ * the archive and the working set. `_includeDeleted` is sent for completeness:
+ * the list cannot return a soft-deleted row even then (design.md, D9).
*
* @param ctx The API context
* @param schema The schema id
@@ -158,9 +161,10 @@ async function objectsInLens(
const res = await ctx.get(
`/index.php/apps/openregister/api/objects/${config.register}/${schema}?_limit=5000&_archived=${lens.archived}&_includeDeleted=${lens.includeDeleted}`,
)
- if (!res.ok()) {
- return []
- }
+ expect(
+ res.ok(),
+ `Listing ${schema} with _archived=${lens.archived} failed with ${res.status()}`,
+ ).toBe(true)
const body = await res.json()
const list = body?.results ?? body ?? []
return (Array.isArray(list) ? list : []).filter((row) =>
@@ -358,7 +362,8 @@ test.describe.serial('CMDB import section', () => {
config.contactpersoon_schema,
config.module_schema,
]) {
- // Archived and deleted objects too: the archive test may have stopped half-way.
+ // Archived objects too: the archive test may have stopped half-way. A
+ // soft-deleted one is not listable and stays in the trash until it is purged.
for (const row of await objectsInLens(
ctx,
schema,
@@ -560,8 +565,16 @@ test.describe.serial('CMDB import section', () => {
// 2. The Applications page lists 1234 only under the quick filter "Archived".
await gotoAppRoute(page, '/modules')
await dismissSupportDialog(page)
- // Not under the default tab: wait for the list to settle, then look.
- await page.waitForLoadState('networkidle')
+ // Not under the default tab. Nextcloud keeps polls open, so no network
+ // idle wait: wait until the list shows a working application (the
+ // export brought more than the two this test took out), then look.
+ expect(
+ working.modules,
+ 'the working set must still hold an application the list can show',
+ ).toBeGreaterThan(0)
+ await expect(page.locator('tbody tr').first()).toBeVisible({
+ timeout: 30000,
+ })
await expect(page.getByText(ARCHIVED_APPLICATION_NAME)).toHaveCount(0)
await page.getByRole('tab', { name: 'Archived', exact: true }).click()
await expect(page.getByText(ARCHIVED_APPLICATION_NAME).first()).toBeVisible({
From 4b70494f196b0827470980ea242d6df8c4a9db9a Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 19:07:37 +0200
Subject: [PATCH 084/124] fix(applications): say on the Archived tab that
facets and search are ignored
Closes "Archived tab keeps facet sidebar and search active but ignores
them": FacetedCatalogIndexView tracks the active quick-filter tab and shows
a note under the toolbar while a tab whose filter carries the empty _ids
override is active; en and nl strings and the generated l10n js are added.
A mounted view test asserts the note appears on the Archived tab, is gone on
another tab, and goes red with the note removed.
Closes "Archived-tab facet override is tested against a local merge, not the
view": the facet-override test now calls the library's own useSelfFetchList
and reads the fixedFilters getter it hands the list composable, instead of
restating the spread; only the list composable and store are replaced. It
goes red when the _ids override is removed from the manifest. The source
grep test that pinned the merge order is dropped as redundant.
Class sweep: the Services page uses the same view but has no quick filter
with the override, so it shows no note.
Co-Authored-By: Claude Opus 5.5
---
l10n/en.js | 3 +-
l10n/en.json | 3 +-
l10n/nl.js | 3 +-
l10n/nl.json | 3 +-
src/views/FacetedCatalogIndexView.vue | 49 +++++++++-
tests/vitest/cmdbArchiveQuickFilter.spec.js | 86 +++++++++++-----
.../vitest/facetedCatalogArchiveNote.spec.js | 98 +++++++++++++++++++
7 files changed, 214 insertions(+), 31 deletions(-)
create mode 100644 tests/vitest/facetedCatalogArchiveNote.spec.js
diff --git a/l10n/en.js b/l10n/en.js
index 613cb76f2..c4ee74c0a 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1153,7 +1153,8 @@ OC.L10N.register(
"{count} unarchived": "{count} unarchived",
"{count} deleted": "{count} deleted",
"{count} restored": "{count} restored",
- "Applications: {changes}.": "Applications: {changes}."
+ "Applications: {changes}.": "Applications: {changes}.",
+ "This tab lists the whole archive. It ignores the GEMMA filters and the search term.": "This tab lists the whole archive. It ignores the GEMMA filters and the search term."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index 29d312f53..22795a655 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1152,6 +1152,7 @@
"{count} unarchived": "{count} unarchived",
"{count} deleted": "{count} deleted",
"{count} restored": "{count} restored",
- "Applications: {changes}.": "Applications: {changes}."
+ "Applications: {changes}.": "Applications: {changes}.",
+ "This tab lists the whole archive. It ignores the GEMMA filters and the search term.": "This tab lists the whole archive. It ignores the GEMMA filters and the search term."
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index 1dc8b83d9..7407af21b 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1221,7 +1221,8 @@ OC.L10N.register(
"{count} unarchived": "{count} uit het archief gehaald",
"{count} deleted": "{count} verwijderd",
"{count} restored": "{count} teruggezet",
- "Applications: {changes}.": "Applicaties: {changes}."
+ "Applications: {changes}.": "Applicaties: {changes}.",
+ "This tab lists the whole archive. It ignores the GEMMA filters and the search term.": "Dit tabblad toont het hele archief. De GEMMA-filters en de zoekterm worden genegeerd."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index 7963d0ebc..26705400c 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1220,6 +1220,7 @@
"{count} unarchived": "{count} uit het archief gehaald",
"{count} deleted": "{count} verwijderd",
"{count} restored": "{count} teruggezet",
- "Applications: {changes}.": "Applicaties: {changes}."
+ "Applications: {changes}.": "Applicaties: {changes}.",
+ "This tab lists the whole archive. It ignores the GEMMA filters and the search term.": "Dit tabblad toont het hele archief. De GEMMA-filters en de zoekterm worden genegeerd."
}
}
diff --git a/src/views/FacetedCatalogIndexView.vue b/src/views/FacetedCatalogIndexView.vue
index 427245c47..cfb2737b2 100644
--- a/src/views/FacetedCatalogIndexView.vue
+++ b/src/views/FacetedCatalogIndexView.vue
@@ -92,6 +92,18 @@ generic route-query-to-filter passthrough never sees it (see the
+
+ {{
+ t(
+ 'stackiq',
+ 'This tab lists the whole archive. It ignores the GEMMA filters and the search term.',
+ )
+ }}
+
@@ -281,6 +294,7 @@ export default {
showSaveViewModal: false,
savingView: false,
searchDebounceTimer: null,
+ activeQuickFilterIndex: null,
}
},
@@ -340,6 +354,20 @@ export default {
)
},
+ /**
+ * Whether the active quick-filter tab drops the facet id set and the
+ * search term: a tab whose filter carries the empty-string `_ids`
+ * override (the Archived tab, see the `quickFilters` prop). The facet
+ * sidebar and the search box stay on screen, so the page says so.
+ *
+ * @return {boolean} True while such a tab is active.
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017
+ */
+ archivedTabIgnoresFilters() {
+ const tab = (this.quickFilters ?? [])[this.activeQuickFilterIndex]
+ return tab?.filter?._ids === ''
+ },
+
/**
* Remount key for `CnIndexPage`: its self-fetch mode has no reactive
* watcher on the `filter` prop (only route/quick-filter/tab changes
@@ -375,6 +403,18 @@ export default {
},
methods: {
+ /**
+ * Remember which quick-filter tab is active (single-select mode emits the
+ * tab index; multi-select emits an array, which never selects the
+ * Archived tab here).
+ *
+ * @param {number|number[]} index The tab index, or the selected indices
+ * @return {void}
+ */
+ onQuickFilterChange(index) {
+ this.activeQuickFilterIndex = Number.isInteger(index) ? index : null
+ },
+
/**
* Open the detail page of a clicked row, or of the row whose View action was used.
*
@@ -538,6 +578,13 @@ export default {
flex: 1;
}
+.faceted-catalog-index__archive-note {
+ color: var(--color-text-maxcontrast);
+ margin: 0;
+ padding: 0 calc(var(--default-grid-baseline, 8px) * 2)
+ calc(var(--default-grid-baseline, 8px) * 2);
+}
+
.faceted-catalog-index__body {
display: flex;
align-items: flex-start;
diff --git a/tests/vitest/cmdbArchiveQuickFilter.spec.js b/tests/vitest/cmdbArchiveQuickFilter.spec.js
index ff4df73ee..f93a42ba1 100644
--- a/tests/vitest/cmdbArchiveQuickFilter.spec.js
+++ b/tests/vitest/cmdbArchiveQuickFilter.spec.js
@@ -18,12 +18,34 @@ import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { describe, expect, it, vi } from 'vitest'
+import { effectScope } from 'vue'
// resolveFilterTokens reads the current user at import time, which needs a
// browser; the tokens this spec merges (`_archived`, `_ids`) resolve no user.
vi.mock('@nextcloud/auth', () => ({ getCurrentUser: () => null }))
+// useSelfFetchList is the real function; only the list composable it feeds its
+// `fixedFilters` getter to is replaced, so the test can read the options it got.
+const captured = vi.hoisted(() => ({ options: null }))
+vi.mock(
+ '../../node_modules/@conduction/nextcloud-vue/src/composables/index.js',
+ () => ({
+ useListView: (objectType, options) => {
+ captured.options = options
+ return { refresh: () => {}, searchTerm: { value: '' } }
+ },
+ }),
+)
+vi.mock(
+ '../../node_modules/@conduction/nextcloud-vue/src/composables/useObjectSubscription.js',
+ () => ({
+ useObjectSubscription: () => ({}),
+ }),
+)
+vi.mock('../../node_modules/@conduction/nextcloud-vue/src/store/index.js', () => ({
+ useObjectStore: () => ({ registerObjectType: () => {} }),
+}))
+import { useSelfFetchList } from '../../node_modules/@conduction/nextcloud-vue/src/components/CnIndexPage/useSelfFetchList.js'
// The REAL implementations from the installed package — not a local restatement.
import { buildQueryString } from '../../node_modules/@conduction/nextcloud-vue/src/utils/headers.js'
-import { resolveFilterMap } from '../../node_modules/@conduction/nextcloud-vue/src/utils/routeFilters.js'
import { facetNarrowingFilter } from '../../src/utils/facetNarrowing.js'
const here = path.dirname(fileURLToPath(import.meta.url))
@@ -74,41 +96,53 @@ describe('the archive quick filter', () => {
// matched) and merges the active tab's filter over it. OpenRegister leaves
// archived objects out of that id set, so `_ids` AND `_archived=true` lists
// nothing: with a facet selected, the Archived tab must not send `_ids`.
+ // The merge is the library's own: useSelfFetchList hands useListView a
+ // `fixedFilters` getter, which is called here exactly as a fetch calls it.
it('sends no _ids with the Archived tab when a facet narrowed the list', () => {
const page = pages().find((candidate) => candidate.id === 'Modules')
- const archived = page.config.quickFilters.find(
+ const quickFilters = page.config.quickFilters
+ const archivedIndex = quickFilters.findIndex(
(tab) => tab.label === 'Archived',
)
const facetFilter = facetNarrowingFilter(['live-1', 'live-2'])
- // The merge useSelfFetchList#fixedFilters does in single-tab mode:
- // the page's filter, then the active tab's filter spread last.
- const archivedQuery = buildQueryString({
- ...facetFilter,
- ...resolveFilterMap(archived.filter, {}, {}),
- })
- const allQuery = buildQueryString({
- ...facetFilter,
- ...resolveFilterMap({}, {}, {}),
- })
+ /**
+ * The query string the library builds for the list with one tab active.
+ *
+ * @param {number|null} tabIndex The active quick-filter tab
+ * @return {string} The query string
+ */
+ function queryWithTab(tabIndex) {
+ const scope = effectScope()
+ let fixedFilters
+ captured.options = null
+ scope.run(() => {
+ const result = useSelfFetchList(
+ {
+ register: 'voorzieningen',
+ schema: 'module',
+ filter: facetFilter,
+ quickFilters,
+ columns: [],
+ },
+ null,
+ () => null,
+ )
+ result.activeQuickFilterIndex.value = tabIndex
+ fixedFilters = captured.options.fixedFilters
+ })
+ const query = buildQueryString(fixedFilters())
+ scope.stop()
+ return query
+ }
+
+ const archivedQuery = queryWithTab(archivedIndex)
+ const otherQuery = queryWithTab(archivedIndex === 0 ? 1 : 0)
expect(new URLSearchParams(archivedQuery).get('_archived')).toBe('true')
expect(archivedQuery).not.toContain('_ids')
// A tab without the override keeps the facet narrowing.
- expect(allQuery).toContain('_ids')
- })
-
- it('still merges the active tab last, which the override above relies on', () => {
- const source = fs.readFileSync(
- path.join(
- repoRoot,
- 'node_modules/@conduction/nextcloud-vue/src/components/CnIndexPage/useSelfFetchList.js',
- ),
- 'utf8',
- )
- expect(source).toMatch(
- /\.\.\.base, \.\.\.resolveFilterMap\(tabFilter, params, ctx\)/,
- )
+ expect(otherQuery).toContain('_ids')
})
it('the Modules page note names the Archived tab as the exception to the additive merge', () => {
diff --git a/tests/vitest/facetedCatalogArchiveNote.spec.js b/tests/vitest/facetedCatalogArchiveNote.spec.js
new file mode 100644
index 000000000..53f397737
--- /dev/null
+++ b/tests/vitest/facetedCatalogArchiveNote.spec.js
@@ -0,0 +1,98 @@
+// @vitest-environment jsdom
+/**
+ * The Archived tab of the Applications page says it ignores the facets and
+ * the search term.
+ *
+ * The facet sidebar and the search box stay on screen on that tab, but its
+ * filter carries an empty `_ids` that drops the facet id set. Without a note
+ * an admin who picks a facet sees an unchanged list and no reason.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+import { mount } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import { describe, expect, it, vi } from 'vitest'
+import { defineComponent, h } from 'vue'
+
+// CnIndexPage stands in as a component that emits what the real one emits
+// when a quick-filter tab is chosen.
+vi.mock('@conduction/nextcloud-vue', () => ({
+ CnFacetSidebar: defineComponent({
+ name: 'CnFacetSidebar',
+ render: () => h('div'),
+ }),
+ CnIndexPage: defineComponent({
+ name: 'CnIndexPage',
+ emits: ['quick-filter-change'],
+ render() {
+ return h('div', { class: 'index-page-stub' })
+ },
+ }),
+}))
+vi.mock('../../src/modals/SaveFacetViewModal.vue', () => ({
+ default: defineComponent({ name: 'SaveFacetViewModal', render: () => h('div') }),
+}))
+
+import FacetedCatalogIndexView from '../../src/views/FacetedCatalogIndexView.vue'
+
+const NOTE = '.faceted-catalog-index__archive-note'
+const QUICK_FILTERS = [
+ { label: 'All', filter: {}, default: true },
+ { label: 'Archived', filter: { _archived: 'true', _ids: '' } },
+]
+
+/**
+ * Mount the view with the facet store's network calls silenced.
+ *
+ * @return {object} The wrapper
+ */
+function mountView() {
+ setActivePinia(createPinia())
+ return mount(FacetedCatalogIndexView, {
+ props: {
+ schema: 'module',
+ register: 'voorzieningen',
+ title: 'Applications',
+ quickFilters: QUICK_FILTERS,
+ },
+ global: {
+ mocks: {
+ $route: { query: {} },
+ $router: { push: vi.fn() },
+ t: (app, text) => text,
+ },
+ stubs: {
+ NcTextField: true,
+ NcButton: true,
+ NcActions: true,
+ NcActionButton: true,
+ NcActionCaption: true,
+ },
+ plugins: [
+ (app) => {
+ app.config.globalProperties.t = (a, text) => text
+ },
+ ],
+ },
+ })
+}
+
+describe('FacetedCatalogIndexView on the Archived tab', () => {
+ it('shows the note only while the Archived tab is active', async () => {
+ const wrapper = mountView()
+ const index = wrapper.findComponent({ name: 'CnIndexPage' })
+
+ expect(wrapper.find(NOTE).exists()).toBe(false)
+
+ await index.vm.$emit('quick-filter-change', 1)
+ expect(wrapper.find(NOTE).exists()).toBe(true)
+ expect(wrapper.find(NOTE).text()).toContain('ignores the GEMMA filters')
+
+ await index.vm.$emit('quick-filter-change', 0)
+ expect(wrapper.find(NOTE).exists()).toBe(false)
+ })
+})
From 3c17b477431b75c366a2ffc4b5665494817eac5f Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 19:07:37 +0200
Subject: [PATCH 085/124] docs(cmdb-import): tick the UI tasks and document the
archive tab note and unreadable CMDB APPIDs
Makes tasks.md true for this PR after the merge of the reconcile base:
the status line names #1286 for tasks 1-5 and this PR for task 6 (ticked,
with the new Archived-tab note as an acceptance line); task 7 has its
implementation ticked and its Playwright run still open.
docs/features/cmdb-import.md already described archive and soft delete (the
old "left as they are" sentence only remains for the Keep choice); it now
also says that an APPID without a cached value on a CMDB sheet archives
instead of deleting, and that the Archived tab shows a note about the
ignored facets and search.
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 4 ++--
.../changes/cmdb-import-archive-reconciliation/tasks.md | 9 +++++----
2 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index c4d839903..e71756d73 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -277,13 +277,13 @@ APPID is on neither CMDB sheet:
|---|---|
| on the sheet `Gearchiveerde Applicaties` | are **archived**: OpenRegister's archive state, with the reason `cmdb-import: on sheet "Gearchiveerde Applicaties"`. Reported as *archived*. |
| on no sheet at all | are **deleted**: moved to OpenRegister's trash, where they stay restorable for 30 days. Reported as *deleted*. |
-| on no sheet, but the archive sheet cannot be relied on: the workbook has no sheet `Gearchiveerde Applicaties`, the sheet has no `APPID` column, or an `APPID` in it has no cached value (a formula whose result the file does not store) | are **archived**, never deleted. Each case has its own warning in the result: *sheet not found*, *no column APPID*, or *an APPID without a cached value in row …* (for more rows: *in N rows (…)*, naming the first ten). Such a workbook therefore never moves anything to the trash. |
+| on no sheet, but the archive sheet cannot be relied on: the workbook has no sheet `Gearchiveerde Applicaties`, the sheet has no `APPID` column, or an `APPID` in it has no cached value (a formula whose result the file does not store), or an `APPID` on one of the two CMDB sheets has no cached value (the import cannot tell which application that row stands for) | are **archived**, never deleted. Each case has its own warning in the result: *sheet not found*, *no column APPID*, or *an APPID without a cached value in row …* (for more rows: *in N rows (…)*, naming the first ten; for a CMDB sheet the warning names that sheet). Such a workbook therefore never moves anything to the trash. |
An archived or deleted application disappears from the stackiq lists, from
OpenCatalogi and from Portaliq's **Software we use**. In stackiq, the quick
filter **Archived** on the **Applications** and **Applications in use** pages
lists the archive. On the **Applications** page the **Archived** tab lists
-the whole archive: it ignores the GEMMA filters and the search box, because
+the whole archive: it ignores the GEMMA filters and the search box (the page says so in a note under the search box while that tab is active), because
their counts and matches describe the working set, which leaves archived
applications out. An OpenCatalogi caller finds archived applications by
adding `_archived=true` to its request. Contact persons are never archived
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
index b75a1fb11..76d347861 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
@@ -4,7 +4,7 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp
## Implementation Tasks
-Status in this PR (the reconciliation slice, on top of the reader slice): Tasks 1-5 are implemented here (the reader's tasks 1-2 come from the base PR). Tasks 6-7 stay unticked: the UI lands in #1287, and the docs and the e2e run of task 7 are not done yet.
+Status in this PR (the reconciliation slice, on top of the reader slice): Tasks 1-5 are implemented in #1286 (the reader's tasks 1-2 come from the base PR). Task 6 (the UI) is implemented in #1287; task 7 has its fixture, docs and e2e spec written there, and the Playwright run is the one thing not done yet.
### Task 1: Schema opt-in and the import profile
- **spec_ref**: `SPEC#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017`, `SPEC#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012`
@@ -71,9 +71,10 @@ Status in this PR (the reconciliation slice, on top of the reader slice): Tasks
- GIVEN the section THEN a labelled choice "Applications missing from the export" offers Archive (default) and Keep with help text, and `buildImportForm()` sends the choice as `missingRecords`
- GIVEN a report with the new outcomes THEN the summary shows their tiles, the outcome filter offers them, and a reconciliation row shows no row number
- GIVEN the Applications and Applications in use pages THEN a quick filter "Archived" sends `_archived=true`
+ - GIVEN the Applications page with the "Archived" tab active THEN a note under the toolbar says the tab ignores the GEMMA filters and the search term, and it is gone on any other tab
- Every new string is in `l10n/en.json` and `l10n/nl.json`, and the generated `l10n/*.js` are current
-- [ ] Implement
-- [ ] Test
+- [x] Implement
+- [x] Test
### Task 7: Fixture, docs and e2e
- **spec_ref**: `SPEC#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015`
@@ -82,7 +83,7 @@ Status in this PR (the reconciliation slice, on top of the reader slice): Tasks
- GIVEN `build-fixtures.py` THEN it derives `topdesk-archived-applications.xlsx`: APPID 1234 on the archive sheet only, APPID 2 on no sheet, both CMDB sheets present and empty; the hygiene test passes on it
- GIVEN the e2e THEN after the first import, importing the variant archives 1234 and soft-deletes 2, the Applications page lists 1234 under "Archived", and importing the original again reports 1234 `unarchived` and 2 `restored` with the object counts back to the first import's
- GIVEN the docs THEN "Repeat imports" describes archive, soft delete, the archive sheet, the option and the return of an application, and the error table has `ARCHIVE_UNAVAILABLE` and the new meaning of `MISSING_RECORDS_UNSUPPORTED`
-- [ ] Implement
+- [x] Implement
- [ ] Test
- Status: PHPUnit covers the fixture round trip (testTheArchivedApplicationsFixtureArchivesDeletesAndBringsBack); the Playwright test is written and not run yet, because no Nextcloud was reachable from the worktree.
From 49bc84b933964784cbeff11b1e8e93a4576a3d14 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 19:11:45 +0200
Subject: [PATCH 086/124] fix(cmdb-import): announce the mapping block's
loading state, and tidy its tests
- The loading line of the Mapping block is a status region, so a screen
reader hears that the mapping is loading.
- The block styles its help and code lines with its own classes instead
of copies of the parent section's.
- The e2e help check for the shipped sheet names runs only after the
error card shows, so the loading state cannot satisfy it.
- The first-run-wizard helper is shared from spec-coverage/_helpers.ts.
- The e2e header says why nothing is written.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../settings/sections/CmdbImportMapping.vue | 13 ++---
tests/e2e/spec-coverage/_helpers.ts | 35 ++++++++++++++
.../spec-coverage/cmdb-import-mapping.spec.ts | 47 +++++--------------
tests/e2e/spec-coverage/cmdb-import.spec.ts | 36 +-------------
4 files changed, 54 insertions(+), 77 deletions(-)
diff --git a/src/views/settings/sections/CmdbImportMapping.vue b/src/views/settings/sections/CmdbImportMapping.vue
index 9140a58bc..b9c089b18 100644
--- a/src/views/settings/sections/CmdbImportMapping.vue
+++ b/src/views/settings/sections/CmdbImportMapping.vue
@@ -22,7 +22,7 @@
v-show="expanded"
id="cmdb-import-mapping-content"
class="cmdb-import-mapping__content">
-
+
{{
t(
'stackiq',
@@ -34,6 +34,7 @@
{{ t('stackiq', 'Loading the mapping…') }}
@@ -70,7 +71,7 @@
{{ t('stackiq', 'Edit in OpenRegister') }}
-
+ class="cmdb-import-mapping__help">
{{
t(
'stackiq',
@@ -425,13 +426,13 @@ export default {
margin-top: 0.5rem;
}
-.cmdb-import__help {
+.cmdb-import-mapping__help {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: var(--color-text-maxcontrast);
}
-.cmdb-import__code {
+.cmdb-import-mapping__code {
font-size: 0.875rem;
color: var(--color-text-maxcontrast);
}
diff --git a/tests/e2e/spec-coverage/_helpers.ts b/tests/e2e/spec-coverage/_helpers.ts
index 97210bfe8..cb11d1140 100644
--- a/tests/e2e/spec-coverage/_helpers.ts
+++ b/tests/e2e/spec-coverage/_helpers.ts
@@ -285,3 +285,38 @@ export async function expectIndexSurface(
const populated = main.getByText(/Showing\s+\d+\s+of\s+\d+/i).first()
await expect(emptyState.or(populated)).toBeVisible({ timeout: 30000 })
}
+
+/**
+ * Get Nextcloud's own first-run wizard out of the way.
+ *
+ * It opens on an admin's first visit to a fresh instance, and its modal mask
+ * intercepts every click, so the chooser below would time out on a click
+ * that reads like a broken select. It has no close button and ignores
+ * Escape until its last slide, so it is marked as seen through its own
+ * route (what finishing it does) and the page is loaded again. A bounded
+ * wait, because the wizard mounts after the page.
+ *
+ * @param page The page
+ * @return True when the page was reloaded
+ */
+export async function dismissFirstRunWizard(page: Page): Promise {
+ const wizard = page.locator('.first-run-wizard[role="dialog"]')
+ try {
+ await wizard.waitFor({ state: 'visible', timeout: 3000 })
+ } catch {
+ return false
+ }
+ await page.evaluate(async () => {
+ const oc = (
+ window as unknown as {
+ OC: { requestToken: string; generateUrl: (u: string) => string }
+ }
+ ).OC
+ await fetch(oc.generateUrl('/apps/firstrunwizard/wizard'), {
+ method: 'DELETE',
+ headers: { requesttoken: oc.requestToken },
+ })
+ })
+ await page.reload({ waitUntil: 'domcontentloaded' })
+ return true
+}
diff --git a/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts b/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
index 05a66bd4d..cc8c2d3f8 100644
--- a/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
+++ b/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
@@ -6,20 +6,23 @@
*
* Every scenario the delta spec tags
* `@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts` is driven here
- * on the real settings page: one line per pack with its source, its version
- * and the link to its editor in OpenRegister, the link on the error of a
- * broken stored pack, and the sheet names in the help text. The error states
+ * on the real settings page: the tables of the export and of the packs, each
+ * pack with its source, its version and the link to its editor in
+ * OpenRegister, the link on the error of a broken stored pack, and the sheet
+ * names in the help text. The error states
* come from a route intercept, so no pack is broken on a shared instance.
* The endpoint itself, the import and the 403 for a non-admin are driven
* through the API in cmdb-import-packs.spec.ts.
*
- * Nothing is written.
+ * Nothing is written: the page reads the mapping, and reading it does not
+ * seed or update a pack (cmdb-import-packs.spec.ts checks that).
*/
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { newApiContext, RUN_ID } from '../workflows/_fixtures.ts'
+import { dismissFirstRunWizard } from './_helpers.ts'
const MAPPING_PATH = '/index.php/apps/stackiq/api/settings/cmdb-import/mapping'
// The page builds its URL with generateUrl(), which drops `/index.php` on an
@@ -28,35 +31,6 @@ const MAPPING_ROUTE = '**/apps/stackiq/api/settings/cmdb-import/mapping'
const SHEET_PACKS = ['module', 'manufacturer', 'usage', 'businessOwner']
const SHIPPED_SHEETS = ['Onbeh Applicaties CMDB', 'Beheerde Applicaties CMDB']
-/**
- * Close Nextcloud's first-run wizard when it shows, the way
- * cmdb-import.spec.ts does: mark it as seen and load the page again.
- *
- * @param page The page
- * @return True when the page was reloaded
- */
-async function dismissFirstRunWizard(page: Page): Promise {
- const wizard = page.locator('.first-run-wizard[role="dialog"]')
- try {
- await wizard.waitFor({ state: 'visible', timeout: 3000 })
- } catch {
- return false
- }
- await page.evaluate(async () => {
- const oc = (
- window as unknown as {
- OC: { requestToken: string; generateUrl: (u: string) => string }
- }
- ).OC
- await fetch(oc.generateUrl('/apps/firstrunwizard/wizard'), {
- method: 'DELETE',
- headers: { requesttoken: oc.requestToken },
- })
- })
- await page.reload({ waitUntil: 'domcontentloaded' })
- return true
-}
-
/**
* Open stackiq's admin settings and return the CMDB import section.
*
@@ -193,12 +167,13 @@ test.describe('CMDB import mapping block', () => {
}),
)
const section = await gotoCmdbSection(page)
- for (const sheet of SHIPPED_SHEETS) {
- await expect(help).toContainText(sheet)
- }
await section.locator('[data-testid="cmdb-import-mapping-toggle"]').click()
const error = section.locator('[data-testid="cmdb-import-mapping-error"]')
await expect(error).toBeVisible()
+ // Only now: the shipped names also show while the request is in flight.
+ for (const sheet of SHIPPED_SHEETS) {
+ await expect(help).toContainText(sheet)
+ }
await expect(error).toContainText('MAPPING_UNAVAILABLE')
await expect(error).toContainText('topdesk-usage.json: refused in this test')
await expect(
diff --git a/tests/e2e/spec-coverage/cmdb-import.spec.ts b/tests/e2e/spec-coverage/cmdb-import.spec.ts
index 6251f2d8d..e59c7b928 100644
--- a/tests/e2e/spec-coverage/cmdb-import.spec.ts
+++ b/tests/e2e/spec-coverage/cmdb-import.spec.ts
@@ -45,6 +45,7 @@ import {
resolveConfig,
RUN_ID,
} from '../workflows/_fixtures.ts'
+import { dismissFirstRunWizard } from './_helpers.ts'
const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures/cmdb')
const EXPORT_FIXTURE = path.join(FIXTURES_DIR, 'topdesk-export-anonymised.xlsx')
@@ -161,41 +162,6 @@ async function countWritten(ctx: APIRequestContext): Promise<{
}
}
-/**
- * Get Nextcloud's own first-run wizard out of the way.
- *
- * It opens on an admin's first visit to a fresh instance, and its modal mask
- * intercepts every click, so the chooser below would time out on a click
- * that reads like a broken select. It has no close button and ignores
- * Escape until its last slide, so it is marked as seen through its own
- * route (what finishing it does) and the page is loaded again. A bounded
- * wait, because the wizard mounts after the page.
- *
- * @param page The page
- * @return True when the page was reloaded
- */
-async function dismissFirstRunWizard(page: Page): Promise {
- const wizard = page.locator('.first-run-wizard[role="dialog"]')
- try {
- await wizard.waitFor({ state: 'visible', timeout: 3000 })
- } catch {
- return false
- }
- await page.evaluate(async () => {
- const oc = (
- window as unknown as {
- OC: { requestToken: string; generateUrl: (u: string) => string }
- }
- ).OC
- await fetch(oc.generateUrl('/apps/firstrunwizard/wizard'), {
- method: 'DELETE',
- headers: { requesttoken: oc.requestToken },
- })
- })
- await page.reload({ waitUntil: 'domcontentloaded' })
- return true
-}
-
/**
* Open stackiq's admin settings and return the CMDB import section.
*
From 1719b19ee8def2633ba9fc35e7fa4f8df10f2ad5 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 19:17:39 +0200
Subject: [PATCH 087/124] fix(applications): keep the Archived-tab note in step
with the list's tab
The view started with no known tab and only learned of a change from the
tab's click, but it remounts CnIndexPage whenever the facet or search key
changes, which puts the page back on its default tab; the note then showed
over the default list. The view now starts from the tab the library starts
on (first quick filter marked default, else the first) and returns to it
whenever the remount key changes.
Tests: the note follows the default tab at mount, and disappears when a
search change remounts the list while the Archived tab is active; the
remount test goes red without the reset.
The e2e wait for a listed application is scoped to the Applications list
container, so a row of another table cannot satisfy it.
Co-Authored-By: Claude Opus 5.5
---
src/views/FacetedCatalogIndexView.vue | 29 ++++++++++++++++++-
tests/e2e/spec-coverage/cmdb-import.spec.ts | 4 ++-
.../vitest/facetedCatalogArchiveNote.spec.js | 29 +++++++++++++++++--
3 files changed, 58 insertions(+), 4 deletions(-)
diff --git a/src/views/FacetedCatalogIndexView.vue b/src/views/FacetedCatalogIndexView.vue
index cfb2737b2..6cb3f8aec 100644
--- a/src/views/FacetedCatalogIndexView.vue
+++ b/src/views/FacetedCatalogIndexView.vue
@@ -171,6 +171,21 @@ const DIMENSION_LABELS = {
domain: () => t('stackiq', 'Domain'),
}
+/**
+ * The tab CnIndexPage starts on: the first quick filter marked `default`,
+ * else the first one (the library's own rule in `useSelfFetchList.js`).
+ *
+ * @param {Array|null} quickFilters The quick filters
+ * @return {number|null} The index, or null without quick filters
+ */
+function initialQuickFilterIndex(quickFilters) {
+ if (!Array.isArray(quickFilters) || quickFilters.length === 0) {
+ return null
+ }
+ const index = quickFilters.findIndex((tab) => tab && tab.default === true)
+ return index >= 0 ? index : 0
+}
+
/** Debounce delay (ms) between search keystrokes and the facets/list refetch. */
const SEARCH_DEBOUNCE_MS = 400
@@ -294,7 +309,7 @@ export default {
showSaveViewModal: false,
savingView: false,
searchDebounceTimer: null,
- activeQuickFilterIndex: null,
+ activeQuickFilterIndex: initialQuickFilterIndex(this.quickFilters),
}
},
@@ -383,6 +398,18 @@ export default {
},
},
+ watch: {
+ /**
+ * CnIndexPage is remounted when the key changes and starts on its
+ * default tab again, so the tab this view remembers goes back to it.
+ *
+ * @return {void}
+ */
+ indexPageKey() {
+ this.activeQuickFilterIndex = initialQuickFilterIndex(this.quickFilters)
+ },
+ },
+
created() {
this.facetStore.setFiltersFromQuery(this.schema, this.$route.query)
},
diff --git a/tests/e2e/spec-coverage/cmdb-import.spec.ts b/tests/e2e/spec-coverage/cmdb-import.spec.ts
index e86047e41..b10deda29 100644
--- a/tests/e2e/spec-coverage/cmdb-import.spec.ts
+++ b/tests/e2e/spec-coverage/cmdb-import.spec.ts
@@ -572,7 +572,9 @@ test.describe.serial('CMDB import section', () => {
working.modules,
'the working set must still hold an application the list can show',
).toBeGreaterThan(0)
- await expect(page.locator('tbody tr').first()).toBeVisible({
+ await expect(
+ page.locator('.faceted-catalog-index__list tbody tr').first(),
+ ).toBeVisible({
timeout: 30000,
})
await expect(page.getByText(ARCHIVED_APPLICATION_NAME)).toHaveCount(0)
diff --git a/tests/vitest/facetedCatalogArchiveNote.spec.js b/tests/vitest/facetedCatalogArchiveNote.spec.js
index 53f397737..23060dc1e 100644
--- a/tests/vitest/facetedCatalogArchiveNote.spec.js
+++ b/tests/vitest/facetedCatalogArchiveNote.spec.js
@@ -50,14 +50,14 @@ const QUICK_FILTERS = [
*
* @return {object} The wrapper
*/
-function mountView() {
+function mountView(quickFilters = QUICK_FILTERS) {
setActivePinia(createPinia())
return mount(FacetedCatalogIndexView, {
props: {
schema: 'module',
register: 'voorzieningen',
title: 'Applications',
- quickFilters: QUICK_FILTERS,
+ quickFilters,
},
global: {
mocks: {
@@ -95,4 +95,29 @@ describe('FacetedCatalogIndexView on the Archived tab', () => {
await index.vm.$emit('quick-filter-change', 0)
expect(wrapper.find(NOTE).exists()).toBe(false)
})
+
+ it('follows the default tab at mount, before any tab event', () => {
+ expect(mountView().find(NOTE).exists()).toBe(false)
+
+ const archivedFirst = [
+ { label: 'Archived', filter: { _archived: 'true', _ids: '' } },
+ { label: 'All', filter: {} },
+ ]
+ // No default marked: CnIndexPage starts on the first tab.
+ expect(mountView(archivedFirst).find(NOTE).exists()).toBe(true)
+ })
+
+ it('drops the note when a facet or search change remounts the list on its default tab', async () => {
+ const wrapper = mountView()
+ const index = wrapper.findComponent({ name: 'CnIndexPage' })
+ await index.vm.$emit('quick-filter-change', 1)
+ expect(wrapper.find(NOTE).exists()).toBe(true)
+
+ // A search term changes the key CnIndexPage is mounted under.
+ wrapper.vm.facetStore.module.search = 'mail'
+ await wrapper.vm.$nextTick()
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.find(NOTE).exists()).toBe(false)
+ })
})
From e2081bba37f35622a051ba91ce55dc288f517a5d Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 19:20:22 +0200
Subject: [PATCH 088/124] fix(cmdb-import): type the import's pack callback as
never null
resolve() always answers a pack, so the closure load() hands to
loadPacks() returns array, not ?array (phpstan return.unusedType).
Co-Authored-By: Claude Opus 5.5 (1M context)
---
lib/Service/Cmdb/CmdbImportProfile.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/Service/Cmdb/CmdbImportProfile.php b/lib/Service/Cmdb/CmdbImportProfile.php
index c945cedfe..f097ea7c6 100644
--- a/lib/Service/Cmdb/CmdbImportProfile.php
+++ b/lib/Service/Cmdb/CmdbImportProfile.php
@@ -200,7 +200,7 @@ public function __construct(
* @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
*/
public function load(): void {
- $this->loadPacks(storedPack: fn (CmdbImportPackStore $store, array $shipped): ?array => $store->resolve(shipped: $shipped));
+ $this->loadPacks(storedPack: fn (CmdbImportPackStore $store, array $shipped): array => $store->resolve(shipped: $shipped));
}//end load()
/**
From 4fd91944972ec373b69611dd0a82b4431d8cfbeb Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 20:42:08 +0200
Subject: [PATCH 089/124] docs(spec): cover the contact person page and the
list Name column in REQ-UAP-005
Closes review finding f2: the two new name surfaces had no scenario and
their @spec tags pointed at REQ-UAP-003 (usage owners). REQ-UAP-005 states
who sees the name and the empty states; ContactNameCell is retagged.
Class sweep: every @spec tag of ContactPersonName, contactNames.js (new
export) and ContactNameCell now targets REQ-UAP-005 or REQ-UAP-003 by
surface.
Co-Authored-By: Claude Opus 5.5
---
.../specs/application-usage-pages/spec.md | 22 +++++++++++++++++++
src/components/cells/ContactNameCell.vue | 6 ++---
2 files changed, 25 insertions(+), 3 deletions(-)
diff --git a/openspec/specs/application-usage-pages/spec.md b/openspec/specs/application-usage-pages/spec.md
index e022b84f7..36dee9624 100644
--- a/openspec/specs/application-usage-pages/spec.md
+++ b/openspec/specs/application-usage-pages/spec.md
@@ -65,3 +65,25 @@ The usage schema SHALL declare its lifecycle on the status values its rows hold,
- **GIVEN** a usage with status Planned
- **WHEN** the information manager opens it and clicks Go live
- **THEN** its status reads In production
+
+### Requirement: REQ-UAP-005 A contact person's name shows only to who can read the contact
+
+The contact person page and the Name column of an organisation's contact person list SHALL show the name of the linked Nextcloud contact only to a user who can read that contact in their own or shared address books, and SHALL say why there is no name when there is none.
+
+#### Scenario: A reader of the contact sees the name
+@e2e exclude Needs a Contacts address book per user; tests/vitest/contactNames.spec.js asserts the name on the contact person page and in the list column.
+
+- **GIVEN** a contact person linked to a contact in the information manager's own address book
+- **WHEN** the information manager opens the contact person, or the organisation it belongs to
+- **THEN** the contact person page opens with the contact's name
+- **AND** the Name column of the organisation's contact person list shows it
+
+#### Scenario: Without a name the page says why
+@e2e exclude Needs a Contacts address book per user; tests/vitest/contactNames.spec.js asserts each empty state.
+
+- **GIVEN** a contact person whose name the signed-in user does not get
+- **WHEN** the user opens the contact person page
+- **THEN** a contact person without a contactsUid reads that it is not linked to a Nextcloud contact
+- **AND** a contact in an address book the user cannot read reads that the name is kept in Nextcloud Contacts, in an address book the user cannot read
+- **AND** a request that failed (for example rate limited) reads that the name could not be loaded, to reload the page
+- **AND** the list column keeps the contact person's id in each of these cases
diff --git a/src/components/cells/ContactNameCell.vue b/src/components/cells/ContactNameCell.vue
index e8ef534dc..5bd2f487a 100644
--- a/src/components/cells/ContactNameCell.vue
+++ b/src/components/cells/ContactNameCell.vue
@@ -9,7 +9,7 @@ Nextcloud contact instead of its uuid. The name comes from the signed-in
user's own address books (see `utils/contactNames.js`); when there is none,
the uuid stays, as before.
-@spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+@spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-005-a-contact-persons-name-shows-only-to-who-can-read-the-contact
-->
@@ -41,7 +41,7 @@ export default {
computed: {
/**
* @return {string} The contact person uuid, or ''.
- * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-005-a-contact-persons-name-shows-only-to-who-can-read-the-contact
*/
id() {
return contactPersonId(this.value)
@@ -56,7 +56,7 @@ export default {
*
* @param {string} id The contact person uuid.
* @return {Promise}
- * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-005-a-contact-persons-name-shows-only-to-who-can-read-the-contact
*/
async handler(id) {
this.name = null
From e49e441c1a4dcab9d0e695f6b11bba67d4b6da2c Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 20:42:08 +0200
Subject: [PATCH 090/124] fix(contacts): say why a contact person has no name
Closes review finding f1: the page showed 'in an address book you cannot
read' for every empty name. contactNameResult() now tells a failed request
from an answer without a name, and the page gets contactsUid from the
object, so it says: not linked, could not be loaded (reload), or the
address-book sentence only after a successful empty answer.
Class sweep: ContactNameCell shows no hint text (it keeps the uuid), so it
needs no change.
Co-Authored-By: Claude Opus 5.5
---
l10n/en.js | 2 +
l10n/en.json | 2 +
l10n/nl.js | 2 +
l10n/nl.json | 2 +
.../organisations/ContactPersonName.vue | 58 +++++++++++++++----
src/manifest.json | 2 +-
src/utils/contactNames.js | 29 +++++++---
7 files changed, 76 insertions(+), 21 deletions(-)
diff --git a/l10n/en.js b/l10n/en.js
index 238aa4aa3..91ff6d4a8 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1136,6 +1136,8 @@ OC.L10N.register(
"Not signed in": "Not signed in",
"At most %s contact persons per request": "At most %s contact persons per request",
"Loading the name": "Loading the name",
+ "The name could not be loaded. Reload the page to try again.": "The name could not be loaded. Reload the page to try again.",
+ "This contact person is not linked to a Nextcloud contact.": "This contact person is not linked to a Nextcloud contact.",
"The name is kept in Nextcloud Contacts, in an address book you cannot read.": "The name is kept in Nextcloud Contacts, in an address book you cannot read."
},
"nplurals=2; plural=(n != 1);"
diff --git a/l10n/en.json b/l10n/en.json
index 7337c9fbb..21c5b2051 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1135,6 +1135,8 @@
"Not signed in": "Not signed in",
"At most %s contact persons per request": "At most %s contact persons per request",
"Loading the name": "Loading the name",
+ "The name could not be loaded. Reload the page to try again.": "The name could not be loaded. Reload the page to try again.",
+ "This contact person is not linked to a Nextcloud contact.": "This contact person is not linked to a Nextcloud contact.",
"The name is kept in Nextcloud Contacts, in an address book you cannot read.": "The name is kept in Nextcloud Contacts, in an address book you cannot read."
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index d49692144..dfcf9ecae 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1204,6 +1204,8 @@ OC.L10N.register(
"Not signed in": "Niet aangemeld",
"At most %s contact persons per request": "Ten hoogste %s contactpersonen per verzoek",
"Loading the name": "De naam wordt geladen",
+ "The name could not be loaded. Reload the page to try again.": "De naam kon niet worden geladen. Laad de pagina opnieuw om het nog eens te proberen.",
+ "This contact person is not linked to a Nextcloud contact.": "Deze contactpersoon is niet gekoppeld aan een Nextcloud-contact.",
"The name is kept in Nextcloud Contacts, in an address book you cannot read.": "De naam staat in Nextcloud Contacten, in een adresboek dat je niet kunt lezen."
},
"nplurals=2; plural=(n != 1);"
diff --git a/l10n/nl.json b/l10n/nl.json
index edd44c807..ced7d91df 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1203,6 +1203,8 @@
"Not signed in": "Niet aangemeld",
"At most %s contact persons per request": "Ten hoogste %s contactpersonen per verzoek",
"Loading the name": "De naam wordt geladen",
+ "The name could not be loaded. Reload the page to try again.": "De naam kon niet worden geladen. Laad de pagina opnieuw om het nog eens te proberen.",
+ "This contact person is not linked to a Nextcloud contact.": "Deze contactpersoon is niet gekoppeld aan een Nextcloud-contact.",
"The name is kept in Nextcloud Contacts, in an address book you cannot read.": "De naam staat in Nextcloud Contacten, in een adresboek dat je niet kunt lezen."
}
}
diff --git a/src/components/organisations/ContactPersonName.vue b/src/components/organisations/ContactPersonName.vue
index 5e2ddc3ea..ab7ead2c6 100644
--- a/src/components/organisations/ContactPersonName.vue
+++ b/src/components/organisations/ContactPersonName.vue
@@ -20,13 +20,11 @@
{{ name }}
-
- {{
- t(
- 'stackiq',
- 'The name is kept in Nextcloud Contacts, in an address book you cannot read.',
- )
- }}
+
+ {{ hint }}
@@ -37,7 +35,7 @@
import { CnIcon, CnWidgetWrapper } from '@conduction/nextcloud-vue'
import { translate as t } from '@nextcloud/l10n'
import { NcLoadingIcon } from '@nextcloud/vue'
-import { contactName } from '../../utils/contactNames.js'
+import { contactNameResult } from '../../utils/contactNames.js'
/**
* The name of a contact person, on its detail page.
@@ -47,7 +45,7 @@ import { contactName } from '../../utils/contactNames.js'
* signed-in user's own and shared address books, so the name is shown to whoever
* can already read the contact, and to nobody else.
*
- * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-005-a-contact-persons-name-shows-only-to-who-can-read-the-contact
*/
export default {
name: 'ContactPersonName',
@@ -64,15 +62,49 @@ export default {
type: String,
required: true,
},
+
+ /** The Nextcloud contact the person is linked to; empty when not linked. */
+ contactsUid: {
+ type: String,
+ default: '',
+ },
},
data() {
return {
loading: true,
name: null,
+ failed: false,
}
},
+ computed: {
+ /**
+ * Why there is no name to show.
+ *
+ * @return {string} The hint.
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-005-a-contact-persons-name-shows-only-to-who-can-read-the-contact
+ */
+ hint() {
+ if (this.failed) {
+ return t(
+ 'stackiq',
+ 'The name could not be loaded. Reload the page to try again.',
+ )
+ }
+ if (!this.contactsUid) {
+ return t(
+ 'stackiq',
+ 'This contact person is not linked to a Nextcloud contact.',
+ )
+ }
+ return t(
+ 'stackiq',
+ 'The name is kept in Nextcloud Contacts, in an address book you cannot read.',
+ )
+ },
+ },
+
watch: {
objectId: {
immediate: true,
@@ -81,14 +113,16 @@ export default {
*
* @param {string} id The contact person uuid.
* @return {Promise}
- * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-005-a-contact-persons-name-shows-only-to-who-can-read-the-contact
*/
async handler(id) {
this.loading = true
this.name = null
- const name = await contactName(id)
+ this.failed = false
+ const result = await contactNameResult(id)
if (id === this.objectId) {
- this.name = name
+ this.name = result.name
+ this.failed = result.failed
this.loading = false
}
},
diff --git a/src/manifest.json b/src/manifest.json
index ec9a337cf..a51036bfb 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -487,7 +487,7 @@
{ "id": "2", "widgetId": "cp-related", "gridX": 8, "gridY": 0, "gridWidth": 4, "gridHeight": 4 }
],
"bodyWidgets": [
- { "id": "cp-name", "component": "ContactPersonName", "props": { "objectId": "@objectId" }, "placement": "before-body", "colSpan": 12 }
+ { "id": "cp-name", "component": "ContactPersonName", "props": { "objectId": "@objectId", "contactsUid": "@object.contactsUid" }, "placement": "before-body", "colSpan": 12 }
],
"sidebar": {
"enabled": true,
diff --git a/src/utils/contactNames.js b/src/utils/contactNames.js
index ed9513e46..125e2880a 100644
--- a/src/utils/contactNames.js
+++ b/src/utils/contactNames.js
@@ -19,7 +19,7 @@ import { generateUrl } from '@nextcloud/router'
/** The endpoint's own limit (`ContactNamesController::MAX_IDS`). */
export const MAX_IDS_PER_REQUEST = 100
-/** uuid -> Promise; null when no name could be read. */
+/** uuid -> Promise<{name: string|null, failed: boolean}>. */
const cache = new Map()
/** uuid -> { resolve } for the ids of the batch being collected. */
@@ -74,23 +74,24 @@ async function flush() {
if (failed) {
cache.delete(id)
}
- batch.get(id).resolve(name !== '' ? name : null)
+ batch.get(id).resolve({ name: name !== '' ? name : null, failed })
})
}
}
/**
- * The display name of a contact person, or null when the signed-in user
- * cannot read one.
+ * What the endpoint made of a contact person: its name, and whether the
+ * request failed (rate limit, network, server error) as opposed to answering
+ * without a name.
*
* @param {string} id The contact person uuid.
- * @return {Promise} The name.
- * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ * @return {Promise<{name: string|null, failed: boolean}>} The name, or why there is none.
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-005-a-contact-persons-name-shows-only-to-who-can-read-the-contact
*/
-export function contactName(id) {
+export function contactNameResult(id) {
const key = String(id ?? '').toLowerCase()
if (key === '') {
- return Promise.resolve(null)
+ return Promise.resolve({ name: null, failed: false })
}
if (!cache.has(key)) {
cache.set(
@@ -106,6 +107,18 @@ export function contactName(id) {
return cache.get(key)
}
+/**
+ * The display name of a contact person, or null when the signed-in user
+ * cannot read one (or the request failed; see contactNameResult).
+ *
+ * @param {string} id The contact person uuid.
+ * @return {Promise} The name.
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner
+ */
+export async function contactName(id) {
+ return (await contactNameResult(id)).name
+}
+
/**
* Forget every name read so far (tests).
*
From 7ca89b332664bbbcb36306a1ce712188e21e36b2 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 20:42:08 +0200
Subject: [PATCH 091/124] test(contacts): cover the contact person empty states
and the risk signals settings wait
Closes review finding f3: ContactPersonName (name, not linked, no readable
address book, failed request), contactNameResult, and UsageRiskSignals
reading the settings before its first object read.
Co-Authored-By: Claude Opus 5.5
---
tests/vitest/contactNames.spec.js | 17 +++++-
tests/vitest/contactPersonName.spec.js | 79 +++++++++++++++++++++++++
tests/vitest/usageRiskSignals.spec.js | 80 ++++++++++++++++++++++++++
3 files changed, 174 insertions(+), 2 deletions(-)
create mode 100644 tests/vitest/contactPersonName.spec.js
create mode 100644 tests/vitest/usageRiskSignals.spec.js
diff --git a/tests/vitest/contactNames.spec.js b/tests/vitest/contactNames.spec.js
index e303e85db..7a1012844 100644
--- a/tests/vitest/contactNames.spec.js
+++ b/tests/vitest/contactNames.spec.js
@@ -19,8 +19,13 @@ const http = vi.hoisted(() => ({ get: vi.fn() }))
vi.mock('@nextcloud/axios', () => ({ default: http }))
vi.mock('@nextcloud/router', () => ({ generateUrl: (url) => url }))
-const { clearContactNames, contactName, contactPersonId, MAX_IDS_PER_REQUEST } =
- await import('../../src/utils/contactNames.js')
+const {
+ clearContactNames,
+ contactName,
+ contactNameResult,
+ contactPersonId,
+ MAX_IDS_PER_REQUEST,
+} = await import('../../src/utils/contactNames.js')
const ContactNameCell = (
await import('../../src/components/cells/ContactNameCell.vue')
).default
@@ -81,6 +86,14 @@ describe('contactNames', () => {
expect(http.get).toHaveBeenCalledTimes(2)
})
+ it('tells a failed request from an answer without a name', async () => {
+ http.get.mockRejectedValueOnce(new Error('429'))
+ expect(await contactNameResult(A)).toEqual({ name: null, failed: true })
+
+ http.get.mockResolvedValueOnce({ data: { names: {} } })
+ expect(await contactNameResult(B)).toEqual({ name: null, failed: false })
+ })
+
it('reads the uuid from a string or a related object', () => {
expect(contactPersonId(` ${A} `)).toBe(A)
expect(contactPersonId({ id: A })).toBe(A)
diff --git a/tests/vitest/contactPersonName.spec.js b/tests/vitest/contactPersonName.spec.js
new file mode 100644
index 000000000..b956421b6
--- /dev/null
+++ b/tests/vitest/contactPersonName.spec.js
@@ -0,0 +1,79 @@
+// @vitest-environment jsdom
+
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * The name on a contact person's page, and what it says when there is none:
+ * not linked, in an address book the user cannot read, or a failed request.
+ *
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-005-a-contact-persons-name-shows-only-to-who-can-read-the-contact
+ */
+
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const http = vi.hoisted(() => ({ get: vi.fn() }))
+
+vi.mock('@nextcloud/axios', () => ({ default: http }))
+vi.mock('@nextcloud/router', () => ({ generateUrl: (url) => url }))
+vi.mock('@nextcloud/l10n', () => ({ translate: (app, text) => text }))
+vi.mock('@nextcloud/vue', () => ({
+ NcLoadingIcon: { template: '' },
+}))
+vi.mock('@conduction/nextcloud-vue', () => ({
+ CnIcon: { template: '' },
+ CnWidgetWrapper: { template: '
' },
+}))
+
+const { clearContactNames } = await import('../../src/utils/contactNames.js')
+const ContactPersonName = (
+ await import('../../src/components/organisations/ContactPersonName.vue')
+).default
+
+const A = '00000000-0000-0000-0000-00000000000a'
+async function mountIt(props) {
+ const wrapper = mount(ContactPersonName, { props: { objectId: A, ...props } })
+ await flushPromises()
+ return wrapper
+}
+
+describe('ContactPersonName', () => {
+ beforeEach(() => {
+ clearContactNames()
+ http.get.mockReset()
+ })
+
+ it('shows the name', async () => {
+ http.get.mockResolvedValue({ data: { names: { [A]: 'Anna' } } })
+ const wrapper = await mountIt({ contactsUid: 'uid-1' })
+ expect(wrapper.text()).toContain('Anna')
+ expect(
+ wrapper.find('[data-testid="contact-person-name-hint"]').exists(),
+ ).toBe(false)
+ })
+
+ it('says the contact person is not linked when there is no contactsUid', async () => {
+ http.get.mockResolvedValue({ data: { names: {} } })
+ const wrapper = await mountIt({})
+ const hint = wrapper.get('[data-testid="contact-person-name-hint"]').text()
+ expect(hint).toBe(
+ 'This contact person is not linked to a Nextcloud contact.',
+ )
+ })
+
+ it('names the address book only after an answer without a name', async () => {
+ http.get.mockResolvedValue({ data: { names: {} } })
+ const wrapper = await mountIt({ contactsUid: 'uid-1' })
+ const hint = wrapper.get('[data-testid="contact-person-name-hint"]').text()
+ expect(hint).toContain('in an address book you cannot read')
+ })
+
+ it('says the name could not be loaded when the request failed', async () => {
+ http.get.mockRejectedValue(new Error('429'))
+ const wrapper = await mountIt({ contactsUid: 'uid-1' })
+ const hint = wrapper.get('[data-testid="contact-person-name-hint"]').text()
+ expect(hint).toContain('could not be loaded')
+ expect(hint).not.toContain('address book')
+ })
+})
diff --git a/tests/vitest/usageRiskSignals.spec.js b/tests/vitest/usageRiskSignals.spec.js
new file mode 100644
index 000000000..6ba69e658
--- /dev/null
+++ b/tests/vitest/usageRiskSignals.spec.js
@@ -0,0 +1,80 @@
+// @vitest-environment jsdom
+
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * The risk signals widget, opened straight from a link: it reads the app
+ * settings first, so the object types resolve before the first read.
+ *
+ * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-002-the-usage-page-shows-the-risk-signals-next-to-the-risk-score
+ */
+
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const order = vi.hoisted(() => [])
+const store = vi.hoisted(() => ({
+ settings: null,
+ objectTypeRegistry: {},
+ fetchSettings: null,
+ registerObjectType: null,
+ getSchemaConfig: null,
+ fetchObject: null,
+ fetchCollectionForOptions: null,
+}))
+
+vi.mock('../../src/store/store.js', () => ({ objectStore: store }))
+vi.mock('@nextcloud/l10n', () => ({
+ translate: (app, text) => text,
+ translatePlural: (app, singular) => singular,
+}))
+vi.mock('@nextcloud/vue', () => ({
+ NcLoadingIcon: { template: '' },
+ NcNoteCard: { template: '
' },
+}))
+
+const UsageRiskSignals = (
+ await import('../../src/components/portfolio/UsageRiskSignals.vue')
+).default
+
+describe('UsageRiskSignals', () => {
+ beforeEach(() => {
+ order.length = 0
+ store.settings = null
+ store.objectTypeRegistry = {}
+ store.fetchSettings = vi.fn(async () => {
+ order.push('fetchSettings')
+ store.settings = { loaded: true }
+ })
+ store.getSchemaConfig = vi.fn(() => ({ register: 1, schema: 2 }))
+ store.registerObjectType = vi.fn()
+ store.fetchObject = vi.fn(async () => {
+ order.push('fetchObject')
+ return { riskScore: 3 }
+ })
+ store.fetchCollectionForOptions = vi.fn(async () => [])
+ })
+
+ it('reads the settings before its first object read', async () => {
+ const wrapper = mount(UsageRiskSignals, { props: { objectId: 'u1' } })
+ await flushPromises()
+
+ expect(store.fetchSettings).toHaveBeenCalledTimes(1)
+ expect(order).toEqual(['fetchSettings', 'fetchObject'])
+ expect(wrapper.find('[data-testid="usage-risk-score"]').text()).toBe('3 / 5')
+ })
+
+ it('does not read the settings again when the app already has them', async () => {
+ store.settings = { loaded: true }
+ mount(UsageRiskSignals, { props: { objectId: 'u1' } })
+ await flushPromises()
+
+ expect(store.fetchSettings).not.toHaveBeenCalled()
+ expect(store.fetchObject).toHaveBeenCalled()
+ })
+})
From afd1c6fbf201e1e0eed8d34a4c16340743b6dc7d Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 20:45:23 +0200
Subject: [PATCH 092/124] fix(contacts): keep the spinner until the contact
person is loaded
CnDetailPage passes no object while it loads and CnBodySections drops the
unresolved @object.contactsUid, so the 'not linked' hint flashed before the
object arrived. The page now also takes @object.id (loadedId) and shows the
spinner until it is there. Also names the right test files in the REQ-UAP-005
scenario exclude reasons.
Co-Authored-By: Claude Opus 5.5
---
.../specs/application-usage-pages/spec.md | 4 ++--
.../organisations/ContactPersonName.vue | 22 ++++++++++++++++++-
src/manifest.json | 2 +-
tests/vitest/contactPersonName.spec.js | 18 ++++++++++++++-
4 files changed, 41 insertions(+), 5 deletions(-)
diff --git a/openspec/specs/application-usage-pages/spec.md b/openspec/specs/application-usage-pages/spec.md
index 36dee9624..5e34fe5df 100644
--- a/openspec/specs/application-usage-pages/spec.md
+++ b/openspec/specs/application-usage-pages/spec.md
@@ -71,7 +71,7 @@ The usage schema SHALL declare its lifecycle on the status values its rows hold,
The contact person page and the Name column of an organisation's contact person list SHALL show the name of the linked Nextcloud contact only to a user who can read that contact in their own or shared address books, and SHALL say why there is no name when there is none.
#### Scenario: A reader of the contact sees the name
-@e2e exclude Needs a Contacts address book per user; tests/vitest/contactNames.spec.js asserts the name on the contact person page and in the list column.
+@e2e exclude Needs a Contacts address book per user; tests/vitest/contactPersonName.spec.js asserts the name on the contact person page and tests/vitest/contactNames.spec.js the list column cell.
- **GIVEN** a contact person linked to a contact in the information manager's own address book
- **WHEN** the information manager opens the contact person, or the organisation it belongs to
@@ -79,7 +79,7 @@ The contact person page and the Name column of an organisation's contact person
- **AND** the Name column of the organisation's contact person list shows it
#### Scenario: Without a name the page says why
-@e2e exclude Needs a Contacts address book per user; tests/vitest/contactNames.spec.js asserts each empty state.
+@e2e exclude Needs a Contacts address book per user; tests/vitest/contactPersonName.spec.js asserts each empty state, and that none shows before the object has loaded; tests/vitest/contactNames.spec.js asserts a failed request apart from an empty answer.
- **GIVEN** a contact person whose name the signed-in user does not get
- **WHEN** the user opens the contact person page
diff --git a/src/components/organisations/ContactPersonName.vue b/src/components/organisations/ContactPersonName.vue
index ab7ead2c6..4170a25f9 100644
--- a/src/components/organisations/ContactPersonName.vue
+++ b/src/components/organisations/ContactPersonName.vue
@@ -13,7 +13,7 @@
@@ -68,6 +68,15 @@ export default {
type: String,
default: '',
},
+
+ /**
+ * The id of the loaded object (`@object.id`); absent while the page is
+ * still reading it, when `contactsUid` is empty for that reason too.
+ */
+ loadedId: {
+ type: String,
+ default: '',
+ },
},
data() {
@@ -79,6 +88,17 @@ export default {
},
computed: {
+ /**
+ * Whether to show the spinner: the name is being read, or the object is
+ * not loaded yet, so "not linked" cannot be told from "not known yet".
+ *
+ * @return {boolean} True while waiting.
+ * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-005-a-contact-persons-name-shows-only-to-who-can-read-the-contact
+ */
+ waiting() {
+ return this.loading || !this.loadedId
+ },
+
/**
* Why there is no name to show.
*
diff --git a/src/manifest.json b/src/manifest.json
index a51036bfb..28196a531 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -487,7 +487,7 @@
{ "id": "2", "widgetId": "cp-related", "gridX": 8, "gridY": 0, "gridWidth": 4, "gridHeight": 4 }
],
"bodyWidgets": [
- { "id": "cp-name", "component": "ContactPersonName", "props": { "objectId": "@objectId", "contactsUid": "@object.contactsUid" }, "placement": "before-body", "colSpan": 12 }
+ { "id": "cp-name", "component": "ContactPersonName", "props": { "objectId": "@objectId", "contactsUid": "@object.contactsUid", "loadedId": "@object.id" }, "placement": "before-body", "colSpan": 12 }
],
"sidebar": {
"enabled": true,
diff --git a/tests/vitest/contactPersonName.spec.js b/tests/vitest/contactPersonName.spec.js
index b956421b6..aa31408fd 100644
--- a/tests/vitest/contactPersonName.spec.js
+++ b/tests/vitest/contactPersonName.spec.js
@@ -33,7 +33,9 @@ const ContactPersonName = (
const A = '00000000-0000-0000-0000-00000000000a'
async function mountIt(props) {
- const wrapper = mount(ContactPersonName, { props: { objectId: A, ...props } })
+ const wrapper = mount(ContactPersonName, {
+ props: { objectId: A, loadedId: A, ...props },
+ })
await flushPromises()
return wrapper
}
@@ -76,4 +78,18 @@ describe('ContactPersonName', () => {
expect(hint).toContain('could not be loaded')
expect(hint).not.toContain('address book')
})
+
+ it('shows no hint while the object is not loaded yet', async () => {
+ http.get.mockResolvedValue({ data: { names: {} } })
+ const wrapper = await mountIt({ loadedId: '' })
+ expect(
+ wrapper.find('[data-testid="contact-person-name-hint"]').exists(),
+ ).toBe(false)
+ expect(wrapper.find('.loading').exists()).toBe(true)
+
+ await wrapper.setProps({ loadedId: A })
+ expect(
+ wrapper.get('[data-testid="contact-person-name-hint"]').text(),
+ ).toContain('not linked')
+ })
})
From c79702085ff87678dafee2cab5e69a6a85ddb587 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 20:47:51 +0200
Subject: [PATCH 093/124] fix(cmdb-import): refuse pack mappings of fields
other subsystems own
A pack may map any plain schema property, which let an edited pack write
the service desk exchange's keys and stamps (serviceDeskSystem,
serviceDeskRecordId, serviceDeskUrl, serviceDeskSyncedAt), the calculated
suggestedTimeClassification and the installed version, all written with
RBAC off. The rules now refuse every property the register hides from
forms (hideOnForm, in the base register or a fragment) or calculates
(x-openregister-calculations), derived from the schema rather than listed,
plus the usage installedVersion, which carries no marker. They are refused
as mappings and as defaults ("is set outside the import").
Closes be3-f1 (widened pack rule admits ITSM-owned usage fields). Class
sweep: every schema of the base register and the fragments is read by the
same rule, so the connection and contract fields of the exchange are
covered too; the allowed-field test no longer maps serviceDeskUrl or the
hidden module standards property, and new tests cover each refused field
and a hidden, a calculated and a file property.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Cmdb/CmdbImportPackRules.php | 82 +++++++++++++++----
.../Service/Cmdb/CmdbImportPackRulesTest.php | 67 ++++++++++++++-
2 files changed, 130 insertions(+), 19 deletions(-)
diff --git a/lib/Service/Cmdb/CmdbImportPackRules.php b/lib/Service/Cmdb/CmdbImportPackRules.php
index 026e26fc7..252cc1fb9 100644
--- a/lib/Service/Cmdb/CmdbImportPackRules.php
+++ b/lib/Service/Cmdb/CmdbImportPackRules.php
@@ -8,9 +8,11 @@
* edits in OpenRegister may map any plain property of its target schema in
* the register stackiq ships, fragments included (ADR-037), but never the
* object's metadata, the keys and relations the import sets itself, any
- * other relation, a file, the publication dates or the fields only an
- * administrator or the catalogue sets, and must keep the mappings the import
- * matches records on (REQ-CMDB-021).
+ * other relation, a file, a field the register hides from forms or calculates
+ * (other subsystems own those: the service desk exchange, the derivations),
+ * the publication dates or the fields only an administrator or the catalogue
+ * sets, and must keep the mappings the import matches records on
+ * (REQ-CMDB-021).
*
* @category Service
* @package OCA\Stackiq\Service\Cmdb
@@ -103,7 +105,7 @@ class CmdbImportPackRules {
];
/**
- * Further protected fields per target: an organisation's approval status and a module's type.
+ * Further protected fields per target: an organisation's approval status, a module's type and the version the service desk records as installed.
*
* @var array>
*/
@@ -111,6 +113,7 @@ class CmdbImportPackRules {
'module' => ['type'],
'manufacturer' => ['status'],
'municipality' => ['status'],
+ 'usage' => ['installedVersion'],
];
/**
@@ -134,11 +137,13 @@ class CmdbImportPackRules {
];
/**
- * Per schema of the shipped register, once read: each property name and whether no pack may write it.
+ * Per schema of the shipped register, once read: each property name and why no pack may write it.
*
- * A property is not writable when it is a relation or a file.
+ * The reason is `relation` (a relation or a file), `system` (hidden from
+ * forms or calculated: another subsystem owns it) or an empty string
+ * (a plain, writable property).
*
- * @var array>|null
+ * @var array>|null
*/
private ?array $schemaFields = null;
@@ -179,7 +184,8 @@ public function violations(string $target, array $pack, array $profile): array {
$refused = [
'managed' => array_merge(self::MANAGED_FIELDS, (self::MATCH_FIELDS[$target] ?? []), array_map('strval', $never)),
'protected' => array_merge(self::PROTECTED_FIELDS, (self::PROTECTED_TARGET_FIELDS[$target] ?? [])),
- 'relation' => $this->relationFields(target: $target),
+ 'relation' => $this->fieldsWithReason(target: $target, reason: 'relation'),
+ 'system' => $this->fieldsWithReason(target: $target, reason: 'system'),
];
$allowed = array_values(array_diff($this->targetFields(target: $target), ...array_values($refused)));
@@ -328,6 +334,10 @@ private static function fieldViolation(string $field, array $refused, string $ta
return '"' . $field . '" is set outside the import and cannot be mapped';
}
+ if (in_array($field, $refused['system'], true) === true) {
+ return '"' . $field . '" is set outside the import and cannot be mapped';
+ }
+
if (in_array($field, $refused['relation'], true) === true) {
return '"' . $field . '" is a relation or a file and cannot be mapped';
}
@@ -356,9 +366,10 @@ private function targetFields(string $target): array {
}//end targetFields()
/**
- * The properties of a target's schema that point at other objects or hold a file.
+ * The properties of a target's schema that no pack may write for one reason.
*
* @param string $target The target.
+ * @param string $reason `relation` or `system`.
*
* @return array
*
@@ -366,14 +377,15 @@ private function targetFields(string $target): array {
*
* @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
*/
- private function relationFields(string $target): array {
+ private function fieldsWithReason(string $target, string $reason): array {
$schema = (self::TARGET_SCHEMAS[$target] ?? null);
if ($schema === null) {
return [];
}
- return array_map('strval', array_keys(array_filter(($this->schemaFields()[$schema] ?? []))));
- }//end relationFields()
+ $fields = array_filter(($this->schemaFields()[$schema] ?? []), static fn (string $why): bool => $why === $reason);
+ return array_map('strval', array_keys($fields));
+ }//end fieldsWithReason()
/**
* Whether a property definition, or that of its items, points at another object or holds a file.
@@ -417,11 +429,34 @@ private static function isRelationOrFile(mixed $definition): bool {
}//end isRelationOrFile()
/**
- * The properties per schema of the register stackiq ships, fragments included, each with whether no pack may write it.
+ * Whether a property definition is hidden from forms or calculated.
+ *
+ * Such a field is written by another subsystem (the service desk
+ * exchange stamps, the calculated suggestions), not edited by people.
+ *
+ * @param mixed $definition The property definition.
+ * @param bool $calculated Whether the schema calculates the property.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private static function isSystemOwned(mixed $definition, bool $calculated): bool {
+ if ($calculated === true) {
+ return true;
+ }
+
+ return is_array($definition) === true && ($definition['hideOnForm'] ?? false) === true;
+ }//end isSystemOwned()
+
+ /**
+ * The properties per schema of the register stackiq ships, fragments included, each with why no pack may write it.
*
- * A property is not writable when any of its definitions makes it a relation or a file.
+ * A property is refused when any of its definitions makes it a relation
+ * or a file (`relation`), or hides it from forms or calculates it (`system`);
+ * a relation wins.
*
- * @return array>
+ * @return array>
*
* @throws CmdbImportException MAPPING_UNAVAILABLE when the register file cannot be read.
*
@@ -448,9 +483,22 @@ private function schemaFields(): array {
$fields = [];
foreach (array_merge([$register], array_map(fn (string $file): ?array => $this->decodeJson(file: $file), $fragments)) as $decoded) {
foreach (($decoded['components']['schemas'] ?? []) as $schema => $definition) {
+ $calculations = (array)($definition['configuration']['x-openregister-calculations'] ?? []);
foreach ((array)($definition['properties'] ?? []) as $property => $propertyDefinition) {
- $notWritable = self::isRelationOrFile(definition: $propertyDefinition);
- $fields[(string)$schema][(string)$property] = (($fields[(string)$schema][(string)$property] ?? false) || $notWritable);
+ $reason = '';
+ if (self::isRelationOrFile(definition: $propertyDefinition) === true) {
+ $reason = 'relation';
+ } elseif (self::isSystemOwned(definition: $propertyDefinition, calculated: array_key_exists((string)$property, $calculations)) === true) {
+ $reason = 'system';
+ }
+
+ $before = ($fields[(string)$schema][(string)$property] ?? '');
+ if ($before !== 'relation' && $reason !== '') {
+ $fields[(string)$schema][(string)$property] = $reason;
+ continue;
+ }
+
+ $fields[(string)$schema][(string)$property] = $before;
}
}
}
diff --git a/tests/Unit/Service/Cmdb/CmdbImportPackRulesTest.php b/tests/Unit/Service/Cmdb/CmdbImportPackRulesTest.php
index cfd739ecd..04adb83f5 100644
--- a/tests/Unit/Service/Cmdb/CmdbImportPackRulesTest.php
+++ b/tests/Unit/Service/Cmdb/CmdbImportPackRulesTest.php
@@ -174,10 +174,10 @@ public function testAnyPlainSchemaFieldCanBeMapped(): void {
$profile = $this->shipped(file: 'topdesk-profile.json');
$rules = new CmdbImportPackRules();
$fields = [
- 'module' => ['licentietype', 'website', 'licence', 'hostingLocation', 'dpiaStatus', 'standards'],
+ 'module' => ['licentietype', 'website', 'licence', 'hostingLocation', 'dpiaStatus'],
'manufacturer' => ['name', 'oin', 'website', 'summary'],
'municipality' => ['name', 'tooi', 'description'],
- 'usage' => ['status', 'interneAnnotation', 'serviceDeskUrl', 'riskScore', 'startDatePlanned'],
+ 'usage' => ['status', 'interneAnnotation', 'riskScore', 'startDatePlanned'],
'businessOwner' => ['name', 'role'],
];
@@ -196,6 +196,69 @@ public function testAnyPlainSchemaFieldCanBeMapped(): void {
}
}//end testAnyPlainSchemaFieldCanBeMapped()
+ /**
+ * A field another subsystem owns is refused: the service desk exchange's keys and stamps, the calculated suggestion and the installed version.
+ *
+ * @return void
+ */
+ public function testAFieldAnotherSubsystemOwnsIsRefused(): void {
+ $profile = $this->shipped(file: 'topdesk-profile.json');
+ $rules = new CmdbImportPackRules();
+ $owned = ['serviceDeskSystem', 'serviceDeskRecordId', 'serviceDeskUrl', 'serviceDeskSyncedAt', 'installedVersion', 'suggestedTimeClassification'];
+
+ foreach ($owned as $field) {
+ $pack = $this->shipped(file: 'topdesk-usage.json');
+ $pack['fieldMappings'][] = ['source' => 'Opmerking', 'target' => $field];
+ $this->assertSame(['"' . $field . '" is set outside the import and cannot be mapped'], $rules->violations(target: 'usage', pack: $pack, profile: $profile), $field);
+
+ $pack = $this->shipped(file: 'topdesk-usage.json');
+ $pack['defaults'][$field] = 'x';
+ $this->assertSame(['default "' . $field . '" is set outside the import and cannot be mapped'], $rules->violations(target: 'usage', pack: $pack, profile: $profile), 'default ' . $field);
+ }
+ }//end testAFieldAnotherSubsystemOwnsIsRefused()
+
+ /**
+ * A property the schema hides from forms or calculates is refused, and a file property is refused as a file.
+ *
+ * @return void
+ */
+ public function testAHiddenCalculatedOrFilePropertyIsRefused(): void {
+ $directory = sys_get_temp_dir() . '/stackiq-pack-rules-' . bin2hex(random_bytes(4));
+ mkdir($directory . '/register.d', 0700, true);
+ $properties = [
+ 'status' => ['type' => 'string'],
+ 'hidden' => ['type' => 'string', 'hideOnForm' => true],
+ 'derived' => ['type' => 'string'],
+ 'attachment' => ['type' => 'file'],
+ ];
+ $register = ['components' => ['schemas' => ['usage' => ['properties' => $properties, 'configuration' => ['x-openregister-calculations' => ['derived' => ['type' => 'string']]]]]]];
+ file_put_contents($directory . '/softwarecatalogus_register.json', json_encode($register));
+
+ $pack = ['fieldMappings' => [
+ ['source' => 'A', 'target' => 'status'],
+ ['source' => 'B', 'target' => 'hidden'],
+ ['source' => 'C', 'target' => 'derived'],
+ ['source' => 'D', 'target' => 'attachment'],
+ ]];
+
+ try {
+ $violations = (new CmdbImportPackRules(settingsDirectory: $directory))->violations(target: 'usage', pack: $pack, profile: []);
+ } finally {
+ rmdir($directory . '/register.d');
+ unlink($directory . '/softwarecatalogus_register.json');
+ rmdir($directory);
+ }
+
+ $this->assertSame(
+ [
+ '"hidden" is set outside the import and cannot be mapped',
+ '"derived" is set outside the import and cannot be mapped',
+ '"attachment" is a relation or a file and cannot be mapped',
+ ],
+ $violations
+ );
+ }//end testAHiddenCalculatedOrFilePropertyIsRefused()
+
/**
* A plain field that the register turns into a relation is refused: the schema has the last word.
*
From 81b1bc7d3d50a27f604e9650f2bc9413d056147e Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 20:48:39 +0200
Subject: [PATCH 094/124] fix(cmdb-import): show the mapping the next import
runs, and keep a pack with no updatedAt
Closes be3-f2 (the read-only view showed a stored pack that the next
import replaces) and be3-f4 (the update guard was off when the stored
pack had no updatedAt).
CmdbImportPackStore::find() now answers an unedited, outdated stored pack
as the import will run it: the shipped pack and version, with
updatePending true, still writing nothing. The overview carries
updatePending per pack (false for a file, a current or an edited pack) and
storedVersion keeps what OpenRegister holds. resolve() no longer updates a
pack whose entity has no updatedAt, because OpenRegister then skips the
guard against an edit saved in between; it keeps the stored pack (found),
and find() does not claim a pending update for it either.
Class sweep: one predicate (updateWouldRun) serves both the import and the
view, so they cannot disagree; the fake service can now answer no
updatedAt.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Cmdb/CmdbImportPackStore.php | 39 +++++++++++++--
lib/Service/Cmdb/CmdbImportProfile.php | 9 ++--
.../Service/Cmdb/CmdbImportPackStoreTest.php | 47 +++++++++++++++++++
.../Service/Cmdb/CmdbImportProfileTest.php | 10 +++-
.../Unit/Support/FakeMigrationPackService.php | 21 +++++++--
5 files changed, 112 insertions(+), 14 deletions(-)
diff --git a/lib/Service/Cmdb/CmdbImportPackStore.php b/lib/Service/Cmdb/CmdbImportPackStore.php
index 7963d5981..f046b13c1 100644
--- a/lib/Service/Cmdb/CmdbImportPackStore.php
+++ b/lib/Service/Cmdb/CmdbImportPackStore.php
@@ -215,7 +215,9 @@ public function assertShippedFallbackAllowed(): void {
* The stored pack for a shipped pack, read only: nothing is created, updated or recorded.
*
* For a view of the mapping. The import run and the seed step use resolve(),
- * which writes.
+ * which writes. An unedited stored pack that the next import run replaces
+ * is answered as that run will see it: the shipped pack and its version,
+ * with `updatePending` true; nothing is written.
*
* @param array $shipped The shipped pack, already validated.
*
@@ -235,9 +237,35 @@ public function find(array $shipped): ?array {
return null;
}
- return $this->describe(entity: $entity, slug: $slug, shipped: $shipped, outcome: self::FOUND);
+ $answer = $this->describe(entity: $entity, slug: $slug, shipped: $shipped, outcome: self::FOUND);
+ if ($this->updateWouldRun(entity: $entity, shipped: $shipped) === true) {
+ $answer['pack'] = $shipped;
+ $answer['version'] = (string)($shipped['version'] ?? '');
+ $answer['updatePending'] = true;
+ }
+
+ return $answer;
}//end find()
+ /**
+ * Whether the next import run replaces this stored pack with the shipped one.
+ *
+ * Only an unedited, outdated pack whose last save is known: without an
+ * `updatedAt` the update cannot be guarded against an edit in between, so
+ * the stored pack is kept.
+ *
+ * @param object $entity The stored pack as read.
+ * @param array $shipped The shipped pack.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ */
+ private function updateWouldRun(object $entity, array $shipped): bool {
+ return $entity->getUpdatedAt() !== null
+ && $this->marker->isOutdated(definition: self::definitionOf(entity: $entity), shipped: $shipped) === true;
+ }//end updateWouldRun()
+
/**
* The stored pack for a shipped pack: found, seeded when missing, or updated when unedited and outdated.
*
@@ -278,7 +306,7 @@ public function resolve(array $shipped): array {
throw $e;
}
}
- } elseif ($this->marker->isOutdated(definition: self::definitionOf(entity: $entity), shipped: $shipped) === true) {
+ } elseif ($this->updateWouldRun(entity: $entity, shipped: $shipped) === true) {
[$entity, $outcome] = $this->updateUnedited(service: $service, entity: $entity, slug: $slug, shipped: $shipped);
}
@@ -291,7 +319,8 @@ public function resolve(array $shipped): array {
*
* The update carries the read pack's `updatedAt`, so an administrator's
* edit saved in between is refused by OpenRegister instead of overwritten;
- * that edit is then read back and kept.
+ * that edit is then read back and kept. resolve() calls this
+ * only for a pack with an `updatedAt` (see updateWouldRun()).
*
* @param object $service OpenRegister's service.
* @param object $entity The stored pack as read.
@@ -346,11 +375,13 @@ private function describe(object $entity, string $slug, array $shipped, string $
'id' => (int)$entity->getId(),
'slug' => $slug,
'version' => (string)($definition['version'] ?? ''),
+ 'storedVersion' => (string)($definition['version'] ?? ''),
'definition' => $definition,
'pack' => $this->marker->strip(definition: $definition),
'edited' => $this->marker->isEdited(definition: $definition),
'newerShipped' => $this->marker->isNewerShipped(definition: $definition, shipped: $shipped),
'outcome' => $outcome,
+ 'updatePending' => false,
];
}//end describe()
diff --git a/lib/Service/Cmdb/CmdbImportProfile.php b/lib/Service/Cmdb/CmdbImportProfile.php
index f097ea7c6..bd9d7f55f 100644
--- a/lib/Service/Cmdb/CmdbImportProfile.php
+++ b/lib/Service/Cmdb/CmdbImportProfile.php
@@ -136,7 +136,7 @@ class CmdbImportProfile {
/**
* Where each loaded pack came from, per target.
*
- * @var array> Per target: source, packId, storedVersion, shippedVersion, edited, newerShipped.
+ * @var array> Per target: source, packId, storedVersion, shippedVersion, edited, newerShipped, updatePending.
*/
private array $sources = [];
@@ -256,6 +256,7 @@ private function loadPacks(callable $storedPack): void {
'shippedVersion' => (string)($shipped['version'] ?? ''),
'edited' => null,
'newerShipped' => false,
+ 'updatePending' => false,
];
$stored = null;
@@ -273,9 +274,10 @@ private function loadPacks(callable $storedPack): void {
[
'source' => 'openregister',
'packId' => $stored['id'],
- 'storedVersion' => $stored['version'],
+ 'storedVersion' => $stored['storedVersion'],
'edited' => $stored['edited'],
'newerShipped' => $stored['newerShipped'],
+ 'updatePending' => $stored['updatePending'],
]
);
}
@@ -1065,7 +1067,8 @@ private function assertValidPack(object $validator, array $pack, string $label,
* The import writes with OpenRegister's RBAC and multitenancy off, so a
* pack may map any plain property of its target schema, never the
* object's metadata, the keys the import sets itself, a relation, a file,
- * the publication dates or an admin field, and must keep the mappings the
+ * a field the register hides from forms or calculates, the publication
+ * dates or an admin field, and must keep the mappings the
* import matches records on (REQ-CMDB-021, CmdbImportPackRules).
*
* @param string $target The target.
diff --git a/tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php b/tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php
index aeea3e91d..296d1971e 100644
--- a/tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php
+++ b/tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php
@@ -257,6 +257,53 @@ public function testAnUneditedPackFollowsANewerShippedPack(): void {
$this->assertFalse((new CmdbImportPackMarker())->isNewerShipped(definition: $definition, shipped: $newer));
}//end testAnUneditedPackFollowsANewerShippedPack()
+ /**
+ * A stored pack without an `updatedAt` cannot be updated under the race guard, so it is kept as stored.
+ *
+ * @return void
+ */
+ public function testAnUneditedPackWithoutAnUpdatedAtIsKept(): void {
+ $id = $this->service->put(definition: (new CmdbImportPackMarker())->seed(shipped: $this->shippedUsage()));
+ $this->service->withoutUpdatedAt[] = $id;
+
+ $stored = $this->store()->resolve(shipped: $this->newerUsage());
+ $this->assertSame(CmdbImportPackStore::FOUND, $stored['outcome']);
+ $this->assertSame([], $this->service->writes, 'no unguarded update');
+ $this->assertSame('1.0.0', $stored['version']);
+
+ $view = $this->store()->find(shipped: $this->newerUsage());
+ $this->assertFalse($view['updatePending'], 'the import does not replace it, so the view does not say so');
+ $this->assertSame('1.0.0', $view['version']);
+ }//end testAnUneditedPackWithoutAnUpdatedAtIsKept()
+
+ /**
+ * Reading a pack for the view answers what the next import runs, and writes nothing.
+ *
+ * @return void
+ */
+ public function testFindAnswersTheShippedPackWhenTheNextImportReplacesTheStoredOne(): void {
+ $id = $this->service->put(definition: (new CmdbImportPackMarker())->seed(shipped: $this->shippedUsage()));
+ $newer = $this->newerUsage();
+
+ $view = $this->store()->find(shipped: $newer);
+
+ $this->assertTrue($view['updatePending']);
+ $this->assertSame('1.1.0', $view['version']);
+ $this->assertSame($newer, $view['pack']);
+ $this->assertSame('1.0.0', $view['storedVersion']);
+ $this->assertSame(CmdbImportPackStore::FOUND, $view['outcome']);
+ $this->assertSame([], $this->service->writes);
+
+ $current = $this->store()->find(shipped: $this->shippedUsage());
+ $this->assertFalse($current['updatePending'], 'an unedited current pack is as stored');
+
+ $this->service->edit(id: $id, definition: array_merge($this->service->definition(id: $id), ['description' => 'edited']));
+ $edited = $this->store()->find(shipped: $newer);
+ $this->assertFalse($edited['updatePending'], 'an edited pack is never replaced');
+ $this->assertSame('edited', $edited['pack']['description']);
+ $this->assertTrue($edited['newerShipped']);
+ }//end testFindAnswersTheShippedPackWhenTheNextImportReplacesTheStoredOne()
+
/**
* An edited pack is kept when a newer pack ships, and reported as such.
*
diff --git a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php
index d3235f2c9..ad7d3c52e 100644
--- a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php
+++ b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php
@@ -547,7 +547,7 @@ public function testUneditedPacksFollowTheReleaseAndEditedOnesAreReported(): voi
}//end testUneditedPacksFollowTheReleaseAndEditedOnesAreReported()
/**
- * Viewing the mapping writes nothing: an outdated unedited pack is shown as stored, a missing one from its file.
+ * Viewing the mapping writes nothing: an outdated unedited pack is shown as the import will run it (shipped, update pending), a missing one from its file.
*
* @return void
*
@@ -569,7 +569,13 @@ public function testTheMappingOverviewOnlyReadsTheStore(): void {
$this->assertSame([], $config, 'no row id is recorded');
$this->assertSame('openregister', $byTarget['usage']['source']);
$this->assertSame($usageId, $byTarget['usage']['packId']);
- $this->assertSame('0.9.0', $byTarget['usage']['storedVersion'], 'the stored pack, not yet updated');
+ $shipped = $this->shipped(file: 'topdesk-usage.json');
+ $this->assertSame('0.9.0', $byTarget['usage']['storedVersion'], 'what OpenRegister holds');
+ $this->assertSame($shipped['version'], $byTarget['usage']['version'], 'the shipped version, which the next import runs');
+ $this->assertSame($shipped['fieldMappings'][0]['transform']['map'], $byTarget['usage']['fieldMappings'][0]['transform']['map'], 'the shipped mapping, with Besteld');
+ $this->assertTrue($byTarget['usage']['updatePending']);
+ $this->assertFalse($byTarget['usage']['edited']);
+ $this->assertFalse($byTarget['module']['updatePending']);
$this->assertSame('file', $byTarget['module']['source'], 'not stored yet: shown from the shipped file');
$this->assertNull($byTarget['module']['packId']);
diff --git a/tests/Unit/Support/FakeMigrationPackService.php b/tests/Unit/Support/FakeMigrationPackService.php
index 31450fd91..c241315ff 100644
--- a/tests/Unit/Support/FakeMigrationPackService.php
+++ b/tests/Unit/Support/FakeMigrationPackService.php
@@ -79,6 +79,13 @@ final class FakeMigrationPackService {
*/
public ?\Closure $beforeUpdate = null;
+ /**
+ * Row ids whose entity answers no `updatedAt`, as a row written by something else would.
+ *
+ * @var array
+ */
+ public array $withoutUpdatedAt = [];
+
/**
* The clock: the Unix time the next write records.
*
@@ -338,7 +345,7 @@ private function failIfAsked(): void {
*/
private function entity(int $id): object {
$row = $this->rows[$id];
- return new class($id, $row['slug'], $row['definition'], $row['builtin'], $row['updatedAt']) {
+ return new class($id, $row['slug'], $row['definition'], $row['builtin'], (in_array($id, $this->withoutUpdatedAt, true) === true) ? null : $row['updatedAt']) {
/**
* Constructor.
*
@@ -346,14 +353,14 @@ private function entity(int $id): object {
* @param string $slug The slug.
* @param string $definition The definition JSON.
* @param bool $builtin Whether the pack is built in.
- * @param int $updatedAt The Unix time of the last write.
+ * @param int|null $updatedAt The Unix time of the last write; null when the row has none.
*/
public function __construct(
private int $id,
private string $slug,
private string $definition,
private bool $builtin,
- private int $updatedAt,
+ private ?int $updatedAt,
) {
}
@@ -369,9 +376,13 @@ public function getBuiltin(): bool {
/**
* When the pack was last written.
*
- * @return DateTime
+ * @return DateTime|null
*/
- public function getUpdatedAt(): DateTime {
+ public function getUpdatedAt(): ?DateTime {
+ if ($this->updatedAt === null) {
+ return null;
+ }
+
return (new DateTime())->setTimestamp($this->updatedAt);
}
From f7421aa06e6e0bc2164a9bf8fb71e0cfd0d76ac6 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 20:48:39 +0200
Subject: [PATCH 095/124] test(cmdb-import): pin every write method and opening
marker of the store probe
Closes be3-f3 (the controller probe was only partly pinned). A data
provider opens each of create, update, destroy, import and preview with
each of NoCSRFRequired, NoAdminRequired, PublicPage and
AuthorizedAdminSetting, as attribute and as docblock tag, and expects the
store unavailable; a missing write method, a missing hardened class, a
container without the service, a service without its methods, a missing
controller and a throwing container are covered too. Mutation-checked:
dropping preview from the write methods or PublicPage from the markers
turns the suite red.
Co-Authored-By: Claude Opus 5.5
---
.../Cmdb/CmdbImportPackStoreProbeTest.php | 209 ++++++++++++++++++
1 file changed, 209 insertions(+)
create mode 100644 tests/Unit/Service/Cmdb/CmdbImportPackStoreProbeTest.php
diff --git a/tests/Unit/Service/Cmdb/CmdbImportPackStoreProbeTest.php b/tests/Unit/Service/Cmdb/CmdbImportPackStoreProbeTest.php
new file mode 100644
index 000000000..fc50b25c5
--- /dev/null
+++ b/tests/Unit/Service/Cmdb/CmdbImportPackStoreProbeTest.php
@@ -0,0 +1,209 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Tests\Unit\Service\Cmdb;
+
+use OCA\Stackiq\Service\Cmdb\CmdbImportPackStoreProbe;
+use PHPUnit\Framework\TestCase;
+use Psr\Container\ContainerInterface;
+use stdClass;
+
+/**
+ * CmdbImportPackStoreProbe.
+ */
+class CmdbImportPackStoreProbeTest extends TestCase {
+ private const WRITE_METHODS = ['create', 'update', 'destroy', 'import', 'preview'];
+
+ private const MARKERS = ['NoCSRFRequired', 'NoAdminRequired', 'PublicPage', 'AuthorizedAdminSetting'];
+
+ /**
+ * Counter for unique generated class names.
+ *
+ * @var int
+ */
+ private static int $classes = 0;
+
+ /**
+ * Generate a controller class and return its name.
+ *
+ * @param array $marked Marker per method, written as `attribute` or `docblock`; keyed `method:marker`.
+ * @param array $without Methods the class lacks.
+ *
+ * @return string
+ */
+ private static function controller(array $marked = [], array $without = []): string {
+ $name = 'ProbeController' . (++self::$classes);
+ $body = '';
+ foreach (self::WRITE_METHODS as $method) {
+ if (in_array($method, $without, true) === true) {
+ continue;
+ }
+
+ $attribute = '';
+ $doc = '';
+ foreach ($marked as $key => $style) {
+ [$onMethod, $marker] = explode(':', $key);
+ if ($onMethod !== $method) {
+ continue;
+ }
+
+ if ($style === 'attribute') {
+ $attribute .= '#[\\OCP\\AppFramework\\Http\\Attribute\\' . $marker . ']';
+ } else {
+ $doc .= ' * @' . $marker . "\n";
+ }
+ }
+
+ $body .= "/**\n * Method.\n" . $doc . " */\n" . $attribute . ' public function ' . $method . "() {}\n";
+ }
+
+ eval('namespace OCA\\Stackiq\\Tests\\Unit\\Generated; class ' . $name . ' {' . $body . '}');
+ return 'OCA\\Stackiq\\Tests\\Unit\\Generated\\' . $name;
+ }//end controller()
+
+ /**
+ * A service with the methods the store calls.
+ *
+ * @return object
+ */
+ private static function service(): object {
+ return new class() {
+ /** @return null */
+ public function findByPackSlug(): mixed {
+ return null;
+ }
+
+ /** @return null */
+ public function find(): mixed {
+ return null;
+ }
+
+ /** @return null */
+ public function create(): mixed {
+ return null;
+ }
+
+ /** @return null */
+ public function update(): mixed {
+ return null;
+ }
+ };
+ }//end service()
+
+ /**
+ * A probe over a container that knows (or not) the service.
+ *
+ * @param string $controller The controller class.
+ * @param object|null $service What the container holds; null for nothing.
+ * @param string $hardened The hardened marker class.
+ *
+ * @return CmdbImportPackStoreProbe
+ */
+ private function probe(string $controller, ?object $service, string $hardened = stdClass::class): CmdbImportPackStoreProbe {
+ $container = $this->createMock(ContainerInterface::class);
+ $container->method('has')->willReturn($service !== null);
+ $container->method('get')->willReturn($service);
+
+ return new CmdbImportPackStoreProbe(container: $container, serviceClass: 'Some\\Service', hardenedClass: $hardened, controllerClass: $controller);
+ }//end probe()
+
+ /**
+ * The guarded controller with a complete service is usable.
+ *
+ * @return void
+ */
+ public function testAGuardedControllerAndACompleteServiceAreUsable(): void {
+ $service = self::service();
+ $this->assertSame($service, $this->probe(controller: self::controller(), service: $service)->service());
+ }//end testAGuardedControllerAndACompleteServiceAreUsable()
+
+ /**
+ * Every write method with every opening marker, as attribute and as docblock tag, makes the store unavailable.
+ *
+ * @return array
+ */
+ public static function openedWrites(): array {
+ $cases = [];
+ foreach (self::WRITE_METHODS as $method) {
+ foreach (self::MARKERS as $marker) {
+ foreach (['attribute', 'docblock'] as $style) {
+ $cases[$method . ' ' . $marker . ' ' . $style] = [$method, $marker, $style];
+ }
+ }
+ }
+
+ return $cases;
+ }//end openedWrites()
+
+ /**
+ * A write method carrying an opening marker.
+ *
+ * @param string $method The method.
+ * @param string $marker The marker.
+ * @param string $style attribute or docblock.
+ *
+ * @return void
+ *
+ * @dataProvider openedWrites
+ */
+ public function testAnOpenedWriteMethodMakesTheStoreUnavailable(string $method, string $marker, string $style): void {
+ $controller = self::controller(marked: [$method . ':' . $marker => $style]);
+ $this->assertNull($this->probe(controller: $controller, service: self::service())->service());
+ }//end testAnOpenedWriteMethodMakesTheStoreUnavailable()
+
+ /**
+ * A controller that lacks any one write method makes the store unavailable.
+ *
+ * @return void
+ */
+ public function testAMissingWriteMethodMakesTheStoreUnavailable(): void {
+ foreach (self::WRITE_METHODS as $method) {
+ $controller = self::controller(without: [$method]);
+ $this->assertNull($this->probe(controller: $controller, service: self::service())->service(), $method);
+ }
+ }//end testAMissingWriteMethodMakesTheStoreUnavailable()
+
+ /**
+ * Without the hardened marker class, the service in the container or one of its methods, the store is unavailable.
+ *
+ * @return void
+ */
+ public function testAMissingClassServiceOrServiceMethodMakesTheStoreUnavailable(): void {
+ $controller = self::controller();
+
+ $this->assertNull($this->probe(controller: $controller, service: self::service(), hardened: 'No\\Such\\Class')->service(), 'hardened class missing');
+ $this->assertNull($this->probe(controller: $controller, service: null)->service(), 'the container has no service');
+ $this->assertNull($this->probe(controller: $controller, service: new stdClass())->service(), 'the service lacks its methods');
+ $this->assertNull($this->probe(controller: 'No\\Such\\Controller', service: self::service())->service(), 'controller missing');
+ }//end testAMissingClassServiceOrServiceMethodMakesTheStoreUnavailable()
+
+ /**
+ * A container that throws makes the store unavailable.
+ *
+ * @return void
+ */
+ public function testAContainerThatThrowsMakesTheStoreUnavailable(): void {
+ $container = $this->createMock(ContainerInterface::class);
+ $container->method('has')->willThrowException(new \RuntimeException('boom'));
+ $probe = new CmdbImportPackStoreProbe(container: $container, serviceClass: 'Some\\Service', hardenedClass: stdClass::class, controllerClass: self::controller());
+
+ $this->assertNull($probe->service());
+ }//end testAContainerThatThrowsMakesTheStoreUnavailable()
+}//end class
From 6111cb8846f995b1007c999a29dddf9be0817221 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 20:48:39 +0200
Subject: [PATCH 096/124] docs(cmdb-import): align spec, design and docs with
the pack rules and the pending-update view
Closes sp3-f1 (section and UI wording that belongs to the frontend PR:
REQ-CMDB-014 references, the section acceptance criterion, the section
tasks now marked #1289), sp3-f2 (the proposal said pack writes are
admin-only through AuthorizedAdminSetting, which the probe refuses; it now
states what the probe requires, in the proposal and in REQ-CMDB-021) and
sp3-f3 (REQ-CMDB-021 names files in its refusal list). Also records the
refusal of hidden and calculated fields (be3-f1), the updatePending field
of the mapping endpoint and the kept pack without updatedAt (be3-f2, be3-f4)
in REQ-CMDB-020/021, the design, tasks and docs/features/cmdb-import.md.
Class sweep: git grep for REQ-CMDB-014, "admin-only through" and the
section wording in the change finds no remaining claim this PR does not
back.
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 13 +++++++++----
.../changes/cmdb-import-mapping-view/design.md | 10 +++++-----
.../cmdb-import-mapping-view/proposal.md | 14 +++++++-------
.../specs/cmdb-export-import/spec.md | 18 +++++++++---------
.../changes/cmdb-import-mapping-view/tasks.md | 10 +++++-----
5 files changed, 35 insertions(+), 30 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index ed2b17e16..76760cbfc 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -396,9 +396,12 @@ rules, so it checks every pack before it reads a row. It refuses a pack, with
writes the name and the role only. Refused are the fields the import sets
itself (the import key, the provider, the consumer, the application of a
usage, the business owner, the type of an organisation: a Supplier stays a
- Supplier), every link to another record, files, the publication dates, and
- the fields only an administrator or the catalogue sets, such as an
- organisation's status and who registered it. The status and registration
+ Supplier), every link to another record, files, the publication dates, the
+ fields the register hides from forms or calculates (the service desk
+ exchange's keys and stamps, the suggested TIME classification) and the
+ installed version the service desk owns, and the fields only an
+ administrator or the catalogue sets, such as an organisation's status and
+ who registered it. The status and registration
defaults of the vendor and municipality packs stay as shipped.
The mapping endpoint answers the same reason, with the link to the pack's
@@ -420,7 +423,9 @@ then on.
import on this Nextcloud, whatever the municipality.
**App updates.** A pack you have not changed follows the app: when an update
-ships a newer version, the stored pack is replaced by it. A pack you changed
+ships a newer version, the stored pack is replaced by it (the mapping
+endpoint already shows the new pack, with `updatePending` true, before the
+next import runs). A pack you changed
is never overwritten, also not when you save it while an import is updating
it. When an update ships a newer version of a pack you changed, the mapping
endpoint reports it (`newerShipped`); compare the two and copy what you need
diff --git a/openspec/changes/cmdb-import-mapping-view/design.md b/openspec/changes/cmdb-import-mapping-view/design.md
index 8aef9796c..c77046774 100644
--- a/openspec/changes/cmdb-import-mapping-view/design.md
+++ b/openspec/changes/cmdb-import-mapping-view/design.md
@@ -11,7 +11,7 @@ The CMDB import (archived change `2026-10-05-cmdb-export-import`) maps a TOPdesk
- The import runs the pack the administrator saved in OpenRegister, unchanged.
- An edit is never lost: not overwritten by an update, never silently replaced by the shipped file.
- An edit cannot make the import write what it must not write.
-- The settings section tells the administrator where each pack comes from and links to its editor.
+- The mapping endpoint tells where each pack comes from and links to its editor; the settings section that shows it is part of #1289.
**Non-Goals**
@@ -71,7 +71,7 @@ OpenRegister's pack store has no organisation column: an instance has one TOPdes
A stored pack carries `x-stackiq: {shippedVersion, shippedHash}`: the version of the shipped pack it was seeded from and the sha256 of that pack's canonical JSON (keys of every object sorted recursively, list order kept, because the order of `fieldMappings` decides which mapping wins a target). OpenRegister's validator ignores keys it does not know, so the marker travels with the definition. A stored definition that, without the marker, still hashes to `shippedHash` is unedited.
-`CmdbImportPackStore::resolve()` is the one place that writes: it creates a missing pack as built-in with the marker (never through OpenRegister's import, which upserts and would overwrite an edit), replaces an unedited pack whose marker hash differs from the current shipped pack, and leaves an edited pack alone, reporting `newerShipped`. A pack without a marker (created by hand under the same slug) counts as edited with `edited: null`, and is never replaced. The repair step `SeedCmdbImportPacks` runs it on install and upgrade, after `InitializeSettings`; the import's `CmdbImportProfile::load()` runs it too, so a pack deleted in OpenRegister is seeded again on the next import, and an instance whose OpenRegister gained the store after stackiq's upgrade is seeded by its next import. Viewing the mapping uses `loadForView()`, which calls `CmdbImportPackStore::find()`: it only reads, and a pack the store does not hold yet is shown from its shipped file. The update of an unedited pack passes the read pack's `updatedAt`, so OpenRegister refuses it (`PackChangedException`) when an administrator saved the pack in between; the store then keeps that edit. `CmdbImportPackStoreProbe` decides whether the store may be used at all: the slug-lock class exists and no write method of OpenRegister's pack controller carries an attribute or annotation that skips the CSRF check or admits non-admins.
+`CmdbImportPackStore::resolve()` is the one place that writes: it creates a missing pack as built-in with the marker (never through OpenRegister's import, which upserts and would overwrite an edit), replaces an unedited pack whose marker hash differs from the current shipped pack, and leaves an edited pack alone, reporting `newerShipped`. A pack OpenRegister reports no last-saved time for is left alone too: the update's guard against an edit saved in between needs that time. `find()`, which the mapping view uses and which writes nothing, answers an unedited outdated pack as the next import will run it: the shipped pack and version with `updatePending: true`. A pack without a marker (created by hand under the same slug) counts as edited with `edited: null`, and is never replaced. The repair step `SeedCmdbImportPacks` runs it on install and upgrade, after `InitializeSettings`; the import's `CmdbImportProfile::load()` runs it too, so a pack deleted in OpenRegister is seeded again on the next import, and an instance whose OpenRegister gained the store after stackiq's upgrade is seeded by its next import. Viewing the mapping uses `loadForView()`, which calls `CmdbImportPackStore::find()`: it only reads, and a pack the store does not hold yet is shown from its shipped file. The update of an unedited pack passes the read pack's `updatedAt`, so OpenRegister refuses it (`PackChangedException`) when an administrator saved the pack in between; the store then keeps that edit. `CmdbImportPackStoreProbe` decides whether the store may be used at all: the slug-lock class exists and no write method of OpenRegister's pack controller carries an attribute or annotation that skips the CSRF check or admits non-admins.
Version numbers alone cannot tell an edit from a release: OpenRegister's editor bumps the version on save, independently of stackiq's releases. The hash can.
@@ -157,11 +157,11 @@ l10n/en.json, en.js, nl.json, nl.js
## Testing
-- PHPUnit against an in-memory `MigrationPackService`: seeding with the marker, an unedited pack following a newer release, an edited pack kept with `newerShipped`, a pack without marker never replaced, a renamed pack and a failing store failing closed, a deleted pack seeded again, a seed that loses a race to another request, an OpenRegister without the hardened store, the canonical hash.
-- PHPUnit on the rules: the shipped packs pass, any plain schema field can be mapped, files are refused, relations, publication dates and admin fields are refused as mappings and as defaults, a plain field the register turns into a relation is refused.
+- PHPUnit against an in-memory `MigrationPackService`: seeding with the marker, an unedited pack following a newer release, an edited pack kept with `newerShipped`, an unedited pack with no `updatedAt` kept, the view answering the shipped pack with `updatePending`, a pack without marker never replaced, a renamed pack and a failing store failing closed, a deleted pack seeded again, a seed that loses a race to another request, an OpenRegister without the hardened store, the canonical hash.
+- PHPUnit on the rules: the shipped packs pass, any plain schema field can be mapped, files are refused, relations, fields the register hides or calculates, publication dates and admin fields are refused as mappings and as defaults, a plain field the register turns into a relation is refused.
- PHPUnit on the loader: a stored pack is what runs, an invalid stored pack and thirteen broken rules are 503 naming the pack and id, no store before a seed uses the files, no store after a seed fails closed, a stored date column is converted, the concat separator is the pack's, also with a later plain mapping of the note.
- PHPUnit on the import: an edit in OpenRegister changes the mapped usage, the separator and a new date column; without a store the report carries the fallback warning.
- PHPUnit on the endpoint and the repair step: the extended answer, `editUrl` from the page route and null when it cannot be built, 503 naming the pack with the link to its editor; seeding four packs, updating and keeping, no store, a failing store, the registration order.
- Playwright, through the API (`cmdb-import-packs.spec.ts`): the four packs stored as built-in packs with the marker, the mapping with source, `packId` and `editUrl`, a broken stored pack refused by the mapping and by the import, a module pack without its required name refused, an edit in OpenRegister run by the import, a deleted pack seeded again, the 403 for a non-admin. Each test that edits a pack puts back the definition it read.
-- Playwright, on the settings page (Task 7, with the section in Task 4): the section shows the source and the editor link per pack.
+- Playwright, on the settings page (Task 7, with the section in Task 4, both in #1289): the section shows the source and the editor link per pack.
- Install, upgrade and a missing or older store are not driven in e2e: they need another OpenRegister or a reinstall. The requirements state them, and the PHPUnit tests above prove them.
diff --git a/openspec/changes/cmdb-import-mapping-view/proposal.md b/openspec/changes/cmdb-import-mapping-view/proposal.md
index f8172ddaf..ff4576dc9 100644
--- a/openspec/changes/cmdb-import-mapping-view/proposal.md
+++ b/openspec/changes/cmdb-import-mapping-view/proposal.md
@@ -7,7 +7,7 @@ depends_on: []
## Summary
-A Nextcloud administrator can view and edit the column mapping of the CMDB import in OpenRegister. The four sheet packs of the import (module, manufacturer, usage and business owner) move into OpenRegister's existing migration-pack store, where OpenRegister's pack editor shows and changes them. The import reads each pack from that store first and runs exactly what the administrator saved. The packs that ship under `lib/Settings/cmdb-import/` become the seed and the fallback: a repair step stores them in OpenRegister as built-in packs, and replaces a stored pack nobody edited when a later release ships a newer one. An edited pack is never overwritten. A stored pack that is invalid, or that breaks a rule of the import, stops the import with 503 `MAPPING_UNAVAILABLE` naming the pack; it never falls back to the files silently. `GET /api/settings/cmdb-import/mapping` says per pack where it comes from, which versions are stored and shipped, whether it was edited, and links to its editor in OpenRegister. The "CMDB import" section of stackiq's admin settings shows that per pack, with the sheet names the import reads.
+A Nextcloud administrator can view and edit the column mapping of the CMDB import in OpenRegister. The four sheet packs of the import (module, manufacturer, usage and business owner) move into OpenRegister's existing migration-pack store, where OpenRegister's pack editor shows and changes them. The import reads each pack from that store first and runs exactly what the administrator saved. The packs that ship under `lib/Settings/cmdb-import/` become the seed and the fallback: a repair step stores them in OpenRegister as built-in packs, and replaces a stored pack nobody edited when a later release ships a newer one. An edited pack is never overwritten. A stored pack that is invalid, or that breaks a rule of the import, stops the import with 503 `MAPPING_UNAVAILABLE` naming the pack; it never falls back to the files silently. `GET /api/settings/cmdb-import/mapping` says per pack where it comes from, which versions are stored and shipped, whether it was edited, and links to its editor in OpenRegister. Showing that per pack in the "CMDB import" section of stackiq's admin settings is the frontend part (#1289).
## Motivation
@@ -21,11 +21,11 @@ None.
### Modified Capabilities
-- `cmdb-export-import`: ADDED REQ-CMDB-020 (the mapping endpoint and section, now with source, versions and the editor link), REQ-CMDB-021 (the import takes the sheet packs from OpenRegister's store, with the import's rules and fail-closed answers) and REQ-CMDB-022 (seeding and updating the packs in OpenRegister); MODIFIED REQ-CMDB-005 (where the packs come from, the date columns a pack adds, the pack's own concat separator) and REQ-CMDB-014 (the section shows the mapping summary).
+- `cmdb-export-import`: ADDED REQ-CMDB-020 (the mapping endpoint, now with source, versions and the editor link; the section that shows it is #1289), REQ-CMDB-021 (the import takes the sheet packs from OpenRegister's store, with the import's rules and fail-closed answers) and REQ-CMDB-022 (seeding and updating the packs in OpenRegister); MODIFIED REQ-CMDB-005 (where the packs come from, the date columns a pack adds, the pack's own concat separator).
## Affected Projects
-- [ ] Project: `stackiq`: the pack resolution and rules in `lib/Service/Cmdb/`, a repair step, the extended endpoint, the slimmed mapping block in the "CMDB import" section, documentation, translations, tests.
+- [ ] Project: `stackiq`: the pack resolution and rules in `lib/Service/Cmdb/`, a repair step, the extended endpoint, the slimmed mapping block in the "CMDB import" section (#1289), documentation, translations, tests.
- [ ] Project: `openregister` (separate change and PR): the migration-pack list and editor pages that the `editUrl` opens, the envelope body on `PUT /api/migration-packs/{id}`, and a builtin pack keeping its slug.
## Scope
@@ -38,8 +38,8 @@ None.
- The repair step `SeedCmdbImportPacks` on install and upgrade: seeds missing packs as built-in, replaces unedited packs with the shipped pack of the release, keeps edited ones and reports them; the row ids are kept in stackiq's app config.
- The `x-stackiq` marker in a stored definition: the shipped version and the hash of the shipped pack it was seeded from.
- Excel date conversion for every column a pack maps with a date transform; the note cleanup on the pack's own concat separator.
-- `GET /api/settings/cmdb-import/mapping` extended per pack with `source`, `packId`, `storedVersion`, `shippedVersion`, `edited`, `newerShipped`, `editable` and `editUrl`, and `openRegisterPacks` at the top level.
-- The section's mapping block: per pack its source, version, a hint when a newer shipped pack was not applied, and a link to its editor in OpenRegister (Task 4); the sheet names in the help text come from the endpoint.
+- `GET /api/settings/cmdb-import/mapping` extended per pack with `source`, `packId`, `storedVersion`, `shippedVersion`, `edited`, `newerShipped`, `updatePending`, `editable` and `editUrl`, and `openRegisterPacks` at the top level.
+- The section's mapping block (#1289): per pack its source, version, a hint when a newer shipped pack was not applied, and a link to its editor in OpenRegister (Task 4); the sheet names in the help text come from the endpoint.
- `docs/features/cmdb-import.md`: viewing and editing the mapping in OpenRegister.
### Out of Scope
@@ -61,13 +61,13 @@ None.
## Impact
- **Backend**: `lib/Service/Cmdb/CmdbImportProfile.php` (resolution, sources, date columns, concat separator), new `CmdbImportPackStore.php`, `CmdbImportPackMarker.php`, `CmdbImportPackRules.php`, new `lib/Repair/SeedCmdbImportPacks.php` and its registration in `appinfo/info.xml`, `lib/Controller/SettingsController.php` (`editUrl`, details), `lib/Controller/CmdbImportController.php` (the message names the pack), `lib/Service/CmdbExportImportService.php` (fallback warning, separator).
-- **Frontend**: the mapping block in the "CMDB import" section.
+- **Frontend** (#1289): the mapping block in the "CMDB import" section.
- **Data**: four rows in OpenRegister's `openregister_migration_packs`, created by the repair step or the first import; one app config key, `cmdb_import_pack_ids`.
## Cross-Project Dependencies
- **openregister** (consumed): `Service\MigrationPackService` (`findByPackSlug`, `find`, `create`, `update`), `Db\MigrationPack::getDefinitionArray()`, `Service\MigrationPack\PackDefinitionValidator`, `Service\MigrationPack\MappingEngine`, and the page route `dashboard#catchAll` for `/migration-packs/{id}`.
-- **openregister** (required before the store is used): OpenRegister's change for WOO-588 (`feat/woo588-migration-pack-editor`): the pack writes require a CSRF token and are admin-only through `#[AuthorizedAdminSetting]`, a built-in pack's slug is locked (`Exception\BuiltinPackSlugLockedException`), and it adds the migration-pack pages and the envelope body. Released OpenRegister versions write packs without a CSRF check, and the packs drive writes with RBAC off, so stackiq uses the store only when `BuiltinPackSlugLockedException` exists; on an older OpenRegister the import keeps the shipped files with a warning. Do not promote this change to beta before that OpenRegister change is released.
+- **openregister** (required before the store is used): OpenRegister's change for WOO-588 (`feat/woo588-migration-pack-editor`): the pack writes require a CSRF token and are admin-only (no `#[NoAdminRequired]`, `#[PublicPage]`, `#[AuthorizedAdminSetting]` or `#[NoCSRFRequired]` on a write method: stackiq refuses a store that has one), a built-in pack's slug is locked (`Exception\BuiltinPackSlugLockedException`), and it adds the migration-pack pages and the envelope body. Released OpenRegister versions write packs without a CSRF check, and the packs drive writes with RBAC off, so stackiq uses the store only when `BuiltinPackSlugLockedException` exists; on an older OpenRegister the import keeps the shipped files with a warning. Do not promote this change to beta before that OpenRegister change is released.
## Risks
diff --git a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
index 991044edf..b37ea6b6d 100644
--- a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
@@ -8,7 +8,7 @@
## Purpose
-The CMDB import maps the columns of a TOPdesk export to stackiq fields through an import profile and five migration packs that OpenRegister's mapping engine executes (REQ-CMDB-005). Until now the packs were JSON files under `lib/Settings/cmdb-import/`, which an administrator could only read on the server and an app update overwrote. This delta moves the four sheet packs into OpenRegister's migration-pack store, where an administrator views and edits them, makes the import run what is stored there, and shows in the "CMDB import" section where each pack comes from with a link to its editor (Jira WOO-588, sub-task of WOO-586; decision 2026-10-12: mapping lives in OpenRegister).
+The CMDB import maps the columns of a TOPdesk export to stackiq fields through an import profile and five migration packs that OpenRegister's mapping engine executes (REQ-CMDB-005). Until now the packs were JSON files under `lib/Settings/cmdb-import/`, which an administrator could only read on the server and an app update overwrote. This delta moves the four sheet packs into OpenRegister's migration-pack store, where an administrator views and edits them, and makes the import run what is stored there; the mapping endpoint says where each pack comes from with a link to its editor, and the settings section that shows it follows in #1289 (Jira WOO-588, sub-task of WOO-586; decision 2026-10-12: mapping lives in OpenRegister).
Nextcloud OCP interfaces used: `OCP\IL10N` (messages), `OCP\IURLGenerator` (the editor link), `OCP\IAppConfig` (the stored pack ids), `OCP\Migration\IRepairStep` (seeding). OpenRegister: `Service\MigrationPackService`, `Service\MigrationPack\PackDefinitionValidator` and `Service\MigrationPack\MappingEngine`, resolved defensively.
@@ -19,10 +19,10 @@ Nextcloud OCP interfaces used: `OCP\IL10N` (messages), `OCP\IURLGenerator` (the
`GET /api/settings/cmdb-import/mapping` SHALL be reachable only by Nextcloud admins and SHALL require Nextcloud's CSRF token; like the import routes (REQ-CMDB-001) it SHALL NOT carry `#[AuthorizedAdminSetting]`, `#[NoAdminRequired]` or `#[NoCSRFRequired]`. It SHALL load the import profile and every pack through the same loader, store and validator the import uses (REQ-CMDB-021), and SHALL answer 200 with a flat envelope of three keys:
- `profile`: the profile's `id`, `name`, `version`, `sheets` with each sheet's `name`, `constants` and `absentColumns`, `sheetPrecedence`, `keyColumn`, `nameColumn`, `requiredColumns`, `dateColumns` (the columns the import converts from Excel dates, REQ-CMDB-005), `idColumns`, `emptyValues`, `externalKeyPrefix`, `missingRecords` and `profileFile`;
-- `packs`: one entry per target in the order module, manufacturer, municipality, usage, businessOwner, each with `target`, `file`, `id`, `name`, `version`, `description`, `fieldMappings`, the write rules `defaults`, `createOnlyDefaults`, `createOnlyFields` and `neverWritten`, and where the pack comes from: `source` (`openregister` or `file`), `packId` (the OpenRegister row id, or null), `storedVersion` (or null), `shippedVersion`, `edited` (whether the stored pack differs from what stackiq seeded; null when unknown or for a file), `newerShipped` (an edited pack kept a newer shipped pack out), `editable` (false for the municipality pack) and `editUrl` (the absolute link to the pack's editor in OpenRegister, built by Nextcloud's URL generator so an instance that needs `/index.php` gets it, or null when the pack is not stored there or the link cannot be built);
+- `packs`: one entry per target in the order module, manufacturer, municipality, usage, businessOwner, each with `target`, `file`, `id`, `name`, `version`, `description`, `fieldMappings`, the write rules `defaults`, `createOnlyDefaults`, `createOnlyFields` and `neverWritten`, and where the pack comes from: `source` (`openregister` or `file`), `packId` (the OpenRegister row id, or null), `storedVersion` (or null), `shippedVersion`, `edited` (whether the stored pack differs from what stackiq seeded; null when unknown or for a file), `newerShipped` (an edited pack kept a newer shipped pack out), `editable` (false for the municipality pack), `updatePending` (an unedited stored pack that the next import run replaces with the shipped one; then `version`, `fieldMappings`, `defaults` and the other pack fields show the shipped pack and `storedVersion` what OpenRegister holds) and `editUrl` (the absolute link to the pack's editor in OpenRegister, built by Nextcloud's URL generator so an instance that needs `/index.php` gets it, or null when the pack is not stored there or the link cannot be built);
- `openRegisterPacks`: whether OpenRegister's migration-pack store is available.
-Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it. `createOnlyDefaults` holds values the import sets on create only, `createOnlyFields` the mapped fields written on create or, on an existing object, only when the stored value is empty (a key of `createOnlyDefaults` only when it is also a mapped field), and `neverWritten` the fields the import never writes on an existing object. When the validator is missing, a pack is unreadable, invalid or breaks a rule of the import, or the store that holds the packs is gone, the endpoint SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason` (its first line, at most 500 characters), and for a pack stored in OpenRegister its slug in `details.pack`, its row id in `details.packId` and the link to its editor in `details.editUrl` (null when the link cannot be built). Reading the mapping SHALL change nothing: it SHALL NOT seed, update or delete a pack in OpenRegister and SHALL NOT write stackiq's app config; a pack the store does not hold yet SHALL be answered from its shipped file (`source` `file`), and an unedited stored pack that a newer shipped pack would replace SHALL be answered as stored until the next import run or upgrade replaces it.
+Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it. `createOnlyDefaults` holds values the import sets on create only, `createOnlyFields` the mapped fields written on create or, on an existing object, only when the stored value is empty (a key of `createOnlyDefaults` only when it is also a mapped field), and `neverWritten` the fields the import never writes on an existing object. When the validator is missing, a pack is unreadable, invalid or breaks a rule of the import, or the store that holds the packs is gone, the endpoint SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason` (its first line, at most 500 characters), and for a pack stored in OpenRegister its slug in `details.pack`, its row id in `details.packId` and the link to its editor in `details.editUrl` (null when the link cannot be built). Reading the mapping SHALL change nothing: it SHALL NOT seed, update or delete a pack in OpenRegister and SHALL NOT write stackiq's app config; a pack the store does not hold yet SHALL be answered from its shipped file (`source` `file`), and an unedited stored pack that a newer shipped pack would replace SHALL be answered as the import will run it, that is as the shipped pack with `updatePending` `true`, without writing anything; a stored pack of which OpenRegister reports no last-saved time is kept by the import and answered as stored.
#### Scenario: An admin reads the mapping the import uses
@e2e tests/e2e/spec-coverage/cmdb-import-packs.spec.ts
@@ -58,10 +58,10 @@ Every pack, stored or shipped, SHALL pass OpenRegister's `PackDefinitionValidato
- the module pack SHALL map `name`, and SHALL map the profile's key column (`APPID`) to `externalNumber`, both with `required: true`; the manufacturer, municipality and business-owner packs SHALL map `name` with `required: true`;
- a pack SHALL map, and set as a default, only properties of its target schema in the register stackiq ships, fragments included (module; organisation for manufacturer and municipality; usage); every such property is allowed unless a rule below refuses it, so no list of fields is kept in code; the business-owner pack, which writes into no schema, only `name` and `role`;
-- no pack SHALL map or default `@self`, `id`, `externalKey`, `provider`, `consumer`, `module`, `businessOwner`, a field the profile lists as never written, a relation (a property whose definition or items carry a `$ref`, an `inversedBy`, a `writeBack`, `format: uuid` or a related object), `publicationDate`, `depublicationDate`, `registeredBy`, `registrationStatus`, `mergedInto`, `contactsUid`, `pki`, `xml`, an organisation's `status` or a module's `type`;
+- no pack SHALL map or default `@self`, `id`, `externalKey`, `provider`, `consumer`, `module`, `businessOwner`, a field the profile lists as never written, a relation or a file (a property whose definition or items carry a `$ref`, an `inversedBy`, a `writeBack`, `format: uuid`, `type: file` or a related object), a property the register hides from forms (`hideOnForm: true`, in the base register or a fragment) or calculates (an `x-openregister-calculations` entry), which other subsystems own (the service desk exchange's `serviceDeskSystem`, `serviceDeskRecordId`, `serviceDeskUrl` and `serviceDeskSyncedAt`, the calculated `suggestedTimeClassification`), a usage's `installedVersion`, `publicationDate`, `depublicationDate`, `registeredBy`, `registrationStatus`, `mergedInto`, `contactsUid`, `pki`, `xml`, an organisation's `status` or a module's `type`;
- the manufacturer and municipality packs SHALL NOT map `type` and SHALL keep the defaults as shipped: `type` `Supplier` and `Municipality`, `status` `Active`, and the manufacturer's `registeredBy` `Supplier`.
-OpenRegister's store SHALL count as available only when its pack writes require a CSRF token and lock a built-in pack's slug (the class `OCA\OpenRegister\Exception\BuiltinPackSlugLockedException` exists). When OpenRegister's store is not available and no pack was ever stored there, the import SHALL use the shipped files and SHALL add one warning to the report saying so. Once the packs were stored there, a store that is not available SHALL stop the import with 503 `MAPPING_UNAVAILABLE` naming the stored packs. A pack whose stored row now carries another slug SHALL stop the import with 503 naming the row and both slugs; a pack whose row was deleted SHALL be seeded again. The import's 503 message SHALL name the OpenRegister pack and its id when one is at fault.
+OpenRegister's store SHALL count as available only when its pack writes require a CSRF token and lock a built-in pack's slug (the class `OCA\OpenRegister\Exception\BuiltinPackSlugLockedException` exists), and none of the pack controller's write methods (create, update, destroy, import, preview) is opened to non-admins or exempted from the CSRF check (`#[NoAdminRequired]`, `#[PublicPage]`, `#[AuthorizedAdminSetting]` or `#[NoCSRFRequired]`). When OpenRegister's store is not available and no pack was ever stored there, the import SHALL use the shipped files and SHALL add one warning to the report saying so. Once the packs were stored there, a store that is not available SHALL stop the import with 503 `MAPPING_UNAVAILABLE` naming the stored packs. A pack whose stored row now carries another slug SHALL stop the import with 503 naming the row and both slugs; a pack whose row was deleted SHALL be seeded again. The import's 503 message SHALL name the OpenRegister pack and its id when one is at fault.
#### Scenario: An edit in OpenRegister is what the import runs
@e2e tests/e2e/spec-coverage/cmdb-import-packs.spec.ts
@@ -159,16 +159,16 @@ The service SHALL map each normalised row with OpenRegister's `MigrationPack\Map
- **Performance:** loading the mapping reads seven small JSON files and the register definition, and makes at most one OpenRegister lookup per sheet pack; it SHALL answer within the time an admin settings page loads. Reading the mapping writes nothing; only the import run and the seed step seed or update a pack.
- **Security:** admin-only with CSRF, as the import routes; OpenRegister's pack writes are admin-only with CSRF, and stackiq uses no store without those guards. The import writes with OpenRegister's RBAC and multitenancy off, so every pack SHALL follow the import's rules (REQ-CMDB-021). An edit that maps a new column makes the import read that column. The answer and the reasons hold column names, field names, lookup tables, pack names and ids; no object data and no person data. A store failure is named by its exception class, never its message.
- **Reliability:** an edit is never overwritten by an upgrade and never silently replaced by the shipped files (REQ-CMDB-021, REQ-CMDB-022).
-- **Accessibility:** not applicable to this part: the endpoint, the import rules and the seed step have no user interface. The section that shows the mapping is part of Task 4 and targets WCAG 2.2 AA.
-- **Internationalization:** Dutch and English MUST be supported (ADR-005) for the block, the messages and the import warning.
+- **Accessibility:** not applicable to this part: the endpoint, the import rules and the seed step have no user interface. The section that shows the mapping is part of #1289.
+- **Internationalization:** Dutch and English MUST be supported (ADR-005) for the messages and the import warning.
## Acceptance Criteria
- [ ] After install, OpenRegister lists the four `stackiq-topdesk-*` packs as built-in packs.
- [ ] A Nextcloud admin edits a lookup value of the usage pack in OpenRegister, and the next CMDB import uses it.
-- [ ] A pack edited to map a field the import manages makes the import answer 503 naming the pack; the mapping block shows the same.
+- [ ] A pack edited to map a field the import manages makes the import answer 503 naming the pack; the mapping endpoint answers the same 503.
- [ ] Deleting the stored pack in OpenRegister makes the next import seed the shipped pack again.
-- [ ] The "CMDB import" section shows per pack its source and a link that opens the pack's editor (Task 4).
+- [ ] The mapping endpoint answers per pack its source and the link that opens the pack's editor (the section that shows them is #1289).
- [ ] A signed-in user who is not a Nextcloud admin gets 403 from the endpoint.
## Notes
diff --git a/openspec/changes/cmdb-import-mapping-view/tasks.md b/openspec/changes/cmdb-import-mapping-view/tasks.md
index 67e261fd8..fe0f07745 100644
--- a/openspec/changes/cmdb-import-mapping-view/tasks.md
+++ b/openspec/changes/cmdb-import-mapping-view/tasks.md
@@ -34,13 +34,13 @@ Spec: `openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.m
- **acceptance_criteria**:
- GIVEN the route WHEN its method is reflected THEN it carries no attribute, declares `@auth admin-only` with a reason and no exemption annotation
- GIVEN the shipped files and no store THEN 200 with `{profile, packs, openRegisterPacks: false}`, every pack `source: file`, `editUrl: null`
- - GIVEN the store THEN the four sheet packs answer `source: openregister`, `packId`, `storedVersion`, `shippedVersion`, `edited`, `newerShipped`, `editable: true` and `editUrl` built from OpenRegister's page route; the municipality pack answers `editable: false`
+ - GIVEN the store THEN the four sheet packs answer `source: openregister`, `packId`, `storedVersion`, `shippedVersion`, `edited`, `newerShipped`, `updatePending`, `editable: true` and `editUrl` built from OpenRegister's page route; the municipality pack answers `editable: false`
- GIVEN a broken stored pack THEN 503 with `details.reason`, `details.pack` and `details.packId`
- [x] Implement
- [x] Test
-### Task 4: The section's mapping block shows the source and the editor link per pack
-- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014` (cmdb-export-import#REQ-CMDB-014) and `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020)
+### Task 4 (#1289): The section's mapping block shows the source and the editor link per pack
+- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020); the section requirement is changed in #1289
- **files**: `src/views/settings/sections/CmdbImportMapping.vue`, `src/views/settings/sections/CmdbImport.vue`, `src/utils/cmdbImport.js`, `tests/vitest/cmdbImportMapping.spec.js`
- **acceptance_criteria**:
- GIVEN the endpoint's answer WHEN the block renders THEN each pack shows its source, its version, a hint when `newerShipped`, and a link to its editor (`editUrl`) when there is one
@@ -71,8 +71,8 @@ Spec: `openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.m
- [x] Implement
- [x] Test
-### Task 7: End-to-end coverage of the section
-- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014` (cmdb-export-import#REQ-CMDB-014)
+### Task 7 (#1289): End-to-end coverage of the section
+- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020); the section itself is specified in #1289
- **files**: `tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts`
- **acceptance_criteria**:
- GIVEN an admin on the settings page WHEN the mapping has loaded THEN every sheet pack shows its source and a link to its editor
From 94f976ecfd317500d5c7d51781556db218668875 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 20:48:39 +0200
Subject: [PATCH 097/124] test(cmdb-import): put the deleted business-owner
pack back when the test fails
Closes sp3-f4. The deleted-pack e2e test only re-seeded the pack through
its own import call, so a failed assertion before it left the rig without
the stored pack. The finally block now imports when the pack is still
missing.
Co-Authored-By: Claude Opus 5.5
---
tests/e2e/spec-coverage/cmdb-import-packs.spec.ts | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/tests/e2e/spec-coverage/cmdb-import-packs.spec.ts b/tests/e2e/spec-coverage/cmdb-import-packs.spec.ts
index ae9bb5ab1..30993b2ce 100644
--- a/tests/e2e/spec-coverage/cmdb-import-packs.spec.ts
+++ b/tests/e2e/spec-coverage/cmdb-import-packs.spec.ts
@@ -505,6 +505,13 @@ test.describe.serial('CMDB import packs in OpenRegister', () => {
expect(after.id).toBe(owner.packId)
expect(after.builtin).toBe(true)
} finally {
+ // A failed assertion above must not leave the pack deleted: the import puts it back.
+ if (
+ (await storedPacks(ctx))['stackiq-topdesk-business-owner']
+ === undefined
+ ) {
+ await importExport(ctx)
+ }
await ctx.dispose()
}
})
From 1004ee711a87837cf46e90610fa2b5084051144d Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 20:53:20 +0200
Subject: [PATCH 098/124] test(cmdb-import): expect updatePending in the
mapping endpoint's pack keys
The controller test pins the keys the settings section reads; the pack
entries now carry updatePending (be3-f2), so it lists the key and asserts
it is false for a current stored pack.
Co-Authored-By: Claude Opus 5.5
---
.../Controller/SettingsControllerCmdbImportMappingTest.php | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php b/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
index 46433b1a3..49fac6991 100644
--- a/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
+++ b/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php
@@ -449,6 +449,7 @@ function (string $route, array $parameters) use (&$routes): string {
$this->assertTrue($pack['editable'], $target);
$this->assertFalse($pack['edited'], $target . ' is as seeded');
$this->assertFalse($pack['newerShipped'], $target);
+ $this->assertFalse($pack['updatePending'], $target);
$this->assertSame($pack['shippedVersion'], $pack['storedVersion'], $target);
}
@@ -457,7 +458,7 @@ function (string $route, array $parameters) use (&$routes): string {
$this->assertNull($byTarget['municipality']['editUrl']);
$this->assertFalse($byTarget['municipality']['editable']);
$this->assertSame(
- ['target', 'file', 'id', 'name', 'version', 'description', 'fieldMappings', 'defaults', 'createOnlyDefaults', 'createOnlyFields', 'neverWritten', 'source', 'packId', 'storedVersion', 'shippedVersion', 'edited', 'newerShipped', 'editable', 'editUrl'],
+ ['target', 'file', 'id', 'name', 'version', 'description', 'fieldMappings', 'defaults', 'createOnlyDefaults', 'createOnlyFields', 'neverWritten', 'source', 'packId', 'storedVersion', 'shippedVersion', 'edited', 'newerShipped', 'updatePending', 'editable', 'editUrl'],
array_keys($byTarget['module']),
'the keys the settings section reads'
);
From 0a349a853fa48a0f4bb9bd035ecb820a5e0f3b61 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 20:59:24 +0200
Subject: [PATCH 099/124] fix(cmdb-import): fail closed when a register
fragment cannot be read
The pack rules read the base register and its register.d fragments, but
skipped a fragment that was unreadable or invalid while the base register
threw. The fragments now carry the hideOnForm and calculated protection
(for example the service desk fields in sharing-itsm-exchange.json), so a
skipped fragment made those fields mappable again. An unreadable fragment,
or a register.d listing that fails, now throws MAPPING_UNAVAILABLE naming
the fragment, as the base register does.
Class sweep: schemaFields() is the only reader of the register files in
the rules; the base register, each fragment and the listing all fail
closed now. Test covers an invalid and an empty fragment and goes red
with the check removed.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Cmdb/CmdbImportPackRules.php | 22 ++++++++++++---
.../Service/Cmdb/CmdbImportPackRulesTest.php | 27 +++++++++++++++++++
2 files changed, 46 insertions(+), 3 deletions(-)
diff --git a/lib/Service/Cmdb/CmdbImportPackRules.php b/lib/Service/Cmdb/CmdbImportPackRules.php
index 252cc1fb9..02f5cd3dc 100644
--- a/lib/Service/Cmdb/CmdbImportPackRules.php
+++ b/lib/Service/Cmdb/CmdbImportPackRules.php
@@ -458,7 +458,7 @@ private static function isSystemOwned(mixed $definition, bool $calculated): bool
*
* @return array