diff --git a/.github/workflows/branch-protection.yml b/.github/workflows/branch-protection.yml
index d3e40cad8..3d7f6785d 100644
--- a/.github/workflows/branch-protection.yml
+++ b/.github/workflows/branch-protection.yml
@@ -7,7 +7,7 @@ on:
- beta
jobs:
- protect:
+ branch-protection:
# The callee's `check-branch` job is a single bash comparison of
# `github.base_ref` against `github.head_ref` — no checkout, no network, no
# API call. It needs nothing, so it gets nothing. `{}` is an explicit empty
diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index 026f5a693..81c61d62d 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -132,14 +132,14 @@ jobs:
app-name: stackiq
php-version: "8.3"
# THE LIST IS THE WHOLE DECLARED RANGE. appinfo/info.xml declares
- # , so 32, 33 and 34 each get
- # a leg. Adopting NC 34 by REPLACING the list left 32 and 33 advertised to
- # the App Store with no job touching them — the declared floor became the
+ # , so 32, 33, 34 and 35 each
+ # get a leg. Adopting NC 34 by REPLACING the list left 32 and 33 advertised
+ # to the App Store with no job touching them — the declared floor became the
# untested end, which is the same drift as never testing 34, reversed.
- # stable34 leads because newman, playwright and journeydoc-capture all read
- # `fromJSON(inputs.nextcloud-test-refs)[0]` as their single server, and 34
- # is the major this app had never been exercised on.
- nextcloud-test-refs: '["stable34", "stable32", "stable33"]'
+ # stable35 leads because newman, playwright and journeydoc-capture all read
+ # `fromJSON(inputs.nextcloud-test-refs)[0]` as their single server, and 35
+ # is the newest major this app declares (same order as thematiq).
+ nextcloud-test-refs: '["stable35", "stable32", "stable33", "stable34"]'
enable-psalm: true
enable-phpstan: true
enable-phpmetrics: true
@@ -158,7 +158,14 @@ jobs:
# object API directly (tests/e2e/workflows/_fixtures.ts), so testing
# against `main` measures a different backend than the one this app is
# written for. Pinned to `development` to match.
- additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"development"}]'
+ #
+ # integriq is here because the Integrations page reads integriq's
+ # `app_connection` rows (adopt-connection-registry). Without it the page
+ # shows the missing-dependency screen and
+ # `tests/e2e/workflows/integrations-page.spec.ts` fails on every run.
+ # `app` is `integriq`, verified in its appinfo/info.xml on `development`
+ # on 2026-09-14.
+ additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"development"},{"repo":"ConductionNL/integriq","app":"integriq","ref":"development"}]'
# Newman disabled: tests/magic-mapper-import.postman_collection.json was
# written against a dev env with URL rewriting + a fixed disk layout — it
# hits bare paths like `/configurations` and uploads files from
diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
index 98db0b547..4c664413e 100644
--- a/.github/workflows/documentation.yml
+++ b/.github/workflows/documentation.yml
@@ -14,6 +14,13 @@ on:
jobs:
deploy:
+ # Permission CEILING for the called workflow, not a grant: the callee's
+ # deploy job declares `contents: write` (gh-pages push) and its fallback
+ # image job `packages: write` (GHCR). A ceiling below what the callee
+ # declares makes the call fail to start.
+ permissions:
+ contents: write
+ packages: write
uses: ConductionNL/.github/.github/workflows/documentation.yml@main
# A reusable workflow receives NO secrets by default. Without this block the
# callee's publish step finds CF_API_TOKEN empty, skips itself on its own
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9771ab106..87505881a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,13 @@
# Changelog
## [Unreleased]
+### Added
+- CMDB import: a Nextcloud admin imports a TOPdesk CMDB export (xlsx) for one
+ municipality from the admin settings. Every application row of the two CMDB
+ 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`.
+
### Changed
- EOL feed: the register and schema slugs the feature reads from are corrected.
`EOL_DEFAULT_REGISTER` still said `openconnector`, a register renamed to
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 15874ec6c..aa70380b6 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -50,7 +50,7 @@ Vrij en open source onder de EUPL-licentie.
**Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. Voor een Service Level Agreement (SLA), neem contact op via sales@conduction.nl.
]]>
- 0.2.2-unstable.20260910105110
+ 0.2.4-unstable.20261001120000EUPL-1.2ConductionStackiq
@@ -91,7 +91,7 @@ Vrij en open source onder de EUPL-licentie.
pgsqlsqlitemysql
-
+
@@ -224,6 +224,7 @@ Vrij en open source onder de EUPL-licentie.
OCA\Stackiq\Repair\InitializeSettingsOCA\Stackiq\Repair\MigrateContactsToNcOCA\Stackiq\Repair\BackfillContractApprovalState
+ OCA\Stackiq\Repair\BackfillModuleVersionPublication
+
+# AI systems
+
+An AI system is an AI agent, an AI model or an AI feature that your organisation uses. You register it next to the application it runs in, classify it under the EU AI Act, and keep the documents the act asks for.
+
+Specification: [`openspec/specs/ai-system-inventory/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/ai-system-inventory/spec.md).
+
+## Registering an AI system
+
+Open **Applications** in the navigation menu, then **AI systems**, and click **Add**. Fill in:
+
+- **Name** and **Description**.
+- **Kind**: an AI agent acts on its own, an AI model is a trained model, an AI feature is part of an application.
+- **Application**: the application it runs in or supports.
+- **Supplier** and **Purpose**: who supplies it and what it decides, recommends or produces.
+
+The page of the application shows its AI systems in the **AI systems** section.
+
+## Classifying it under the AI Act
+
+Each AI system records:
+
+- **AI Act risk category**: prohibited, high risk, limited risk, minimal risk, or not yet assessed. A new system starts as not yet assessed.
+- **Role under the AI Act**: provider or deployer.
+- **Last assessed on** and the **Algorithm register entry**, the link to the system in the Dutch algorithm register.
+
+The category is your organisation's own classification. Stackiq records it; it does not decide it.
+
+## Evidence
+
+Attach documents to the AI system under **Documents** and tag each one: FRIA (fundamental rights impact assessment), Technical documentation, Human oversight or Logging. The **AI Act evidence** panel on the page lists the four tags and shows which have a document.
+
+Fill in **Fundamental rights impact assessment** with a reference to the FRIA. A high-risk AI system without one:
+
+- reads **FRIA missing** in the list,
+- shows a warning on its page,
+- and appears under the **High risk without FRIA** filter above the list.
+
+The other filters above the list select one risk category each.
+
+Screenshots follow once the feature runs on the demo instance.
diff --git a/docs/features/application-page.md b/docs/features/application-page.md
new file mode 100644
index 000000000..39581ff1c
--- /dev/null
+++ b/docs/features/application-page.md
@@ -0,0 +1,29 @@
+
+
+# The application page
+
+One page per application shows what the catalogue knows about it: its data, the organisations that use it, its versions, its compliance claims and the contracts behind it.
+
+Specification: [`openspec/specs/application-page/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/application-page/spec.md).
+
+## Opening the page
+
+Open **Applications** from the navigation menu and click a row, or use the **View** action on it. The page opens at `/modules/`. The page also opens from an organisation's list of applications.
+
+## What the page shows
+
+- **Application**: the name, the short and long description, the website, the supplier, the supplier's contact person for this product, and the hosting, licence, BBN and DPIA fields.
+- **Documentation**: files attached to the application.
+- **Vendor and services**: the supplier and the services and connections linked to the application.
+- **Application versions**: every registered version with its status. A row opens the version.
+- **Usages**: every organisation that registered a usage of the application, with the version it uses and the status of that usage. You see the usages you may read: a municipality sees its own, a supplier sees the usages of its products.
+- **Compliance claims**: the standards and BIO measures the application claims, with the evidence. A row opens the claim.
+- **Contracts**: every contract on a usage of the application, and every contract on a service that offers it, each listed once, with its number, type, end date and status. A row opens the contract. You see only the contracts you may read.
+- **Reviews**: ratings and reviews of the application.
+
+## Why a contract shows up here
+
+A contract in stackiq belongs to a usage and a service, not to the application directly. The page follows both links: a contract appears when its usage is a usage of this application, or when its service offers this application.
diff --git a/docs/features/applications-in-use.md b/docs/features/applications-in-use.md
new file mode 100644
index 000000000..0c4e63fb2
--- /dev/null
+++ b/docs/features/applications-in-use.md
@@ -0,0 +1,38 @@
+
+
+# Applications in use
+
+An application in use records that your organisation uses an application: which version it runs, where it stands in its lifecycle, and who owns it on the business side and on the technical side. The portfolio views, the lifecycle roadmap and the end-of-support warnings all start from these records.
+
+Specification: [`openspec/specs/application-usage-pages/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/application-usage-pages/spec.md).
+
+## Adding an application to your landscape
+
+Open the application's page under **Applications** and click **Add to our landscape** in the usages section. The application is already filled in. Pick:
+
+- **Consumer**: your organisation.
+- **Version**: the version you run. The list only offers versions of this application.
+- **Status**: Acquisition, Planned, In production, To be phased out or Phased out.
+- **Business owner** and **Technical owner**: contact persons of your organisation.
+
+## Browsing what you use
+
+Open **Applications** in the navigation menu, then **Applications in use**. The list shows each application with its version, status, owners and TIME classification. The tabs above the list filter on status. Your organisation's page lists the same records under **Applications in use**.
+
+## Moving through the lifecycle
+
+Open an application in use. The actions at the top follow its status:
+
+- **Plan** moves Acquisition to Planned.
+- **Go live** moves Planned to In production.
+- **Phase out** moves In production to To be phased out.
+- **Retire** moves To be phased out to Phased out.
+
+Every change is kept in the **History** tab.
+
+## Who sees the owners
+
+The owners are contact persons of the organisation that uses the application. A supplier can read the usages of its own products, but it cannot open the contact persons of its customers.
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
new file mode 100644
index 000000000..b9d505abc
--- /dev/null
+++ b/docs/features/cmdb-import.md
@@ -0,0 +1,365 @@
+
+
+# CMDB import
+
+Imports a TOPdesk CMDB export (an Excel workbook, `.xlsx`) for one
+municipality. Every application row of the two CMDB sheets becomes, or
+updates:
+
+- a **module** (the application, `schema:SoftwareApplication`);
+- its vendor (the maker of the software) as an **organisation** of type
+ Supplier;
+- a **usage** that links the application to the municipality;
+- a **contact person** of the municipality for its owner, with the identity
+ in Nextcloud Contacts. Owners are never readable by the public.
+
+All of it is stored as OpenRegister objects in the stackiq register. Import a
+newer export later and the same applications are updated, not duplicated.
+
+Specification: [`openspec/specs/cmdb-export-import/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/cmdb-export-import/spec.md) (change archived in [`openspec/changes/archive/2026-10-05-cmdb-export-import/`](https://github.com/ConductionNL/stackiq/tree/development/openspec/changes/archive/2026-10-05-cmdb-export-import)).
+
+## Who can import
+
+Only Nextcloud administrators. The import writes into the catalogue of
+any municipality, past OpenRegister's access rules and organisation
+boundaries, so it is not open to the groups an administrator delegated
+stackiq's admin settings to (**Administration settings → Administration
+privileges**), nor to members of `software-catalog-admins`. The section is
+part of stackiq's admin settings, under **Administration settings → Stackiq
+→ CMDB import**.
+
+## Before you start
+
+The import itself only needs stackiq and OpenRegister. To see the imported
+applications in the other apps, two things must be set up there. The import
+does not change either of them.
+
+**OpenCatalogi (search).** OpenCatalogi lists an application only through a
+catalogue that includes the register `stackiq` and the schema `module`. In
+OpenCatalogi, open the catalogue that should show the municipality's
+applications and add that register and schema. A newly imported module gets
+a publication date (the moment the import started), so it is listed from then
+on, unless you turn off **Publish the applications this import creates**
+(see the steps below).
+
+**Portaliq ("Software we use").** Portaliq shows an application to a
+municipality through a usage whose consumer is that municipality. The portal
+account of the municipality needs the claim `stackiq.organisationId` set to
+the uuid of the municipality organisation the import used. The uuid is in
+the import result (the municipality line) and on the organisation's detail
+page in stackiq.
+
+## Steps
+
+1. Open **Administration settings → Stackiq** and scroll to **CMDB import**.
+2. **Municipality.** Pick an existing organisation of type Municipality from
+ the list, or type the name of a new one and press Enter. The list shows
+ only active municipalities, not merged or inactive ones; municipalities
+ with the same name show the start of their id after the name. A typed name
+ is sent to the server as a name, and a name that matches one existing
+ municipality (ignoring case and extra spaces) uses that municipality; otherwise a new organisation of type Municipality with
+ status Active is created during the import, and the result warns about
+ it. When more than one municipality has the typed name, the import is
+ refused (`MUNICIPALITY_AMBIGUOUS`): pick the right one from the list.
+3. **File.** Choose the TOPdesk export (`.xlsx`, at most 10 MB by default; see
+ [Limits](#limits)).
+4. **Update existing records.** On by default. Turn it off to import only
+ applications that are new for this municipality; rows that match an
+ existing application are then reported as *skipped* with reason `exists`
+ and nothing about them changes.
+5. **Publish the applications this import creates.** On by default. A
+ published application is visible to anyone, including anonymous visitors
+ of OpenCatalogi. Turn it off to create the new applications without a
+ publication date; they stay unpublished until you publish them by hand,
+ and the summary counts them as *created unpublished*. Applications that
+ were imported before keep their publication as it is, whichever you
+ choose.
+6. Press **Import**. A progress bar shows how many rows have been processed.
+ **Cancel import** stops the import before the next row; rows that were
+ already processed stay imported. 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.
+
+One import runs at a time. While an import is running, a second one, from
+another administrator or another browser tab, is refused with
+`IMPORT_IN_PROGRESS` and reads nothing; start it again when the first has
+finished.
+
+When the import finishes, the section shows:
+
+- the **summary**: rows read, created, updated, unchanged, skipped, failed
+ and warnings, and, when publishing was off, how many applications were
+ created unpublished;
+- **warnings for the whole file**, for example an optional column that is
+ missing;
+- the **rows** table: sheet, row number, APPID, application, outcome,
+ and the reasons and warnings for that row. Filter it with **Show rows with
+ outcome**, and sort it by clicking a column header. It shows 100 rows at a
+ time; **Show 100 more rows** adds the next ones. The application name links
+ to the module in stackiq.
+
+The municipality stays selected after an import, so a second import goes to
+the same organisation.
+
+## The file
+
+The import reads the two CMDB sheets of the export and ignores all others,
+including the `Invoer` sheets they are derived from:
+
+| Sheet | What it holds | Recorded on the usage |
+|---|---|---|
+| `Onbeh Applicaties CMDB` | applications **without** arranged maintenance (from the AIA export) | `Beheer geregeld: nee` |
+| `Beheerde Applicaties CMDB` | applications **with** arranged maintenance (from the APP export) | `Beheer geregeld: ja` |
+
+At least one of the two must be present. Row 1 of each sheet holds the
+column names. Columns are found by name per sheet, not by position: case,
+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)).
+
+Two columns are **required** on every CMDB sheet that is present: `APPID`
+and `Applicatie Naam`. Every other column is optional; when one is missing,
+the import names it once in the warnings for the whole file.
+
+**Formulas.** The CMDB sheets are formulas that read the `Invoer` sheets.
+The import reads the value Excel stored with each formula cell; formulas are
+never calculated. Save the workbook in Excel before importing it, so every
+formula has a stored value. A formula without a stored value is read as an
+empty cell and the row carries the warning `Column "…": formula without a
+cached value, read as empty`; the row is still imported. A stored `0` is what
+Excel shows for a reference to an empty cell, and is read as empty too.
+External data connections, Power Query queries and links in the workbook are
+never opened. Macro-enabled workbooks (`.xlsm`), old Excel files (`.xls`) and
+CSV files are not accepted.
+
+**Placeholder values.** The CMDB sheets fill an empty `BNN Classificatie`
+with `NB` ("niet bekend"); that is read as empty. Dates are stored as the
+file has them: the CMDB's 2036-01-01 in `End-of-Life Functioneel` is imported
+as 2036-01-01. The CMDB writes that date when TOPdesk has no end-of-life date,
+so it is a stand-in for "no date". It is stored as the phase-out date of the
+usage and shown like any real one, and the usage reads as "Phased out"
+once that date is reached. A re-import with **Update existing records** on
+fills the date where it was left empty before. It also replaces a different
+date stored there, including one set by hand.
+
+### Columns and where they go
+
+| Column | Goes to | Rule |
+|---|---|---|
+| APPID | module external number, and the match key | required, see [Repeat imports](#repeat-imports) |
+| Applicatie Naam | module name | required |
+| Applicatie Code | module external id | reference only; it can change in TOPdesk, so it is not the match key |
+| Roepnaam, Nickname | module short description | Roepnaam when filled, otherwise Nickname (only on `Beheerde Applicaties CMDB`) |
+| Functionele Omschrijving | module long description | |
+| Applicatiesoort | module application type (`applicationType`) and hosting model (`cloudDienstverleningsmodel`) | stored as is as the application type (Webapplicatie, Client/server, Saas, …); `Saas` → SaaS, `PaaS` → PaaS, `IaaS` → IaaS, `On-premise(s)` → On-premises (self-managed) also set the hosting model, any other kind leaves it empty |
+| BNN Classificatie | module BBN level | `1`, `2`, `2+`, `3` (and `BBN1`/`BBN 1`/`BNN1` etc.) become `BBN1`, `BBN2`, `BBN2+`, `BBN3`; `NB` is empty; another value is dropped with a warning |
+| Datum | module external creation date | Excel date |
+| Referentie datum wijziging | module external modification date | Excel date |
+| Vendor | Supplier organisation, set as provider on the module and the usage | one organisation per name, see below |
+| Applicatie Status | usage status | follows the export on every import (see [Repeat imports](#repeat-imports)); In productie → In production, In voorraad → Planned, In ontwikkeling → Acquisition, Uit te faseren and Moet verwijderd worden → To be phased out, Uitgefaseerd and Verwijderd → Phased out, Besteld and Wordt getest → Acquisition, Stand-by voor continuïteit → In production; another value is dropped with a warning |
+| Classificatie | usage TIME classification | set only when the usage is new or its TIME classification is empty; Tolereren/Tolerate (also `1. Tolereren (wordt ingelezen)`), Investeren/Invest, Migreren/Migrate, Elimineren/Eliminate |
+| End-of-Life Functioneel | usage phase-out date | Excel date, stored as is |
+| (the sheet), Cluster, Applicatie Eigenaar (Afdeling) | usage internal annotation | `Beheer geregeld: ja` or `nee`, the cluster and the department, joined with ` / `; written only when the usage is new or the note is empty |
+| Applicatie Eigenaar (Persoon), Applicatie Eigenaar (Functie) | usage business owner (contact person) | see [Owners](#owners) |
+
+Columns not in this table are not read at all. That includes Hostingpartij
+and Leverancier (not mapped yet), the BIV and value columns (Beschikbaarheid,
+Integriteit, Vertrouwelijkheid, Applicatienut and the like), Behandelgroep,
+Cloud, Rappeldatum, Rappelreden, Locatie BIOToets, Software Suite, Standaard,
+Top5, COTS and Applicatie Nummer.
+
+**Vendors.** Names are compared after trimming, collapsing spaces and
+ignoring case, so `Fabfrikant`, `Fabfrikant ` and `FABFRIKANT` are one
+Supplier. An existing organisation with the same name is reused: a
+Municipality first, then a Supplier. A municipality that builds its own
+applications (Vendor `Gemeente Rotterdam`) is therefore its own provider,
+not a second organisation. Only a name no organisation has creates a
+Supplier. A row without a vendor is imported without a provider.
+
+## Repeat imports
+
+An application is recognised by its **APPID within the municipality**: the
+match key is `topdesk::`. The APPID (TOPdesk's ICT
+Applicatienummer) stays the same when TOPdesk changes the Applicatie Code
+(Middel-ID). Two municipalities can each have an APPID `101` without
+colliding.
+
+- **New APPID**: a module and a usage are created. With **Publish the
+ applications this import creates** on, the module gets a publication date
+ (the moment the import started), so OpenCatalogi lists it; with it off,
+ the module has no publication date and is not public.
+- **Known APPID, values changed**: only the fields in the column table
+ are updated, and of those, the usage's TIME classification and internal
+ note only when they are empty: a classification set in stackiq stays,
+ whatever the export says. A re-import does overwrite 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, so a status change in TOPdesk comes through. Everything else on the module stays as it is, for example a
+ website an administrator added. The publication date and the depublication
+ date are never changed: a module an administrator depublished stays
+ depublished. The row is reported as *updated*.
+- **Known APPID, status changed in TOPdesk**: the usage gets the new status,
+ also where the regular steps of the usage lifecycle (Acquisition →
+ Planned → In production → To be phased out → Phased out) do not lead
+ there. The usage schema allows this jump to administrators only; the
+ import is open to Nextcloud administrators only, so it may always make
+ it. Other users still follow the regular steps.
+- **Known APPID, nothing changed**: nothing is saved; the row is reported
+ as *unchanged*. Importing the same export twice creates nothing the second
+ time.
+- **APPID missing from a newer export**: the application, its usage and
+ its contact persons are left as they are. They are not changed, depublished
+ or deleted.
+- Each application keeps exactly one usage for the municipality.
+- **Known APPID, but the application belongs to another organisation**: an
+ application found by its match key is only updated when the municipality
+ already uses it, or when no organisation uses it yet. When only other
+ organisations use it, the row is *skipped* with reason `conflict: the
+ application with this import key is used by another organisation, so it
+ is not changed`; nothing is changed and no second application is created.
+ Check the application's import key in stackiq. Only a Nextcloud
+ administrator can change an import key.
+
+Rows are **skipped** when the APPID is empty (`missing APPID`), when the
+Applicatie Naam is empty (`missing Applicatie Naam`), when an APPID appears a
+second time in the same upload (`duplicate APPID in file`), or, with **Update
+existing records** off, when the application already exists (`exists`). On
+one sheet the first occurrence is imported. When an APPID is on both sheets,
+the `Beheerde Applicaties CMDB` row is imported and the `Onbeh Applicaties
+CMDB` row is skipped, with a warning naming the APPID.
+
+An application that moves from `Onbeh Applicaties CMDB` to `Beheerde
+Applicaties CMDB` keeps its module and usage (same APPID); its internal note
+is not rewritten when it already has one.
+
+Every row is processed on its own. When one row fails, for example because
+OpenRegister refuses to save it, that row is reported as *failed* with the
+step that failed, and the other rows are imported. Importing again completes
+the failed row.
+
+## Owners
+
+The owner becomes a **contact person of the municipality**, never a
+Nextcloud user account. It comes from `Applicatie Eigenaar (Persoon)`; its
+function (`Applicatie Eigenaar (Functie)`) is stored as the contact person's
+role, and the department (`Applicatie Eigenaar (Afdeling)`) goes into the
+usage's internal note. When TOPdesk has no owner, the CMDB sheet shows the
+owner's function in the person column; the import then uses that function as
+the contact's name. No technical owner is imported: the functional
+administrator (FB contactpersoon) is not read.
+
+The identity is kept in **Nextcloud Contacts**, in a dedicated address book,
+**Stackiq CMDB owners**, of the administrator who runs the import; the import
+creates that address book the first time it needs it. The import looks for
+an existing contact only in that address book, never in the administrator's
+own address books: a personal contact who happens to have the owner's name
+is not linked to the application. The CMDB sheets have no e-mail address, so
+a contact is found by an exact match on the name in **Stackiq CMDB owners**,
+and created there when there is none. The
+stackiq contact person object only holds the link to that contact, the role
+and the municipality. The same owner on several rows is one contact person.
+
+When the Contacts app is disabled, applications and usages are still
+imported; the owners are skipped and each affected row carries a warning.
+
+**Never public.** Contact persons and usages have no public read rule, so an
+anonymous visitor cannot read them through OpenRegister, and a published
+module in an OpenCatalogi search result refers to them by id at most. The
+import report and the Nextcloud log never contain owner names.
+
+## Errors and what to do
+
+When the file or the request cannot be imported at all, nothing is written
+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. |
+| `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. |
+| `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. |
+| `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. |
+| `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. |
+| `IMPORT_FAILED` | Something unexpected went wrong. | The Nextcloud log has the details. |
+
+**The connection was cut off.** The import runs in one request. When that
+request ends without an answer from the import (the connection drops, or a
+proxy gives up waiting with a 502, 503 or 504), the import may still be
+running on the server. The section then keeps following the import's
+progress and shows its report when it finishes. When it cannot find out,
+it shows `IMPORT_INTERRUPTED`: wait a few minutes and check the
+municipality's applications before importing again. Importing the same file
+again creates no duplicates.
+
+A message that you are not signed in, not an administrator, or that your
+session expired comes from Nextcloud itself: sign in again, use an
+administrator account (see [Who can import](#who-can-import)), or reload the
+page.
+
+## Limits
+
+Five limits are read from `lib/Settings/cmdb-import/topdesk-profile.json` on
+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 |
+| `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 |
+
+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.
+
+## Adjusting 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:
+
+| File | 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.
diff --git a/docs/features/connections.md b/docs/features/connections.md
new file mode 100644
index 000000000..e36180ec9
--- /dev/null
+++ b/docs/features/connections.md
@@ -0,0 +1,32 @@
+
+
+# Connections
+
+A connection records that one application exchanges data with another application, or with a national provision such as a basisregistratie: over which transport, in which direction, and in which state.
+
+Specification: [`openspec/specs/catalogue-connection-pages/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/catalogue-connection-pages/spec.md).
+
+## The connections list
+
+Open **Applications** in the navigation menu, then **Connections**. The list at `/koppelingen` shows every connection you may read, with its type, its status, both applications, the national provision and the direction.
+
+- The chips above the list filter on status: in use, in development, end of support and withdrawn.
+- The filter menu in the table header filters on type (for example api or file transfer), status and direction.
+- Click a row to open the connection.
+
+You see the connections of your own organisation, and the connections that are published.
+
+## A connection's page
+
+The page shows the connection's data, both applications, the national provision and the intermediary application if there is one, the lifecycle dates, attached documents, and its history.
+
+The actions at the top move a connection through its lifecycle: release (in development to in use), sunset (in use to end of support) and withdraw (to withdrawn).
+
+When you record a connection to a national provision, the picker lists the GEMMA elements of type Buitengemeentelijke voorziening.
+
+## Connections on the application page
+
+The page of an application has two lists: **Connections from this application**, where it is application A, and **Connections to this application**, where it is application B. Each row opens the connection, and View all opens the connections list filtered on that application.
diff --git a/docs/features/licence-seats.md b/docs/features/licence-seats.md
new file mode 100644
index 000000000..162522431
--- /dev/null
+++ b/docs/features/licence-seats.md
@@ -0,0 +1,39 @@
+
+
+# Licence seats
+
+A licence contract records how the licence is measured and how many licences were bought and are in use. Stackiq sets the two numbers against each other on the contract and on the License posture page, so you see where use runs over what was bought.
+
+Specification: [`openspec/specs/licence-seats/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/licence-seats/spec.md).
+
+## Recording the licence on a contract
+
+Open a contract and edit it. Three fields describe the licence:
+
+- **Licence metric**: per named user, per concurrent user, per device, per inhabitant, per organisation, or other.
+- **Licences bought**: the number the contract pays for.
+- **Licences in use**: the number in use today.
+
+All three are optional. A count cannot be negative.
+
+## The licences panel on a contract
+
+The contract page shows a **Licences** panel with the metric, both counts, a bar of in use against bought, and one of these states:
+
+- **Within licence**: in use is at or below bought.
+- **Over licence by N**: in use is N above bought. The bar turns red.
+- **Unknown**: one of the counts is empty.
+- **Not counted**: the metric is per organisation or other, so there is nothing to count and no bar.
+
+The panel also shows the date the contract was last changed, so you can judge how current the count is.
+
+## The Seats section on the License posture page
+
+Open **License posture** in the navigation menu. The **Seats** section has one row per contract with a counted metric and a number of licences bought. Each row names the application, the organisation, the metric, bought, in use and the state. Contracts over their licence come first, the furthest over at the top.
+
+You see the contracts you may read; the section uses the same access rules as the rest of the page.
+
+Screenshots of the panel and the section follow once the feature runs on the demo instance.
diff --git a/docs/features/maintenance-and-roadmap.md b/docs/features/maintenance-and-roadmap.md
new file mode 100644
index 000000000..880f87e94
--- /dev/null
+++ b/docs/features/maintenance-and-roadmap.md
@@ -0,0 +1,35 @@
+
+
+# Maintenance and roadmap
+
+Suppliers announce planned maintenance on their applications and publish where each application is heading. Organisations that use an application see the maintenance on their dashboard and on the application's page, and read the roadmap before they plan an upgrade.
+
+Specification: [`openspec/specs/maintenance-and-supplier-roadmap/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/maintenance-and-supplier-roadmap/spec.md).
+
+## Announcing maintenance
+
+Open your application's page under **Applications** and click **Announce maintenance** in the **Planned maintenance** section. Fill in:
+
+- **Title** and **Description**: what happens and what users should do.
+- **Version**: only when the maintenance concerns one version.
+- **Starts at** and **Ends at**.
+- **Impact**: No impact, Degraded or Unavailable.
+
+A new window starts as Planned. From its page you move it to In progress, Completed or Cancelled.
+
+## Who is told
+
+When you announce a window, stackiq looks up every organisation that uses the application and the business owner and technical owner of each usage. Those owners get a Nextcloud notification, and a reminder the day before the window starts while it is still planned. Owners are set on the usage, under **Applications in use**; a usage without owners still shows the window on its organisation's dashboard.
+
+## Following maintenance
+
+The dashboard lists **Planned maintenance** on the applications your organisation uses in the next 30 days, with the time window and the impact. Each application's page lists all its planned maintenance.
+
+## The roadmap
+
+A supplier writes the direction of an application in the **Roadmap** field of the application. The application's page shows it with the application's versions on a timeline, the planned versions first. A version is placed on its go-live date, or on the date development started when it has no go-live date yet.
+
+Under **Module versions**, the **Planned releases** tab lists the versions still in development, with the date development started. A supplier releases a planned version from its page with **Release**.
diff --git a/docs/features/portfolio-value-assessment.md b/docs/features/portfolio-value-assessment.md
new file mode 100644
index 000000000..e37335a17
--- /dev/null
+++ b/docs/features/portfolio-value-assessment.md
@@ -0,0 +1,44 @@
+
+
+# Value assessment
+
+An information manager scores each application the organisation uses on business value, technical fit and risk. The scores sit next to the cost stackiq already adds up from contracts, and they point to a TIME class. The TIME class the organisation records stays the decision: the scores back it up or question it, they never change it.
+
+Specification: [`openspec/specs/application-value-assessment/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/application-value-assessment/spec.md).
+
+## Scoring an application
+
+Open the application under **Applications in use** and edit it. Fill in:
+
+- **Business value**: how much the organisation depends on it, from 1 (little) to 5 (critical).
+- **Technical fit**: how well it fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).
+- **Risk**: the risk the organisation sees in running it, from 1 (low) to 5 (high).
+- **Scored on**: the date you set the scores.
+
+When you save, stackiq fills in **Suggested TIME classification**:
+
+| Business value | Technical fit | Suggested class |
+|---|---|---|
+| 3 or more | 3 or more | Invest |
+| 3 or more | below 3 | Migrate |
+| below 3 | 3 or more | Tolerate |
+| below 3 | below 3 | Eliminate |
+
+While either score is missing there is no suggestion. The **Value assessment** section of the page shows the scores, the recorded TIME class and the suggestion side by side.
+
+## Risk signals
+
+Below the data of the page, **Risk signals** shows what backs a risk score: whether the version you run is past its end of support (or withdrawn), and how many known vulnerabilities are linked to the application. You set the risk score yourself; the signals only inform it.
+
+## The portfolio report
+
+The portfolio report (**Reports**, then **Portfolio rationalization**) adds, for the organisation you select:
+
+- **Business value against technical fit**: one circle per scored application, its size the annualised cost. The circle's colour is the suggested class; a dark ring marks a recorded class that differs from the scores. Applications without both scores are counted below the chart.
+- Two columns in the table: **Suggested by scores** and **Value / fit / risk**.
+- The switch **Recorded class differs from scores**, which lists only the applications whose recorded class and suggestion disagree.
+
+The CSV export carries the columns businessValue, technicalFit, riskScore, scoredOn, suggestedTimeClassification and timeMismatch.
diff --git a/docs/features/service-desk-exchange.md b/docs/features/service-desk-exchange.md
new file mode 100644
index 000000000..d1d3f6b46
--- /dev/null
+++ b/docs/features/service-desk-exchange.md
@@ -0,0 +1,57 @@
+
+
+# Service desk exchange
+
+Stackiq is your CMDB for applications. It keeps the applications you use, their connections, licences and contracts in step with TOPdesk or ServiceNow, in both directions. Stackiq never holds the service desk password: integriq does.
+
+Specification: [`openspec/changes/sharing-itsm-exchange`](https://github.com/ConductionNL/stackiq/blob/development/openspec/changes/sharing-itsm-exchange/).
+
+## Who owns which field
+
+Each field has one owner, written in integriq's mapping. The owner's value wins. A timestamp never decides.
+
+| Field | Owner |
+|---|---|
+| Name, supplier, installed version, status, the record id and link | The service desk |
+| BBN level, TIME class, publication | Stackiq |
+| Licence and contract fields: number, supplier reference, type, start, end, cost, currency, metric, licences bought | Stackiq |
+| Relations between applications | The service desk |
+
+The import only writes fields the service desk owns on a record stackiq already has. The export only sends fields stackiq owns on a record the service desk already has. A new record gets every field, from whichever side creates it.
+
+A licence or contract the service desk knows and stackiq does not is created with all its fields. After that stackiq owns its licence and contract fields.
+
+## Set it up
+
+1. In integriq, open the TOPdesk or ServiceNow source. Fill in your tenant address, the login name, and the password as a credential.
+2. In the admin settings, open **Service desk exchange**.
+3. Choose the service desk and your organisation. For TOPdesk, also enter the id of your Application asset template: TOPdesk needs it to create an asset.
+4. Choose **Set up the exchange**.
+
+Stackiq checks every flow with OpenRegister before it saves any. If one is refused, nothing is created and the message names the step and the reason. Setting up again updates the same flows.
+
+The flows show on stackiq's **Flows** page. The imports run every night at 02:00. A change to a stackiq-owned field goes to the service desk within a minute.
+
+## What the import does
+
+- It reads applications, relations, licences and contracts from the service desk.
+- It matches an application on its service desk record first, then on name and supplier.
+- It creates the supplier, the application and your organisation's use of it when there is no match.
+- A second run updates. It never adds a second copy.
+
+A near-duplicate, such as a spelling variant of an application that already exists, is created as a new application. It then shows up on OpenRegister's duplicate candidates page, where a steward merges the two.
+
+## Why nothing echoes back
+
+Each direction only writes its own fields, and each skips a record whose own fields did not change. So an import never triggers an export call, and an export never comes back as an import write.
+
+## Import a file
+
+No service desk API? On the **CMDB** page, import a CSV or XLSX file. Use these columns: `recordId`, `name`, `supplierName`, `installedVersion`, `status`. Every row needs a `recordId`. Importing the same file again updates the applications instead of adding them twice.
+
+## Next
+
+Open the **CMDB** page under Applications to see what stackiq records, and connect your service desk from there.
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index ebf283ebf..8fd4f0273 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -388,7 +388,7 @@
},
"src/store/modules/settings.js": {
"no-console": {
- "count": 18
+ "count": 17
},
"no-unused-vars": {
"count": 8
diff --git a/l10n/.schema-l10n-baseline.json b/l10n/.schema-l10n-baseline.json
index 85c694064..8332713f6 100644
--- a/l10n/.schema-l10n-baseline.json
+++ b/l10n/.schema-l10n-baseline.json
@@ -1,3 +1,3 @@
{
- "uncovered": 480
+ "uncovered": 479
}
diff --git a/l10n/en.js b/l10n/en.js
index 08b68094d..70c3bfcef 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1,6 +1,28 @@
OC.L10N.register(
"stackiq",
{
+ "Licence metric": "Licence metric",
+ "What one licence covers, as the contract with the supplier states it.": "What one licence covers, as the contract with the supplier states it.",
+ "Licences bought": "Licences bought",
+ "The number of licences this contract buys.": "The number of licences this contract buys.",
+ "Licences in use": "Licences in use",
+ "The number of licences in use today, as the application owner counted them.": "The number of licences in use today, as the application owner counted them.",
+ "Per named user": "Per named user",
+ "Per concurrent user": "Per concurrent user",
+ "Per device": "Per device",
+ "Per inhabitant": "Per inhabitant",
+ "Per organisation": "Per organisation",
+ "Other": "Other",
+ "Licences": "Licences",
+ "Loading licences": "Loading licences",
+ "The licences could not be loaded.": "The licences could not be loaded.",
+ "Last changed on {date}": "Last changed on {date}",
+ "{inUse} of {bought} in use": "{inUse} of {bought} in use",
+ "Within licence": "Within licence",
+ "Over licence by {count}": "Over licence by {count}",
+ "Not counted": "Not counted",
+ "Seats": "Seats",
+ "No licence contracts with counts": "No licence contracts with counts",
"AMEF elements": "AMEF elements",
"AMEF standards": "AMEF standards",
"ArchiMate element": "ArchiMate element",
@@ -704,7 +726,411 @@ OC.L10N.register(
"xmlns": "xmlns",
"xsi": "xsi",
"Pre-filled with the names the Integriq endoflife-date-source change provisions. Change them if your instance uses different names, no code change required.": "Pre-filled with the names the Integriq endoflife-date-source change provisions. Change them if your instance uses different names, no code change required.",
- "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?"
+ "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?",
+ "Integrations": "Integrations",
+ "Status message": "Status message",
+ "Last checked": "Last checked",
+ "All connections": "All connections",
+ "Add integration": "Add integration",
+ "Open settings": "Open settings",
+ "Configured": "Configured",
+ "Limited": "Limited",
+ "Not configured": "Not configured",
+ "Simulated": "Simulated",
+ "Not available": "Not available",
+ "Error": "Error",
+ "Settings": "Settings",
+ "Applications supplied": "Applications supplied",
+ "Services supplied": "Services supplied",
+ "Applications managed": "Applications managed",
+ "Services managed": "Services managed",
+ "Cancel import": "Cancel import",
+ "Import cancelled. {count} objects were saved before it stopped.": "Import cancelled. {count} objects were saved before it stopped.",
+ "Checking the file": "Checking the file",
+ "Reading the model": "Reading the model",
+ "Converting the model": "Converting the model",
+ "Saving objects": "Saving objects",
+ "Finishing the import": "Finishing the import",
+ "Importing": "Importing",
+ "{processed} of {total} objects saved": "{processed} of {total} objects saved",
+ "Contract": "Contract",
+ "Loading contracts": "Loading contracts",
+ "No contracts found for this application": "No contracts found for this application",
+ "The contracts could not be loaded.": "The contracts could not be loaded.",
+ "Usages": "Usages",
+ "No organisation registered a usage yet": "No organisation registered a usage yet",
+ "Every connection between applications, and from an application to a national provision.": "Every connection between applications, and from an application to a national provision.",
+ "In use": "In use",
+ "In development": "In development",
+ "Applications and standards": "Applications and standards",
+ "Documents": "Documents",
+ "Connections from this application": "Connections from this application",
+ "Connections to this application": "Connections to this application",
+ "To application": "To application",
+ "To national provision": "To national provision",
+ "From application": "From application",
+ "No connections start at this application": "No connections start at this application",
+ "No connections end at this application": "No connections end at this application",
+ "AI system": "AI system",
+ "An AI agent, AI model or AI feature the organisation uses, with its EU AI Act classification.": "An AI agent, AI model or AI feature the organisation uses, with its EU AI Act classification.",
+ "The name the organisation uses for this AI system.": "The name the organisation uses for this AI system.",
+ "What the AI system is and how it is used.": "What the AI system is and how it is used.",
+ "Kind": "Kind",
+ "An AI agent acts on its own, an AI model is a trained model, an AI feature is part of an application.": "An AI agent acts on its own, an AI model is a trained model, an AI feature is part of an application.",
+ "AI agent": "AI agent",
+ "AI model": "AI model",
+ "AI feature": "AI feature",
+ "The application this AI system runs in or supports.": "The application this AI system runs in or supports.",
+ "The organisation that supplies the AI system.": "The organisation that supplies the AI system.",
+ "Purpose": "Purpose",
+ "What the AI system decides, recommends or produces.": "What the AI system decides, recommends or produces.",
+ "AI Act risk category": "AI Act risk category",
+ "The risk category under the EU AI Act, as the organisation classified it.": "The risk category under the EU AI Act, as the organisation classified it.",
+ "Prohibited": "Prohibited",
+ "High risk": "High risk",
+ "Limited risk": "Limited risk",
+ "Minimal risk": "Minimal risk",
+ "Not yet assessed": "Not yet assessed",
+ "Role under the AI Act": "Role under the AI Act",
+ "Whether the organisation provides the AI system or deploys it.": "Whether the organisation provides the AI system or deploys it.",
+ "Deployer": "Deployer",
+ "Algorithm register entry": "Algorithm register entry",
+ "The link to this system in the Dutch algorithm register.": "The link to this system in the Dutch algorithm register.",
+ "Last assessed on": "Last assessed on",
+ "The date the classification was last assessed.": "The date the classification was last assessed.",
+ "Fundamental rights impact assessment": "Fundamental rights impact assessment",
+ "A reference to the fundamental rights impact assessment (FRIA). A high-risk system without one is flagged.": "A reference to the fundamental rights impact assessment (FRIA). A high-risk system without one is flagged.",
+ "Where the AI system stands in its lifecycle.": "Where the AI system stands in its lifecycle.",
+ "AI Act evidence": "AI Act evidence",
+ "Attach a document under Documents and give it the matching tag.": "Attach a document under Documents and give it the matching tag.",
+ "FRIA missing": "FRIA missing",
+ "Fundamental rights impact assessment (FRIA)": "Fundamental rights impact assessment (FRIA)",
+ "Human oversight": "Human oversight",
+ "Loading the evidence": "Loading the evidence",
+ "Logging": "Logging",
+ "Missing": "Missing",
+ "Technical documentation": "Technical documentation",
+ "The evidence could not be loaded.": "The evidence could not be loaded.",
+ "This is a high-risk AI system without a fundamental rights impact assessment.": "This is a high-risk AI system without a fundamental rights impact assessment.",
+ "AI systems": "AI systems",
+ "The AI agents, AI models and AI features your organisation uses, with their EU AI Act risk category.": "The AI agents, AI models and AI features your organisation uses, with their EU AI Act risk category.",
+ "FRIA": "FRIA",
+ "High risk without FRIA": "High risk without FRIA",
+ "Application and supplier": "Application and supplier",
+ "History": "History",
+ "No AI systems registered for this application": "No AI systems registered for this application",
+ "Application in use": "Application in use",
+ "To be phased out": "To be phased out",
+ "Business owner": "Business owner",
+ "Technical owner": "Technical owner",
+ "The person in the organisation who is responsible for how the application is used.": "The person in the organisation who is responsible for how the application is used.",
+ "The person in the organisation who is responsible for running and maintaining the application.": "The person in the organisation who is responsible for running and maintaining the application.",
+ "Add to our landscape": "Add to our landscape",
+ "Connections and services": "Connections and services",
+ "The applications your organisation uses, with the version it runs, where it stands and who owns it.": "The applications your organisation uses, with the version it runs, where it stands and who owns it.",
+ "No applications in use recorded for this organisation yet": "No applications in use recorded for this organisation yet",
+ "Maintenance window": "Maintenance window",
+ "Maintenance a supplier plans on one of its products, with the time window and the expected impact.": "Maintenance a supplier plans on one of its products, with the time window and the expected impact.",
+ "The product the maintenance is on.": "The product the maintenance is on.",
+ "The version the maintenance is on, when it concerns one version.": "The version the maintenance is on, when it concerns one version.",
+ "What the maintenance is, in a few words.": "What the maintenance is, in a few words.",
+ "What changes and what users should do.": "What changes and what users should do.",
+ "Starts at": "Starts at",
+ "When the maintenance starts.": "When the maintenance starts.",
+ "Ends at": "Ends at",
+ "When the maintenance ends.": "When the maintenance ends.",
+ "Impact": "Impact",
+ "What users notice while the maintenance runs.": "What users notice while the maintenance runs.",
+ "Where the maintenance stands.": "Where the maintenance stands.",
+ "Owners to notify": "Owners to notify",
+ "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.": "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.",
+ "Owners resolved at": "Owners resolved at",
+ "When the owners to notify were resolved.": "When the owners to notify were resolved.",
+ "No impact": "No impact",
+ "Degraded": "Degraded",
+ "Unavailable": "Unavailable",
+ "In progress": "In progress",
+ "Completed": "Completed",
+ "Cancelled": "Cancelled",
+ "Start the maintenance.": "Start the maintenance.",
+ "Complete the maintenance.": "Complete the maintenance.",
+ "Cancel the maintenance.": "Cancel the maintenance.",
+ "Roadmap": "Roadmap",
+ "The direction the supplier takes with this application: what the next versions bring and when.": "The direction the supplier takes with this application: what the next versions bring and when.",
+ "Planned maintenance": "Planned maintenance",
+ "Announce maintenance": "Announce maintenance",
+ "No maintenance planned on this application": "No maintenance planned on this application",
+ "Planned releases": "Planned releases",
+ "Loading planned maintenance": "Loading planned maintenance",
+ "The planned maintenance could not be loaded.": "The planned maintenance could not be loaded.",
+ "No maintenance planned on the applications you use in the next 30 days": "No maintenance planned on the applications you use in the next 30 days",
+ "{start} to {end}": "{start} to {end}",
+ "Loading the roadmap": "Loading the roadmap",
+ "The roadmap could not be loaded.": "The roadmap could not be loaded.",
+ "The supplier has not published a roadmap for this application": "The supplier has not published a roadmap for this application",
+ "No versions with a date yet": "No versions with a date yet",
+ "{name}: business value {value}, technical fit {fit}, annualised cost {cost}": "{name}: business value {value}, technical fit {fit}, annualised cost {cost}",
+ "%n application in use is not scored yet.": "%n application in use is not scored yet.",
+ "%n applications in use are not scored yet.": "%n applications in use are not scored yet.",
+ "%n vulnerability linked to this application": "%n vulnerability linked to this application",
+ "%n vulnerabilities linked to this application": "%n vulnerabilities linked to this application",
+ "Business value": "Business value",
+ "Business value against technical fit": "Business value against technical fit",
+ "Business value against technical fit for %n scored application in use": "Business value against technical fit for %n scored application in use",
+ "Business value against technical fit for %n scored applications in use": "Business value against technical fit for %n scored applications in use",
+ "Each circle is an application in use, its size the annualised cost. A dark ring marks a recorded TIME class that differs from the scores.": "Each circle is an application in use, its size the annualised cost. A dark ring marks a recorded TIME class that differs from the scores.",
+ "Known vulnerabilities": "Known vulnerabilities",
+ "Loading the risk signals": "Loading the risk signals",
+ "No end of support date known": "No end of support date known",
+ "Not scored": "Not scored",
+ "Passed on {date}": "Passed on {date}",
+ "Recorded class differs from scores": "Recorded class differs from scores",
+ "Risk score": "Risk score",
+ "Risk signals": "Risk signals",
+ "Suggested by scores": "Suggested by scores",
+ "Supported until {date}": "Supported until {date}",
+ "Technical fit": "Technical fit",
+ "The risk signals could not be loaded.": "The risk signals could not be loaded.",
+ "This version was withdrawn": "This version was withdrawn",
+ "Value / fit / risk": "Value / fit / risk",
+ "Value assessment": "Value assessment",
+ "How much the organisation depends on this application, from 1 (little) to 5 (critical).": "How much the organisation depends on this application, from 1 (little) to 5 (critical).",
+ "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).": "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).",
+ "Risk": "Risk",
+ "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.": "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.",
+ "Scored on": "Scored on",
+ "The date the scores were set.": "The date the scores were set.",
+ "Suggested TIME classification": "Suggested TIME classification",
+ "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.",
+ "CMDB": "CMDB",
+ "Stackiq is your configuration management database for applications. It records what your organisation runs, what it is made of, how it connects and what you pay for it.": "Stackiq is your configuration management database for applications. It records what your organisation runs, what it is made of, how it connects and what you pay for it.",
+ "What stackiq records": "What stackiq records",
+ "What stackiq does not record": "What stackiq does not record",
+ "Servers, laptops and network devices are not recorded here, and stackiq does not discover hardware on your network. Tickets and incidents stay in your service desk.": "Servers, laptops and network devices are not recorded here, and stackiq does not discover hardware on your network. Tickets and incidents stay in your service desk.",
+ "Service desk exchange": "Service desk exchange",
+ "Stackiq and {desk} are in step. The import runs every night at 02:00, and stackiq sends its own changes straight away.": "Stackiq and {desk} are in step. The import runs every night at 02:00, and stackiq sends its own changes straight away.",
+ "No service desk is connected yet. An admin connects TOPdesk or ServiceNow in the admin settings.": "No service desk is connected yet. An admin connects TOPdesk or ServiceNow in the admin settings.",
+ "The service desk owns the name, supplier, installed version and status of an application. Stackiq owns licences, contracts, BBN level, TIME class and publication. Each side only overwrites the fields it owns.": "The service desk owns the name, supplier, installed version and status of an application. Stackiq owns licences, contracts, BBN level, TIME class and publication. Each side only overwrites the fields it owns.",
+ "Change the exchange": "Change the exchange",
+ "Connect a service desk": "Connect a service desk",
+ "Import a file": "Import a file",
+ "No service desk API? Import a CSV or XLSX file with the columns recordId, name, supplierName, installedVersion and status. Importing the same file again updates the applications instead of adding them twice.": "No service desk API? Import a CSV or XLSX file with the columns recordId, name, supplierName, installedVersion and status. Importing the same file again updates the applications instead of adding them twice.",
+ "File to import": "File to import",
+ "Import file": "Import file",
+ "What your organisation runs, which version, and who owns it.": "What your organisation runs, which version, and who owns it.",
+ "Applications and their components": "Applications and their components",
+ "The products, from which supplier, and what they consist of.": "The products, from which supplier, and what they consist of.",
+ "Which application exchanges data with which.": "Which application exchanges data with which.",
+ "Licences and contracts": "Licences and contracts",
+ "What you bought, how many licences, until when and at what cost.": "What you bought, how many licences, until when and at what cost.",
+ "The import of {rows} rows has started. The applications appear as the run goes.": "The import of {rows} rows has started. The applications appear as the run goes.",
+ "The import did not start.": "The import did not start.",
+ "Keep applications, connections, licences and contracts in step with TOPdesk or ServiceNow. Add the service desk source in integriq first; stackiq never holds its password.": "Keep applications, connections, licences and contracts in step with TOPdesk or ServiceNow. Add the service desk source in integriq first; stackiq never holds its password.",
+ "Loading the service desk exchange…": "Loading the service desk exchange…",
+ "OpenRegister's flow engine is not available, so the exchange cannot run.": "OpenRegister's flow engine is not available, so the exchange cannot run.",
+ "Set up for {desk} on {date}. The import runs every night at 02:00.": "Set up for {desk} on {date}. The import runs every night at 02:00.",
+ "Service desk": "Service desk",
+ "Choose TOPdesk or ServiceNow": "Choose TOPdesk or ServiceNow",
+ "Your organisation": "Your organisation",
+ "The organisation whose applications are exchanged": "The organisation whose applications are exchanged",
+ "Set up again": "Set up again",
+ "Set up the exchange": "Set up the exchange",
+ "{count} flows are set up. The first import runs tonight.": "{count} flows are set up. The first import runs tonight.",
+ "The service desk this record is kept in step with.": "The service desk this record is kept in step with.",
+ "TOPdesk": "TOPdesk",
+ "ServiceNow": "ServiceNow",
+ "File import": "File import",
+ "Service desk record": "Service desk record",
+ "The record id in the service desk. The import matches on it first.": "The record id in the service desk. The import matches on it first.",
+ "Service desk link": "Service desk link",
+ "Opens the record in the service desk.": "Opens the record in the service desk.",
+ "Last synchronised": "Last synchronised",
+ "When the service desk exchange last wrote this record.": "When the service desk exchange last wrote this record.",
+ "Installed version": "Installed version",
+ "The version the service desk records as installed. The service desk owns it.": "The version the service desk records as installed. The service desk owns it.",
+ "Publish this application in use in the public catalogue from this date. Leave empty to keep it internal.": "Publish this application in use in the public catalogue from this date. Leave empty to keep it internal.",
+ "Supplier reference": "Supplier reference",
+ "The supplier's own number for this contract or licence agreement.": "The supplier's own number for this contract or licence agreement.",
+ "Currency": "Currency",
+ "The currency of the costs, as a three-letter ISO 4217 code.": "The currency of the costs, as a three-letter ISO 4217 code.",
+ "The organisation this contract or licence was bought from.": "The organisation this contract or licence was bought from.",
+ "TOPdesk asset template id": "TOPdesk asset template id",
+ "TOPdesk needs a template to create an asset. Copy the id of your Application template from TOPdesk.": "TOPdesk needs a template to create an asset. Copy the id of your Application template from TOPdesk.",
+ "{processed} of {total} rows processed": "{processed} of {total} rows processed",
+ "{size} KB": "{size} KB",
+ "{size} MB": "{size} MB",
+ "A new municipality \"{name}\" is created, unless one with this name already exists.": "A new municipality \"{name}\" is created, unless one with this name already exists.",
+ "A sheet has more rows than the import can process.": "A sheet has more rows than the import can process.",
+ "A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.": "A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.",
+ "All outcomes": "All outcomes",
+ "Check the connection and try again.": "Check the connection and try again.",
+ "Choose a municipality first.": "Choose a municipality first.",
+ "Choose or type a municipality": "Choose or type a municipality",
+ "Choose the TOPdesk export": "Choose the TOPdesk export",
+ "Choose the TOPdesk export and try again.": "Choose the TOPdesk export and try again.",
+ "CMDB import": "CMDB import",
+ "Created": "Created",
+ "Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.": "Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.",
+ "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",
+ "Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.",
+ "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 the export…": "Importing the export…",
+ "Importing…": "Importing…",
+ "APPID": "APPID",
+ "Municipality": "Municipality",
+ "No file was uploaded.": "No file was uploaded.",
+ "No rows with this outcome": "No rows with this outcome",
+ "Nothing more is known on this page; the Nextcloud log has the details.": "Nothing more is known on this page; the Nextcloud log has the details.",
+ "Only Nextcloud administrators can import a CMDB export.": "Only Nextcloud administrators can import a CMDB export.",
+ "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.": "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.",
+ "Outcome": "Outcome",
+ "Pick an existing municipality or type the name of a new one.": "Pick an existing municipality or type the name of a new one.",
+ "Pick an existing organisation of type Municipality, or type a new name and press Enter.": "Pick an existing organisation of type Municipality, or type a new name and press Enter.",
+ "Pick an organisation of type Municipality, or type the name of a new one.": "Pick an organisation of type Municipality, or type the name of a new one.",
+ "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.",
+ "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",
+ "Rows read": "Rows read",
+ "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.": "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.",
+ "Sheet": "Sheet",
+ "Show rows with outcome": "Show rows with outcome",
+ "Sign in again and retry the import.": "Sign in again and retry the import.",
+ "Skipped": "Skipped",
+ "The chosen organisation is not a municipality.": "The chosen organisation is not a municipality.",
+ "The columns \"APPID\" and \"Applicatie Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "The columns \"APPID\" and \"Applicatie Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.",
+ "The Excel reader is not available.": "The Excel reader is not available.",
+ "The file": "The file",
+ "The file is larger than 10 MB.": "The file is larger than 10 MB.",
+ "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 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}\".",
+ "The sheets \"Onbeh Applicaties CMDB\" (applications without arranged maintenance) and \"Beheerde Applicaties CMDB\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"Onbeh Applicaties CMDB\" (applications without arranged maintenance) and \"Beheerde Applicaties CMDB\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "The workbook has none of the sheets the import reads.": "The workbook has none of the sheets the import reads.",
+ "This file is not an Excel workbook (.xlsx).": "This file is not an Excel workbook (.xlsx).",
+ "This import is no longer running.": "This import is no longer running.",
+ "Unchanged": "Unchanged",
+ "Update existing records": "Update existing records",
+ "Updated": "Updated",
+ "Warnings": "Warnings",
+ "Warnings for the whole file": "Warnings for the whole file",
+ "When off, applications imported before are left as they are and reported as skipped.": "When off, applications imported before are left as they are and reported as skipped.",
+ "You are not signed in.": "You are not signed in.",
+ "Your session has expired.": "Your session has expired.",
+ "Stackiq is not configured for the import.": "Stackiq is not configured for the import.",
+ "The sheet \"{sheet}\" has more rows than the import can process.": "The sheet \"{sheet}\" has more rows than the import can process.",
+ "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.": "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.",
+ "Choose a municipality or enter the name of a new one.": "Choose a municipality or enter the name of a new one.",
+ "No running CMDB import has this id.": "No running CMDB import has this id.",
+ "Only keeping records that are missing from the export is supported.": "Only keeping records that are missing from the export is supported.",
+ "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.",
+ "The Excel reader is not available: OpenRegister is missing or incomplete.": "The Excel reader is not available: OpenRegister is missing or incomplete.",
+ "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 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",
+ "Owner from column \"%s\" could not be resolved": "Owner from column \"%s\" could not be resolved",
+ "Owner from column \"%s\" could not be resolved in Nextcloud Contacts": "Owner from column \"%s\" could not be resolved in Nextcloud Contacts",
+ "Owners skipped: Nextcloud Contacts is unavailable": "Owners skipped: Nextcloud Contacts is unavailable",
+ "duplicate %s in file": "duplicate %s in file",
+ "exists": "exists",
+ "missing %s": "missing %s",
+ "step \"%1$s\" failed: %2$s": "step \"%1$s\" failed: %2$s",
+ "step \"%s\" failed": "step \"%s\" failed",
+ "Column \"%s\": formula without a cached value, read as empty": "Column \"%s\": formula without a cached value, read as empty",
+ "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.",
+ "Source id": "Source id",
+ "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.": "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.",
+ "Source number": "Source number",
+ "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.": "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.",
+ "Import key": "Import key",
+ "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.": "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.",
+ "Created in source": "Created in source",
+ "The date the application was registered in the source system.": "The date the application was registered in the source system.",
+ "Changed in source": "Changed in source",
+ "The date the application was last changed in the source system.": "The date the application was last changed in the source system.",
+ "Application published": "Application published",
+ "The publication date of the application this version belongs to, copied from it. A version is public only while its application is.": "The publication date of the application this version belongs to, copied from it. A version is public only while its application is.",
+ "Application registered by": "Application registered by",
+ "Who registered the application this version belongs to, copied from it. A supplier's application is public, and so are its versions.": "Who registered the application this version belongs to, copied from it. A supplier's application is public, and so are its versions.",
+ "The file could not be sent. Check your connection and try again.": "The file could not be sent. Check your connection and try again.",
+ "The organisation register is not configured, so there are no organisations to choose from.": "The organisation register is not configured, so there are no organisations to choose from.",
+ "The organisations could not be loaded. Reload the page to try again.": "The organisations could not be loaded. Reload the page to try again.",
+ "Whether a service desk is connected could not be loaded. Reload the page to try again.": "Whether a service desk is connected could not be loaded. Reload the page to try again.",
+ "Application type": "Application type",
+ "The kind of application as the source system records it, such as the TOPdesk Applicatiesoort (Webapplicatie, Client/server, Saas). Kept as the source has it.": "The kind of application as the source system records it, such as the TOPdesk Applicatiesoort (Webapplicatie, Client/server, Saas). Kept as the source has it.",
+ "The BBN level (Baseline Informatiebeveiliging Overheid) this application is classified at. BBN2+ is a level the TOPdesk CMDB export uses.": "The BBN level (Baseline Informatiebeveiliging Overheid) this application is classified at. BBN2+ is a level the TOPdesk CMDB export uses.",
+ "Field \"%1$s\" must be one of: %2$s.": "Field \"%1$s\" must be one of: %2$s.",
+ "Field \"%s\" has an invalid value.": "Field \"%s\" has an invalid value.",
+ "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.",
+ "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.",
+ "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.",
+ "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.",
+ "Accepted values: {accepted}. Reload the page and try again.": "Accepted values: {accepted}. Reload the page and try again.",
+ "The server could not store the uploaded file.": "The server could not store the uploaded file.",
+ "Try again. If it keeps failing, the Nextcloud log has the details; check the free space and the upload settings of the server.": "Try again. If it keeps failing, the Nextcloud log has the details; check the free space and the upload settings of the server.",
+ "No municipality named \"%s\" was found, so it was created. Check the name if you meant an existing one.": "No municipality named \"%s\" was found, so it was created. Check the name if you meant an existing one.",
+ "APPID %1$s is also on sheet \"%2$s\", which wins; this row is not imported": "APPID %1$s is also on sheet \"%2$s\", which wins; this row is not imported",
+ "Stackiq CMDB owners": "Stackiq CMDB owners",
+ "step \"%1$s\" failed (%2$s)": "step \"%1$s\" failed (%2$s)",
+ "The workbook is too large to read once unpacked.": "The workbook is too large to read once unpacked.",
+ "The stackiq register is out of date; import its configuration again.": "The stackiq register is out of date; import its configuration again.",
+ "Another CMDB import is running; try again when it has finished.": "Another CMDB import is running; try again when it has finished.",
+ "Several municipalities have this name; choose one from the list.": "Several municipalities have this name; choose one from the list.",
+ "Unpacked, the workbook is larger than {size}, the most the import reads.": "Unpacked, the workbook is larger than {size}, the most the import reads.",
+ "The \"{schema}\" schema of the stackiq register is out of date.": "The \"{schema}\" schema of the stackiq register is out of date.",
+ "The stackiq register is out of date.": "The stackiq register is out of date.",
+ "It lacks properties the import matches on. Press Force Update at the top of this page to import the register configuration again, then try again. Nothing was imported.": "It lacks properties the import matches on. Press Force Update at the top of this page to import the register configuration again, then try again. Nothing was imported.",
+ "Another CMDB import is running.": "Another CMDB import is running.",
+ "Only one import runs at a time. Wait until it has finished and try again. Nothing was imported.": "Only one import runs at a time. Wait until it has finished and try again. Nothing was imported.",
+ "{count} municipalities have this name.": "{count} municipalities have this name.",
+ "Several municipalities have this name.": "Several municipalities have this name.",
+ "Choose the municipality from the list instead of typing its name. Nothing was imported.": "Choose the municipality from the list instead of typing its name. Nothing was imported.",
+ "Publish the applications this import creates": "Publish the applications this import creates",
+ "A published application is visible to anyone, including anonymous visitors of OpenCatalogi. When off, the applications this import creates stay unpublished until you publish them by hand. Applications imported before keep their publication as it is.": "A published application is visible to anyone, including anonymous visitors of OpenCatalogi. When off, the applications this import creates stay unpublished until you publish them by hand. Applications imported before keep their publication as it is.",
+ "Created unpublished": "Created unpublished",
+ "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; only a Nextcloud admin can change it.": "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; only a Nextcloud admin can change it.",
+ "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.",
+ "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."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index 81aaa9c63..c1996692c 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1,5 +1,27 @@
{
"translations": {
+ "Licence metric": "Licence metric",
+ "What one licence covers, as the contract with the supplier states it.": "What one licence covers, as the contract with the supplier states it.",
+ "Licences bought": "Licences bought",
+ "The number of licences this contract buys.": "The number of licences this contract buys.",
+ "Licences in use": "Licences in use",
+ "The number of licences in use today, as the application owner counted them.": "The number of licences in use today, as the application owner counted them.",
+ "Per named user": "Per named user",
+ "Per concurrent user": "Per concurrent user",
+ "Per device": "Per device",
+ "Per inhabitant": "Per inhabitant",
+ "Per organisation": "Per organisation",
+ "Other": "Other",
+ "Licences": "Licences",
+ "Loading licences": "Loading licences",
+ "The licences could not be loaded.": "The licences could not be loaded.",
+ "Last changed on {date}": "Last changed on {date}",
+ "{inUse} of {bought} in use": "{inUse} of {bought} in use",
+ "Within licence": "Within licence",
+ "Over licence by {count}": "Over licence by {count}",
+ "Not counted": "Not counted",
+ "Seats": "Seats",
+ "No licence contracts with counts": "No licence contracts with counts",
"AMEF elements": "AMEF elements",
"AMEF standards": "AMEF standards",
"ArchiMate element": "ArchiMate element",
@@ -703,6 +725,410 @@
"xmlns": "xmlns",
"xsi": "xsi",
"Pre-filled with the names the Integriq endoflife-date-source change provisions. Change them if your instance uses different names, no code change required.": "Pre-filled with the names the Integriq endoflife-date-source change provisions. Change them if your instance uses different names, no code change required.",
- "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?"
+ "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?",
+ "Integrations": "Integrations",
+ "Status message": "Status message",
+ "Last checked": "Last checked",
+ "All connections": "All connections",
+ "Add integration": "Add integration",
+ "Open settings": "Open settings",
+ "Configured": "Configured",
+ "Limited": "Limited",
+ "Not configured": "Not configured",
+ "Simulated": "Simulated",
+ "Not available": "Not available",
+ "Error": "Error",
+ "Settings": "Settings",
+ "Applications supplied": "Applications supplied",
+ "Services supplied": "Services supplied",
+ "Applications managed": "Applications managed",
+ "Services managed": "Services managed",
+ "Cancel import": "Cancel import",
+ "Import cancelled. {count} objects were saved before it stopped.": "Import cancelled. {count} objects were saved before it stopped.",
+ "Checking the file": "Checking the file",
+ "Reading the model": "Reading the model",
+ "Converting the model": "Converting the model",
+ "Saving objects": "Saving objects",
+ "Finishing the import": "Finishing the import",
+ "Importing": "Importing",
+ "{processed} of {total} objects saved": "{processed} of {total} objects saved",
+ "Contract": "Contract",
+ "Loading contracts": "Loading contracts",
+ "No contracts found for this application": "No contracts found for this application",
+ "The contracts could not be loaded.": "The contracts could not be loaded.",
+ "Usages": "Usages",
+ "No organisation registered a usage yet": "No organisation registered a usage yet",
+ "Every connection between applications, and from an application to a national provision.": "Every connection between applications, and from an application to a national provision.",
+ "In use": "In use",
+ "In development": "In development",
+ "Applications and standards": "Applications and standards",
+ "Documents": "Documents",
+ "Connections from this application": "Connections from this application",
+ "Connections to this application": "Connections to this application",
+ "To application": "To application",
+ "To national provision": "To national provision",
+ "From application": "From application",
+ "No connections start at this application": "No connections start at this application",
+ "No connections end at this application": "No connections end at this application",
+ "AI system": "AI system",
+ "An AI agent, AI model or AI feature the organisation uses, with its EU AI Act classification.": "An AI agent, AI model or AI feature the organisation uses, with its EU AI Act classification.",
+ "The name the organisation uses for this AI system.": "The name the organisation uses for this AI system.",
+ "What the AI system is and how it is used.": "What the AI system is and how it is used.",
+ "Kind": "Kind",
+ "An AI agent acts on its own, an AI model is a trained model, an AI feature is part of an application.": "An AI agent acts on its own, an AI model is a trained model, an AI feature is part of an application.",
+ "AI agent": "AI agent",
+ "AI model": "AI model",
+ "AI feature": "AI feature",
+ "The application this AI system runs in or supports.": "The application this AI system runs in or supports.",
+ "The organisation that supplies the AI system.": "The organisation that supplies the AI system.",
+ "Purpose": "Purpose",
+ "What the AI system decides, recommends or produces.": "What the AI system decides, recommends or produces.",
+ "AI Act risk category": "AI Act risk category",
+ "The risk category under the EU AI Act, as the organisation classified it.": "The risk category under the EU AI Act, as the organisation classified it.",
+ "Prohibited": "Prohibited",
+ "High risk": "High risk",
+ "Limited risk": "Limited risk",
+ "Minimal risk": "Minimal risk",
+ "Not yet assessed": "Not yet assessed",
+ "Role under the AI Act": "Role under the AI Act",
+ "Whether the organisation provides the AI system or deploys it.": "Whether the organisation provides the AI system or deploys it.",
+ "Deployer": "Deployer",
+ "Algorithm register entry": "Algorithm register entry",
+ "The link to this system in the Dutch algorithm register.": "The link to this system in the Dutch algorithm register.",
+ "Last assessed on": "Last assessed on",
+ "The date the classification was last assessed.": "The date the classification was last assessed.",
+ "Fundamental rights impact assessment": "Fundamental rights impact assessment",
+ "A reference to the fundamental rights impact assessment (FRIA). A high-risk system without one is flagged.": "A reference to the fundamental rights impact assessment (FRIA). A high-risk system without one is flagged.",
+ "Where the AI system stands in its lifecycle.": "Where the AI system stands in its lifecycle.",
+ "AI Act evidence": "AI Act evidence",
+ "Attach a document under Documents and give it the matching tag.": "Attach a document under Documents and give it the matching tag.",
+ "FRIA missing": "FRIA missing",
+ "Fundamental rights impact assessment (FRIA)": "Fundamental rights impact assessment (FRIA)",
+ "Human oversight": "Human oversight",
+ "Loading the evidence": "Loading the evidence",
+ "Logging": "Logging",
+ "Missing": "Missing",
+ "Technical documentation": "Technical documentation",
+ "The evidence could not be loaded.": "The evidence could not be loaded.",
+ "This is a high-risk AI system without a fundamental rights impact assessment.": "This is a high-risk AI system without a fundamental rights impact assessment.",
+ "AI systems": "AI systems",
+ "The AI agents, AI models and AI features your organisation uses, with their EU AI Act risk category.": "The AI agents, AI models and AI features your organisation uses, with their EU AI Act risk category.",
+ "FRIA": "FRIA",
+ "High risk without FRIA": "High risk without FRIA",
+ "Application and supplier": "Application and supplier",
+ "History": "History",
+ "No AI systems registered for this application": "No AI systems registered for this application",
+ "Application in use": "Application in use",
+ "To be phased out": "To be phased out",
+ "Business owner": "Business owner",
+ "Technical owner": "Technical owner",
+ "The person in the organisation who is responsible for how the application is used.": "The person in the organisation who is responsible for how the application is used.",
+ "The person in the organisation who is responsible for running and maintaining the application.": "The person in the organisation who is responsible for running and maintaining the application.",
+ "Add to our landscape": "Add to our landscape",
+ "Connections and services": "Connections and services",
+ "The applications your organisation uses, with the version it runs, where it stands and who owns it.": "The applications your organisation uses, with the version it runs, where it stands and who owns it.",
+ "No applications in use recorded for this organisation yet": "No applications in use recorded for this organisation yet",
+ "Maintenance window": "Maintenance window",
+ "Maintenance a supplier plans on one of its products, with the time window and the expected impact.": "Maintenance a supplier plans on one of its products, with the time window and the expected impact.",
+ "The product the maintenance is on.": "The product the maintenance is on.",
+ "The version the maintenance is on, when it concerns one version.": "The version the maintenance is on, when it concerns one version.",
+ "What the maintenance is, in a few words.": "What the maintenance is, in a few words.",
+ "What changes and what users should do.": "What changes and what users should do.",
+ "Starts at": "Starts at",
+ "When the maintenance starts.": "When the maintenance starts.",
+ "Ends at": "Ends at",
+ "When the maintenance ends.": "When the maintenance ends.",
+ "Impact": "Impact",
+ "What users notice while the maintenance runs.": "What users notice while the maintenance runs.",
+ "Where the maintenance stands.": "Where the maintenance stands.",
+ "Owners to notify": "Owners to notify",
+ "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.": "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.",
+ "Owners resolved at": "Owners resolved at",
+ "When the owners to notify were resolved.": "When the owners to notify were resolved.",
+ "No impact": "No impact",
+ "Degraded": "Degraded",
+ "Unavailable": "Unavailable",
+ "In progress": "In progress",
+ "Completed": "Completed",
+ "Cancelled": "Cancelled",
+ "Start the maintenance.": "Start the maintenance.",
+ "Complete the maintenance.": "Complete the maintenance.",
+ "Cancel the maintenance.": "Cancel the maintenance.",
+ "Roadmap": "Roadmap",
+ "The direction the supplier takes with this application: what the next versions bring and when.": "The direction the supplier takes with this application: what the next versions bring and when.",
+ "Planned maintenance": "Planned maintenance",
+ "Announce maintenance": "Announce maintenance",
+ "No maintenance planned on this application": "No maintenance planned on this application",
+ "Planned releases": "Planned releases",
+ "Loading planned maintenance": "Loading planned maintenance",
+ "The planned maintenance could not be loaded.": "The planned maintenance could not be loaded.",
+ "No maintenance planned on the applications you use in the next 30 days": "No maintenance planned on the applications you use in the next 30 days",
+ "{start} to {end}": "{start} to {end}",
+ "Loading the roadmap": "Loading the roadmap",
+ "The roadmap could not be loaded.": "The roadmap could not be loaded.",
+ "The supplier has not published a roadmap for this application": "The supplier has not published a roadmap for this application",
+ "No versions with a date yet": "No versions with a date yet",
+ "{name}: business value {value}, technical fit {fit}, annualised cost {cost}": "{name}: business value {value}, technical fit {fit}, annualised cost {cost}",
+ "%n application in use is not scored yet.": "%n application in use is not scored yet.",
+ "%n applications in use are not scored yet.": "%n applications in use are not scored yet.",
+ "%n vulnerability linked to this application": "%n vulnerability linked to this application",
+ "%n vulnerabilities linked to this application": "%n vulnerabilities linked to this application",
+ "Business value": "Business value",
+ "Business value against technical fit": "Business value against technical fit",
+ "Business value against technical fit for %n scored application in use": "Business value against technical fit for %n scored application in use",
+ "Business value against technical fit for %n scored applications in use": "Business value against technical fit for %n scored applications in use",
+ "Each circle is an application in use, its size the annualised cost. A dark ring marks a recorded TIME class that differs from the scores.": "Each circle is an application in use, its size the annualised cost. A dark ring marks a recorded TIME class that differs from the scores.",
+ "Known vulnerabilities": "Known vulnerabilities",
+ "Loading the risk signals": "Loading the risk signals",
+ "No end of support date known": "No end of support date known",
+ "Not scored": "Not scored",
+ "Passed on {date}": "Passed on {date}",
+ "Recorded class differs from scores": "Recorded class differs from scores",
+ "Risk score": "Risk score",
+ "Risk signals": "Risk signals",
+ "Suggested by scores": "Suggested by scores",
+ "Supported until {date}": "Supported until {date}",
+ "Technical fit": "Technical fit",
+ "The risk signals could not be loaded.": "The risk signals could not be loaded.",
+ "This version was withdrawn": "This version was withdrawn",
+ "Value / fit / risk": "Value / fit / risk",
+ "Value assessment": "Value assessment",
+ "How much the organisation depends on this application, from 1 (little) to 5 (critical).": "How much the organisation depends on this application, from 1 (little) to 5 (critical).",
+ "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).": "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).",
+ "Risk": "Risk",
+ "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.": "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.",
+ "Scored on": "Scored on",
+ "The date the scores were set.": "The date the scores were set.",
+ "Suggested TIME classification": "Suggested TIME classification",
+ "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.",
+ "CMDB": "CMDB",
+ "Stackiq is your configuration management database for applications. It records what your organisation runs, what it is made of, how it connects and what you pay for it.": "Stackiq is your configuration management database for applications. It records what your organisation runs, what it is made of, how it connects and what you pay for it.",
+ "What stackiq records": "What stackiq records",
+ "What stackiq does not record": "What stackiq does not record",
+ "Servers, laptops and network devices are not recorded here, and stackiq does not discover hardware on your network. Tickets and incidents stay in your service desk.": "Servers, laptops and network devices are not recorded here, and stackiq does not discover hardware on your network. Tickets and incidents stay in your service desk.",
+ "Service desk exchange": "Service desk exchange",
+ "Stackiq and {desk} are in step. The import runs every night at 02:00, and stackiq sends its own changes straight away.": "Stackiq and {desk} are in step. The import runs every night at 02:00, and stackiq sends its own changes straight away.",
+ "No service desk is connected yet. An admin connects TOPdesk or ServiceNow in the admin settings.": "No service desk is connected yet. An admin connects TOPdesk or ServiceNow in the admin settings.",
+ "The service desk owns the name, supplier, installed version and status of an application. Stackiq owns licences, contracts, BBN level, TIME class and publication. Each side only overwrites the fields it owns.": "The service desk owns the name, supplier, installed version and status of an application. Stackiq owns licences, contracts, BBN level, TIME class and publication. Each side only overwrites the fields it owns.",
+ "Change the exchange": "Change the exchange",
+ "Connect a service desk": "Connect a service desk",
+ "Import a file": "Import a file",
+ "No service desk API? Import a CSV or XLSX file with the columns recordId, name, supplierName, installedVersion and status. Importing the same file again updates the applications instead of adding them twice.": "No service desk API? Import a CSV or XLSX file with the columns recordId, name, supplierName, installedVersion and status. Importing the same file again updates the applications instead of adding them twice.",
+ "File to import": "File to import",
+ "Import file": "Import file",
+ "What your organisation runs, which version, and who owns it.": "What your organisation runs, which version, and who owns it.",
+ "Applications and their components": "Applications and their components",
+ "The products, from which supplier, and what they consist of.": "The products, from which supplier, and what they consist of.",
+ "Which application exchanges data with which.": "Which application exchanges data with which.",
+ "Licences and contracts": "Licences and contracts",
+ "What you bought, how many licences, until when and at what cost.": "What you bought, how many licences, until when and at what cost.",
+ "The import of {rows} rows has started. The applications appear as the run goes.": "The import of {rows} rows has started. The applications appear as the run goes.",
+ "The import did not start.": "The import did not start.",
+ "Keep applications, connections, licences and contracts in step with TOPdesk or ServiceNow. Add the service desk source in integriq first; stackiq never holds its password.": "Keep applications, connections, licences and contracts in step with TOPdesk or ServiceNow. Add the service desk source in integriq first; stackiq never holds its password.",
+ "Loading the service desk exchange…": "Loading the service desk exchange…",
+ "OpenRegister's flow engine is not available, so the exchange cannot run.": "OpenRegister's flow engine is not available, so the exchange cannot run.",
+ "Set up for {desk} on {date}. The import runs every night at 02:00.": "Set up for {desk} on {date}. The import runs every night at 02:00.",
+ "Service desk": "Service desk",
+ "Choose TOPdesk or ServiceNow": "Choose TOPdesk or ServiceNow",
+ "Your organisation": "Your organisation",
+ "The organisation whose applications are exchanged": "The organisation whose applications are exchanged",
+ "Set up again": "Set up again",
+ "Set up the exchange": "Set up the exchange",
+ "{count} flows are set up. The first import runs tonight.": "{count} flows are set up. The first import runs tonight.",
+ "The service desk this record is kept in step with.": "The service desk this record is kept in step with.",
+ "TOPdesk": "TOPdesk",
+ "ServiceNow": "ServiceNow",
+ "File import": "File import",
+ "Service desk record": "Service desk record",
+ "The record id in the service desk. The import matches on it first.": "The record id in the service desk. The import matches on it first.",
+ "Service desk link": "Service desk link",
+ "Opens the record in the service desk.": "Opens the record in the service desk.",
+ "Last synchronised": "Last synchronised",
+ "When the service desk exchange last wrote this record.": "When the service desk exchange last wrote this record.",
+ "Installed version": "Installed version",
+ "The version the service desk records as installed. The service desk owns it.": "The version the service desk records as installed. The service desk owns it.",
+ "Publish this application in use in the public catalogue from this date. Leave empty to keep it internal.": "Publish this application in use in the public catalogue from this date. Leave empty to keep it internal.",
+ "Supplier reference": "Supplier reference",
+ "The supplier's own number for this contract or licence agreement.": "The supplier's own number for this contract or licence agreement.",
+ "Currency": "Currency",
+ "The currency of the costs, as a three-letter ISO 4217 code.": "The currency of the costs, as a three-letter ISO 4217 code.",
+ "The organisation this contract or licence was bought from.": "The organisation this contract or licence was bought from.",
+ "TOPdesk asset template id": "TOPdesk asset template id",
+ "TOPdesk needs a template to create an asset. Copy the id of your Application template from TOPdesk.": "TOPdesk needs a template to create an asset. Copy the id of your Application template from TOPdesk.",
+ "{processed} of {total} rows processed": "{processed} of {total} rows processed",
+ "{size} KB": "{size} KB",
+ "{size} MB": "{size} MB",
+ "A new municipality \"{name}\" is created, unless one with this name already exists.": "A new municipality \"{name}\" is created, unless one with this name already exists.",
+ "A sheet has more rows than the import can process.": "A sheet has more rows than the import can process.",
+ "A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.": "A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.",
+ "All outcomes": "All outcomes",
+ "Check the connection and try again.": "Check the connection and try again.",
+ "Choose a municipality first.": "Choose a municipality first.",
+ "Choose or type a municipality": "Choose or type a municipality",
+ "Choose the TOPdesk export": "Choose the TOPdesk export",
+ "Choose the TOPdesk export and try again.": "Choose the TOPdesk export and try again.",
+ "CMDB import": "CMDB import",
+ "Created": "Created",
+ "Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.": "Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.",
+ "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",
+ "Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.",
+ "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 the export…": "Importing the export…",
+ "Importing…": "Importing…",
+ "APPID": "APPID",
+ "Municipality": "Municipality",
+ "No file was uploaded.": "No file was uploaded.",
+ "No rows with this outcome": "No rows with this outcome",
+ "Nothing more is known on this page; the Nextcloud log has the details.": "Nothing more is known on this page; the Nextcloud log has the details.",
+ "Only Nextcloud administrators can import a CMDB export.": "Only Nextcloud administrators can import a CMDB export.",
+ "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.": "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.",
+ "Outcome": "Outcome",
+ "Pick an existing municipality or type the name of a new one.": "Pick an existing municipality or type the name of a new one.",
+ "Pick an existing organisation of type Municipality, or type a new name and press Enter.": "Pick an existing organisation of type Municipality, or type a new name and press Enter.",
+ "Pick an organisation of type Municipality, or type the name of a new one.": "Pick an organisation of type Municipality, or type the name of a new one.",
+ "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.",
+ "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",
+ "Rows read": "Rows read",
+ "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.": "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.",
+ "Sheet": "Sheet",
+ "Show rows with outcome": "Show rows with outcome",
+ "Sign in again and retry the import.": "Sign in again and retry the import.",
+ "Skipped": "Skipped",
+ "The chosen organisation is not a municipality.": "The chosen organisation is not a municipality.",
+ "The columns \"APPID\" and \"Applicatie Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "The columns \"APPID\" and \"Applicatie Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.",
+ "The Excel reader is not available.": "The Excel reader is not available.",
+ "The file": "The file",
+ "The file is larger than 10 MB.": "The file is larger than 10 MB.",
+ "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 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}\".",
+ "The sheets \"Onbeh Applicaties CMDB\" (applications without arranged maintenance) and \"Beheerde Applicaties CMDB\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"Onbeh Applicaties CMDB\" (applications without arranged maintenance) and \"Beheerde Applicaties CMDB\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "The workbook has none of the sheets the import reads.": "The workbook has none of the sheets the import reads.",
+ "This file is not an Excel workbook (.xlsx).": "This file is not an Excel workbook (.xlsx).",
+ "This import is no longer running.": "This import is no longer running.",
+ "Unchanged": "Unchanged",
+ "Update existing records": "Update existing records",
+ "Updated": "Updated",
+ "Warnings": "Warnings",
+ "Warnings for the whole file": "Warnings for the whole file",
+ "When off, applications imported before are left as they are and reported as skipped.": "When off, applications imported before are left as they are and reported as skipped.",
+ "You are not signed in.": "You are not signed in.",
+ "Your session has expired.": "Your session has expired.",
+ "Stackiq is not configured for the import.": "Stackiq is not configured for the import.",
+ "The sheet \"{sheet}\" has more rows than the import can process.": "The sheet \"{sheet}\" has more rows than the import can process.",
+ "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.": "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.",
+ "Choose a municipality or enter the name of a new one.": "Choose a municipality or enter the name of a new one.",
+ "No running CMDB import has this id.": "No running CMDB import has this id.",
+ "Only keeping records that are missing from the export is supported.": "Only keeping records that are missing from the export is supported.",
+ "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.",
+ "The Excel reader is not available: OpenRegister is missing or incomplete.": "The Excel reader is not available: OpenRegister is missing or incomplete.",
+ "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 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",
+ "Owner from column \"%s\" could not be resolved": "Owner from column \"%s\" could not be resolved",
+ "Owner from column \"%s\" could not be resolved in Nextcloud Contacts": "Owner from column \"%s\" could not be resolved in Nextcloud Contacts",
+ "Owners skipped: Nextcloud Contacts is unavailable": "Owners skipped: Nextcloud Contacts is unavailable",
+ "duplicate %s in file": "duplicate %s in file",
+ "exists": "exists",
+ "missing %s": "missing %s",
+ "step \"%1$s\" failed: %2$s": "step \"%1$s\" failed: %2$s",
+ "step \"%s\" failed": "step \"%s\" failed",
+ "Column \"%s\": formula without a cached value, read as empty": "Column \"%s\": formula without a cached value, read as empty",
+ "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.",
+ "Source id": "Source id",
+ "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.": "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.",
+ "Source number": "Source number",
+ "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.": "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.",
+ "Import key": "Import key",
+ "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.": "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.",
+ "Created in source": "Created in source",
+ "The date the application was registered in the source system.": "The date the application was registered in the source system.",
+ "Changed in source": "Changed in source",
+ "The date the application was last changed in the source system.": "The date the application was last changed in the source system.",
+ "Application published": "Application published",
+ "The publication date of the application this version belongs to, copied from it. A version is public only while its application is.": "The publication date of the application this version belongs to, copied from it. A version is public only while its application is.",
+ "Application registered by": "Application registered by",
+ "Who registered the application this version belongs to, copied from it. A supplier's application is public, and so are its versions.": "Who registered the application this version belongs to, copied from it. A supplier's application is public, and so are its versions.",
+ "The file could not be sent. Check your connection and try again.": "The file could not be sent. Check your connection and try again.",
+ "The organisation register is not configured, so there are no organisations to choose from.": "The organisation register is not configured, so there are no organisations to choose from.",
+ "The organisations could not be loaded. Reload the page to try again.": "The organisations could not be loaded. Reload the page to try again.",
+ "Whether a service desk is connected could not be loaded. Reload the page to try again.": "Whether a service desk is connected could not be loaded. Reload the page to try again.",
+ "Application type": "Application type",
+ "The kind of application as the source system records it, such as the TOPdesk Applicatiesoort (Webapplicatie, Client/server, Saas). Kept as the source has it.": "The kind of application as the source system records it, such as the TOPdesk Applicatiesoort (Webapplicatie, Client/server, Saas). Kept as the source has it.",
+ "The BBN level (Baseline Informatiebeveiliging Overheid) this application is classified at. BBN2+ is a level the TOPdesk CMDB export uses.": "The BBN level (Baseline Informatiebeveiliging Overheid) this application is classified at. BBN2+ is a level the TOPdesk CMDB export uses.",
+ "Field \"%1$s\" must be one of: %2$s.": "Field \"%1$s\" must be one of: %2$s.",
+ "Field \"%s\" has an invalid value.": "Field \"%s\" has an invalid value.",
+ "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.",
+ "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.",
+ "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.",
+ "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.",
+ "Accepted values: {accepted}. Reload the page and try again.": "Accepted values: {accepted}. Reload the page and try again.",
+ "The server could not store the uploaded file.": "The server could not store the uploaded file.",
+ "Try again. If it keeps failing, the Nextcloud log has the details; check the free space and the upload settings of the server.": "Try again. If it keeps failing, the Nextcloud log has the details; check the free space and the upload settings of the server.",
+ "No municipality named \"%s\" was found, so it was created. Check the name if you meant an existing one.": "No municipality named \"%s\" was found, so it was created. Check the name if you meant an existing one.",
+ "APPID %1$s is also on sheet \"%2$s\", which wins; this row is not imported": "APPID %1$s is also on sheet \"%2$s\", which wins; this row is not imported",
+ "Stackiq CMDB owners": "Stackiq CMDB owners",
+ "step \"%1$s\" failed (%2$s)": "step \"%1$s\" failed (%2$s)",
+ "The workbook is too large to read once unpacked.": "The workbook is too large to read once unpacked.",
+ "The stackiq register is out of date; import its configuration again.": "The stackiq register is out of date; import its configuration again.",
+ "Another CMDB import is running; try again when it has finished.": "Another CMDB import is running; try again when it has finished.",
+ "Several municipalities have this name; choose one from the list.": "Several municipalities have this name; choose one from the list.",
+ "Unpacked, the workbook is larger than {size}, the most the import reads.": "Unpacked, the workbook is larger than {size}, the most the import reads.",
+ "The \"{schema}\" schema of the stackiq register is out of date.": "The \"{schema}\" schema of the stackiq register is out of date.",
+ "The stackiq register is out of date.": "The stackiq register is out of date.",
+ "It lacks properties the import matches on. Press Force Update at the top of this page to import the register configuration again, then try again. Nothing was imported.": "It lacks properties the import matches on. Press Force Update at the top of this page to import the register configuration again, then try again. Nothing was imported.",
+ "Another CMDB import is running.": "Another CMDB import is running.",
+ "Only one import runs at a time. Wait until it has finished and try again. Nothing was imported.": "Only one import runs at a time. Wait until it has finished and try again. Nothing was imported.",
+ "{count} municipalities have this name.": "{count} municipalities have this name.",
+ "Several municipalities have this name.": "Several municipalities have this name.",
+ "Choose the municipality from the list instead of typing its name. Nothing was imported.": "Choose the municipality from the list instead of typing its name. Nothing was imported.",
+ "Publish the applications this import creates": "Publish the applications this import creates",
+ "A published application is visible to anyone, including anonymous visitors of OpenCatalogi. When off, the applications this import creates stay unpublished until you publish them by hand. Applications imported before keep their publication as it is.": "A published application is visible to anyone, including anonymous visitors of OpenCatalogi. When off, the applications this import creates stay unpublished until you publish them by hand. Applications imported before keep their publication as it is.",
+ "Created unpublished": "Created unpublished",
+ "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; only a Nextcloud admin can change it.": "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; only a Nextcloud admin can change it.",
+ "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.",
+ "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."
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index 6c52495c3..b2c3a6257 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1,10 +1,30 @@
OC.L10N.register(
"stackiq",
{
+ "Licence metric": "Licentiemetriek",
+ "What one licence covers, as the contract with the supplier states it.": "Waar één licentie voor geldt, zoals het contract met de leverancier het vastlegt.",
+ "Licences bought": "Gekochte licenties",
+ "The number of licences this contract buys.": "Het aantal licenties dat dit contract koopt.",
+ "Licences in use": "Licenties in gebruik",
+ "The number of licences in use today, as the application owner counted them.": "Het aantal licenties dat vandaag in gebruik is, zoals de applicatie-eigenaar ze telde.",
+ "Per named user": "Per benoemde gebruiker",
+ "Per concurrent user": "Per gelijktijdige gebruiker",
+ "Per device": "Per apparaat",
+ "Per inhabitant": "Per inwoner",
+ "Per organisation": "Per organisatie",
+ "Other": "Overig",
+ "Licences": "Licenties",
+ "Loading licences": "Licenties laden",
+ "The licences could not be loaded.": "De licenties konden niet worden geladen.",
+ "Last changed on {date}": "Laatst gewijzigd op {date}",
+ "{inUse} of {bought} in use": "{inUse} van {bought} in gebruik",
+ "Within licence": "Binnen de licentie",
+ "Over licence by {count}": "{count} boven de licentie",
+ "Not counted": "Niet geteld",
+ "Seats": "Licentiegebruik",
+ "No licence contracts with counts": "Geen licentiecontracten met aantallen",
"Load example data?": "Voorbeeldgegevens laden?",
- "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, detailpagina’s en dashboards, zodat je de app meteen ziet werken. Kies \"Geen\" op een productieomgeving.",
- "Load the example data": "Laad de voorbeeldgegevens",
- "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt wat je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen.",
+ "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Each card has its own Load button. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, detailpagina’s en dashboards, zodat je de app meteen ziet werken. Elke kaart heeft een eigen knop Laden. Kies \"Geen\" op een productieomgeving.",
"None, I will set this up myself": "Geen, ik richt dit zelf in",
"Nothing is imported. You start with an empty app and add your own data.": "Er wordt niets geïmporteerd. Je begint met een lege app en voegt zelf gegevens toe.",
"Example data": "Voorbeeldgegevens",
@@ -777,7 +797,408 @@ OC.L10N.register(
"xmlns": "xmlns",
"xsi": "xsi",
"Pre-filled with the names the Integriq endoflife-date-source change provisions. Change them if your instance uses different names, no code change required.": "Vooraf ingevuld met de namen die de Integriq-wijziging endoflife-date-source aanmaakt. Wijzig ze als uw omgeving andere namen gebruikt, geen codewijziging nodig.",
- "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "het geconfigureerde register of schema kon niet worden gevonden. Is de Integriq-wijziging endoflife-date-source geïnstalleerd?"
+ "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "het geconfigureerde register of schema kon niet worden gevonden. Is de Integriq-wijziging endoflife-date-source geïnstalleerd?",
+ "Integrations": "Koppelingen",
+ "Status message": "Statusbericht",
+ "Last checked": "Laatst gecontroleerd",
+ "All connections": "Alle verbindingen",
+ "Add integration": "Integratie toevoegen",
+ "Open settings": "Instellingen openen",
+ "Configured": "Ingericht",
+ "Limited": "Beperkt",
+ "Not configured": "Niet geconfigureerd",
+ "Simulated": "Gesimuleerd",
+ "Not available": "Niet beschikbaar",
+ "Error": "Fout",
+ "Settings": "Instellingen",
+ "Applications supplied": "Geleverde applicaties",
+ "Services supplied": "Geleverde diensten",
+ "Applications managed": "Beheerde applicaties",
+ "Services managed": "Beheerde diensten",
+ "Cancel import": "Import annuleren",
+ "Import cancelled. {count} objects were saved before it stopped.": "Import geannuleerd. {count} objecten waren al opgeslagen toen hij stopte.",
+ "Checking the file": "Bestand controleren",
+ "Reading the model": "Model inlezen",
+ "Converting the model": "Model omzetten",
+ "Saving objects": "Objecten opslaan",
+ "Finishing the import": "Import afronden",
+ "Importing": "Importeren",
+ "{processed} of {total} objects saved": "{processed} van {total} objecten opgeslagen",
+ "Loading contracts": "Contracten laden",
+ "No contracts found for this application": "Geen contracten gevonden voor deze applicatie",
+ "The contracts could not be loaded.": "De contracten konden niet worden geladen.",
+ "Usages": "Gebruik",
+ "No organisation registered a usage yet": "Nog geen organisatie heeft gebruik geregistreerd",
+ "Every connection between applications, and from an application to a national provision.": "Alle koppelingen tussen applicaties, en van een applicatie naar een landelijke voorziening.",
+ "In use": "In gebruik",
+ "In development": "In ontwikkeling",
+ "Applications and standards": "Applicaties en standaarden",
+ "Connections from this application": "Koppelingen vanuit deze applicatie",
+ "Connections to this application": "Koppelingen naar deze applicatie",
+ "To application": "Naar applicatie",
+ "To national provision": "Naar landelijke voorziening",
+ "From application": "Van applicatie",
+ "No connections start at this application": "Er beginnen geen koppelingen bij deze applicatie",
+ "No connections end at this application": "Er eindigen geen koppelingen bij deze applicatie",
+ "AI system": "AI-systeem",
+ "An AI agent, AI model or AI feature the organisation uses, with its EU AI Act classification.": "Een AI-agent, AI-model of AI-functie die de organisatie gebruikt, met de indeling onder de Europese AI-verordening.",
+ "The name the organisation uses for this AI system.": "De naam die de organisatie voor dit AI-systeem gebruikt.",
+ "What the AI system is and how it is used.": "Wat het AI-systeem is en hoe het wordt gebruikt.",
+ "Kind": "Soort",
+ "An AI agent acts on its own, an AI model is a trained model, an AI feature is part of an application.": "Een AI-agent handelt zelfstandig, een AI-model is een getraind model, een AI-functie is onderdeel van een applicatie.",
+ "AI agent": "AI-agent",
+ "AI model": "AI-model",
+ "AI feature": "AI-functie",
+ "The application this AI system runs in or supports.": "De applicatie waarin dit AI-systeem draait of die het ondersteunt.",
+ "The organisation that supplies the AI system.": "De organisatie die het AI-systeem levert.",
+ "Purpose": "Doel",
+ "What the AI system decides, recommends or produces.": "Wat het AI-systeem beslist, adviseert of maakt.",
+ "AI Act risk category": "Risicocategorie AI-verordening",
+ "The risk category under the EU AI Act, as the organisation classified it.": "De risicocategorie onder de Europese AI-verordening, zoals de organisatie die heeft vastgesteld.",
+ "Prohibited": "Verboden",
+ "High risk": "Hoog risico",
+ "Limited risk": "Beperkt risico",
+ "Minimal risk": "Minimaal risico",
+ "Not yet assessed": "Nog niet beoordeeld",
+ "Role under the AI Act": "Rol onder de AI-verordening",
+ "Whether the organisation provides the AI system or deploys it.": "Of de organisatie het AI-systeem aanbiedt of gebruikt.",
+ "Deployer": "Gebruiksverantwoordelijke",
+ "Algorithm register entry": "Vermelding in het algoritmeregister",
+ "The link to this system in the Dutch algorithm register.": "De link naar dit systeem in het Algoritmeregister van de Nederlandse overheid.",
+ "Last assessed on": "Laatst beoordeeld op",
+ "The date the classification was last assessed.": "De datum waarop de indeling voor het laatst is beoordeeld.",
+ "Fundamental rights impact assessment": "Grondrechteneffectbeoordeling",
+ "A reference to the fundamental rights impact assessment (FRIA). A high-risk system without one is flagged.": "Een verwijzing naar de grondrechteneffectbeoordeling (FRIA). Een systeem met hoog risico zonder beoordeling krijgt een waarschuwing.",
+ "Where the AI system stands in its lifecycle.": "Waar het AI-systeem staat in zijn levenscyclus.",
+ "AI Act evidence": "Bewijs voor de AI-verordening",
+ "Attach a document under Documents and give it the matching tag.": "Voeg een document toe onder Documenten en geef het de passende tag.",
+ "FRIA missing": "FRIA ontbreekt",
+ "Fundamental rights impact assessment (FRIA)": "Grondrechteneffectbeoordeling (FRIA)",
+ "Human oversight": "Menselijk toezicht",
+ "Loading the evidence": "Het bewijs wordt geladen",
+ "Logging": "Logging",
+ "Missing": "Ontbreekt",
+ "Technical documentation": "Technische documentatie",
+ "The evidence could not be loaded.": "Het bewijs kon niet worden geladen.",
+ "This is a high-risk AI system without a fundamental rights impact assessment.": "Dit is een AI-systeem met hoog risico zonder grondrechteneffectbeoordeling.",
+ "AI systems": "AI-systemen",
+ "The AI agents, AI models and AI features your organisation uses, with their EU AI Act risk category.": "De AI-agents, AI-modellen en AI-functies die je organisatie gebruikt, met hun risicocategorie onder de Europese AI-verordening.",
+ "FRIA": "FRIA",
+ "High risk without FRIA": "Hoog risico zonder FRIA",
+ "Application and supplier": "Applicatie en leverancier",
+ "No AI systems registered for this application": "Nog geen AI-systemen geregistreerd voor deze applicatie",
+ "Application in use": "Applicatie in gebruik",
+ "To be phased out": "Uit te faseren",
+ "Business owner": "Functioneel eigenaar",
+ "Technical owner": "Technisch eigenaar",
+ "The person in the organisation who is responsible for how the application is used.": "De persoon in de organisatie die verantwoordelijk is voor hoe de applicatie wordt gebruikt.",
+ "The person in the organisation who is responsible for running and maintaining the application.": "De persoon in de organisatie die verantwoordelijk is voor het draaien en onderhouden van de applicatie.",
+ "Add to our landscape": "Toevoegen aan ons landschap",
+ "Connections and services": "Koppelingen en diensten",
+ "The applications your organisation uses, with the version it runs, where it stands and who owns it.": "De applicaties die uw organisatie gebruikt, met de versie die draait, de fase waarin ze staan en wie de eigenaar is.",
+ "No applications in use recorded for this organisation yet": "Nog geen applicaties in gebruik vastgelegd voor deze organisatie",
+ "Maintenance window": "Onderhoudsvenster",
+ "Maintenance a supplier plans on one of its products, with the time window and the expected impact.": "Onderhoud dat een leverancier plant op een van zijn producten, met het tijdvenster en de verwachte impact.",
+ "The product the maintenance is on.": "Het product waarop het onderhoud plaatsvindt.",
+ "The version the maintenance is on, when it concerns one version.": "De versie waarop het onderhoud plaatsvindt, als het om één versie gaat.",
+ "What the maintenance is, in a few words.": "Wat het onderhoud is, in een paar woorden.",
+ "What changes and what users should do.": "Wat er verandert en wat gebruikers moeten doen.",
+ "Starts at": "Begint op",
+ "When the maintenance starts.": "Wanneer het onderhoud begint.",
+ "Ends at": "Eindigt op",
+ "When the maintenance ends.": "Wanneer het onderhoud eindigt.",
+ "Impact": "Impact",
+ "What users notice while the maintenance runs.": "Wat gebruikers merken zolang het onderhoud loopt.",
+ "Where the maintenance stands.": "Waar het onderhoud staat.",
+ "Owners to notify": "Te informeren eigenaren",
+ "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.": "De Nextcloud-gebruikers die eigenaar zijn van een gebruik van het product, bepaald bij de aankondiging van het onderhoud.",
+ "Owners resolved at": "Eigenaren bepaald op",
+ "When the owners to notify were resolved.": "Wanneer de te informeren eigenaren zijn bepaald.",
+ "No impact": "Geen impact",
+ "Degraded": "Verminderd",
+ "Unavailable": "Niet beschikbaar",
+ "In progress": "Bezig",
+ "Completed": "Afgerond",
+ "Cancelled": "Geannuleerd",
+ "Start the maintenance.": "Start het onderhoud.",
+ "Complete the maintenance.": "Rond het onderhoud af.",
+ "Cancel the maintenance.": "Annuleer het onderhoud.",
+ "Roadmap": "Roadmap",
+ "The direction the supplier takes with this application: what the next versions bring and when.": "De richting die de leverancier met deze applicatie kiest: wat de volgende versies brengen en wanneer.",
+ "Planned maintenance": "Gepland onderhoud",
+ "Announce maintenance": "Onderhoud aankondigen",
+ "No maintenance planned on this application": "Geen onderhoud gepland op deze applicatie",
+ "Planned releases": "Geplande releases",
+ "Loading planned maintenance": "Gepland onderhoud laden",
+ "The planned maintenance could not be loaded.": "Het geplande onderhoud kon niet worden geladen.",
+ "No maintenance planned on the applications you use in the next 30 days": "Geen onderhoud gepland op de applicaties die u gebruikt in de komende 30 dagen",
+ "{start} to {end}": "{start} tot {end}",
+ "Loading the roadmap": "Roadmap laden",
+ "The roadmap could not be loaded.": "De roadmap kon niet worden geladen.",
+ "The supplier has not published a roadmap for this application": "De leverancier heeft geen roadmap voor deze applicatie gepubliceerd",
+ "No versions with a date yet": "Nog geen versies met een datum",
+ "{name}: business value {value}, technical fit {fit}, annualised cost {cost}": "{name}: bedrijfswaarde {value}, technische geschiktheid {fit}, jaarlijkse kosten {cost}",
+ "%n application in use is not scored yet.": "%n applicatie in gebruik heeft nog geen score.",
+ "%n applications in use are not scored yet.": "%n applicaties in gebruik hebben nog geen score.",
+ "%n vulnerability linked to this application": "%n kwetsbaarheid gekoppeld aan deze applicatie",
+ "%n vulnerabilities linked to this application": "%n kwetsbaarheden gekoppeld aan deze applicatie",
+ "Business value": "Bedrijfswaarde",
+ "Business value against technical fit": "Bedrijfswaarde tegenover technische geschiktheid",
+ "Business value against technical fit for %n scored application in use": "Bedrijfswaarde tegenover technische geschiktheid voor %n applicatie in gebruik met een score",
+ "Business value against technical fit for %n scored applications in use": "Bedrijfswaarde tegenover technische geschiktheid voor %n applicaties in gebruik met een score",
+ "Each circle is an application in use, its size the annualised cost. A dark ring marks a recorded TIME class that differs from the scores.": "Elke cirkel is een applicatie in gebruik, de grootte staat voor de jaarlijkse kosten. Een donkere rand markeert een vastgelegde TIME-klasse die afwijkt van de scores.",
+ "Known vulnerabilities": "Bekende kwetsbaarheden",
+ "Loading the risk signals": "Risicosignalen laden",
+ "No end of support date known": "Geen datum voor einde ondersteuning bekend",
+ "Not scored": "Geen score",
+ "Passed on {date}": "Verlopen op {date}",
+ "Recorded class differs from scores": "Vastgelegde klasse wijkt af van de scores",
+ "Risk score": "Risicoscore",
+ "Risk signals": "Risicosignalen",
+ "Suggested by scores": "Voorgesteld door de scores",
+ "Supported until {date}": "Ondersteund tot {date}",
+ "Technical fit": "Technische geschiktheid",
+ "The risk signals could not be loaded.": "De risicosignalen konden niet worden geladen.",
+ "This version was withdrawn": "Deze versie is ingetrokken",
+ "Value / fit / risk": "Waarde / geschiktheid / risico",
+ "Value assessment": "Waardebeoordeling",
+ "How much the organisation depends on this application, from 1 (little) to 5 (critical).": "Hoe sterk de organisatie van deze applicatie afhangt, van 1 (weinig) tot 5 (kritiek).",
+ "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).": "Hoe goed de applicatie past bij de architectuur en de standaarden die de organisatie volgt, van 1 (slecht) tot 5 (goed).",
+ "Risk": "Risico",
+ "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.": "Het risico dat de organisatie ziet in het gebruik van deze applicatie, van 1 (laag) tot 5 (hoog). De pagina toont het einde van de ondersteuning en bekende kwetsbaarheden ernaast.",
+ "Scored on": "Gescoord op",
+ "The date the scores were set.": "De datum waarop de scores zijn vastgesteld.",
+ "Suggested TIME classification": "Voorgestelde TIME-classificatie",
+ "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "De TIME-klasse waar de bedrijfswaarde en de technische geschiktheid op wijzen. Berekend bij het opslaan van het gebruik; de vastgelegde TIME-classificatie blijft het besluit.",
+ "CMDB": "CMDB",
+ "Stackiq is your configuration management database for applications. It records what your organisation runs, what it is made of, how it connects and what you pay for it.": "Stackiq is je configuratiebeheerdatabase voor applicaties. Het legt vast wat je organisatie draait, waaruit het bestaat, hoe het samenhangt en wat je ervoor betaalt.",
+ "What stackiq records": "Wat stackiq vastlegt",
+ "What stackiq does not record": "Wat stackiq niet vastlegt",
+ "Servers, laptops and network devices are not recorded here, and stackiq does not discover hardware on your network. Tickets and incidents stay in your service desk.": "Servers, laptops en netwerkapparatuur leg je hier niet vast, en stackiq ontdekt geen hardware op je netwerk. Meldingen en incidenten blijven in je servicedesk.",
+ "Service desk exchange": "Uitwisseling met de servicedesk",
+ "Stackiq and {desk} are in step. The import runs every night at 02:00, and stackiq sends its own changes straight away.": "Stackiq en {desk} lopen gelijk. De import draait elke nacht om 02:00, en stackiq stuurt zijn eigen wijzigingen meteen door.",
+ "No service desk is connected yet. An admin connects TOPdesk or ServiceNow in the admin settings.": "Er is nog geen servicedesk gekoppeld. Een beheerder koppelt TOPdesk of ServiceNow in de beheerinstellingen.",
+ "The service desk owns the name, supplier, installed version and status of an application. Stackiq owns licences, contracts, BBN level, TIME class and publication. Each side only overwrites the fields it owns.": "De servicedesk is eigenaar van de naam, leverancier, geïnstalleerde versie en status van een applicatie. Stackiq is eigenaar van licenties, contracten, BBN-niveau, TIME-klasse en publicatie. Elke kant overschrijft alleen de velden waarvan hij eigenaar is.",
+ "Change the exchange": "Uitwisseling wijzigen",
+ "Connect a service desk": "Servicedesk koppelen",
+ "Import a file": "Bestand importeren",
+ "No service desk API? Import a CSV or XLSX file with the columns recordId, name, supplierName, installedVersion and status. Importing the same file again updates the applications instead of adding them twice.": "Geen API op je servicedesk? Importeer een CSV- of XLSX-bestand met de kolommen recordId, name, supplierName, installedVersion en status. Importeer je hetzelfde bestand opnieuw, dan worden de applicaties bijgewerkt in plaats van dubbel toegevoegd.",
+ "File to import": "Te importeren bestand",
+ "Import file": "Bestand importeren",
+ "What your organisation runs, which version, and who owns it.": "Wat je organisatie draait, welke versie, en wie eigenaar is.",
+ "Applications and their components": "Applicaties en hun onderdelen",
+ "The products, from which supplier, and what they consist of.": "De producten, van welke leverancier, en waaruit ze bestaan.",
+ "Which application exchanges data with which.": "Welke applicatie gegevens uitwisselt met welke.",
+ "Licences and contracts": "Licenties en contracten",
+ "What you bought, how many licences, until when and at what cost.": "Wat je hebt gekocht, hoeveel licenties, tot wanneer en tegen welke kosten.",
+ "The import of {rows} rows has started. The applications appear as the run goes.": "De import van {rows} rijen is gestart. De applicaties verschijnen terwijl de run loopt.",
+ "The import did not start.": "De import is niet gestart.",
+ "Keep applications, connections, licences and contracts in step with TOPdesk or ServiceNow. Add the service desk source in integriq first; stackiq never holds its password.": "Houd applicaties, koppelingen, licenties en contracten gelijk met TOPdesk of ServiceNow. Voeg eerst de servicedeskbron toe in integriq; stackiq bewaart het wachtwoord nooit.",
+ "Loading the service desk exchange…": "Uitwisseling met de servicedesk laden…",
+ "OpenRegister's flow engine is not available, so the exchange cannot run.": "De flow-engine van OpenRegister is niet beschikbaar, dus de uitwisseling kan niet draaien.",
+ "Set up for {desk} on {date}. The import runs every night at 02:00.": "Ingesteld voor {desk} op {date}. De import draait elke nacht om 02:00.",
+ "Service desk": "Servicedesk",
+ "Choose TOPdesk or ServiceNow": "Kies TOPdesk of ServiceNow",
+ "Your organisation": "Je organisatie",
+ "The organisation whose applications are exchanged": "De organisatie waarvan de applicaties worden uitgewisseld",
+ "Set up again": "Opnieuw instellen",
+ "Set up the exchange": "Uitwisseling instellen",
+ "{count} flows are set up. The first import runs tonight.": "{count} flows zijn ingesteld. De eerste import draait vannacht.",
+ "The service desk this record is kept in step with.": "De servicedesk waarmee dit record gelijk wordt gehouden.",
+ "TOPdesk": "TOPdesk",
+ "ServiceNow": "ServiceNow",
+ "File import": "Bestandsimport",
+ "Service desk record": "Servicedeskrecord",
+ "The record id in the service desk. The import matches on it first.": "Het record-id in de servicedesk. De import zoekt hier eerst op.",
+ "Service desk link": "Link naar de servicedesk",
+ "Opens the record in the service desk.": "Opent het record in de servicedesk.",
+ "Last synchronised": "Laatst gesynchroniseerd",
+ "When the service desk exchange last wrote this record.": "Wanneer de uitwisseling met de servicedesk dit record het laatst heeft geschreven.",
+ "Installed version": "Geïnstalleerde versie",
+ "The version the service desk records as installed. The service desk owns it.": "De versie die de servicedesk als geïnstalleerd vastlegt. De servicedesk is er eigenaar van.",
+ "Publish this application in use in the public catalogue from this date. Leave empty to keep it internal.": "Publiceer deze applicatie in gebruik vanaf deze datum in de openbare catalogus. Laat leeg om haar intern te houden.",
+ "Supplier reference": "Referentie van de leverancier",
+ "The supplier's own number for this contract or licence agreement.": "Het eigen nummer van de leverancier voor dit contract of deze licentieovereenkomst.",
+ "Currency": "Valuta",
+ "The currency of the costs, as a three-letter ISO 4217 code.": "De valuta van de kosten, als ISO 4217-code van drie letters.",
+ "The organisation this contract or licence was bought from.": "De organisatie waarvan dit contract of deze licentie is gekocht.",
+ "TOPdesk asset template id": "Id van het TOPdesk-assetsjabloon",
+ "TOPdesk needs a template to create an asset. Copy the id of your Application template from TOPdesk.": "TOPdesk heeft een sjabloon nodig om een asset aan te maken. Kopieer het id van je sjabloon Applicatie uit TOPdesk.",
+ "{processed} of {total} rows processed": "{processed} van {total} rijen verwerkt",
+ "{size} KB": "{size} kB",
+ "{size} MB": "{size} MB",
+ "A new municipality \"{name}\" is created, unless one with this name already exists.": "Er wordt een nieuwe gemeente \"{name}\" aangemaakt, tenzij er al een gemeente met deze naam bestaat.",
+ "A sheet has more rows than the import can process.": "Een tabblad heeft meer rijen dan de import kan verwerken.",
+ "A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.": "Een brontabblad mag hoogstens 10.000 rijen bevatten. Splits de export en importeer de delen na elkaar.",
+ "All outcomes": "Alle resultaten",
+ "Check the connection and try again.": "Controleer de verbinding en probeer het opnieuw.",
+ "Choose a municipality first.": "Kies eerst een gemeente.",
+ "Choose or type a municipality": "Kies of typ een gemeente",
+ "Choose the TOPdesk export": "Kies de TOPdesk-export",
+ "Choose the TOPdesk export and try again.": "Kies de TOPdesk-export en probeer het opnieuw.",
+ "CMDB import": "CMDB-import",
+ "Created": "Aangemaakt",
+ "Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.": "Elke applicatierij uit de export wordt een applicatie (of werkt die bij), met de leverancier van de software (Vendor) en een gebruik dat de applicatie aan de gekozen gemeente koppelt. De applicatie-eigenaar wordt een contactpersoon van de gemeente in Nextcloud Contacten; eigenaren worden nooit openbaar getoond.",
+ "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",
+ "Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import voltooid. De gemeente {name} is aangemaakt. {read} rijen gelezen: {created} aangemaakt, {updated} bijgewerkt, {unchanged} ongewijzigd.",
+ "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 the export…": "De export wordt geïmporteerd…",
+ "Importing…": "Importeren…",
+ "APPID": "APPID",
+ "Municipality": "Gemeente",
+ "No file was uploaded.": "Er is geen bestand geüpload.",
+ "No rows with this outcome": "Geen rijen met dit resultaat",
+ "Nothing more is known on this page; the Nextcloud log has the details.": "Op deze pagina is niet meer bekend; het Nextcloud-logboek bevat de details.",
+ "Only Nextcloud administrators can import a CMDB export.": "Alleen Nextcloud-beheerders kunnen een CMDB-export importeren.",
+ "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.": "De mapping-engine van OpenRegister ontbreekt of een mappingbestand is ongeldig. Werk OpenRegister bij en bekijk het Nextcloud-logboek.",
+ "Outcome": "Resultaat",
+ "Pick an existing municipality or type the name of a new one.": "Kies een bestaande gemeente of typ de naam van een nieuwe.",
+ "Pick an existing organisation of type Municipality, or type a new name and press Enter.": "Kies een bestaande organisatie van het type Gemeente, of typ een nieuwe naam en druk op Enter.",
+ "Pick an organisation of type Municipality, or type the name of a new one.": "Kies een organisatie van het type Gemeente, of typ de naam van een nieuwe.",
+ "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.",
+ "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",
+ "Rows read": "Rijen gelezen",
+ "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.": "Sla de TOPdesk-export op als Excel-werkmap (.xlsx). CSV-, .xls- en .xlsm-bestanden met macro's worden niet geaccepteerd.",
+ "Sheet": "Tabblad",
+ "Show rows with outcome": "Toon rijen met resultaat",
+ "Sign in again and retry the import.": "Meld u opnieuw aan en probeer de import nog eens.",
+ "Skipped": "Overgeslagen",
+ "The chosen organisation is not a municipality.": "De gekozen organisatie is geen gemeente.",
+ "The columns \"APPID\" and \"Applicatie Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "De kolommen \"APPID\" en \"Applicatie Naam\" zijn op elk brontabblad verplicht. Voeg de kolom toe aan de export en probeer het opnieuw. Er is niets geïmporteerd.",
+ "The Excel reader is not available.": "De Excel-lezer is niet beschikbaar.",
+ "The file": "Het bestand",
+ "The file is larger than 10 MB.": "Het bestand is groter dan 10 MB.",
+ "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 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}\".",
+ "The sheets \"Onbeh Applicaties CMDB\" (applications without arranged maintenance) and \"Beheerde Applicaties CMDB\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"Onbeh Applicaties CMDB\" (applicaties zonder geregeld beheer) en \"Beheerde Applicaties CMDB\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "The workbook has none of the sheets the import reads.": "De werkmap bevat geen van de tabbladen die de import leest.",
+ "This file is not an Excel workbook (.xlsx).": "Dit bestand is geen Excel-werkmap (.xlsx).",
+ "This import is no longer running.": "Deze import loopt niet meer.",
+ "Unchanged": "Ongewijzigd",
+ "Update existing records": "Bestaande records bijwerken",
+ "Updated": "Bijgewerkt",
+ "Warnings": "Waarschuwingen",
+ "Warnings for the whole file": "Waarschuwingen voor het hele bestand",
+ "When off, applications imported before are left as they are and reported as skipped.": "Als dit uit staat, blijven eerder geïmporteerde applicaties zoals ze zijn en worden ze als overgeslagen gemeld.",
+ "You are not signed in.": "U bent niet aangemeld.",
+ "Your session has expired.": "Uw sessie is verlopen.",
+ "Stackiq is not configured for the import.": "Stackiq is niet ingesteld voor de import.",
+ "The sheet \"{sheet}\" has more rows than the import can process.": "Het tabblad \"{sheet}\" heeft meer rijen dan de import kan verwerken.",
+ "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.": "Het stackiq-register of de schema's ervan zijn niet gevonden. Voer bovenaan deze pagina Auto Configure uit en probeer het daarna opnieuw.",
+ "Choose a municipality or enter the name of a new one.": "Kies een gemeente of voer de naam van een nieuwe in.",
+ "No running CMDB import has this id.": "Er loopt geen CMDB-import met deze id.",
+ "Only keeping records that are missing from the export is supported.": "Alleen het behouden van records die in de export ontbreken, wordt ondersteund.",
+ "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.",
+ "The Excel reader is not available: OpenRegister is missing or incomplete.": "De Excel-lezer is niet beschikbaar: OpenRegister ontbreekt of is onvolledig.",
+ "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 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",
+ "Owner from column \"%s\" could not be resolved": "Eigenaar uit kolom \"%s\" kon niet worden gevonden",
+ "Owner from column \"%s\" could not be resolved in Nextcloud Contacts": "Eigenaar uit kolom \"%s\" kon niet worden gevonden in Nextcloud Contacten",
+ "Owners skipped: Nextcloud Contacts is unavailable": "Eigenaren overgeslagen: Nextcloud Contacten is niet beschikbaar",
+ "duplicate %s in file": "dubbele %s in het bestand",
+ "exists": "bestaat al",
+ "missing %s": "%s ontbreekt",
+ "step \"%1$s\" failed: %2$s": "stap \"%1$s\" mislukt: %2$s",
+ "step \"%s\" failed": "stap \"%s\" mislukt",
+ "Column \"%s\": formula without a cached value, read as empty": "Kolom \"%s\": formule zonder opgeslagen waarde, gelezen als leeg",
+ "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formulecellen worden gelezen als de waarde die Excel bij de werkmap heeft opgeslagen; formules worden nooit berekend. Sla de werkmap op in Excel voordat u hem importeert.",
+ "Source id": "Bron-id",
+ "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.": "De code van de applicatie in het bronsysteem waaruit ze is geïmporteerd, zoals de TOPdesk Applicatie Code (het Middel-ID). Ter informatie; ze kan in de bron veranderen, dus records worden er niet op gekoppeld.",
+ "Source number": "Bronnummer",
+ "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.": "Het applicatienummer in het bronsysteem, zoals het APPID van TOPdesk (ICT Applicatienummer). Een herhaalde CMDB-import koppelt erop, via de importsleutel.",
+ "Import key": "Importsleutel",
+ "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.": "De sleutel waarop een herhaalde import deze applicatie koppelt: topdesk::. Gezet door de CMDB-import; niet aanpassen.",
+ "Created in source": "Aangemaakt in de bron",
+ "The date the application was registered in the source system.": "De datum waarop de applicatie in het bronsysteem is geregistreerd.",
+ "Changed in source": "Gewijzigd in de bron",
+ "The date the application was last changed in the source system.": "De datum waarop de applicatie in het bronsysteem het laatst is gewijzigd.",
+ "Application published": "Applicatie gepubliceerd",
+ "The publication date of the application this version belongs to, copied from it. A version is public only while its application is.": "De publicatiedatum van de applicatie waar deze versie bij hoort, daarvan overgenomen. Een versie is alleen openbaar zolang haar applicatie dat is.",
+ "Application registered by": "Applicatie geregistreerd door",
+ "Who registered the application this version belongs to, copied from it. A supplier's application is public, and so are its versions.": "Wie de applicatie registreerde waar deze versie bij hoort, daarvan overgenomen. De applicatie van een leverancier is openbaar, en haar versies ook.",
+ "The file could not be sent. Check your connection and try again.": "Het bestand kon niet worden verstuurd. Controleer uw verbinding en probeer het opnieuw.",
+ "The organisation register is not configured, so there are no organisations to choose from.": "Het organisatieregister is niet ingesteld, dus er zijn geen organisaties om uit te kiezen.",
+ "The organisations could not be loaded. Reload the page to try again.": "De organisaties konden niet worden geladen. Herlaad de pagina om het opnieuw te proberen.",
+ "Whether a service desk is connected could not be loaded. Reload the page to try again.": "Kon niet worden geladen of er een servicedesk is gekoppeld. Herlaad de pagina om het opnieuw te proberen.",
+ "Application type": "Applicatiesoort",
+ "The kind of application as the source system records it, such as the TOPdesk Applicatiesoort (Webapplicatie, Client/server, Saas). Kept as the source has it.": "Het soort applicatie zoals het bronsysteem het vastlegt, zoals de TOPdesk-Applicatiesoort (Webapplicatie, Client/server, Saas). Overgenomen zoals de bron het heeft.",
+ "The BBN level (Baseline Informatiebeveiliging Overheid) this application is classified at. BBN2+ is a level the TOPdesk CMDB export uses.": "Het BBN-niveau (Baseline Informatiebeveiliging Overheid) waarop deze applicatie is geclassificeerd. BBN2+ is een niveau dat de TOPdesk-CMDB-export gebruikt.",
+ "Field \"%1$s\" must be one of: %2$s.": "Veld \"%1$s\" moet een van deze waarden hebben: %2$s.",
+ "Field \"%s\" has an invalid value.": "Veld \"%s\" heeft een ongeldige waarde.",
+ "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.",
+ "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.",
+ "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.",
+ "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.",
+ "Accepted values: {accepted}. Reload the page and try again.": "Toegestane waarden: {accepted}. Laad de pagina opnieuw en probeer het nog eens.",
+ "The server could not store the uploaded file.": "De server kon het geüploade bestand niet opslaan.",
+ "Try again. If it keeps failing, the Nextcloud log has the details; check the free space and the upload settings of the server.": "Probeer het opnieuw. Blijft het mislukken, dan staan de details in het Nextcloud-logboek; controleer de vrije ruimte en de uploadinstellingen van de server.",
+ "No municipality named \"%s\" was found, so it was created. Check the name if you meant an existing one.": "Er is geen gemeente met de naam \"%s\" gevonden, dus die is aangemaakt. Controleer de naam als u een bestaande gemeente bedoelde.",
+ "APPID %1$s is also on sheet \"%2$s\", which wins; this row is not imported": "APPID %1$s staat ook op het tabblad \"%2$s\", dat voorgaat; deze rij wordt niet geïmporteerd",
+ "Stackiq CMDB owners": "Stackiq CMDB-eigenaren",
+ "step \"%1$s\" failed (%2$s)": "stap \"%1$s\" mislukt (%2$s)",
+ "The workbook is too large to read once unpacked.": "Het werkboek is uitgepakt te groot om te lezen.",
+ "The stackiq register is out of date; import its configuration again.": "Het stackiq-register is verouderd; importeer de configuratie ervan opnieuw.",
+ "Another CMDB import is running; try again when it has finished.": "Er loopt al een andere CMDB-import; probeer het opnieuw wanneer die klaar is.",
+ "Several municipalities have this name; choose one from the list.": "Meerdere gemeenten hebben deze naam; kies er een uit de lijst.",
+ "Unpacked, the workbook is larger than {size}, the most the import reads.": "Uitgepakt is het werkboek groter dan {size}, het maximum dat de import leest.",
+ "The \"{schema}\" schema of the stackiq register is out of date.": "Het schema \"{schema}\" van het stackiq-register is verouderd.",
+ "The stackiq register is out of date.": "Het stackiq-register is verouderd.",
+ "It lacks properties the import matches on. Press Force Update at the top of this page to import the register configuration again, then try again. Nothing was imported.": "Het mist eigenschappen waarop de import records herkent. Klik bovenaan deze pagina op Force Update om de registerconfiguratie opnieuw te importeren en probeer het daarna opnieuw. Er is niets geïmporteerd.",
+ "Another CMDB import is running.": "Er loopt al een andere CMDB-import.",
+ "Only one import runs at a time. Wait until it has finished and try again. Nothing was imported.": "Er loopt maar één import tegelijk. Wacht tot die klaar is en probeer het opnieuw. Er is niets geïmporteerd.",
+ "{count} municipalities have this name.": "{count} gemeenten hebben deze naam.",
+ "Several municipalities have this name.": "Meerdere gemeenten hebben deze naam.",
+ "Choose the municipality from the list instead of typing its name. Nothing was imported.": "Kies de gemeente uit de lijst in plaats van de naam te typen. Er is niets geïmporteerd.",
+ "Publish the applications this import creates": "De applicaties die deze import aanmaakt publiceren",
+ "A published application is visible to anyone, including anonymous visitors of OpenCatalogi. When off, the applications this import creates stay unpublished until you publish them by hand. Applications imported before keep their publication as it is.": "Een gepubliceerde applicatie is voor iedereen zichtbaar, ook voor anonieme bezoekers van OpenCatalogi. Staat dit uit, dan blijven de applicaties die deze import aanmaakt ongepubliceerd tot u ze zelf publiceert. Eerder geïmporteerde applicaties houden hun publicatie zoals die is.",
+ "Created unpublished": "Ongepubliceerd aangemaakt",
+ "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; only a Nextcloud admin can change it.": "De sleutel waarop een herhaalde import deze applicatie koppelt: topdesk::. Gezet door de CMDB-import; alleen een Nextcloud-beheerder kan deze wijzigen.",
+ "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.",
+ "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."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index 0848195e0..e226a52ca 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1,9 +1,29 @@
{
"translations": {
+ "Licence metric": "Licentiemetriek",
+ "What one licence covers, as the contract with the supplier states it.": "Waar één licentie voor geldt, zoals het contract met de leverancier het vastlegt.",
+ "Licences bought": "Gekochte licenties",
+ "The number of licences this contract buys.": "Het aantal licenties dat dit contract koopt.",
+ "Licences in use": "Licenties in gebruik",
+ "The number of licences in use today, as the application owner counted them.": "Het aantal licenties dat vandaag in gebruik is, zoals de applicatie-eigenaar ze telde.",
+ "Per named user": "Per benoemde gebruiker",
+ "Per concurrent user": "Per gelijktijdige gebruiker",
+ "Per device": "Per apparaat",
+ "Per inhabitant": "Per inwoner",
+ "Per organisation": "Per organisatie",
+ "Other": "Overig",
+ "Licences": "Licenties",
+ "Loading licences": "Licenties laden",
+ "The licences could not be loaded.": "De licenties konden niet worden geladen.",
+ "Last changed on {date}": "Laatst gewijzigd op {date}",
+ "{inUse} of {bought} in use": "{inUse} van {bought} in gebruik",
+ "Within licence": "Binnen de licentie",
+ "Over licence by {count}": "{count} boven de licentie",
+ "Not counted": "Niet geteld",
+ "Seats": "Licentiegebruik",
+ "No licence contracts with counts": "Geen licentiecontracten met aantallen",
"Load example data?": "Voorbeeldgegevens laden?",
- "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, detailpagina’s en dashboards, zodat je de app meteen ziet werken. Kies \"Geen\" op een productieomgeving.",
- "Load the example data": "Laad de voorbeeldgegevens",
- "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt wat je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen.",
+ "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Each card has its own Load button. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, detailpagina’s en dashboards, zodat je de app meteen ziet werken. Elke kaart heeft een eigen knop Laden. Kies \"Geen\" op een productieomgeving.",
"None, I will set this up myself": "Geen, ik richt dit zelf in",
"Nothing is imported. You start with an empty app and add your own data.": "Er wordt niets geïmporteerd. Je begint met een lege app en voegt zelf gegevens toe.",
"Example data": "Voorbeeldgegevens",
@@ -776,6 +796,407 @@
"xmlns": "xmlns",
"xsi": "xsi",
"Pre-filled with the names the Integriq endoflife-date-source change provisions. Change them if your instance uses different names, no code change required.": "Vooraf ingevuld met de namen die de Integriq-wijziging endoflife-date-source aanmaakt. Wijzig ze als uw omgeving andere namen gebruikt, geen codewijziging nodig.",
- "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "het geconfigureerde register of schema kon niet worden gevonden. Is de Integriq-wijziging endoflife-date-source geïnstalleerd?"
+ "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "het geconfigureerde register of schema kon niet worden gevonden. Is de Integriq-wijziging endoflife-date-source geïnstalleerd?",
+ "Integrations": "Koppelingen",
+ "Status message": "Statusbericht",
+ "Last checked": "Laatst gecontroleerd",
+ "All connections": "Alle verbindingen",
+ "Add integration": "Integratie toevoegen",
+ "Open settings": "Instellingen openen",
+ "Configured": "Ingericht",
+ "Limited": "Beperkt",
+ "Not configured": "Niet geconfigureerd",
+ "Simulated": "Gesimuleerd",
+ "Not available": "Niet beschikbaar",
+ "Error": "Fout",
+ "Settings": "Instellingen",
+ "Applications supplied": "Geleverde applicaties",
+ "Services supplied": "Geleverde diensten",
+ "Applications managed": "Beheerde applicaties",
+ "Services managed": "Beheerde diensten",
+ "Cancel import": "Import annuleren",
+ "Import cancelled. {count} objects were saved before it stopped.": "Import geannuleerd. {count} objecten waren al opgeslagen toen hij stopte.",
+ "Checking the file": "Bestand controleren",
+ "Reading the model": "Model inlezen",
+ "Converting the model": "Model omzetten",
+ "Saving objects": "Objecten opslaan",
+ "Finishing the import": "Import afronden",
+ "Importing": "Importeren",
+ "{processed} of {total} objects saved": "{processed} van {total} objecten opgeslagen",
+ "Loading contracts": "Contracten laden",
+ "No contracts found for this application": "Geen contracten gevonden voor deze applicatie",
+ "The contracts could not be loaded.": "De contracten konden niet worden geladen.",
+ "Usages": "Gebruik",
+ "No organisation registered a usage yet": "Nog geen organisatie heeft gebruik geregistreerd",
+ "Every connection between applications, and from an application to a national provision.": "Alle koppelingen tussen applicaties, en van een applicatie naar een landelijke voorziening.",
+ "In use": "In gebruik",
+ "In development": "In ontwikkeling",
+ "Applications and standards": "Applicaties en standaarden",
+ "Connections from this application": "Koppelingen vanuit deze applicatie",
+ "Connections to this application": "Koppelingen naar deze applicatie",
+ "To application": "Naar applicatie",
+ "To national provision": "Naar landelijke voorziening",
+ "From application": "Van applicatie",
+ "No connections start at this application": "Er beginnen geen koppelingen bij deze applicatie",
+ "No connections end at this application": "Er eindigen geen koppelingen bij deze applicatie",
+ "AI system": "AI-systeem",
+ "An AI agent, AI model or AI feature the organisation uses, with its EU AI Act classification.": "Een AI-agent, AI-model of AI-functie die de organisatie gebruikt, met de indeling onder de Europese AI-verordening.",
+ "The name the organisation uses for this AI system.": "De naam die de organisatie voor dit AI-systeem gebruikt.",
+ "What the AI system is and how it is used.": "Wat het AI-systeem is en hoe het wordt gebruikt.",
+ "Kind": "Soort",
+ "An AI agent acts on its own, an AI model is a trained model, an AI feature is part of an application.": "Een AI-agent handelt zelfstandig, een AI-model is een getraind model, een AI-functie is onderdeel van een applicatie.",
+ "AI agent": "AI-agent",
+ "AI model": "AI-model",
+ "AI feature": "AI-functie",
+ "The application this AI system runs in or supports.": "De applicatie waarin dit AI-systeem draait of die het ondersteunt.",
+ "The organisation that supplies the AI system.": "De organisatie die het AI-systeem levert.",
+ "Purpose": "Doel",
+ "What the AI system decides, recommends or produces.": "Wat het AI-systeem beslist, adviseert of maakt.",
+ "AI Act risk category": "Risicocategorie AI-verordening",
+ "The risk category under the EU AI Act, as the organisation classified it.": "De risicocategorie onder de Europese AI-verordening, zoals de organisatie die heeft vastgesteld.",
+ "Prohibited": "Verboden",
+ "High risk": "Hoog risico",
+ "Limited risk": "Beperkt risico",
+ "Minimal risk": "Minimaal risico",
+ "Not yet assessed": "Nog niet beoordeeld",
+ "Role under the AI Act": "Rol onder de AI-verordening",
+ "Whether the organisation provides the AI system or deploys it.": "Of de organisatie het AI-systeem aanbiedt of gebruikt.",
+ "Deployer": "Gebruiksverantwoordelijke",
+ "Algorithm register entry": "Vermelding in het algoritmeregister",
+ "The link to this system in the Dutch algorithm register.": "De link naar dit systeem in het Algoritmeregister van de Nederlandse overheid.",
+ "Last assessed on": "Laatst beoordeeld op",
+ "The date the classification was last assessed.": "De datum waarop de indeling voor het laatst is beoordeeld.",
+ "Fundamental rights impact assessment": "Grondrechteneffectbeoordeling",
+ "A reference to the fundamental rights impact assessment (FRIA). A high-risk system without one is flagged.": "Een verwijzing naar de grondrechteneffectbeoordeling (FRIA). Een systeem met hoog risico zonder beoordeling krijgt een waarschuwing.",
+ "Where the AI system stands in its lifecycle.": "Waar het AI-systeem staat in zijn levenscyclus.",
+ "AI Act evidence": "Bewijs voor de AI-verordening",
+ "Attach a document under Documents and give it the matching tag.": "Voeg een document toe onder Documenten en geef het de passende tag.",
+ "FRIA missing": "FRIA ontbreekt",
+ "Fundamental rights impact assessment (FRIA)": "Grondrechteneffectbeoordeling (FRIA)",
+ "Human oversight": "Menselijk toezicht",
+ "Loading the evidence": "Het bewijs wordt geladen",
+ "Logging": "Logging",
+ "Missing": "Ontbreekt",
+ "Technical documentation": "Technische documentatie",
+ "The evidence could not be loaded.": "Het bewijs kon niet worden geladen.",
+ "This is a high-risk AI system without a fundamental rights impact assessment.": "Dit is een AI-systeem met hoog risico zonder grondrechteneffectbeoordeling.",
+ "AI systems": "AI-systemen",
+ "The AI agents, AI models and AI features your organisation uses, with their EU AI Act risk category.": "De AI-agents, AI-modellen en AI-functies die je organisatie gebruikt, met hun risicocategorie onder de Europese AI-verordening.",
+ "FRIA": "FRIA",
+ "High risk without FRIA": "Hoog risico zonder FRIA",
+ "Application and supplier": "Applicatie en leverancier",
+ "No AI systems registered for this application": "Nog geen AI-systemen geregistreerd voor deze applicatie",
+ "Application in use": "Applicatie in gebruik",
+ "To be phased out": "Uit te faseren",
+ "Business owner": "Functioneel eigenaar",
+ "Technical owner": "Technisch eigenaar",
+ "The person in the organisation who is responsible for how the application is used.": "De persoon in de organisatie die verantwoordelijk is voor hoe de applicatie wordt gebruikt.",
+ "The person in the organisation who is responsible for running and maintaining the application.": "De persoon in de organisatie die verantwoordelijk is voor het draaien en onderhouden van de applicatie.",
+ "Add to our landscape": "Toevoegen aan ons landschap",
+ "Connections and services": "Koppelingen en diensten",
+ "The applications your organisation uses, with the version it runs, where it stands and who owns it.": "De applicaties die uw organisatie gebruikt, met de versie die draait, de fase waarin ze staan en wie de eigenaar is.",
+ "No applications in use recorded for this organisation yet": "Nog geen applicaties in gebruik vastgelegd voor deze organisatie",
+ "Maintenance window": "Onderhoudsvenster",
+ "Maintenance a supplier plans on one of its products, with the time window and the expected impact.": "Onderhoud dat een leverancier plant op een van zijn producten, met het tijdvenster en de verwachte impact.",
+ "The product the maintenance is on.": "Het product waarop het onderhoud plaatsvindt.",
+ "The version the maintenance is on, when it concerns one version.": "De versie waarop het onderhoud plaatsvindt, als het om één versie gaat.",
+ "What the maintenance is, in a few words.": "Wat het onderhoud is, in een paar woorden.",
+ "What changes and what users should do.": "Wat er verandert en wat gebruikers moeten doen.",
+ "Starts at": "Begint op",
+ "When the maintenance starts.": "Wanneer het onderhoud begint.",
+ "Ends at": "Eindigt op",
+ "When the maintenance ends.": "Wanneer het onderhoud eindigt.",
+ "Impact": "Impact",
+ "What users notice while the maintenance runs.": "Wat gebruikers merken zolang het onderhoud loopt.",
+ "Where the maintenance stands.": "Waar het onderhoud staat.",
+ "Owners to notify": "Te informeren eigenaren",
+ "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.": "De Nextcloud-gebruikers die eigenaar zijn van een gebruik van het product, bepaald bij de aankondiging van het onderhoud.",
+ "Owners resolved at": "Eigenaren bepaald op",
+ "When the owners to notify were resolved.": "Wanneer de te informeren eigenaren zijn bepaald.",
+ "No impact": "Geen impact",
+ "Degraded": "Verminderd",
+ "Unavailable": "Niet beschikbaar",
+ "In progress": "Bezig",
+ "Completed": "Afgerond",
+ "Cancelled": "Geannuleerd",
+ "Start the maintenance.": "Start het onderhoud.",
+ "Complete the maintenance.": "Rond het onderhoud af.",
+ "Cancel the maintenance.": "Annuleer het onderhoud.",
+ "Roadmap": "Roadmap",
+ "The direction the supplier takes with this application: what the next versions bring and when.": "De richting die de leverancier met deze applicatie kiest: wat de volgende versies brengen en wanneer.",
+ "Planned maintenance": "Gepland onderhoud",
+ "Announce maintenance": "Onderhoud aankondigen",
+ "No maintenance planned on this application": "Geen onderhoud gepland op deze applicatie",
+ "Planned releases": "Geplande releases",
+ "Loading planned maintenance": "Gepland onderhoud laden",
+ "The planned maintenance could not be loaded.": "Het geplande onderhoud kon niet worden geladen.",
+ "No maintenance planned on the applications you use in the next 30 days": "Geen onderhoud gepland op de applicaties die u gebruikt in de komende 30 dagen",
+ "{start} to {end}": "{start} tot {end}",
+ "Loading the roadmap": "Roadmap laden",
+ "The roadmap could not be loaded.": "De roadmap kon niet worden geladen.",
+ "The supplier has not published a roadmap for this application": "De leverancier heeft geen roadmap voor deze applicatie gepubliceerd",
+ "No versions with a date yet": "Nog geen versies met een datum",
+ "{name}: business value {value}, technical fit {fit}, annualised cost {cost}": "{name}: bedrijfswaarde {value}, technische geschiktheid {fit}, jaarlijkse kosten {cost}",
+ "%n application in use is not scored yet.": "%n applicatie in gebruik heeft nog geen score.",
+ "%n applications in use are not scored yet.": "%n applicaties in gebruik hebben nog geen score.",
+ "%n vulnerability linked to this application": "%n kwetsbaarheid gekoppeld aan deze applicatie",
+ "%n vulnerabilities linked to this application": "%n kwetsbaarheden gekoppeld aan deze applicatie",
+ "Business value": "Bedrijfswaarde",
+ "Business value against technical fit": "Bedrijfswaarde tegenover technische geschiktheid",
+ "Business value against technical fit for %n scored application in use": "Bedrijfswaarde tegenover technische geschiktheid voor %n applicatie in gebruik met een score",
+ "Business value against technical fit for %n scored applications in use": "Bedrijfswaarde tegenover technische geschiktheid voor %n applicaties in gebruik met een score",
+ "Each circle is an application in use, its size the annualised cost. A dark ring marks a recorded TIME class that differs from the scores.": "Elke cirkel is een applicatie in gebruik, de grootte staat voor de jaarlijkse kosten. Een donkere rand markeert een vastgelegde TIME-klasse die afwijkt van de scores.",
+ "Known vulnerabilities": "Bekende kwetsbaarheden",
+ "Loading the risk signals": "Risicosignalen laden",
+ "No end of support date known": "Geen datum voor einde ondersteuning bekend",
+ "Not scored": "Geen score",
+ "Passed on {date}": "Verlopen op {date}",
+ "Recorded class differs from scores": "Vastgelegde klasse wijkt af van de scores",
+ "Risk score": "Risicoscore",
+ "Risk signals": "Risicosignalen",
+ "Suggested by scores": "Voorgesteld door de scores",
+ "Supported until {date}": "Ondersteund tot {date}",
+ "Technical fit": "Technische geschiktheid",
+ "The risk signals could not be loaded.": "De risicosignalen konden niet worden geladen.",
+ "This version was withdrawn": "Deze versie is ingetrokken",
+ "Value / fit / risk": "Waarde / geschiktheid / risico",
+ "Value assessment": "Waardebeoordeling",
+ "How much the organisation depends on this application, from 1 (little) to 5 (critical).": "Hoe sterk de organisatie van deze applicatie afhangt, van 1 (weinig) tot 5 (kritiek).",
+ "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).": "Hoe goed de applicatie past bij de architectuur en de standaarden die de organisatie volgt, van 1 (slecht) tot 5 (goed).",
+ "Risk": "Risico",
+ "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.": "Het risico dat de organisatie ziet in het gebruik van deze applicatie, van 1 (laag) tot 5 (hoog). De pagina toont het einde van de ondersteuning en bekende kwetsbaarheden ernaast.",
+ "Scored on": "Gescoord op",
+ "The date the scores were set.": "De datum waarop de scores zijn vastgesteld.",
+ "Suggested TIME classification": "Voorgestelde TIME-classificatie",
+ "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "De TIME-klasse waar de bedrijfswaarde en de technische geschiktheid op wijzen. Berekend bij het opslaan van het gebruik; de vastgelegde TIME-classificatie blijft het besluit.",
+ "CMDB": "CMDB",
+ "Stackiq is your configuration management database for applications. It records what your organisation runs, what it is made of, how it connects and what you pay for it.": "Stackiq is je configuratiebeheerdatabase voor applicaties. Het legt vast wat je organisatie draait, waaruit het bestaat, hoe het samenhangt en wat je ervoor betaalt.",
+ "What stackiq records": "Wat stackiq vastlegt",
+ "What stackiq does not record": "Wat stackiq niet vastlegt",
+ "Servers, laptops and network devices are not recorded here, and stackiq does not discover hardware on your network. Tickets and incidents stay in your service desk.": "Servers, laptops en netwerkapparatuur leg je hier niet vast, en stackiq ontdekt geen hardware op je netwerk. Meldingen en incidenten blijven in je servicedesk.",
+ "Service desk exchange": "Uitwisseling met de servicedesk",
+ "Stackiq and {desk} are in step. The import runs every night at 02:00, and stackiq sends its own changes straight away.": "Stackiq en {desk} lopen gelijk. De import draait elke nacht om 02:00, en stackiq stuurt zijn eigen wijzigingen meteen door.",
+ "No service desk is connected yet. An admin connects TOPdesk or ServiceNow in the admin settings.": "Er is nog geen servicedesk gekoppeld. Een beheerder koppelt TOPdesk of ServiceNow in de beheerinstellingen.",
+ "The service desk owns the name, supplier, installed version and status of an application. Stackiq owns licences, contracts, BBN level, TIME class and publication. Each side only overwrites the fields it owns.": "De servicedesk is eigenaar van de naam, leverancier, geïnstalleerde versie en status van een applicatie. Stackiq is eigenaar van licenties, contracten, BBN-niveau, TIME-klasse en publicatie. Elke kant overschrijft alleen de velden waarvan hij eigenaar is.",
+ "Change the exchange": "Uitwisseling wijzigen",
+ "Connect a service desk": "Servicedesk koppelen",
+ "Import a file": "Bestand importeren",
+ "No service desk API? Import a CSV or XLSX file with the columns recordId, name, supplierName, installedVersion and status. Importing the same file again updates the applications instead of adding them twice.": "Geen API op je servicedesk? Importeer een CSV- of XLSX-bestand met de kolommen recordId, name, supplierName, installedVersion en status. Importeer je hetzelfde bestand opnieuw, dan worden de applicaties bijgewerkt in plaats van dubbel toegevoegd.",
+ "File to import": "Te importeren bestand",
+ "Import file": "Bestand importeren",
+ "What your organisation runs, which version, and who owns it.": "Wat je organisatie draait, welke versie, en wie eigenaar is.",
+ "Applications and their components": "Applicaties en hun onderdelen",
+ "The products, from which supplier, and what they consist of.": "De producten, van welke leverancier, en waaruit ze bestaan.",
+ "Which application exchanges data with which.": "Welke applicatie gegevens uitwisselt met welke.",
+ "Licences and contracts": "Licenties en contracten",
+ "What you bought, how many licences, until when and at what cost.": "Wat je hebt gekocht, hoeveel licenties, tot wanneer en tegen welke kosten.",
+ "The import of {rows} rows has started. The applications appear as the run goes.": "De import van {rows} rijen is gestart. De applicaties verschijnen terwijl de run loopt.",
+ "The import did not start.": "De import is niet gestart.",
+ "Keep applications, connections, licences and contracts in step with TOPdesk or ServiceNow. Add the service desk source in integriq first; stackiq never holds its password.": "Houd applicaties, koppelingen, licenties en contracten gelijk met TOPdesk of ServiceNow. Voeg eerst de servicedeskbron toe in integriq; stackiq bewaart het wachtwoord nooit.",
+ "Loading the service desk exchange…": "Uitwisseling met de servicedesk laden…",
+ "OpenRegister's flow engine is not available, so the exchange cannot run.": "De flow-engine van OpenRegister is niet beschikbaar, dus de uitwisseling kan niet draaien.",
+ "Set up for {desk} on {date}. The import runs every night at 02:00.": "Ingesteld voor {desk} op {date}. De import draait elke nacht om 02:00.",
+ "Service desk": "Servicedesk",
+ "Choose TOPdesk or ServiceNow": "Kies TOPdesk of ServiceNow",
+ "Your organisation": "Je organisatie",
+ "The organisation whose applications are exchanged": "De organisatie waarvan de applicaties worden uitgewisseld",
+ "Set up again": "Opnieuw instellen",
+ "Set up the exchange": "Uitwisseling instellen",
+ "{count} flows are set up. The first import runs tonight.": "{count} flows zijn ingesteld. De eerste import draait vannacht.",
+ "The service desk this record is kept in step with.": "De servicedesk waarmee dit record gelijk wordt gehouden.",
+ "TOPdesk": "TOPdesk",
+ "ServiceNow": "ServiceNow",
+ "File import": "Bestandsimport",
+ "Service desk record": "Servicedeskrecord",
+ "The record id in the service desk. The import matches on it first.": "Het record-id in de servicedesk. De import zoekt hier eerst op.",
+ "Service desk link": "Link naar de servicedesk",
+ "Opens the record in the service desk.": "Opent het record in de servicedesk.",
+ "Last synchronised": "Laatst gesynchroniseerd",
+ "When the service desk exchange last wrote this record.": "Wanneer de uitwisseling met de servicedesk dit record het laatst heeft geschreven.",
+ "Installed version": "Geïnstalleerde versie",
+ "The version the service desk records as installed. The service desk owns it.": "De versie die de servicedesk als geïnstalleerd vastlegt. De servicedesk is er eigenaar van.",
+ "Publish this application in use in the public catalogue from this date. Leave empty to keep it internal.": "Publiceer deze applicatie in gebruik vanaf deze datum in de openbare catalogus. Laat leeg om haar intern te houden.",
+ "Supplier reference": "Referentie van de leverancier",
+ "The supplier's own number for this contract or licence agreement.": "Het eigen nummer van de leverancier voor dit contract of deze licentieovereenkomst.",
+ "Currency": "Valuta",
+ "The currency of the costs, as a three-letter ISO 4217 code.": "De valuta van de kosten, als ISO 4217-code van drie letters.",
+ "The organisation this contract or licence was bought from.": "De organisatie waarvan dit contract of deze licentie is gekocht.",
+ "TOPdesk asset template id": "Id van het TOPdesk-assetsjabloon",
+ "TOPdesk needs a template to create an asset. Copy the id of your Application template from TOPdesk.": "TOPdesk heeft een sjabloon nodig om een asset aan te maken. Kopieer het id van je sjabloon Applicatie uit TOPdesk.",
+ "{processed} of {total} rows processed": "{processed} van {total} rijen verwerkt",
+ "{size} KB": "{size} kB",
+ "{size} MB": "{size} MB",
+ "A new municipality \"{name}\" is created, unless one with this name already exists.": "Er wordt een nieuwe gemeente \"{name}\" aangemaakt, tenzij er al een gemeente met deze naam bestaat.",
+ "A sheet has more rows than the import can process.": "Een tabblad heeft meer rijen dan de import kan verwerken.",
+ "A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.": "Een brontabblad mag hoogstens 10.000 rijen bevatten. Splits de export en importeer de delen na elkaar.",
+ "All outcomes": "Alle resultaten",
+ "Check the connection and try again.": "Controleer de verbinding en probeer het opnieuw.",
+ "Choose a municipality first.": "Kies eerst een gemeente.",
+ "Choose or type a municipality": "Kies of typ een gemeente",
+ "Choose the TOPdesk export": "Kies de TOPdesk-export",
+ "Choose the TOPdesk export and try again.": "Kies de TOPdesk-export en probeer het opnieuw.",
+ "CMDB import": "CMDB-import",
+ "Created": "Aangemaakt",
+ "Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.": "Elke applicatierij uit de export wordt een applicatie (of werkt die bij), met de leverancier van de software (Vendor) en een gebruik dat de applicatie aan de gekozen gemeente koppelt. De applicatie-eigenaar wordt een contactpersoon van de gemeente in Nextcloud Contacten; eigenaren worden nooit openbaar getoond.",
+ "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",
+ "Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import voltooid. De gemeente {name} is aangemaakt. {read} rijen gelezen: {created} aangemaakt, {updated} bijgewerkt, {unchanged} ongewijzigd.",
+ "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 the export…": "De export wordt geïmporteerd…",
+ "Importing…": "Importeren…",
+ "APPID": "APPID",
+ "Municipality": "Gemeente",
+ "No file was uploaded.": "Er is geen bestand geüpload.",
+ "No rows with this outcome": "Geen rijen met dit resultaat",
+ "Nothing more is known on this page; the Nextcloud log has the details.": "Op deze pagina is niet meer bekend; het Nextcloud-logboek bevat de details.",
+ "Only Nextcloud administrators can import a CMDB export.": "Alleen Nextcloud-beheerders kunnen een CMDB-export importeren.",
+ "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.": "De mapping-engine van OpenRegister ontbreekt of een mappingbestand is ongeldig. Werk OpenRegister bij en bekijk het Nextcloud-logboek.",
+ "Outcome": "Resultaat",
+ "Pick an existing municipality or type the name of a new one.": "Kies een bestaande gemeente of typ de naam van een nieuwe.",
+ "Pick an existing organisation of type Municipality, or type a new name and press Enter.": "Kies een bestaande organisatie van het type Gemeente, of typ een nieuwe naam en druk op Enter.",
+ "Pick an organisation of type Municipality, or type the name of a new one.": "Kies een organisatie van het type Gemeente, of typ de naam van een nieuwe.",
+ "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.",
+ "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",
+ "Rows read": "Rijen gelezen",
+ "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.": "Sla de TOPdesk-export op als Excel-werkmap (.xlsx). CSV-, .xls- en .xlsm-bestanden met macro's worden niet geaccepteerd.",
+ "Sheet": "Tabblad",
+ "Show rows with outcome": "Toon rijen met resultaat",
+ "Sign in again and retry the import.": "Meld u opnieuw aan en probeer de import nog eens.",
+ "Skipped": "Overgeslagen",
+ "The chosen organisation is not a municipality.": "De gekozen organisatie is geen gemeente.",
+ "The columns \"APPID\" and \"Applicatie Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "De kolommen \"APPID\" en \"Applicatie Naam\" zijn op elk brontabblad verplicht. Voeg de kolom toe aan de export en probeer het opnieuw. Er is niets geïmporteerd.",
+ "The Excel reader is not available.": "De Excel-lezer is niet beschikbaar.",
+ "The file": "Het bestand",
+ "The file is larger than 10 MB.": "Het bestand is groter dan 10 MB.",
+ "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 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}\".",
+ "The sheets \"Onbeh Applicaties CMDB\" (applications without arranged maintenance) and \"Beheerde Applicaties CMDB\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"Onbeh Applicaties CMDB\" (applicaties zonder geregeld beheer) en \"Beheerde Applicaties CMDB\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "The workbook has none of the sheets the import reads.": "De werkmap bevat geen van de tabbladen die de import leest.",
+ "This file is not an Excel workbook (.xlsx).": "Dit bestand is geen Excel-werkmap (.xlsx).",
+ "This import is no longer running.": "Deze import loopt niet meer.",
+ "Unchanged": "Ongewijzigd",
+ "Update existing records": "Bestaande records bijwerken",
+ "Updated": "Bijgewerkt",
+ "Warnings": "Waarschuwingen",
+ "Warnings for the whole file": "Waarschuwingen voor het hele bestand",
+ "When off, applications imported before are left as they are and reported as skipped.": "Als dit uit staat, blijven eerder geïmporteerde applicaties zoals ze zijn en worden ze als overgeslagen gemeld.",
+ "You are not signed in.": "U bent niet aangemeld.",
+ "Your session has expired.": "Uw sessie is verlopen.",
+ "Stackiq is not configured for the import.": "Stackiq is niet ingesteld voor de import.",
+ "The sheet \"{sheet}\" has more rows than the import can process.": "Het tabblad \"{sheet}\" heeft meer rijen dan de import kan verwerken.",
+ "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.": "Het stackiq-register of de schema's ervan zijn niet gevonden. Voer bovenaan deze pagina Auto Configure uit en probeer het daarna opnieuw.",
+ "Choose a municipality or enter the name of a new one.": "Kies een gemeente of voer de naam van een nieuwe in.",
+ "No running CMDB import has this id.": "Er loopt geen CMDB-import met deze id.",
+ "Only keeping records that are missing from the export is supported.": "Alleen het behouden van records die in de export ontbreken, wordt ondersteund.",
+ "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.",
+ "The Excel reader is not available: OpenRegister is missing or incomplete.": "De Excel-lezer is niet beschikbaar: OpenRegister ontbreekt of is onvolledig.",
+ "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 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",
+ "Owner from column \"%s\" could not be resolved": "Eigenaar uit kolom \"%s\" kon niet worden gevonden",
+ "Owner from column \"%s\" could not be resolved in Nextcloud Contacts": "Eigenaar uit kolom \"%s\" kon niet worden gevonden in Nextcloud Contacten",
+ "Owners skipped: Nextcloud Contacts is unavailable": "Eigenaren overgeslagen: Nextcloud Contacten is niet beschikbaar",
+ "duplicate %s in file": "dubbele %s in het bestand",
+ "exists": "bestaat al",
+ "missing %s": "%s ontbreekt",
+ "step \"%1$s\" failed: %2$s": "stap \"%1$s\" mislukt: %2$s",
+ "step \"%s\" failed": "stap \"%s\" mislukt",
+ "Column \"%s\": formula without a cached value, read as empty": "Kolom \"%s\": formule zonder opgeslagen waarde, gelezen als leeg",
+ "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formulecellen worden gelezen als de waarde die Excel bij de werkmap heeft opgeslagen; formules worden nooit berekend. Sla de werkmap op in Excel voordat u hem importeert.",
+ "Source id": "Bron-id",
+ "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.": "De code van de applicatie in het bronsysteem waaruit ze is geïmporteerd, zoals de TOPdesk Applicatie Code (het Middel-ID). Ter informatie; ze kan in de bron veranderen, dus records worden er niet op gekoppeld.",
+ "Source number": "Bronnummer",
+ "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.": "Het applicatienummer in het bronsysteem, zoals het APPID van TOPdesk (ICT Applicatienummer). Een herhaalde CMDB-import koppelt erop, via de importsleutel.",
+ "Import key": "Importsleutel",
+ "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.": "De sleutel waarop een herhaalde import deze applicatie koppelt: topdesk::. Gezet door de CMDB-import; niet aanpassen.",
+ "Created in source": "Aangemaakt in de bron",
+ "The date the application was registered in the source system.": "De datum waarop de applicatie in het bronsysteem is geregistreerd.",
+ "Changed in source": "Gewijzigd in de bron",
+ "The date the application was last changed in the source system.": "De datum waarop de applicatie in het bronsysteem het laatst is gewijzigd.",
+ "Application published": "Applicatie gepubliceerd",
+ "The publication date of the application this version belongs to, copied from it. A version is public only while its application is.": "De publicatiedatum van de applicatie waar deze versie bij hoort, daarvan overgenomen. Een versie is alleen openbaar zolang haar applicatie dat is.",
+ "Application registered by": "Applicatie geregistreerd door",
+ "Who registered the application this version belongs to, copied from it. A supplier's application is public, and so are its versions.": "Wie de applicatie registreerde waar deze versie bij hoort, daarvan overgenomen. De applicatie van een leverancier is openbaar, en haar versies ook.",
+ "The file could not be sent. Check your connection and try again.": "Het bestand kon niet worden verstuurd. Controleer uw verbinding en probeer het opnieuw.",
+ "The organisation register is not configured, so there are no organisations to choose from.": "Het organisatieregister is niet ingesteld, dus er zijn geen organisaties om uit te kiezen.",
+ "The organisations could not be loaded. Reload the page to try again.": "De organisaties konden niet worden geladen. Herlaad de pagina om het opnieuw te proberen.",
+ "Whether a service desk is connected could not be loaded. Reload the page to try again.": "Kon niet worden geladen of er een servicedesk is gekoppeld. Herlaad de pagina om het opnieuw te proberen.",
+ "Application type": "Applicatiesoort",
+ "The kind of application as the source system records it, such as the TOPdesk Applicatiesoort (Webapplicatie, Client/server, Saas). Kept as the source has it.": "Het soort applicatie zoals het bronsysteem het vastlegt, zoals de TOPdesk-Applicatiesoort (Webapplicatie, Client/server, Saas). Overgenomen zoals de bron het heeft.",
+ "The BBN level (Baseline Informatiebeveiliging Overheid) this application is classified at. BBN2+ is a level the TOPdesk CMDB export uses.": "Het BBN-niveau (Baseline Informatiebeveiliging Overheid) waarop deze applicatie is geclassificeerd. BBN2+ is een niveau dat de TOPdesk-CMDB-export gebruikt.",
+ "Field \"%1$s\" must be one of: %2$s.": "Veld \"%1$s\" moet een van deze waarden hebben: %2$s.",
+ "Field \"%s\" has an invalid value.": "Veld \"%s\" heeft een ongeldige waarde.",
+ "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.",
+ "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.",
+ "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.",
+ "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.",
+ "Accepted values: {accepted}. Reload the page and try again.": "Toegestane waarden: {accepted}. Laad de pagina opnieuw en probeer het nog eens.",
+ "The server could not store the uploaded file.": "De server kon het geüploade bestand niet opslaan.",
+ "Try again. If it keeps failing, the Nextcloud log has the details; check the free space and the upload settings of the server.": "Probeer het opnieuw. Blijft het mislukken, dan staan de details in het Nextcloud-logboek; controleer de vrije ruimte en de uploadinstellingen van de server.",
+ "No municipality named \"%s\" was found, so it was created. Check the name if you meant an existing one.": "Er is geen gemeente met de naam \"%s\" gevonden, dus die is aangemaakt. Controleer de naam als u een bestaande gemeente bedoelde.",
+ "APPID %1$s is also on sheet \"%2$s\", which wins; this row is not imported": "APPID %1$s staat ook op het tabblad \"%2$s\", dat voorgaat; deze rij wordt niet geïmporteerd",
+ "Stackiq CMDB owners": "Stackiq CMDB-eigenaren",
+ "step \"%1$s\" failed (%2$s)": "stap \"%1$s\" mislukt (%2$s)",
+ "The workbook is too large to read once unpacked.": "Het werkboek is uitgepakt te groot om te lezen.",
+ "The stackiq register is out of date; import its configuration again.": "Het stackiq-register is verouderd; importeer de configuratie ervan opnieuw.",
+ "Another CMDB import is running; try again when it has finished.": "Er loopt al een andere CMDB-import; probeer het opnieuw wanneer die klaar is.",
+ "Several municipalities have this name; choose one from the list.": "Meerdere gemeenten hebben deze naam; kies er een uit de lijst.",
+ "Unpacked, the workbook is larger than {size}, the most the import reads.": "Uitgepakt is het werkboek groter dan {size}, het maximum dat de import leest.",
+ "The \"{schema}\" schema of the stackiq register is out of date.": "Het schema \"{schema}\" van het stackiq-register is verouderd.",
+ "The stackiq register is out of date.": "Het stackiq-register is verouderd.",
+ "It lacks properties the import matches on. Press Force Update at the top of this page to import the register configuration again, then try again. Nothing was imported.": "Het mist eigenschappen waarop de import records herkent. Klik bovenaan deze pagina op Force Update om de registerconfiguratie opnieuw te importeren en probeer het daarna opnieuw. Er is niets geïmporteerd.",
+ "Another CMDB import is running.": "Er loopt al een andere CMDB-import.",
+ "Only one import runs at a time. Wait until it has finished and try again. Nothing was imported.": "Er loopt maar één import tegelijk. Wacht tot die klaar is en probeer het opnieuw. Er is niets geïmporteerd.",
+ "{count} municipalities have this name.": "{count} gemeenten hebben deze naam.",
+ "Several municipalities have this name.": "Meerdere gemeenten hebben deze naam.",
+ "Choose the municipality from the list instead of typing its name. Nothing was imported.": "Kies de gemeente uit de lijst in plaats van de naam te typen. Er is niets geïmporteerd.",
+ "Publish the applications this import creates": "De applicaties die deze import aanmaakt publiceren",
+ "A published application is visible to anyone, including anonymous visitors of OpenCatalogi. When off, the applications this import creates stay unpublished until you publish them by hand. Applications imported before keep their publication as it is.": "Een gepubliceerde applicatie is voor iedereen zichtbaar, ook voor anonieme bezoekers van OpenCatalogi. Staat dit uit, dan blijven de applicaties die deze import aanmaakt ongepubliceerd tot u ze zelf publiceert. Eerder geïmporteerde applicaties houden hun publicatie zoals die is.",
+ "Created unpublished": "Ongepubliceerd aangemaakt",
+ "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; only a Nextcloud admin can change it.": "De sleutel waarop een herhaalde import deze applicatie koppelt: topdesk::. Gezet door de CMDB-import; alleen een Nextcloud-beheerder kan deze wijzigen.",
+ "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.",
+ "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."
}
}
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index 672b1cdec..3c5f2d851 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -22,6 +22,7 @@
use OCA\OpenRegister\Contract\ObjectServiceInterface;
use OCA\OpenRegister\Event\ObjectCreatedEvent;
+use OCA\OpenRegister\Event\ObjectDeletedEvent;
use OCA\OpenRegister\Event\ObjectUpdatedEvent;
use OCA\OpenRegister\Event\UserProfileUpdatedEvent;
use OCA\OpenRegister\Service\OrganisationService as OpenRegisterOrganisationService;
@@ -32,6 +33,8 @@
use OCA\Stackiq\Controller\ContactpersonenController;
use OCA\Stackiq\Dashboard\ConceptOrganisatiesWidget;
use OCA\Stackiq\EventListener\DecisionConcludedListener;
+use OCA\Stackiq\EventListener\MaintenanceRecipientsListener;
+use OCA\Stackiq\EventListener\ModuleVersionPublicationListener;
use OCA\Stackiq\EventListener\ModuleComplianceSubscriber;
use OCA\Stackiq\EventListener\ModuleRegistrationSubscriber;
use OCA\Stackiq\EventListener\TestEventListener;
@@ -39,6 +42,7 @@
use OCA\Stackiq\Service\ArchiMateExportService;
use OCA\Stackiq\Service\ArchiMateImportService;
use OCA\Stackiq\Service\ArchiMateService;
+use OCA\Stackiq\Service\ConnectionReportService;
use OCA\Stackiq\Service\ContactpersoonService;
use OCA\Stackiq\Service\ContractApprovalService;
use OCA\Stackiq\Service\ContractStatusService;
@@ -364,7 +368,9 @@ function ($container) {
function ($container) {
return new StackiqContactSyncService(
contactsManager: $container->get('OCP\Contacts\IManager'),
- logger: $container->get('Psr\Log\LoggerInterface')
+ logger: $container->get('Psr\Log\LoggerInterface'),
+ container: $container,
+ userSession: $container->get('OCP\IUserSession')
);
}
);
@@ -515,14 +521,14 @@ function ($container) {
function ($container) {
return new ArchiMateImportService(
config: $container->get(IAppConfig::class),
- rootFolder: $container->get('OCP\Files\IRootFolder'),
userSession: $container->get('OCP\IUserSession'),
appManager: $container->get('OCP\App\IAppManager'),
container: $container,
logger: $container->get('Psr\Log\LoggerInterface'),
settingsService: $container->get(SettingsService::class),
organisationService: $container->get(OpenRegisterOrganisationService::class),
- dbConnection: $container->get(IDBConnection::class)
+ dbConnection: $container->get(IDBConnection::class),
+ progressTracker: $container->get(ProgressTracker::class)
);
}
);
@@ -543,14 +549,14 @@ function ($container) {
function ($container) {
return new ArchiMateService(
config: $container->get(IAppConfig::class),
- rootFolder: $container->get('OCP\Files\IRootFolder'),
userSession: $container->get('OCP\IUserSession'),
appManager: $container->get('OCP\App\IAppManager'),
container: $container,
logger: $container->get('Psr\Log\LoggerInterface'),
settingsService: $container->get(SettingsService::class),
importService: $container->get(ArchiMateImportService::class),
- exportService: $container->get(ArchiMateExportService::class)
+ exportService: $container->get(ArchiMateExportService::class),
+ progressTracker: $container->get(ProgressTracker::class)
);
}
);
@@ -593,8 +599,11 @@ function ($container) {
ProgressTracker::class,
function ($container) {
return new ProgressTracker(
- session: $container->get('OCP\ISession'),
- logger: $container->get('Psr\Log\LoggerInterface')
+ cacheFactory: $container->get(ICacheFactory::class),
+ userSession: $container->get('OCP\IUserSession'),
+ logger: $container->get('Psr\Log\LoggerInterface'),
+ config: $container->get(IConfig::class),
+ appConfig: $container->get(IAppConfig::class)
);
}
);
@@ -674,7 +683,11 @@ function ($container) {
config: $container->get(FederationConfig::class),
merger: $container->get(FederationMerger::class),
settingsService: $container->get(SettingsService::class),
- logger: $container->get(LoggerInterface::class)
+ logger: $container->get(LoggerInterface::class),
+ // The integriq connection report (adopt-connection-registry). Passed by
+ // name: this factory is hand-built, so the constructor default of null
+ // would otherwise switch every federation report off without a sound.
+ connectionReports: $container->get(ConnectionReportService::class)
);
}
);
@@ -706,7 +719,9 @@ function ($container) {
settingsService: $container->get(SettingsService::class),
matcher: $container->get(EolMatcherService::class),
timeFactory: $container->get('OCP\AppFramework\Utility\ITimeFactory'),
- logger: $container->get(LoggerInterface::class)
+ logger: $container->get(LoggerInterface::class),
+ // Same reason as the FederationService factory above.
+ connectionReports: $container->get(ConnectionReportService::class)
);
}
);
@@ -799,6 +814,14 @@ private function registerEventListeners(IRegistrationContext $context): void {
$context->registerEventListener(ObjectCreatedEvent::class, ModuleRegistrationSubscriber::class);
$context->registerEventListener(ObjectUpdatedEvent::class, ModuleRegistrationSubscriber::class);
+ // Queue the owner resolution when a supplier announces maintenance (lifecycle-maintenance-and-supplier-roadmap).
+ $context->registerEventListener(ObjectCreatedEvent::class, MaintenanceRecipientsListener::class);
+
+ // A module version is public only while its application is (publication-field-rules).
+ $context->registerEventListener(ObjectCreatedEvent::class, ModuleVersionPublicationListener::class);
+ $context->registerEventListener(ObjectUpdatedEvent::class, ModuleVersionPublicationListener::class);
+ $context->registerEventListener(ObjectDeletedEvent::class, ModuleVersionPublicationListener::class);
+
// Sync user profile updates into the contactpersoon mirror.
$context->registerEventListener(UserProfileUpdatedEvent::class, UserProfileUpdatedEventListener::class);
diff --git a/lib/BackgroundJob/MaintenanceRecipientsJob.php b/lib/BackgroundJob/MaintenanceRecipientsJob.php
new file mode 100644
index 000000000..fd9c05d37
--- /dev/null
+++ b/lib/BackgroundJob/MaintenanceRecipientsJob.php
@@ -0,0 +1,107 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\BackgroundJob;
+
+use OCA\Stackiq\Service\MaintenanceRecipientService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\IJobList;
+use OCP\BackgroundJob\QueuedJob;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * One owner resolution for one maintenance window.
+ *
+ * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified
+ */
+class MaintenanceRecipientsJob extends QueuedJob {
+
+ /**
+ * How many times one resolution is tried before it is given up and logged as critical.
+ */
+ public const MAX_ATTEMPTS = 3;
+
+ /**
+ * Seconds between a failed resolution and the next try.
+ */
+ public const RETRY_DELAY = 300;
+
+ /**
+ * Constructor.
+ *
+ * @param ITimeFactory $time The time factory.
+ * @param MaintenanceRecipientService $recipients The owner resolution.
+ * @param IJobList $jobList Queues the job again after a failure.
+ * @param LoggerInterface $logger The logger.
+ */
+ public function __construct(
+ ITimeFactory $time,
+ private readonly MaintenanceRecipientService $recipients,
+ private readonly IJobList $jobList,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(time: $time);
+ }//end __construct()
+
+ /**
+ * Resolve and record the owners for the window in the argument.
+ *
+ * A queued job is removed before it runs, so a resolution that fails (the
+ * window or the product cannot be read, or the window cannot be written)
+ * is queued again a few minutes later, up to MAX_ATTEMPTS tries.
+ *
+ * @param mixed $argument `{uuid, register, schema, attempt?}` of the window.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified
+ */
+ protected function run($argument): void {
+ if (is_array($argument) === false || is_string($argument['uuid'] ?? null) === false) {
+ return;
+ }
+
+ try {
+ $this->recipients->recordRecipientsFor(
+ uuid: $argument['uuid'],
+ register: ($argument['register'] ?? null),
+ schema: ($argument['schema'] ?? null)
+ );
+ } catch (DoesNotExistException $e) {
+ // The window was deleted before its owners were resolved: nothing to do.
+ return;
+ } catch (Throwable $e) {
+ $attempt = (int) ($argument['attempt'] ?? 1);
+ $context = ['uuid' => $argument['uuid'], 'attempt' => $attempt, 'error' => $e->getMessage()];
+ if ($attempt >= self::MAX_ATTEMPTS) {
+ $this->logger->critical('MaintenanceRecipientsJob: gave up recording the owners to notify; nothing was written', $context);
+ return;
+ }
+
+ $this->logger->error('MaintenanceRecipientsJob: could not record the owners to notify; tried again later', $context);
+ $argument['attempt'] = ($attempt + 1);
+ $this->jobList->scheduleAfter(self::class, $this->time->getTime() + self::RETRY_DELAY, $argument);
+ }
+ }//end run()
+}//end class
diff --git a/lib/BackgroundJob/ModuleVersionPublicationJob.php b/lib/BackgroundJob/ModuleVersionPublicationJob.php
new file mode 100644
index 000000000..6c5928563
--- /dev/null
+++ b/lib/BackgroundJob/ModuleVersionPublicationJob.php
@@ -0,0 +1,183 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\BackgroundJob;
+
+use OCA\OpenRegister\Contract\ObjectEntityInterface;
+use OCA\OpenRegister\Contract\ObjectServiceInterface;
+use OCA\Stackiq\Service\ModuleVersionPublicationService;
+use OCA\Stackiq\Service\SettingsService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\IJobList;
+use OCP\BackgroundJob\QueuedJob;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+use Throwable;
+
+/**
+ * One copy of one module's publication onto its versions.
+ *
+ * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ */
+class ModuleVersionPublicationJob extends QueuedJob {
+
+ /**
+ * How many times one copy is tried before it is given up and logged as critical.
+ */
+ public const MAX_ATTEMPTS = 3;
+
+ /**
+ * Seconds between a failed copy and the next try.
+ */
+ public const RETRY_DELAY = 300;
+
+ /**
+ * Constructor.
+ *
+ * @param ITimeFactory $time The time factory.
+ * @param ModuleVersionPublicationService $publication The mirror.
+ * @param SettingsService $settingsService Resolves the module register and schema.
+ * @param ContainerInterface $container Resolves OpenRegister's object service.
+ * @param LoggerInterface $logger The logger.
+ * @param IJobList $jobList Queues the job again after a failed copy.
+ */
+ public function __construct(
+ ITimeFactory $time,
+ private readonly ModuleVersionPublicationService $publication,
+ private readonly SettingsService $settingsService,
+ private readonly ContainerInterface $container,
+ private readonly LoggerInterface $logger,
+ private readonly IJobList $jobList,
+ ) {
+ parent::__construct(time: $time);
+ }//end __construct()
+
+ /**
+ * Copy the publication of the module in the argument onto its versions.
+ *
+ * A deleted module, or one that no longer exists, takes its versions out
+ * of public view. A module that cannot be read, or a version that cannot be
+ * written, is tried again later.
+ *
+ * @param mixed $argument `{module, deleted, attempt?}`: the module's id, whether it was deleted, and the try.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ */
+ protected function run($argument): void {
+ $moduleUuid = '';
+ if (is_array($argument) === true && is_string($argument['module'] ?? null) === true) {
+ $moduleUuid = $argument['module'];
+ }
+
+ if ($moduleUuid === '') {
+ return;
+ }
+
+ if (($argument['deleted'] ?? false) === true) {
+ $this->retryOnFailure(argument: $argument, failed: $this->publication->clearVersions(moduleUuid: $moduleUuid)['failed']);
+ return;
+ }
+
+ try {
+ $module = $this->findModule(moduleUuid: $moduleUuid);
+ } catch (DoesNotExistException $e) {
+ $module = null;
+ } catch (Throwable $e) {
+ $this->logger->error(
+ 'ModuleVersionPublicationJob: could not read the module; its versions are tried again later',
+ ['module' => $moduleUuid, 'error' => $e->getMessage()]
+ );
+ $this->retryOnFailure(argument: $argument, failed: 1);
+ return;
+ }
+
+ $result = ['failed' => 0];
+ if ($module !== null) {
+ $result = $this->publication->backfillModule(module: $module);
+ }
+
+ if ($module === null) {
+ $result = $this->publication->clearVersions(moduleUuid: $moduleUuid);
+ }
+
+ $this->retryOnFailure(argument: $argument, failed: $result['failed']);
+ }//end run()
+
+ /**
+ * Queue the copy again after a failure, until MAX_ATTEMPTS tries were made.
+ *
+ * @param array $argument The job's argument.
+ * @param int $failed The versions or reads that failed in this try.
+ *
+ * @return void
+ */
+ private function retryOnFailure(array $argument, int $failed): void {
+ if ($failed === 0) {
+ return;
+ }
+
+ $attempt = ((int) ($argument['attempt'] ?? 1));
+ if ($attempt >= self::MAX_ATTEMPTS) {
+ $this->logger->critical(
+ 'ModuleVersionPublicationJob: gave up copying the publication onto the versions; save the module again to retry',
+ ['module' => $argument['module'], 'attempts' => $attempt, 'failed' => $failed]
+ );
+ return;
+ }
+
+ $argument['attempt'] = ($attempt + 1);
+ $this->jobList->scheduleAfter(self::class, $this->time->getTime() + self::RETRY_DELAY, $argument);
+ }//end retryOnFailure()
+
+ /**
+ * Read the module as it is now.
+ *
+ * @param string $moduleUuid The module.
+ *
+ * @return ObjectEntityInterface|null The module, or null when it no longer exists.
+ *
+ * @throws DoesNotExistException When the module no longer exists (OpenRegister's find() throws rather than returning null).
+ * @throws Throwable When OpenRegister is absent or the read fails.
+ */
+ private function findModule(string $moduleUuid): ?ObjectEntityInterface {
+ $objects = $this->container->get(ObjectServiceInterface::class);
+ if (($objects instanceof ObjectServiceInterface) === false) {
+ throw new RuntimeException('OpenRegister\'s object service is not available');
+ }
+
+ return $objects->find(
+ id: $moduleUuid,
+ register: $this->settingsService->getRegisterIdForObjectType('module'),
+ schema: $this->settingsService->getSchemaIdForObjectType('module'),
+ _rbac: false,
+ _multitenancy: false
+ );
+ }//end findModule()
+}//end class
diff --git a/lib/BackgroundJob/OrganizationContactSyncJob.php b/lib/BackgroundJob/OrganizationContactSyncJob.php
index 575107af6..880a34679 100644
--- a/lib/BackgroundJob/OrganizationContactSyncJob.php
+++ b/lib/BackgroundJob/OrganizationContactSyncJob.php
@@ -46,6 +46,13 @@
*/
class OrganizationContactSyncJob extends TimedJob {
+ /**
+ * The id this job's switch is stored under in `cronjob_config`.
+ *
+ * @var string
+ */
+ public const JOB_ID = 'organization_contact_sync';
+
/**
* Organization synchronization service
*
@@ -102,6 +109,13 @@ protected function run($argument): void {
return;
}
+ // An admin can switch the job off in the cronjob settings (for example
+ // during a migration); then neither the sync nor the link refresh runs.
+ if ($this->settingsService->isCronjobEnabled(jobId: self::JOB_ID) === false) {
+ $this->logger->info('[OrganizationContactSyncJob] Switched off in the cronjob settings, skipping sync');
+ return;
+ }
+
$this->logger->info('[OrganizationContactSyncJob] Starting scheduled organization sync');
try {
$this->orgSyncService->performScheduledSync();
diff --git a/lib/Controller/CmdbImportController.php b/lib/Controller/CmdbImportController.php
new file mode 100644
index 000000000..7d1c00a8f
--- /dev/null
+++ b/lib/Controller/CmdbImportController.php
@@ -0,0 +1,557 @@
+
+ * @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-export-import/tasks.md#task-8
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Controller;
+
+use OCA\Stackiq\AppInfo\Application;
+use OCA\Stackiq\Exception\CmdbImportException;
+use OCA\Stackiq\Service\CmdbExportImportService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IL10N;
+use OCP\IRequest;
+use Psr\Log\LoggerInterface;
+
+/**
+ * CMDB import and cancel, for Nextcloud admins only and CSRF-protected.
+ *
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The complexity is one branch per
+ * contract error code (message()) and per refused upload or field state, checked in
+ * the order of design D10; spreading those checks over classes would hide that order.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-8
+ */
+class CmdbImportController extends Controller {
+ /**
+ * The multipart field of the export.
+ */
+ public const FILE_FIELD = 'cmdbFile';
+
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The request.
+ * @param CmdbExportImportService $importService The import service.
+ * @param IL10N $l10n Translations of the error messages.
+ * @param LoggerInterface $logger Logger.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-8
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly CmdbExportImportService $importService,
+ private readonly IL10N $l10n,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(appName: Application::APP_ID, request: $request);
+ }//end __construct()
+
+ /**
+ * Import a TOPdesk CMDB export for one municipality.
+ *
+ * Multipart fields: `cmdbFile`, `municipalityUuid` or `municipalityName`,
+ * `updateExisting` (default true), `publish` (default true), `missingRecords` (only `keep`) and
+ * `operationId` (pattern `cmdb-` plus 8 to 64 letters, digits or hyphens).
+ *
+ * @return JSONResponse The report (200), or an error envelope with the contract code.
+ *
+ * @auth admin-only the import writes with RBAC and multitenancy off, across tenants, so only a full Nextcloud admin may run it.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-8
+ */
+ public function import(): JSONResponse {
+ try {
+ // The upload checks run inside the same boundary, so an unexpected
+ // error from the ZIP check is IMPORT_FAILED too, not a bare 500.
+ $validated = $this->validateRequest();
+ if ($validated instanceof JSONResponse) {
+ return $validated;
+ }
+
+ $report = $this->importService->import(path: $validated['path'], options: $validated['options']);
+ } catch (CmdbImportException $e) {
+ $this->logger->info(
+ 'CmdbImportController: import refused',
+ ['error' => $e->getErrorCode(), 'details' => $e->getDetails(), 'reason' => $e->getMessage()]
+ );
+ return $this->fromException(e: $e);
+ } catch (\Throwable $e) {
+ $this->logger->error('CmdbImportController: import failed', $this->failureContext(e: $e));
+ return $this->error(code: 'IMPORT_FAILED', status: Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+
+ return new JSONResponse(data: $report, statusCode: Http::STATUS_OK);
+ }//end import()
+
+ /**
+ * The log context of an unexpected failure: the class, the sanitised first line and where it was thrown.
+ *
+ * Deliberately not the exception object itself under the `exception` key,
+ * as usual elsewhere: Nextcloud would then log the whole message, the previous exceptions and the stack trace with its
+ * call arguments, and those can quote cell values of the export or an
+ * owner's e-mail address (personal data). The service already logs the run's
+ * failure the same way.
+ *
+ * @param \Throwable $e The exception.
+ *
+ * @return array{exception: string, error: string, file: string, line: int}
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess) logSafeMessage() is a pure function of the exception, shared with the service so
+ * both logs sanitise the same way.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-8
+ */
+ private function failureContext(\Throwable $e): array {
+ return [
+ 'exception' => get_class($e),
+ 'error' => CmdbExportImportService::logSafeMessage(step: 'request', e: $e, values: []),
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ ];
+ }//end failureContext()
+
+ /**
+ * Check the request in the order of design D10, before anything is parsed.
+ *
+ * Present, size, xlsx, `missingRecords`, municipality.
+ *
+ * @return array{path: string, options: array}|JSONResponse The import input, or the first error.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-8
+ */
+ private function validateRequest(): array|JSONResponse {
+ $maxBytes = $this->importService->maxFileBytes();
+ $upload = $this->uploadedFile();
+ if ($upload === null) {
+ return $this->missingUpload(maxBytes: $maxBytes);
+ }
+
+ if ($upload['serverError'] !== null) {
+ // The upload reached PHP but could not be stored: a server problem, not the admin's.
+ $this->logger->error('CmdbImportController: the upload could not be stored', ['uploadError' => $upload['serverError']]);
+ return $this->error(code: 'UPLOAD_FAILED', status: Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+
+ if ($upload['tooLarge'] === true) {
+ // PHP's upload_max_filesize fired; it can be lower than the profile's maximum.
+ $limit = min($maxBytes, ($this->iniBytes(name: 'upload_max_filesize') ?? $maxBytes));
+ return $this->error(code: 'FILE_TOO_LARGE', status: Http::STATUS_REQUEST_ENTITY_TOO_LARGE, details: ['maxBytes' => $limit]);
+ }
+
+ if ($upload['size'] > $maxBytes) {
+ return $this->error(code: 'FILE_TOO_LARGE', status: Http::STATUS_REQUEST_ENTITY_TOO_LARGE, details: ['maxBytes' => $maxBytes]);
+ }
+
+ try {
+ $this->importService->assertXlsx(path: $upload['tmpName'], fileName: $upload['name']);
+ } catch (CmdbImportException $e) {
+ return $this->fromException(e: $e);
+ }
+
+ return $this->readOptions(path: $upload['tmpName'], fileName: $upload['name']);
+ }//end validateRequest()
+
+ /**
+ * The answer when no upload arrived: 413 when the body was over post_max_size, else 400.
+ *
+ * PHP drops the whole body, files and fields alike, when it is larger than
+ * post_max_size, so such a request looks like one without a file.
+ *
+ * @param int $maxBytes The profile's maximum.
+ *
+ * @return JSONResponse
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-8
+ */
+ private function missingUpload(int $maxBytes): JSONResponse {
+ $postLimit = $this->iniBytes(name: 'post_max_size');
+ $length = (int)$this->request->getHeader('Content-Length');
+ if ($postLimit !== null && $postLimit > 0 && $length > $postLimit) {
+ return $this->error(
+ code: 'FILE_TOO_LARGE',
+ status: Http::STATUS_REQUEST_ENTITY_TOO_LARGE,
+ details: ['maxBytes' => min($maxBytes, $postLimit)]
+ );
+ }
+
+ return $this->error(code: 'NO_FILE_UPLOADED', status: Http::STATUS_BAD_REQUEST);
+ }//end missingUpload()
+
+ /**
+ * A PHP size setting such as `10M`, in bytes.
+ *
+ * Protected so a test can stand in for php.ini, whose size settings cannot
+ * be changed at run time.
+ *
+ * @param string $name The ini setting.
+ *
+ * @return int|null Null when it is unset or not a size; 0 means no limit.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-8
+ */
+ protected function iniBytes(string $name): ?int {
+ $value = trim((string)ini_get($name));
+ if (preg_match('/^(\d+)\s*([KMG]?)$/i', $value, $matches) !== 1) {
+ return null;
+ }
+
+ $exponent = ['' => 0, 'K' => 1, 'M' => 2, 'G' => 3][strtoupper($matches[2])];
+ return ((int)$matches[1] * (1024 ** $exponent));
+ }//end iniBytes()
+
+ /**
+ * Read and check the form fields, after the upload itself was checked.
+ *
+ * A field sent as an array (`municipalityName[]=x`) is refused instead of
+ * being cast to the string "Array".
+ *
+ * @param string $path The checked upload.
+ * @param string $fileName The upload's name as the client sent it; its base name is passed on for the audit log.
+ *
+ * @return array{path: string, options: array}|JSONResponse The import input, or the first error.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-8
+ */
+ private function readOptions(string $path, string $fileName): array|JSONResponse {
+ $missingRecords = $this->stringParam(name: 'missingRecords', default: 'keep');
+ if ($missingRecords === null) {
+ return $this->invalidField(field: 'missingRecords');
+ }
+
+ if ($this->importService->supportsMissingRecords(mode: $missingRecords) === false) {
+ return $this->error(code: 'MISSING_RECORDS_UNSUPPORTED', status: Http::STATUS_UNPROCESSABLE_ENTITY, details: ['accepted' => ['keep']]);
+ }
+
+ // An unrecognised value is refused rather than read as true: true is the mode that overwrites.
+ $updateExisting = $this->booleanParam(name: 'updateExisting', default: true);
+ if ($updateExisting === null) {
+ return $this->invalidField(field: 'updateExisting', accepted: ['true', 'false']);
+ }
+
+ // Read like updateExisting: a typo must not publish what the admin chose to keep unpublished.
+ $publish = $this->booleanParam(name: 'publish', default: true);
+ if ($publish === null) {
+ return $this->invalidField(field: 'publish', accepted: ['true', 'false']);
+ }
+
+ $municipalityUuid = $this->stringParam(name: 'municipalityUuid', default: '');
+ $municipalityName = $this->stringParam(name: 'municipalityName', default: '');
+ if ($municipalityUuid === null) {
+ return $this->invalidField(field: 'municipalityUuid');
+ }
+
+ if ($municipalityName === null) {
+ return $this->invalidField(field: 'municipalityName');
+ }
+
+ $municipalityUuid = trim($municipalityUuid);
+ $municipalityName = trim($municipalityName);
+ if ($municipalityUuid === '' && $municipalityName === '') {
+ return $this->error(code: CmdbImportException::MUNICIPALITY_REQUIRED, status: Http::STATUS_UNPROCESSABLE_ENTITY);
+ }
+
+ return [
+ 'path' => $path,
+ 'options' => [
+ 'municipalityUuid' => $municipalityUuid,
+ 'municipalityName' => $municipalityName,
+ 'updateExisting' => $updateExisting,
+ 'publish' => $publish,
+ 'operationId' => $this->request->getParam('operationId'),
+ 'fileName' => basename(str_replace('\\', '/', $fileName)),
+ ],
+ ];
+ }//end readOptions()
+
+
+ /**
+ * Ask a running CMDB import to stop between rows.
+ *
+ * @param string $operationId The operation id.
+ *
+ * @return JSONResponse `{success, cancelRequested}`, 404 OPERATION_NOT_FOUND, or 500 IMPORT_FAILED.
+ *
+ * @auth admin-only cancelling is part of the import, which writes with RBAC and multitenancy off, so only a full Nextcloud admin may do it.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ public function cancel(string $operationId): JSONResponse {
+ try {
+ $requested = $this->importService->requestCancel(operationId: $operationId);
+ } catch (\Throwable $e) {
+ $this->logger->error('CmdbImportController: cancel failed', $this->failureContext(e: $e));
+ return $this->error(code: 'IMPORT_FAILED', status: Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+
+ if ($requested === false) {
+ return $this->error(code: 'OPERATION_NOT_FOUND', status: Http::STATUS_NOT_FOUND);
+ }
+
+ return new JSONResponse(data: ['success' => true, 'cancelRequested' => true], statusCode: Http::STATUS_OK);
+ }//end cancel()
+
+ /**
+ * The 400 FIELD_INVALID response for a malformed form field.
+ *
+ * @param string $field The field.
+ * @param array $accepted The values the field accepts, when it has a fixed set.
+ *
+ * @return JSONResponse
+ */
+ private function invalidField(string $field, array $accepted = []): JSONResponse {
+ $details = ['field' => $field];
+ if ($accepted !== []) {
+ $details['accepted'] = $accepted;
+ }
+
+ return $this->error(code: 'FIELD_INVALID', status: Http::STATUS_BAD_REQUEST, details: $details);
+ }//end invalidField()
+
+ /**
+ * Translate a CmdbImportException into its contract response.
+ *
+ * @param CmdbImportException $e The exception.
+ *
+ * @return JSONResponse
+ */
+ private function fromException(CmdbImportException $e): JSONResponse {
+ return $this->error(code: $e->getErrorCode(), status: $e->getHttpStatus(), details: $e->getDetails());
+ }//end fromException()
+
+ /**
+ * The error envelope of contract.md.
+ *
+ * @param string $code The machine error code.
+ * @param int $status The HTTP status.
+ * @param array $details Details, e.g. sheet and column.
+ *
+ * @return JSONResponse
+ */
+ private function error(string $code, int $status, array $details = []): JSONResponse {
+ return new JSONResponse(
+ data: [
+ 'success' => false,
+ 'error' => $code,
+ 'message' => $this->message(code: $code, details: $details),
+ 'details' => (object)$details,
+ ],
+ statusCode: $status
+ );
+ }//end error()
+
+ /**
+ * The translated message of an error code.
+ *
+ * @param string $code The machine error code.
+ * @param array $details The details.
+ *
+ * @return string
+ *
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity) One branch per contract error code.
+ */
+ private function message(string $code, array $details): string {
+ $megabytes = (string)round((int)($details['maxBytes'] ?? $this->importService->maxFileBytes()) / 1048576, 1);
+ $expected = implode(', ', array_map('strval', ($details['expected'] ?? [])));
+ $accepted = implode(', ', array_map('strval', ($details['accepted'] ?? [])));
+
+ return match ($code) {
+ 'NO_FILE_UPLOADED' => $this->l10n->t('No file was uploaded.'),
+ 'NOT_XLSX' => $this->l10n->t('The file is not an Excel workbook (.xlsx).'),
+ 'FILE_TOO_LARGE' => $this->l10n->t('The file is larger than the maximum of %s MB.', [$megabytes]),
+ 'MISSING_RECORDS_UNSUPPORTED' => $this->l10n->t('Only keeping records that are missing from the export is supported.'),
+ 'FIELD_INVALID' => $this->fieldMessage(field: (string)($details['field'] ?? ''), accepted: $accepted),
+ 'MUNICIPALITY_REQUIRED' => $this->l10n->t('Choose a municipality or enter the name of a new one.'),
+ 'MUNICIPALITY_INVALID' => $this->l10n->t('The chosen organisation is not a municipality.'),
+ '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.'),
+ '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.'),
+ 'UPLOAD_FAILED' => $this->l10n->t('The server could not store the uploaded file. The details are in the Nextcloud log.'),
+ 'WORKBOOK_TOO_LARGE' => $this->l10n->t('The workbook is too large to read once unpacked.'),
+ 'SCHEMA_OUTDATED' => $this->l10n->t('The stackiq register is out of date; import its configuration again.'),
+ 'IMPORT_IN_PROGRESS' => $this->l10n->t('Another CMDB import is running; try again when it has finished.'),
+ 'MUNICIPALITY_AMBIGUOUS' => $this->l10n->t('Several municipalities have this name; choose one from the list.'),
+ default => $this->l10n->t('The import failed. The details are in the Nextcloud log.'),
+ };
+ }//end message()
+
+ /**
+ * The message of FIELD_INVALID, naming the accepted values when the field has a fixed set.
+ *
+ * @param string $field The field.
+ * @param string $accepted The accepted values, comma-separated, or ''.
+ *
+ * @return string
+ */
+ private function fieldMessage(string $field, string $accepted): string {
+ if ($accepted === '') {
+ return $this->l10n->t('Field "%s" has an invalid value.', [$field]);
+ }
+
+ return $this->l10n->t('Field "%1$s" must be one of: %2$s.', [$field, $accepted]);
+ }//end fieldMessage()
+
+ /**
+ * A boolean form field: `true`/`false` or `1`/`0`, trimmed and in any case.
+ *
+ * @param string $name The field.
+ * @param bool $default The value when absent or empty.
+ *
+ * @return bool|null Null for any other value, which the caller refuses.
+ */
+ private function booleanParam(string $name, bool $default): ?bool {
+ $value = $this->request->getParam($name);
+ if ($value === null || $value === '') {
+ return $default;
+ }
+
+ if (is_bool($value) === true) {
+ return $value;
+ }
+
+ if (is_string($value) === false && is_int($value) === false) {
+ return null;
+ }
+
+ return match (strtolower(trim((string)$value))) {
+ 'true', '1' => true,
+ 'false', '0' => false,
+ default => null,
+ };
+ }//end booleanParam()
+
+ /**
+ * A text form field.
+ *
+ * @param string $name The field.
+ * @param string $default The value when absent.
+ *
+ * @return string|null Null when the field is not a single value, such as `name[]=x`.
+ */
+ private function stringParam(string $name, string $default): ?string {
+ $value = $this->request->getParam($name, $default);
+ if (is_string($value) === true) {
+ return $value;
+ }
+
+ if (is_int($value) === true || is_float($value) === true) {
+ return (string)$value;
+ }
+
+ return null;
+ }//end stringParam()
+
+ /**
+ * The uploaded export, or null when none was sent.
+ *
+ * `serverError` is the PHP upload error when the file reached the server
+ * but could not be stored (no tmp dir, disk full, an extension stopped it).
+ *
+ * @return array{tmpName: string, name: string, size: int, tooLarge: bool, serverError: int|null}|null
+ */
+ private function uploadedFile(): ?array {
+ $file = $this->request->getUploadedFile(self::FILE_FIELD);
+ if (is_array($file) === false || $file === []) {
+ return null;
+ }
+
+ // `cmdbFile[]` gives arrays for every key; that is no usable upload.
+ $error = $file['error'] ?? UPLOAD_ERR_OK;
+ if (is_int($error) === false) {
+ return null;
+ }
+
+ if (in_array($error, [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true) === true) {
+ return ['tmpName' => '', 'name' => '', 'size' => 0, 'tooLarge' => true, 'serverError' => null];
+ }
+
+ if (in_array($error, [UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE, UPLOAD_ERR_EXTENSION], true) === true) {
+ return ['tmpName' => '', 'name' => '', 'size' => 0, 'tooLarge' => false, 'serverError' => $error];
+ }
+
+ $tmpName = $file['tmp_name'] ?? '';
+ if ($error !== UPLOAD_ERR_OK || is_string($tmpName) === false || $tmpName === '') {
+ return null;
+ }
+
+ return [
+ 'tmpName' => $tmpName,
+ 'name' => self::uploadName(file: $file),
+ 'size' => self::uploadSize(file: $file, tmpName: $tmpName),
+ 'tooLarge' => false,
+ 'serverError' => null,
+ ];
+ }//end uploadedFile()
+
+ /**
+ * The client's file name of an upload, or '' when it is not a single name.
+ *
+ * @param array $file The upload entry.
+ *
+ * @return string
+ */
+ private static function uploadName(array $file): string {
+ $name = $file['name'] ?? '';
+ if (is_string($name) === false) {
+ return '';
+ }
+
+ return $name;
+ }//end uploadName()
+
+ /**
+ * The size of an upload: the size PHP reported, or the stored file's when that is 0.
+ *
+ * @param array $file The upload entry.
+ * @param string $tmpName The stored file.
+ *
+ * @return int
+ */
+ private static function uploadSize(array $file, string $tmpName): int {
+ $size = (int)($file['size'] ?? 0);
+ if ($size === 0 && is_file($tmpName) === true) {
+ $size = (int)filesize($tmpName);
+ }
+
+ return $size;
+ }//end uploadSize()
+}//end class
diff --git a/lib/Controller/ItsmExchangeController.php b/lib/Controller/ItsmExchangeController.php
new file mode 100644
index 000000000..2ef5baefe
--- /dev/null
+++ b/lib/Controller/ItsmExchangeController.php
@@ -0,0 +1,154 @@
+
+ * @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/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Controller;
+
+use OCA\Stackiq\AppInfo\Application;
+use OCA\Stackiq\Service\ItsmExchangeService;
+use OCA\Stackiq\Service\ItsmFileImportService;
+use OCA\Stackiq\Settings\StackiqAdmin;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+use OCP\IUserSession;
+
+/**
+ * Admin set-up and import, and a public-to-users status, for the service desk exchange.
+ *
+ * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential
+ */
+class ItsmExchangeController extends Controller {
+
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The request.
+ * @param ItsmExchangeService $exchange Sets up the flows.
+ * @param ItsmFileImportService $fileImport Imports a spreadsheet.
+ * @param IUserSession $userSession The signed-in user, who the imports run as.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly ItsmExchangeService $exchange,
+ private readonly ItsmFileImportService $fileImport,
+ private readonly IUserSession $userSession,
+ ) {
+ parent::__construct(appName: Application::APP_ID, request: $request);
+ }//end __construct()
+
+ /**
+ * Whether the exchange runs, and with which desk.
+ *
+ * @NoAdminRequired
+ *
+ * @return JSONResponse The summary.
+ *
+ * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-007-the-cmdb-page-says-what-stackiq-is
+ */
+ #[NoAdminRequired]
+ public function status(): JSONResponse {
+ $status = $this->exchange->status();
+
+ return new JSONResponse(
+ [
+ 'available' => $status['available'],
+ 'enabled' => $status['enabled'],
+ 'desk' => $status['desk'],
+ 'setUpAt' => $status['setUpAt'],
+ 'desks' => $status['desks'],
+ ]
+ );
+ }//end status()
+
+ /**
+ * The full set-up, for the admin section.
+ *
+ * @AuthorizedAdminSetting(settings=OCA\Stackiq\Settings\StackiqAdmin)
+ *
+ * @return JSONResponse The set-up.
+ *
+ * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential
+ */
+ #[AuthorizedAdminSetting(settings: StackiqAdmin::class)]
+ public function config(): JSONResponse {
+ return new JSONResponse($this->exchange->status());
+ }//end config()
+
+ /**
+ * Set up, or set up again, the exchange.
+ *
+ * @param string $desk The desk key.
+ * @param string $organisation The organisation uuid.
+ * @param string $templateId The desk's asset template for new records (TOPdesk), or empty.
+ *
+ * @AuthorizedAdminSetting(settings=OCA\Stackiq\Settings\StackiqAdmin)
+ *
+ * @return JSONResponse The outcome; 422 when nothing was created.
+ *
+ * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential
+ */
+ #[AuthorizedAdminSetting(settings: StackiqAdmin::class)]
+ public function setUp(string $desk = '', string $organisation = '', string $templateId = ''): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['created' => false, 'message' => 'Sign in first.'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ $result = $this->exchange->setUp(desk: $desk, organisation: $organisation, runAs: $user->getUID(), templateId: trim($templateId));
+ if ($result['created'] !== true) {
+ return new JSONResponse($result, Http::STATUS_UNPROCESSABLE_ENTITY);
+ }
+
+ return new JSONResponse($result);
+ }//end setUp()
+
+ /**
+ * Import a CSV or XLSX file.
+ *
+ * @AuthorizedAdminSetting(settings=OCA\Stackiq\Settings\StackiqAdmin)
+ *
+ * @return JSONResponse The outcome; 422 when nothing started.
+ *
+ * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-006-a-file-feeds-the-same-import
+ */
+ #[AuthorizedAdminSetting(settings: StackiqAdmin::class)]
+ public function import(): JSONResponse {
+ $file = $this->request->getUploadedFile('file');
+ if (is_array($file) === false || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || is_string($file['tmp_name'] ?? null) === false) {
+ return new JSONResponse(['started' => false, 'message' => 'Choose a .csv or .xlsx file to import.'], Http::STATUS_BAD_REQUEST);
+ }
+
+ $result = $this->fileImport->import(path: $file['tmp_name'], name: (string) ($file['name'] ?? ''));
+ if ($result['started'] !== true) {
+ return new JSONResponse($result, Http::STATUS_UNPROCESSABLE_ENTITY);
+ }
+
+ return new JSONResponse($result);
+ }//end import()
+}//end class
diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php
index 2064ade21..2e5fd4b2e 100644
--- a/lib/Controller/SettingsController.php
+++ b/lib/Controller/SettingsController.php
@@ -26,7 +26,9 @@
use OCA\OpenRegister\Contract\ObjectServiceInterface;
use OCA\OpenRegister\Service\ConfigurationService;
+use OCA\Stackiq\Service\ArchiMateImportService;
use OCA\Stackiq\Service\ArchiMateService;
+use OCA\Stackiq\Service\ConnectionReportService;
use OCA\Stackiq\Service\EolSyncService;
use OCA\Stackiq\Service\OrganizationSyncService;
use OCA\Stackiq\Service\ProgressTracker;
@@ -38,6 +40,7 @@
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\StreamResponse;
use OCP\IAppConfig;
+use OCP\IConfig;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IUserSession;
@@ -84,8 +87,11 @@ class SettingsController extends Controller {
* @param ProgressTracker $progressTracker The progress tracking service.
* @param EolSyncService $eolSyncService The EOL feed sync orchestration service.
* @param LoggerInterface $logger The logger instance.
+ * @param ConnectionReportService|null $connectionReports Asks integriq to look again after an email settings save.
*
* @SuppressWarnings(PHPMD.ExcessiveParameterList)
+ *
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
*/
public function __construct(
$appName,
@@ -101,6 +107,7 @@ public function __construct(
private readonly ProgressTracker $progressTracker,
private readonly EolSyncService $eolSyncService,
private readonly LoggerInterface $logger,
+ private readonly ?ConnectionReportService $connectionReports = null,
) {
parent::__construct(appName: $appName, request: $request);
@@ -431,9 +438,14 @@ private function updateUserGroupSettings(array $data, array &$result): ?JSONResp
* @param array $data The raw request params.
* @param array $result The result accumulator (passed by reference).
*
+ * After the write it asks integriq to resolve the email connection again
+ * (adopt-connection-registry). That never throws, does nothing without
+ * integriq, and never changes the response.
+ *
* @return void
*
* @spec openspec/changes/method-decomposition/tasks.md#task-3
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
*/
private function applyEmailSettingsUpdate(array $data, array &$result): void {
if (isset($data['emailSettings']) === false) {
@@ -441,6 +453,7 @@ private function applyEmailSettingsUpdate(array $data, array &$result): void {
}
$result['emailSettings'] = $this->settingsService->updateEmailSettings($data['emailSettings']);
+ $this->connectionReports?->emailSettingsSaved();
}//end applyEmailSettingsUpdate()
@@ -1273,7 +1286,7 @@ public function consolidatedAutoConfigure(): JSONResponse {
* @NoAdminRequired
* @NoCSRFRequired
*
- * @spec openspec/specs/method-decomposition/spec.md
+ * @spec openspec/changes/operations-sync-status-and-progress/specs/sync-status-and-progress/spec.md#requirement-req-ssp-001-progress-of-a-long-operation-shall-be-readable-from-any-request-and-only-by-users-allowed-to-read-it
*/
public function getProgress(string $operationId): JSONResponse {
$currentUser = $this->userSession->getUser();
@@ -1284,19 +1297,11 @@ public function getProgress(string $operationId): JSONResponse {
try {
$progress = $this->progressTracker->getProgress($operationId);
- if ($progress === null) {
- return new JSONResponse(
- [
- 'success' => false,
- 'message' => 'Operation not found',
- 'error' => 'OPERATION_NOT_FOUND',
- ],
- 404
- );
- }
-
- // Verify the caller owns this operation.
- if (isset($progress['owner_uid']) === true && $progress['owner_uid'] !== $currentUser->getUID()) {
+ // An operation the caller may not read answers like an unknown
+ // one, so an operation id alone reveals nothing.
+ if ($progress === null
+ || $this->mayReadProgress(progress: $progress, uid: $currentUser->getUID()) === false
+ ) {
return new JSONResponse(
[
'success' => false,
@@ -1333,6 +1338,31 @@ public function getProgress(string $operationId): JSONResponse {
}//end try
}//end getProgress()
+ /**
+ * Whether a user may read the progress of an operation.
+ *
+ * Progress lives in a store every request reads (the distributed cache, or
+ * the app config without a shared cache), so any request can load an
+ * operation by its id. Its owner and Nextcloud admins may read it. An
+ * operation without an owner, such as one a background job started, is
+ * for admins only.
+ *
+ * @param array $progress The stored progress snapshot.
+ * @param string $uid The signed-in caller.
+ *
+ * @return bool True when the caller may read the operation.
+ *
+ * @spec openspec/changes/operations-sync-status-and-progress/specs/sync-status-and-progress/spec.md#requirement-req-ssp-001-progress-of-a-long-operation-shall-be-readable-from-any-request-and-only-by-users-allowed-to-read-it
+ */
+ private function mayReadProgress(array $progress, string $uid): bool {
+ $ownerUid = $progress['owner_uid'] ?? null;
+ if ($ownerUid !== null && $ownerUid === $uid) {
+ return true;
+ }
+
+ return $this->groupManager->isAdmin($uid) === true;
+ }//end mayReadProgress()
+
/**
* Stream progress updates using Server-Sent Events.
*
@@ -1352,9 +1382,12 @@ public function streamProgress(string $operationId): Response {
return new JSONResponse(['message' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
}
- // Verify the caller owns this operation before streaming.
+ // Verify the caller may read this operation before streaming. An
+ // unknown operation streams one error event and closes.
$progress = $this->progressTracker->getProgress($operationId);
- if ($progress !== null && isset($progress['owner_uid']) === true && $progress['owner_uid'] !== $currentUser->getUID()) {
+ if ($progress !== null
+ && $this->mayReadProgress(progress: $progress, uid: $currentUser->getUID()) === false
+ ) {
return new JSONResponse(['message' => 'Operation not found', 'error' => 'OPERATION_NOT_FOUND'], 404);
}
@@ -1563,6 +1596,7 @@ private function parseArchiMateFileUpload(): ?array {
'fileName' => $fileData['name'],
'fileSize' => $fileData['size'] ?? filesize($fileData['tmp_name']),
'mimeType' => $fileData['type'] ?? 'text/xml',
+ 'operationId' => $this->request->getParam('operationId'),
];
}//end parseArchiMateFileUpload()
@@ -1610,10 +1644,12 @@ public function exportArchiMate(): Response {
// Same guard as the sibling exportOrgArchiMate(), which has carried it
// all along. This endpoint exports the WHOLE register while the sibling
// exports one organisation, so it was the broader of the two and the
- // only one unguarded. @NoAdminRequired is kept deliberately: the helper
- // grants organisation-admins as well as admins, which is the tier the
- // admin UI relies on and which the annotation's removal would drop.
- $permissionError = $this->verifyOrgExportPermission(currentUser: $currentUser);
+ // only one unguarded. It passes no organisation, so only a Nextcloud
+ // admin gets through: a member of an organisation admin group may
+ // export their own organisation only, through the sibling route
+ // (stackiq#1136). The `organization` body value is not a filter the
+ // export applies, so it cannot scope this route.
+ $permissionError = $this->verifyOrgExportPermission(currentUser: $currentUser, organizationUuid: null);
if ($permissionError !== null) {
return $permissionError;
}
@@ -1677,7 +1713,7 @@ public function exportOrgArchiMate(string $organizationUuid): Response {
return new JSONResponse(['message' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
}
- $permissionError = $this->verifyOrgExportPermission(currentUser: $currentUser);
+ $permissionError = $this->verifyOrgExportPermission(currentUser: $currentUser, organizationUuid: $organizationUuid);
if ($permissionError !== null) {
return $permissionError;
}
@@ -1715,27 +1751,58 @@ public function exportOrgArchiMate(string $organizationUuid): Response {
}//end exportOrgArchiMate()
/**
- * Verify that the current user has permission to export organisation ArchiMate files.
+ * Verify that the current user has permission to export an organisation's ArchiMate file.
+ *
+ * A Nextcloud admin may export anything. A member of one of the saved
+ * organisation admin groups may export only their own active organisation
+ * (user value `core`/`organisation`, the rule
+ * PortfolioReportController::isAuthorisedForOrganisation() applies), so a
+ * call without an organisation is admin-only. Before stackiq#1136 the group
+ * list always read empty and only admins got through; restoring the read
+ * without this scope would have let a group member export any
+ * organisation by uuid.
*
* @param \OCP\IUser $currentUser The currently authenticated user.
+ * @param string|null $organizationUuid The organisation asked for, or null for the whole register.
*
* @return JSONResponse|null Forbidden response, or null when permitted.
*
* @spec openspec/changes/method-decomposition/tasks.md#task-3
*/
- private function verifyOrgExportPermission(\OCP\IUser $currentUser): ?JSONResponse {
+ private function verifyOrgExportPermission(\OCP\IUser $currentUser, ?string $organizationUuid): ?JSONResponse {
if ($this->groupManager->isAdmin($currentUser->getUID()) === true) {
return null;
}
- $orgAdminGroups = $this->settingsService->getOrganizationAdminGroups();
- foreach ($orgAdminGroups as $groupName) {
+ $forbidden = new JSONResponse(['message' => 'Admin or organisation-admin privileges required'], Http::STATUS_FORBIDDEN);
+ if ($organizationUuid === null || $organizationUuid === '') {
+ return $forbidden;
+ }
+
+ $isOrgAdmin = false;
+ foreach ($this->settingsService->getOrganizationAdminGroups() as $groupName) {
if ($this->groupManager->isInGroup($currentUser->getUID(), $groupName) === true) {
- return null;
+ $isOrgAdmin = true;
+ break;
}
}
- return new JSONResponse(['message' => 'Admin or organisation-admin privileges required'], Http::STATUS_FORBIDDEN);
+ if ($isOrgAdmin === false) {
+ return $forbidden;
+ }
+
+ $ownOrganisation = (string)$this->container->get(IConfig::class)->getUserValue(
+ userId: $currentUser->getUID(),
+ appName: 'core',
+ key: 'organisation',
+ default: ''
+ );
+
+ if ($ownOrganisation !== $organizationUuid) {
+ return $forbidden;
+ }
+
+ return null;
}//end verifyOrgExportPermission()
/**
@@ -2025,6 +2092,7 @@ public function getEmailSettings(): JSONResponse {
*
* @return JSONResponse Update result
* @spec openspec/specs/settings-admin-controller/spec.md
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
*/
public function updateEmailSettings(): JSONResponse {
$currentUser = $this->userSession->getUser();
@@ -2041,6 +2109,7 @@ public function updateEmailSettings(): JSONResponse {
$emailSettings = $data['emailSettings'] ?? $data;
$updatedSettings = $this->settingsService->updateEmailSettings($emailSettings);
+ $this->connectionReports?->emailSettingsSaved();
return new JSONResponse(
[
@@ -2786,8 +2855,16 @@ public function cancelArchiMateImport(): JSONResponse {
return new JSONResponse(['message' => 'Admin privileges required'], Http::STATUS_FORBIDDEN);
}
+ $operationId = $this->request->getParam('operationId');
+ if ($operationId !== null
+ && (is_string($operationId) === false
+ || preg_match(ArchiMateImportService::OPERATION_ID_PATTERN, $operationId) !== 1)
+ ) {
+ return new JSONResponse(['success' => false, 'message' => 'Invalid operation id'], Http::STATUS_BAD_REQUEST);
+ }
+
try {
- $result = $this->settingsService->cancelArchiMateImport();
+ $result = $this->settingsService->cancelArchiMateImport(operationId: $operationId);
$message = 'ArchiMate import cancellation failed';
if ($result['cancelled'] === true) {
$message = 'ArchiMate import cancellation succeeded';
@@ -2857,64 +2934,6 @@ public function clearArchiMateExportStatus(): JSONResponse {
}//end try
}//end clearArchiMateExportStatus()
- // ===.
- // ARCHIMATE TESTING METHODS.
- // ===.
-
- /**
- * Test ArchiMate round-trip functionality
- *
- * @NoAdminRequired
- * @NoCSRFRequired
- *
- * @return JSONResponse Round-trip test result
- * @spec openspec/specs/settings-admin-controller/spec.md
- */
- public function testArchiMateRoundTrip(): JSONResponse {
- if ($this->userSession->getUser() === null) {
- return new JSONResponse(['message' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- try {
- $this->logger->info('Stackiq: ArchiMate round-trip test started');
-
- // Call the ArchiMate service to perform round-trip test.
- $result = $this->archiMateService->testRoundTrip();
-
- $this->logger->info(
- 'Stackiq: ArchiMate round-trip test completed',
- [
- 'success' => $result['success'],
- 'message' => $result['message'] ?? 'no message',
- ]
- );
-
- return new JSONResponse(
- [
- 'success' => $result['success'],
- 'message' => $result['message'],
- 'details' => $result['details'] ?? null,
- 'statistics' => $result['statistics'] ?? null,
- ]
- );
- } catch (\Exception $e) {
- $this->logger->error(
- 'Stackiq: ArchiMate round-trip test failed',
- [
- 'exception_class' => get_class($e),
- 'exception_message' => $e->getMessage(),
- ]
- );
- return new JSONResponse(
- [
- 'success' => false,
- 'message' => 'Round-trip test failed: ' . $e->getMessage(),
- ],
- 500
- );
- }//end try
- }//end testArchiMateRoundTrip()
-
/**
* Get ArchiMate settings and status (without object counts for performance)
*
diff --git a/lib/Controller/SetupController.php b/lib/Controller/SetupController.php
index 02d778a47..8686c4501 100644
--- a/lib/Controller/SetupController.php
+++ b/lib/Controller/SetupController.php
@@ -68,11 +68,10 @@ class SetupController extends Controller {
/**
* App-config key holding the dataset the operator picked.
*
- * The wizard's `choice` step writes it through `POST /api/setup/config`, and
- * the `run-action` step that follows reads it back. Two steps rather than
- * one because `CnSetupWizard::runAction()` posts to
- * `/api/setup/action/{action}` with no body: an action cannot carry the
- * answer, so the answer has to be stored before the action runs.
+ * The wizard's `choice` step writes it through `POST /api/setup/config`.
+ * Each card's Load button posts `{ dataset }` to the `load-demo-data`
+ * action, which stores the same key once the load succeeds, so both routes
+ * land in one place (`loadAction` on the step, wizard-dataset-card-load).
*
* @var string
*/
@@ -107,7 +106,7 @@ public function __construct(
*
* @return JSONResponse The status document.
*
- * @spec exclude Setup status document; ADR-042 contract, no per-app behavioural spec.
+ * @spec openspec/changes/wizard-dataset-card-load/specs/first-time-setup/spec.md
*/
#[AuthorizedAdminSetting(StackiqAdmin::class)]
public function status(): JSONResponse {
@@ -122,13 +121,16 @@ public function status(): JSONResponse {
// `optionsSource: datasets` and no options of its own, so a
// dataset missing from this list is a dataset nobody can pick.
'datasets' => $this->demoDataService->listChoices(),
+ // Every id of `manifest.setup.steps`, asserted by the status
+ // contract test: a step the server never reports stays open,
+ // and an open step reopens the wizard on every page.
'steps' => [
- 'demo-data' => ['done' => ($picked !== '')],
- // "None" is an ANSWER, so the load step is finished the moment
- // it is chosen: there is nothing left for the operator to run.
- 'load-demo-data' => [
- 'done' => ($demoDecided === true || $picked === DemoDataService::NONE_DATASET),
- ],
+ 'welcome' => ['done' => true],
+ // Answered once a card was picked ("None" included) or a load
+ // ran. A pick without a load still counts: a wizard that
+ // predates `loadAction` can only record the pick.
+ 'demo-data' => ['done' => ($demoDecided === true || $picked !== '')],
+ 'done' => ['done' => true],
],
]
);
@@ -140,7 +142,7 @@ public function status(): JSONResponse {
*
* @return JSONResponse `{ success, config }`.
*
- * @spec exclude Setup config write; ADR-042 contract, no per-app behavioural spec.
+ * @spec openspec/changes/wizard-dataset-card-load/specs/first-time-setup/spec.md
*/
#[AuthorizedAdminSetting(StackiqAdmin::class)]
public function saveConfig(): JSONResponse {
@@ -161,22 +163,12 @@ public function saveConfig(): JSONResponse {
$submitted = ($value[0] ?? null);
}
- if (is_scalar($submitted) === false) {
- return new JSONResponse(
- data: ['success' => false, 'message' => 'A dataset is named by a string.'],
- statusCode: Http::STATUS_BAD_REQUEST,
- );
+ $refusal = $this->refuseDataset(value: $submitted);
+ if ($refusal !== null) {
+ return $refusal;
}
$datasetId = (string)$submitted;
- $known = array_column($this->demoDataService->listChoices(), 'id');
- if (in_array($datasetId, $known, true) === false) {
- return new JSONResponse(
- data: ['success' => false, 'message' => 'No dataset is called "' . $datasetId . '".'],
- statusCode: Http::STATUS_BAD_REQUEST,
- );
- }
-
$this->appConfig->setValueString(Application::APP_ID, self::DATASET_KEY, $datasetId);
return new JSONResponse(data: ['success' => true, 'config' => [self::DATASET_KEY => $datasetId]]);
@@ -223,7 +215,8 @@ public function runAction(string $actionId): JSONResponse {
}//end runAction()
/**
- * Import the dataset the operator picked in the previous step.
+ * Import the dataset a card's Load button posted as `dataset`, or the
+ * stored pick when nothing is posted.
*
* @param string $actionId The action that asked, which decides whether an
* unanswered choice is refused or means the shipped set.
@@ -233,10 +226,26 @@ public function runAction(string $actionId): JSONResponse {
* throws instead of returning an empty result.
*
* @return JSONResponse `{ success, message }`.
+ *
+ * @spec openspec/changes/wizard-dataset-card-load/specs/first-time-setup/spec.md
*/
private function loadDataset(string $actionId): JSONResponse {
$picked = $this->appConfig->getValueString(Application::APP_ID, self::DATASET_KEY, '');
+ // The card's Load button names its dataset in the body. An older wizard
+ // posts nothing and relies on the choice stored a step earlier. Nothing
+ // is stored before the load succeeds: a failed load must leave the step
+ // open for an operator who asked for data and got none.
+ $posted = $this->request->getParam('dataset');
+ if ($posted !== null) {
+ $refusal = $this->refuseDataset(value: $posted);
+ if ($refusal !== null) {
+ return $refusal;
+ }
+
+ $picked = (string)$posted;
+ }
+
// The legacy id carries no answer, so it means the shipped dataset. A
// caller that posts it has said which one by posting it.
if ($actionId === 'install-demo-data' && $picked === '') {
@@ -254,6 +263,7 @@ private function loadDataset(string $actionId): JSONResponse {
}
if ($picked === DemoDataService::NONE_DATASET) {
+ $this->appConfig->setValueString(Application::APP_ID, self::DATASET_KEY, DemoDataService::NONE_DATASET);
$this->appConfig->setValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, 'skipped');
return new JSONResponse(data: ['success' => true, 'message' => 'No example data was loaded.']);
@@ -273,6 +283,8 @@ private function loadDataset(string $actionId): JSONResponse {
);
}
+ // Loading IS choosing the set, so the pick is recorded too.
+ $this->appConfig->setValueString(Application::APP_ID, self::DATASET_KEY, $picked);
$this->appConfig->setValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, 'installed');
return new JSONResponse(
@@ -283,4 +295,36 @@ private function loadDataset(string $actionId): JSONResponse {
);
}//end loadDataset()
+
+ /**
+ * Refuse a dataset id no dataset answers to.
+ *
+ * Shared by the choice step's config write and the card's Load button, so
+ * both refuse the same values with the same message.
+ *
+ * @param mixed $value The posted value.
+ *
+ * @return JSONResponse|null The refusal, or null when the dataset is known.
+ *
+ * @spec openspec/changes/wizard-dataset-card-load/specs/first-time-setup/spec.md
+ */
+ private function refuseDataset(mixed $value): ?JSONResponse {
+ if (is_scalar($value) === false) {
+ return new JSONResponse(
+ data: ['success' => false, 'message' => 'A dataset is named by a string.'],
+ statusCode: Http::STATUS_BAD_REQUEST,
+ );
+ }
+
+ $known = array_column($this->demoDataService->listChoices(), 'id');
+ if (in_array((string)$value, $known, true) === true) {
+ return null;
+ }
+
+ return new JSONResponse(
+ data: ['success' => false, 'message' => 'No dataset is called "' . (string)$value . '".'],
+ statusCode: Http::STATUS_BAD_REQUEST,
+ );
+
+ }//end refuseDataset()
}//end class
diff --git a/lib/Dashboard/ConceptOrganisatiesWidget.php b/lib/Dashboard/ConceptOrganisatiesWidget.php
index 2c7d72972..8da5c906b 100644
--- a/lib/Dashboard/ConceptOrganisatiesWidget.php
+++ b/lib/Dashboard/ConceptOrganisatiesWidget.php
@@ -37,15 +37,17 @@ public function __construct(
* Returns the unique widget identifier.
*
* @return string The widget ID
+ *
+ * @spec openspec/specs/app-id-rename/spec.md#requirement-externally-owned-identifiers-stay-frozen
*/
public function getId(): string {
- // FROZEN across the stackiq -> stackiq rename. The Dashboard app
+ // FROZEN across the softwarecatalog -> stackiq rename. The Dashboard app
// stores each user's chosen widgets BY WIDGET ID, in its own `dashboard`
// appid namespace in `oc_preferences` — data this app's repair steps
// cannot reach. Renaming this id therefore does not error: the widget
// simply stops matching the stored selection and silently vanishes from
// every dashboard that had it.
- return 'stackiq_concept_organisaties_widget';
+ return 'softwarecatalog_concept_organisaties_widget';
}//end getId()
/**
diff --git a/lib/EventListener/MaintenanceRecipientsListener.php b/lib/EventListener/MaintenanceRecipientsListener.php
new file mode 100644
index 000000000..c9c05a859
--- /dev/null
+++ b/lib/EventListener/MaintenanceRecipientsListener.php
@@ -0,0 +1,93 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\EventListener;
+
+use OCA\OpenRegister\Event\ObjectCreatedEvent;
+use OCA\Stackiq\BackgroundJob\MaintenanceRecipientsJob;
+use OCA\Stackiq\Service\MaintenanceRecipientService;
+use OCP\BackgroundJob\IJobList;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Queues the owner resolution for a newly created maintenance window.
+ *
+ * @template-implements IEventListener
+ *
+ * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified
+ */
+class MaintenanceRecipientsListener implements IEventListener {
+
+ /**
+ * Constructor.
+ *
+ * @param MaintenanceRecipientService $recipients The schema check.
+ * @param IJobList $jobList The background job list.
+ * @param LoggerInterface $logger The logger.
+ */
+ public function __construct(
+ private readonly MaintenanceRecipientService $recipients,
+ private readonly IJobList $jobList,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Handle an object created event.
+ *
+ * @param Event $event The event.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified
+ */
+ public function handle(Event $event): void {
+ if (($event instanceof ObjectCreatedEvent) === false) {
+ return;
+ }
+
+ $object = $event->getObject();
+ try {
+ if ($this->recipients->isMaintenanceWindow(object: $object) === false) {
+ return;
+ }
+
+ $this->jobList->add(
+ MaintenanceRecipientsJob::class,
+ [
+ 'uuid' => $object->getUuid(),
+ 'register' => $object->getRegister(),
+ 'schema' => $object->getSchema(),
+ ]
+ );
+ } catch (\Throwable $e) {
+ $this->logger->error(
+ 'MaintenanceRecipientsListener: could not queue the owner resolution',
+ ['uuid' => $object->getUuid(), 'error' => $e->getMessage()]
+ );
+ }
+ }//end handle()
+}//end class
diff --git a/lib/EventListener/ModuleVersionPublicationListener.php b/lib/EventListener/ModuleVersionPublicationListener.php
new file mode 100644
index 000000000..1e143b513
--- /dev/null
+++ b/lib/EventListener/ModuleVersionPublicationListener.php
@@ -0,0 +1,79 @@
+
+ * @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/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\EventListener;
+
+use OCA\OpenRegister\Event\ObjectCreatedEvent;
+use OCA\OpenRegister\Event\ObjectDeletedEvent;
+use OCA\OpenRegister\Event\ObjectUpdatedEvent;
+use OCA\Stackiq\Service\ModuleVersionPublicationService;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Hands every created, updated or deleted object to the publication mirror.
+ *
+ * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ *
+ * @template-implements IEventListener
+ */
+class ModuleVersionPublicationListener implements IEventListener {
+
+ /**
+ * Constructor.
+ *
+ * @param ModuleVersionPublicationService $publication The mirror.
+ * @param LoggerInterface $logger The logger.
+ */
+ public function __construct(
+ private readonly ModuleVersionPublicationService $publication,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Handle an object event.
+ *
+ * @param Event $event The event.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ */
+ public function handle(Event $event): void {
+ try {
+ if ($event instanceof ObjectUpdatedEvent) {
+ $this->publication->objectSaved(object: $event->getNewObject(), previous: $event->getOldObject());
+ }
+
+ if ($event instanceof ObjectCreatedEvent) {
+ $this->publication->objectSaved(object: $event->getObject());
+ }
+
+ if ($event instanceof ObjectDeletedEvent) {
+ $this->publication->objectDeleted(object: $event->getObject());
+ }
+ } catch (Throwable $e) {
+ $this->logger->error('ModuleVersionPublicationListener: could not mirror the publication', ['error' => $e->getMessage()]);
+ }
+ }//end handle()
+}//end class
diff --git a/lib/Exception/CmdbImportException.php b/lib/Exception/CmdbImportException.php
new file mode 100644
index 000000000..31404c505
--- /dev/null
+++ b/lib/Exception/CmdbImportException.php
@@ -0,0 +1,126 @@
+
+ * @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-export-import/tasks.md#task-3
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Exception;
+
+use RuntimeException;
+use Throwable;
+
+/**
+ * A CMDB import failure with a contract error code and an HTTP status.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ */
+class CmdbImportException extends RuntimeException {
+ public const NOT_XLSX = 'NOT_XLSX';
+ public const NO_SOURCE_SHEET = 'NO_SOURCE_SHEET';
+ public const MISSING_COLUMN = 'MISSING_COLUMN';
+ public const TOO_MANY_ROWS = 'TOO_MANY_ROWS';
+ public const MUNICIPALITY_REQUIRED = 'MUNICIPALITY_REQUIRED';
+ public const MUNICIPALITY_INVALID = 'MUNICIPALITY_INVALID';
+ public const MAPPING_UNAVAILABLE = 'MAPPING_UNAVAILABLE';
+ public const READER_UNAVAILABLE = 'READER_UNAVAILABLE';
+ public const NOT_CONFIGURED = 'NOT_CONFIGURED';
+ public const WORKBOOK_TOO_LARGE = 'WORKBOOK_TOO_LARGE';
+ public const SCHEMA_OUTDATED = 'SCHEMA_OUTDATED';
+ public const IMPORT_IN_PROGRESS = 'IMPORT_IN_PROGRESS';
+ public const MUNICIPALITY_AMBIGUOUS = 'MUNICIPALITY_AMBIGUOUS';
+
+ /**
+ * HTTP status per error code.
+ *
+ * @var array
+ */
+ private const STATUS = [
+ self::NOT_XLSX => 400,
+ self::NO_SOURCE_SHEET => 422,
+ self::MISSING_COLUMN => 422,
+ self::TOO_MANY_ROWS => 422,
+ self::MUNICIPALITY_REQUIRED => 422,
+ self::MUNICIPALITY_INVALID => 422,
+ self::MAPPING_UNAVAILABLE => 503,
+ self::READER_UNAVAILABLE => 503,
+ self::NOT_CONFIGURED => 503,
+ self::WORKBOOK_TOO_LARGE => 413,
+ self::SCHEMA_OUTDATED => 503,
+ self::IMPORT_IN_PROGRESS => 409,
+ self::MUNICIPALITY_AMBIGUOUS => 422,
+ ];
+
+ /**
+ * Constructor.
+ *
+ * @param string $errorCode One of the class constants.
+ * @param string $message English log message, no person data.
+ * @param array $details Contract details, e.g. sheet and column.
+ * @param Throwable|null $previous The cause, if any.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ */
+ public function __construct(
+ private readonly string $errorCode,
+ string $message,
+ private readonly array $details = [],
+ ?Throwable $previous = null,
+ ) {
+ parent::__construct(message: $message, code: 0, previous: $previous);
+ }//end __construct()
+
+ /**
+ * The contract error code, e.g. `MISSING_COLUMN`.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ */
+ public function getErrorCode(): string {
+ return $this->errorCode;
+ }//end getErrorCode()
+
+ /**
+ * The HTTP status the controller answers with.
+ *
+ * @return int
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ */
+ public function getHttpStatus(): int {
+ return (self::STATUS[$this->errorCode] ?? 500);
+ }//end getHttpStatus()
+
+ /**
+ * The contract details of the error.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ */
+ public function getDetails(): array {
+ return $this->details;
+ }//end getDetails()
+}//end class
diff --git a/lib/Repair/BackfillModuleVersionPublication.php b/lib/Repair/BackfillModuleVersionPublication.php
new file mode 100644
index 000000000..861084554
--- /dev/null
+++ b/lib/Repair/BackfillModuleVersionPublication.php
@@ -0,0 +1,166 @@
+
+ * @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/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Repair;
+
+use OCA\OpenRegister\Contract\ObjectEntityInterface;
+use OCA\OpenRegister\Contract\ObjectServiceInterface;
+use OCA\Stackiq\AppInfo\Application;
+use OCA\Stackiq\Service\ModuleVersionPublicationService;
+use OCA\Stackiq\Service\SettingsService;
+use OCP\App\IAppManager;
+use OCP\IAppConfig;
+use OCP\Migration\IOutput;
+use OCP\Migration\IRepairStep;
+use Throwable;
+
+/**
+ * Backfills the mirrored publication on module versions.
+ *
+ * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ */
+class BackfillModuleVersionPublication implements IRepairStep {
+
+ /**
+ * App-config key set after a pass in which every module and version was handled.
+ *
+ * @var string
+ */
+ public const DONE_CONFIG_KEY = 'module_version_publication_backfilled';
+
+ /**
+ * How many modules one read returns.
+ *
+ * @var integer
+ */
+ public const PAGE_SIZE = 200;
+
+ /**
+ * Constructor.
+ *
+ * @param IAppManager $appManager Tells whether OpenRegister is installed.
+ * @param SettingsService $settingsService Resolves the module schema and the object service.
+ * @param ModuleVersionPublicationService $publication The mirror.
+ * @param IAppConfig $appConfig Holds the done marker.
+ */
+ public function __construct(
+ private readonly IAppManager $appManager,
+ private readonly SettingsService $settingsService,
+ private readonly ModuleVersionPublicationService $publication,
+ private readonly IAppConfig $appConfig,
+ ) {
+ }//end __construct()
+
+ /**
+ * The step's name.
+ *
+ * @return string The name.
+ */
+ public function getName(): string {
+ return 'Copy each application\'s publication onto its versions';
+ }//end getName()
+
+ /**
+ * Run the backfill.
+ *
+ * @param IOutput $output The output.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is
+ */
+ public function run(IOutput $output): void {
+ if ($this->appConfig->getValueBool(Application::APP_ID, self::DONE_CONFIG_KEY, false) === true) {
+ return;
+ }
+
+ if (in_array('openregister', $this->appManager->getInstalledApps(), true) === false) {
+ $output->info('OpenRegister not installed, so there are no versions to update.');
+ return;
+ }
+
+ $register = $this->settingsService->getRegisterIdForObjectType('module');
+ $schema = $this->settingsService->getSchemaIdForObjectType('module');
+ $objects = $this->settingsService->getObjectService();
+ if ($register === null || $schema === null || $objects === null) {
+ $output->info('The module schema is not configured yet, so there are no versions to update.');
+ return;
+ }
+
+ try {
+ $result = $this->backfillAll(objects: $objects, register: $register, schema: $schema);
+ } catch (Throwable $e) {
+ $output->warning('Could not read the applications: ' . $e->getMessage());
+ return;
+ }
+
+ $output->info($result['written'] . ' versions now follow their application\'s publication.');
+ if ($result['failed'] > 0) {
+ $output->warning($result['failed'] . ' versions or searches failed; the next upgrade tries again.');
+ return;
+ }
+
+ $this->appConfig->setValueBool(Application::APP_ID, self::DONE_CONFIG_KEY, true);
+ }//end run()
+
+ /**
+ * Backfill every module, page by page.
+ *
+ * @param ObjectServiceInterface $objects The object service.
+ * @param int|string $register The module register.
+ * @param int|string $schema The module schema.
+ *
+ * @return array{written: int, failed: int} The versions written, and the versions or searches that failed.
+ *
+ * @throws Throwable When a page of modules cannot be read.
+ */
+ private function backfillAll(ObjectServiceInterface $objects, int|string $register, int|string $schema): array {
+ $total = ['written' => 0, 'failed' => 0];
+ $offset = 0;
+ do {
+ $modules = (array) $objects->setRegister($register)->setSchema($schema)->findAll(
+ ['limit' => self::PAGE_SIZE, 'offset' => $offset],
+ false,
+ false
+ );
+
+ foreach ($modules as $module) {
+ if (($module instanceof ObjectEntityInterface) === true) {
+ $result = $this->publication->backfillModule(module: $module);
+ $total['written'] += $result['written'];
+ $total['failed'] += $result['failed'];
+ }
+ }
+
+ $offset += self::PAGE_SIZE;
+ $pageSize = count($modules);
+ } while ($pageSize === self::PAGE_SIZE);
+
+ return $total;
+ }//end backfillAll()
+}//end class
diff --git a/lib/Service/ArchiMateExportService.php b/lib/Service/ArchiMateExportService.php
index ac3654009..f94410edb 100644
--- a/lib/Service/ArchiMateExportService.php
+++ b/lib/Service/ArchiMateExportService.php
@@ -53,6 +53,8 @@
* @SuppressWarnings(PHPMD.Superglobals)
* @SuppressWarnings(PHPMD.CamelCaseVariableName)
* @SuppressWarnings(PHPMD.CamelCaseParameterName)
+ *
+ * @spec openspec/specs/archimate-export/spec.md
*/
class ArchiMateExportService {
/**
@@ -2143,11 +2145,6 @@ private function reconstructNestedXmlStructure(array $archiMateData): array {
private function runQualityAssuranceChecks(string $xmlString): void {
$this->logger->info('Running Quality Assurance checks on exported XML');
- // DEBUG: Save XML to file for inspection.
- $debugPath = '/tmp/debug_export.xml';
- file_put_contents($debugPath, $xmlString);
- $this->logger->info('DEBUG: Raw XML saved to ' . $debugPath . ' (size: ' . strlen($xmlString) . ' bytes)');
-
try {
$xml = new \SimpleXMLElement($xmlString);
diff --git a/lib/Service/ArchiMateImportService.php b/lib/Service/ArchiMateImportService.php
index a1a4a1576..7f06a382c 100644
--- a/lib/Service/ArchiMateImportService.php
+++ b/lib/Service/ArchiMateImportService.php
@@ -23,8 +23,8 @@
use OCA\OpenRegister\Contract\ObjectServiceInterface;
use OCA\OpenRegister\Service\ObjectService;
use OCA\OpenRegister\Service\OrganisationService;
+use OCA\Stackiq\Service\ProgressTracker;
use OCP\App\IAppManager;
-use OCP\Files\IRootFolder;
use OCP\IAppConfig;
use OCP\IDBConnection;
use OCP\IUserSession;
@@ -148,6 +148,25 @@ class ArchiMateImportService {
*/
private ?array $lastSaveResult = null;
+ /**
+ * Operation ids a page may name for an import it follows.
+ */
+ public const OPERATION_ID_PATTERN = '/^archimate_import_[A-Za-z0-9]{8,64}$/';
+
+ /**
+ * The progress operation of the running import, or null when it is not tracked.
+ *
+ * @var string|null
+ */
+ private ?string $operationId = null;
+
+ /**
+ * Whether the running import stopped on a cancel.
+ *
+ * @var boolean
+ */
+ private bool $cancelled = false;
+
/**
* Cached configuration values for performance optimization.
*
@@ -159,7 +178,6 @@ class ArchiMateImportService {
* Constructor for ArchiMateImportService
*
* @param IAppConfig $config Nextcloud app configuration service
- * @param IRootFolder $rootFolder Root folder service
* @param IUserSession $userSession User session service
* @param IAppManager $appManager App manager service
* @param ContainerInterface $container PSR-11 container interface
@@ -167,10 +185,10 @@ class ArchiMateImportService {
* @param SettingsService $settingsService Settings service for AMEF configuration.
* @param OrganisationService $organisationService Organisation service.
* @param IDBConnection $dbConnection Database connection interface.
+ * @param ProgressTracker $progressTracker Progress store the page follows and cancels the import through.
*/
public function __construct(
private readonly IAppConfig $config,
- private readonly IRootFolder $rootFolder,
private readonly IUserSession $userSession,
private readonly IAppManager $appManager,
private readonly ContainerInterface $container,
@@ -178,6 +196,7 @@ public function __construct(
private readonly SettingsService $settingsService,
private readonly OrganisationService $organisationService,
private readonly IDBConnection $dbConnection,
+ private readonly ProgressTracker $progressTracker,
) {
}//end __construct()
@@ -352,6 +371,8 @@ public function importArchiMateFileFromPathOptimized(array $options = []): array
$this->logger->info('GEMMA IMPORT DEBUG: Starting optimized import', $options);
// Starting OPTIMIZED ArchiMate XML import.
+ $this->startTracking(options: $options);
+
try {
// OPTIMIZATION: Cache all configuration once at start.
$cacheStartTime = microtime(true);
@@ -360,12 +381,19 @@ public function importArchiMateFileFromPathOptimized(array $options = []): array
// Cache initialization completed.
// STEP 1: Parse XML to array (same as before).
+ $this->trackPhase(phase: 'validating');
$filePath = $this->validateArchiMateFile(options: $options);
+ $this->trackPhase(phase: 'parsing');
$parseStartTime = microtime(true);
$xmlData = $this->parseArchiMateXml(filePath: $filePath);
$parseTime = microtime(true) - $parseStartTime;
+ if ($this->cancelRequested() === true) {
+ $this->progressTracker->cancelOperation();
+ return $this->cancelledResult();
+ }
+
// PERFORMANCE OPTIMIZATION: Clean up memory after XML parsing.
$memoryCleanupTime = 0;
// PERFORMANCE_OPTIMIZATIONS['memory_cleanup'] is a class constant set
@@ -380,6 +408,7 @@ public function importArchiMateFileFromPathOptimized(array $options = []): array
$modelIdentifierTime = microtime(true) - $modelIdStartTime;
// STEP 3: Parse ALL objects in one go (like CSV import).
+ $this->trackPhase(phase: 'analyzing');
$transformStartTime = microtime(true);
$allObjects = $this->transformArchiMateXmlToObjectsBatch(
xmlData: $xmlData,
@@ -387,6 +416,11 @@ public function importArchiMateFileFromPathOptimized(array $options = []): array
);
$transformTime = microtime(true) - $transformStartTime;
+ if ($this->cancelRequested() === true) {
+ $this->progressTracker->cancelOperation();
+ return $this->cancelledResult();
+ }
+
// Parsed and transformed all objects.
// STEP 4: Single saveObjects() call (like CSV import).
$saveStartTime = microtime(true);
@@ -399,6 +433,12 @@ public function importArchiMateFileFromPathOptimized(array $options = []): array
$savedObjects = $this->saveObjectsToDatabase(objects: $allObjects);
$saveTime = microtime(true) - $saveStartTime;
+ if ($this->cancelled === true) {
+ return $this->cancelledResult();
+ }
+
+ $this->trackPhase(phase: 'finalizing');
+
// Capture detailed save timing from internal tracking.
$saveBreakdown = $this->lastSaveTiming;
@@ -410,6 +450,10 @@ public function importArchiMateFileFromPathOptimized(array $options = []): array
$statistics = $this->buildStatisticsFromSaveResult();
$detailedErrors = $this->extractDetailedErrors(statistics: $statistics);
+ if ($this->operationId !== null) {
+ $this->progressTracker->completeOperation();
+ }
+
// OPTIMIZED import completed successfully.
return [
'success' => true,
@@ -453,6 +497,10 @@ public function importArchiMateFileFromPathOptimized(array $options = []): array
]
);
+ if ($this->operationId !== null) {
+ $this->progressTracker->failOperation(message: $e->getMessage());
+ }
+
return [
'success' => false,
'error' => $e->getMessage(),
@@ -1343,7 +1391,63 @@ private function saveObjectsToDatabase(array $objects): array {
]
);
- // Process each schema group.
+ $groupResult = $this->saveSchemaGroups(schemaGroups: $schemaGroups, objectService: $objectService, registerId: $registerId);
+ $allResults = $groupResult['results'];
+ $aggregatedStats = $groupResult['stats'];
+ $countsBySchema = $aggregatedStats['countsBySchema'];
+ unset($aggregatedStats['countsBySchema']);
+
+ // Store aggregated result for statistics, including per-schema counts.
+ $aggregatedStats['countsBySchema'] = $countsBySchema;
+ $this->lastSaveResult = $aggregatedStats;
+ $result = $allResults;
+
+ $batchProcessingTime = microtime(true) - $batchStartTime;
+
+ // POST-PROCESSING: Fix StandaardVersie standaard field UUIDs.
+ // The standaard field was set with ArchiMate identifiers, but we need database UUIDs.
+ // for the inversedBy lookup to work correctly.
+ $this->fixStandaardVersieUuids(registerId: $registerId);
+
+ $totalSaveTime = microtime(true) - $saveStartTime;
+
+ // Database save completed.
+ // Store timing breakdown for performance metrics.
+ // FIX: Use aggregatedStats counts instead of $result which may be empty from bulk operations.
+ $savedCount = count($aggregatedStats['saved'] ?? []);
+ $updatedCount = count($aggregatedStats['updated'] ?? []);
+ $unchangedCount = count($aggregatedStats['unchanged'] ?? []);
+ $totalSavedCount = $savedCount + $updatedCount + $unchangedCount;
+ if ($totalSavedCount > 0) {
+ $objectsSavedValue = $totalSavedCount;
+ } else {
+ $objectsSavedValue = count($objects);
+ }
+
+ $this->lastSaveTiming = [
+ 'total_save_seconds' => round($totalSaveTime, 3),
+ 'service_init_seconds' => round($serviceInitTime, 3),
+ 'gemma_processing_seconds' => round($gemmaProcessingTime, 3),
+ 'batch_processing_seconds' => round($batchProcessingTime, 3),
+ 'objects_saved' => $objectsSavedValue,
+ 'save_rate_objects_per_second' => round(count($objects) / max($totalSaveTime, 0.001), 1),
+ ];
+
+ return $result;
+ }//end saveObjectsToDatabase()
+
+ /**
+ * Save the objects one schema group at a time, recording progress and honouring a cancel.
+ *
+ * @param array>> $schemaGroups Objects keyed by schema id
+ * @param ObjectServiceInterface $objectService OpenRegister's object service
+ * @param int $registerId The AMEF register id
+ *
+ * @return array{results: array, stats: array} The saved objects and the aggregated statistics
+ *
+ * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-001-a-running-import-shall-record-its-phase-and-the-objects-saved-so-far
+ */
+ private function saveSchemaGroups(array $schemaGroups, ObjectServiceInterface $objectService, int $registerId): array {
$allResults = [];
$aggregatedStats = [
'saved' => [],
@@ -1354,9 +1458,21 @@ private function saveObjectsToDatabase(array $objects): array {
// Track counts per schema for accurate statistics (serialized objects lose the 'section' field).
$countsBySchema = [];
+ $processed = 0;
+ $this->trackPhase(
+ phase: 'processing_elements',
+ data: ['total_items' => array_sum(array_map('count', $schemaGroups)), 'reset_progress' => true]
+ );
+
foreach ($schemaGroups as $schemaId => $schemaObjects) {
$schemaObjectCount = count($schemaObjects);
+ // A cancel stops the import before its next batch; what is saved stays saved.
+ if ($this->cancelRequested() === true) {
+ $this->progressTracker->cancelOperation();
+ break;
+ }
+
try {
// Save this schema group with the specific schema ID.
// PERFORMANCE: Disabled validation and events for bulk import (like CSV import pattern).
@@ -1416,46 +1532,105 @@ private function saveObjectsToDatabase(array $objects): array {
]
);
}//end try
+
+ $processed += $schemaObjectCount;
+ if ($this->operationId !== null) {
+ $this->progressTracker->updateProgress(processedItems: $processed, itemType: (string) $schemaId);
+ }
}//end foreach
- // Store aggregated result for statistics, including per-schema counts.
$aggregatedStats['countsBySchema'] = $countsBySchema;
- $this->lastSaveResult = $aggregatedStats;
- $result = $allResults;
- $batchProcessingTime = microtime(true) - $batchStartTime;
+ return ['results' => $allResults, 'stats' => $aggregatedStats];
+ }//end saveSchemaGroups()
- // POST-PROCESSING: Fix StandaardVersie standaard field UUIDs.
- // The standaard field was set with ArchiMate identifiers, but we need database UUIDs.
- // for the inversedBy lookup to work correctly.
- $this->fixStandaardVersieUuids(registerId: $registerId);
+ /**
+ * Start recording progress when the upload names a well-formed operation id.
+ *
+ * An id outside the pattern is ignored, so a caller cannot write into another
+ * operation's keys; the import then runs without progress.
+ *
+ * @param array $options Import options, optionally with operationId
+ *
+ * @return void
+ *
+ * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-001-a-running-import-shall-record-its-phase-and-the-objects-saved-so-far
+ */
+ public function startTracking(array $options): void {
+ $this->operationId = null;
+ $this->cancelled = false;
- $totalSaveTime = microtime(true) - $saveStartTime;
+ $operationId = $options['operationId'] ?? null;
+ if (is_string($operationId) === false || preg_match(self::OPERATION_ID_PATTERN, $operationId) !== 1) {
+ return;
+ }
- // Database save completed.
- // Store timing breakdown for performance metrics.
- // FIX: Use aggregatedStats counts instead of $result which may be empty from bulk operations.
- $savedCount = count($aggregatedStats['saved'] ?? []);
- $updatedCount = count($aggregatedStats['updated'] ?? []);
- $unchangedCount = count($aggregatedStats['unchanged'] ?? []);
- $totalSavedCount = $savedCount + $updatedCount + $unchangedCount;
- if ($totalSavedCount > 0) {
- $objectsSavedValue = $totalSavedCount;
- } else {
- $objectsSavedValue = count($objects);
+ $this->operationId = $this->progressTracker->startOperation(
+ operationType: 'archimate_import',
+ operationId: $operationId
+ );
+ }//end startTracking()
+
+ /**
+ * Whether the last import stopped on a cancel.
+ *
+ * @return bool True when it was cancelled
+ *
+ * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-002-an-admin-shall-be-able-to-cancel-a-running-import
+ */
+ public function wasCancelled(): bool {
+ return $this->cancelled;
+ }//end wasCancelled()
+
+ /**
+ * Record the phase the import is in, when it is tracked.
+ *
+ * @param string $phase The ProgressTracker phase
+ * @param array $data Phase data such as total_items
+ *
+ * @return void
+ */
+ private function trackPhase(string $phase, array $data = []): void {
+ if ($this->operationId !== null) {
+ $this->progressTracker->setPhase(phase: $phase, data: $data);
}
+ }//end trackPhase()
- $this->lastSaveTiming = [
- 'total_save_seconds' => round($totalSaveTime, 3),
- 'service_init_seconds' => round($serviceInitTime, 3),
- 'gemma_processing_seconds' => round($gemmaProcessingTime, 3),
- 'batch_processing_seconds' => round($batchProcessingTime, 3),
- 'objects_saved' => $objectsSavedValue,
- 'save_rate_objects_per_second' => round(count($objects) / max($totalSaveTime, 0.001), 1),
+ /**
+ * Whether a cancel was requested for the tracked import; remembers a yes.
+ *
+ * @return bool True when the import must stop
+ */
+ private function cancelRequested(): bool {
+ if ($this->operationId !== null && $this->progressTracker->isCancelRequested($this->operationId) === true) {
+ $this->cancelled = true;
+ }
+
+ return $this->cancelled;
+ }//end cancelRequested()
+
+ /**
+ * The result of an import that stopped on a cancel, with what it had saved.
+ *
+ * @return array The cancelled result
+ */
+ private function cancelledResult(): array {
+ $stats = $this->lastSaveResult ?? [];
+ $counts = [
+ 'saved' => count($stats['saved'] ?? []),
+ 'updated' => count($stats['updated'] ?? []),
+ 'unchanged' => count($stats['unchanged'] ?? []),
+ 'invalid' => count($stats['invalid'] ?? []),
];
- return $result;
- }//end saveObjectsToDatabase()
+ return [
+ 'success' => false,
+ 'cancelled' => true,
+ 'message' => 'Import cancelled',
+ 'counts' => $counts,
+ 'objects_saved' => $counts['saved'] + $counts['updated'] + $counts['unchanged'],
+ ];
+ }//end cancelledResult()
/**
* Fix StandaardVersie standaard field UUIDs after import
diff --git a/lib/Service/ArchiMateService.php b/lib/Service/ArchiMateService.php
index e08c4617f..b65f1ff8a 100644
--- a/lib/Service/ArchiMateService.php
+++ b/lib/Service/ArchiMateService.php
@@ -24,7 +24,6 @@
use OCA\OpenRegister\Contract\ObjectServiceInterface;
use OCA\OpenRegister\Service\ObjectService;
use OCP\App\IAppManager;
-use OCP\Files\IRootFolder;
use OCP\IAppConfig;
use OCP\IUserSession;
use Psr\Container\ContainerInterface;
@@ -157,7 +156,6 @@ class ArchiMateService {
* Constructor for ArchiMateService
*
* @param IAppConfig $config Nextcloud app configuration service
- * @param IRootFolder $rootFolder Root folder service
* @param IUserSession $userSession User session service
* @param IAppManager $appManager App manager service
* @param ContainerInterface $container PSR-11 container interface
@@ -165,10 +163,10 @@ class ArchiMateService {
* @param SettingsService $settingsService Settings service for schema and organization configuration
* @param ArchiMateImportService $importService Import service for XML parsing
* @param ArchiMateExportService $exportService Export service for XML generation
+ * @param ProgressTracker $progressTracker Progress store a running import reads its cancel from
*/
public function __construct(
private readonly IAppConfig $config,
- private readonly IRootFolder $rootFolder,
private readonly IUserSession $userSession,
private readonly IAppManager $appManager,
private readonly ContainerInterface $container,
@@ -176,9 +174,49 @@ public function __construct(
private readonly SettingsService $settingsService,
private readonly ArchiMateImportService $importService,
private readonly ArchiMateExportService $exportService,
+ private readonly ProgressTracker $progressTracker,
) {
}//end __construct()
+ /**
+ * Cancel a running ArchiMate import.
+ *
+ * The import runs in another request, so this records a cancel that the import
+ * reads before its next save batch, and clears the stored import status.
+ *
+ * @param string|null $operationId The import's operation id, as the page named it
+ *
+ * @return array The cancellation result
+ *
+ * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-002-an-admin-shall-be-able-to-cancel-a-running-import
+ */
+ public function cancelArchiMateImport(?string $operationId = null): array {
+ if ($operationId !== null && preg_match(ArchiMateImportService::OPERATION_ID_PATTERN, $operationId) !== 1) {
+ return [
+ 'cancelled' => false,
+ 'operation_id' => null,
+ 'messages' => ['The operation id is not an ArchiMate import id'],
+ ];
+ }
+
+ $messages = [];
+ if ($operationId !== null) {
+ $this->progressTracker->setCancelRequested($operationId);
+ $messages[] = 'The import stops before its next save batch';
+ }
+
+ $this->config->deleteKey('stackiq', 'archimate_import_status');
+ $messages[] = 'Import status cleared';
+
+ return [
+ 'cancelled' => true,
+ 'operation_id' => $operationId,
+ 'status_cleared' => true,
+ 'cancellation_time' => date('Y-m-d H:i:s'),
+ 'messages' => $messages,
+ ];
+ }//end cancelArchiMateImport()
+
/**
* OPTIMIZED: Import ArchiMate XML file using OpenRegister-style performance optimization
*
@@ -1487,107 +1525,6 @@ private function getSchemaIdForSection(string $section): int {
return $schemaId;
}//end getSchemaIdForSection()
- /**
- * Test round-trip functionality
- *
- * @return array Test results
- * @spec openspec/specs/archimate-import/spec.md
- */
- public function testRoundTrip(): array {
- $this->logger->info('Testing ArchiMate round-trip functionality');
-
- try {
- // Create test XML.
- $testXml = $this->createTestArchiMateXml();
-
- // Import.
- $importResult = $this->importArchiMateFileFromPath(
- options: [
- 'file_path' => $this->createTempFile(content: $testXml),
- ]
- );
-
- if ($importResult['success'] === false) {
- return [
- 'success' => false,
- 'error' => 'Import failed: ' . $importResult['error'],
- ];
- }
-
- // Export.
- $exportResult = $this->exportToArchiMate();
-
- if ($exportResult['success'] === false) {
- return [
- 'success' => false,
- 'error' => 'Export failed: ' . $exportResult['error'],
- ];
- }
-
- // Compare (simplified comparison).
- $importedCount = $importResult['imported_count'];
- $exportedCount = $exportResult['exported_count'];
-
- $success = $importedCount === $exportedCount;
-
- return [
- 'success' => $success,
- 'imported_count' => $importedCount,
- 'exported_count' => $exportedCount,
- 'round_trip_successful' => $success,
- ];
- } catch (\Exception $e) {
- $this->logger->error(
- 'Round-trip test failed',
- [
- 'error' => $e->getMessage(),
- ]
- );
-
- return [
- 'success' => false,
- 'error' => $e->getMessage(),
- ];
- }//end try
- }//end testRoundTrip()
-
- /**
- * Create test ArchiMate XML
- *
- * @return string Test XML content
- */
- private function createTestArchiMateXml(): string {
- return '
-
- Test Model
- Test model for round-trip verification
-
-
- Test Actor
-
-
-
-
- test-element-1
- test-element-2
-
-
-';
- }//end createTestArchiMateXml()
-
- /**
- * Create temporary file with content
- *
- * @param string $content File content
- *
- * @return string Temporary file path
- */
- private function createTempFile(string $content): string {
- $tempFile = tempnam(sys_get_temp_dir(), 'archimate_test_');
- file_put_contents($tempFile, $content);
- return $tempFile;
- }//end createTempFile()
-
/**
* Read a configured id, failing closed on the empty default.
*
diff --git a/lib/Service/Cmdb/CmdbImportProfile.php b/lib/Service/Cmdb/CmdbImportProfile.php
new file mode 100644
index 000000000..1e8805c90
--- /dev/null
+++ b/lib/Service/Cmdb/CmdbImportProfile.php
@@ -0,0 +1,724 @@
+
+ * @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-export-import/tasks.md#task-3
+ *
+ * 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;
+use Psr\Container\ContainerInterface;
+use Throwable;
+
+/**
+ * The validated import profile plus its packs.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ *
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods) One small accessor per profile setting, so
+ * callers never read the raw JSON.
+ * @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.
+ */
+class CmdbImportProfile {
+ /**
+ * OpenRegister's pack validator (not a public contract).
+ */
+ public const VALIDATOR_CLASS = 'OCA\OpenRegister\Service\MigrationPack\PackDefinitionValidator';
+
+ /**
+ * The targets every profile must name a pack for.
+ *
+ * @var array
+ */
+ public const TARGETS = ['module', 'manufacturer', 'municipality', 'usage', 'businessOwner'];
+
+ /**
+ * Default upload limit when the profile file cannot be read (10 MB).
+ */
+ public const DEFAULT_MAX_FILE_BYTES = 10485760;
+
+ /**
+ * Default limit on the unpacked size of a workbook (50 MB).
+ */
+ public const DEFAULT_MAX_UNCOMPRESSED_BYTES = 52428800;
+
+ /**
+ * Default limit on the unpacked size of any one part of a workbook (10 MB).
+ */
+ public const DEFAULT_MAX_PART_BYTES = 10485760;
+
+ /**
+ * Default limit on the number of entries in a workbook's shared-strings table.
+ */
+ public const DEFAULT_MAX_SHARED_STRINGS = 200000;
+
+ /**
+ * Default limit on the shared-string text a workbook's cells reference together (64 MB).
+ */
+ public const DEFAULT_MAX_REFERENCED_STRING_BYTES = 67108864;
+
+ /**
+ * Sources of the municipality pack that come from the request, not from a sheet.
+ *
+ * @var array
+ */
+ private const OPTION_SOURCES = ['municipalityName'];
+
+ /**
+ * The decoded profile, once loaded.
+ *
+ * @var array|null
+ */
+ private ?array $profile = null;
+
+ /**
+ * The validated packs per target, once loaded.
+ *
+ * @var array>
+ */
+ private array $packs = [];
+
+ /**
+ * 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.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ */
+ public function __construct(
+ private readonly ContainerInterface $container,
+ private ?string $directory = null,
+ private readonly string $profileFile = 'topdesk-profile.json',
+ ) {
+ if ($this->directory === null) {
+ $this->directory = __DIR__ . '/../../Settings/cmdb-import';
+ }
+ }//end __construct()
+
+ /**
+ * Load the profile and validate every pack it names.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the validator is missing,
+ * or the profile or a pack is unreadable or invalid.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ */
+ public function load(): void {
+ $validator = $this->resolveValidator();
+ $profile = $this->decodeFile(fileName: $this->profileFile);
+
+ $packs = [];
+ 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
+ );
+ }
+
+ $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))
+ );
+ }
+
+ $packs[$target] = $pack;
+ }
+
+ $this->profile = $profile;
+ $this->packs = $packs;
+ }//end load()
+
+ /**
+ * The upload limit in bytes, readable without validating the packs.
+ *
+ * The controller checks the size before anything else, so this must not
+ * depend on OpenRegister being available.
+ *
+ * @return int
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-8
+ */
+ public function maxFileBytes(): int {
+ try {
+ $profile = $this->profile ?? $this->decodeFile(fileName: $this->profileFile);
+ } catch (CmdbImportException $e) {
+ return self::DEFAULT_MAX_FILE_BYTES;
+ }
+
+ $limit = $profile['maxFileBytes'] ?? null;
+ if (is_int($limit) === true && $limit > 0) {
+ return $limit;
+ }
+
+ return self::DEFAULT_MAX_FILE_BYTES;
+ }//end maxFileBytes()
+
+ /**
+ * The limit on the unpacked size of a workbook, in bytes.
+ *
+ * The upload limit is on the compressed file; a sheet of identical rows
+ * compresses a hundredfold, so the unpacked size is bounded too.
+ *
+ * @return int
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function maxUncompressedBytes(): int {
+ $limit = $this->profile()['maxUncompressedBytes'] ?? null;
+ if (is_int($limit) === true && $limit > 0) {
+ return $limit;
+ }
+
+ return self::DEFAULT_MAX_UNCOMPRESSED_BYTES;
+ }//end maxUncompressedBytes()
+
+ /**
+ * The limit on the unpacked size of any one part of a workbook, in bytes.
+ *
+ * PhpSpreadsheet parses the shared-strings part and every loaded sheet part
+ * whole, into structures many times the part's size, so the parts are
+ * bounded one by one, not only their total.
+ *
+ * @return int
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function maxPartBytes(): int {
+ $limit = $this->profile()['maxPartBytes'] ?? null;
+ if (is_int($limit) === true && $limit > 0) {
+ return $limit;
+ }
+
+ return self::DEFAULT_MAX_PART_BYTES;
+ }//end maxPartBytes()
+
+ /**
+ * The limit on the number of entries in a workbook's shared-strings table.
+ *
+ * PhpSpreadsheet builds the whole table before it reads a sheet, and many
+ * short strings cost far more memory than their bytes.
+ *
+ * @return int
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function maxSharedStrings(): int {
+ $limit = $this->profile()['maxSharedStrings'] ?? null;
+ if (is_int($limit) === true && $limit > 0) {
+ return $limit;
+ }
+
+ return self::DEFAULT_MAX_SHARED_STRINGS;
+ }//end maxSharedStrings()
+
+ /**
+ * The limit on the shared-string text a workbook's cells reference together, in bytes.
+ *
+ * PhpSpreadsheet gives every cell that references a shared string its own
+ * copy of the text, so one long string referenced by many cells costs far
+ * more memory than the file's size; the reader adds up what the cells
+ * reference before it parses a sheet.
+ *
+ * @return int
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function maxReferencedStringBytes(): int {
+ $limit = $this->profile()['maxReferencedStringBytes'] ?? null;
+ if (is_int($limit) === true && $limit > 0) {
+ return $limit;
+ }
+
+ return self::DEFAULT_MAX_REFERENCED_STRING_BYTES;
+ }//end maxReferencedStringBytes()
+
+ /**
+ * The maximum number of non-empty rows per source sheet.
+ *
+ * @return int
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function maxRowsPerSheet(): int {
+ $limit = $this->profile()['maxRowsPerSheet'] ?? 10000;
+ if (is_int($limit) === false || $limit < 0) {
+ return 10000;
+ }
+
+ return $limit;
+ }//end maxRowsPerSheet()
+
+ /**
+ * The source sheets, each with the constants it adds to its rows and the
+ * pack columns it is known not to have.
+ *
+ * @return array, absentColumns: array}>
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function sheets(): array {
+ $sheets = [];
+ foreach (($this->profile()['sheets'] ?? []) as $sheet) {
+ if (is_array($sheet) === false || is_string($sheet['name'] ?? null) === false) {
+ continue;
+ }
+
+ $constants = [];
+ if (is_array($sheet['constants'] ?? null) === true) {
+ foreach ($sheet['constants'] as $column => $value) {
+ if (is_scalar($value) === true) {
+ $constants[(string)$column] = (string)$value;
+ }
+ }
+ }
+
+ $absent = [];
+ if (is_array($sheet['absentColumns'] ?? null) === true) {
+ $absent = array_values(array_map('strval', $sheet['absentColumns']));
+ }
+
+ $sheets[] = ['name' => $sheet['name'], 'constants' => $constants, 'absentColumns' => $absent];
+ }//end foreach
+
+ return $sheets;
+ }//end sheets()
+
+ /**
+ * The names of the source sheets.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function sheetNames(): array {
+ return array_column($this->sheets(), 'name');
+ }//end sheetNames()
+
+ /**
+ * The rank of a sheet when an APPID is on more than one: lower wins.
+ *
+ * The profile's `sheetPrecedence` lists the sheets, the winner first; a
+ * sheet it does not list ranks after every listed one, in profile order.
+ *
+ * @param string $sheetName The sheet name.
+ *
+ * @return int
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ public function sheetRank(string $sheetName): int {
+ $order = array_values(array_unique(array_merge($this->stringList(key: 'sheetPrecedence'), $this->sheetNames())));
+ $rank = array_search($sheetName, $order, true);
+ if ($rank === false) {
+ return count($order);
+ }
+
+ return (int)$rank;
+ }//end sheetRank()
+
+ /**
+ * The constants a sheet adds to each of its rows, as column => value.
+ *
+ * A constant is mapped like a column (the usage pack reads "Beheer"), but
+ * it is never looked up in the sheet.
+ *
+ * @param string $sheetName The sheet name.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ public function sheetConstants(string $sheetName): array {
+ foreach ($this->sheets() as $sheet) {
+ if ($sheet['name'] === $sheetName) {
+ return $sheet['constants'];
+ }
+ }
+
+ return [];
+ }//end sheetConstants()
+
+ /**
+ * The pack columns a sheet is known not to have; their absence is no warning.
+ *
+ * @param string $sheetName The sheet name.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function absentColumns(string $sheetName): array {
+ foreach ($this->sheets() as $sheet) {
+ if ($sheet['name'] === $sheetName) {
+ return $sheet['absentColumns'];
+ }
+ }
+
+ return [];
+ }//end absentColumns()
+
+ /**
+ * The names of every sheet constant; these are never read from a sheet.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function constantColumns(): array {
+ $columns = [];
+ foreach ($this->sheets() as $sheet) {
+ $columns = array_merge($columns, array_keys($sheet['constants']));
+ }
+
+ return array_values(array_unique(array_map('strval', $columns)));
+ }//end constantColumns()
+
+ /**
+ * Values that mean "empty" per column, such as the "NB" a CMDB sheet
+ * writes for an unknown BNN classification.
+ *
+ * @return array>
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function emptyValues(): array {
+ $value = $this->profile()['emptyValues'] ?? [];
+ if (is_array($value) === false) {
+ return [];
+ }
+
+ $empty = [];
+ foreach ($value as $column => $values) {
+ if (is_array($values) === true) {
+ $empty[(string)$column] = array_values(array_map('strval', $values));
+ }
+ }
+
+ return $empty;
+ }//end emptyValues()
+
+ /**
+ * The match key column ("APPID").
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ public function keyColumn(): string {
+ return (string)($this->profile()['keyColumn'] ?? 'APPID');
+ }//end keyColumn()
+
+ /**
+ * The application name column ("Applicatie Naam").
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ public function nameColumn(): string {
+ return (string)($this->profile()['nameColumn'] ?? 'Applicatie Naam');
+ }//end nameColumn()
+
+ /**
+ * Columns whose absence stops the import with MISSING_COLUMN.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function requiredColumns(): array {
+ return $this->stringList(key: 'requiredColumns');
+ }//end requiredColumns()
+
+ /**
+ * Columns that hold Excel serial dates.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function dateColumns(): array {
+ return $this->stringList(key: 'dateColumns');
+ }//end dateColumns()
+
+ /**
+ * Columns that hold identifiers which must not carry a decimal part.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function idColumns(): array {
+ return $this->stringList(key: 'idColumns');
+ }//end idColumns()
+
+ /**
+ * The prefix of the module match key ("topdesk").
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ public function externalKeyPrefix(): string {
+ return (string)($this->profile()['externalKeyPrefix'] ?? 'topdesk');
+ }//end externalKeyPrefix()
+
+ /**
+ * The validated pack for a target.
+ *
+ * @param string $target One of self::TARGETS.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ */
+ public function pack(string $target): array {
+ $this->profile();
+ return ($this->packs[$target] ?? []);
+ }//end pack()
+
+ /**
+ * Values set on create only, per target, as field => value.
+ *
+ * @param string $target The target.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ public function createOnlyDefaults(string $target): array {
+ $value = $this->profile()['createOnly'][$target] ?? [];
+ if (is_array($value) === false || array_is_list($value) === true) {
+ return [];
+ }
+
+ return $value;
+ }//end createOnlyDefaults()
+
+ /**
+ * Mapped fields written on create, or on update only when the stored value is empty.
+ *
+ * @param string $target The target.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ public function createOnlyFields(string $target): array {
+ $value = $this->profile()['createOnly'][$target] ?? [];
+ if (is_array($value) === false) {
+ return [];
+ }
+
+ if (array_is_list($value) === true) {
+ return array_values(array_map('strval', $value));
+ }
+
+ return array_map('strval', array_keys($value));
+ }//end createOnlyFields()
+
+ /**
+ * Fields the import never writes on an existing object.
+ *
+ * @param string $target The target.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ public function neverWrittenOnUpdate(string $target): array {
+ $value = $this->profile()['neverWritten'][$target] ?? [];
+ if (is_array($value) === false) {
+ return [];
+ }
+
+ return array_values(array_map('strval', $value));
+ }//end neverWrittenOnUpdate()
+
+ /**
+ * The accepted values of the missingRecords option.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-8
+ */
+ public function missingRecordsModes(): array {
+ $modes = $this->stringList(key: 'missingRecords');
+ if ($modes === []) {
+ return ['keep'];
+ }
+
+ return $modes;
+ }//end missingRecordsModes()
+
+ /**
+ * Every column the profile or a pack references: the read allowlist.
+ *
+ * The municipality pack maps the request options, not a sheet, and the
+ * sheet constants are added by the import, so both are left out.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ */
+ public function referencedColumns(): array {
+ $columns = array_merge(
+ [$this->keyColumn(), $this->nameColumn()],
+ $this->requiredColumns(),
+ $this->dateColumns(),
+ $this->idColumns()
+ );
+
+ foreach (self::TARGETS as $target) {
+ foreach (($this->pack(target: $target)['fieldMappings'] ?? []) as $mapping) {
+ $columns[] = (string)($mapping['source'] ?? '');
+ foreach (($mapping['transform']['fields'] ?? []) as $extra) {
+ $columns[] = (string)$extra;
+ }
+ }
+ }
+
+ $excluded = array_merge(self::OPTION_SOURCES, $this->constantColumns());
+ $columns = array_filter(
+ $columns,
+ fn (string $column): bool => $column !== '' && $column[0] !== '/' && in_array($column, $excluded, true) === false
+ );
+
+ return array_values(array_unique($columns));
+ }//end referencedColumns()
+
+ /**
+ * The loaded profile.
+ *
+ * @return array
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when load() failed.
+ */
+ private function profile(): array {
+ if ($this->profile === null) {
+ $this->load();
+ }
+
+ return ($this->profile ?? []);
+ }//end profile()
+
+ /**
+ * A list of strings from the profile.
+ *
+ * @param string $key The profile key.
+ *
+ * @return array
+ */
+ private function stringList(string $key): array {
+ $value = $this->profile()[$key] ?? [];
+ if (is_array($value) === false) {
+ return [];
+ }
+
+ return array_values(array_map('strval', $value));
+ }//end stringList()
+
+ /**
+ * Resolve OpenRegister's pack validator.
+ *
+ * @return object The validator, with a `validate(array): array` method.
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when it is not available.
+ */
+ private function resolveValidator(): object {
+ $class = static::VALIDATOR_CLASS;
+
+ try {
+ if ($this->container->has($class) === true) {
+ $validator = $this->container->get($class);
+ if (is_object($validator) === true && method_exists($validator, 'validate') === true) {
+ return $validator;
+ }
+ }
+ } catch (Throwable $e) {
+ // Fall through to the class check below.
+ $validator = null;
+ }
+
+ if (class_exists($class) === true) {
+ $validator = new $class();
+ if (method_exists($validator, 'validate') === true) {
+ return $validator;
+ }
+ }
+
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'OpenRegister PackDefinitionValidator is not available'
+ );
+ }//end resolveValidator()
+
+ /**
+ * Read and decode one JSON file from the profile directory.
+ *
+ * @param string $fileName The file name.
+ *
+ * @return array
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE when the file is missing or not a JSON object.
+ */
+ private function decodeFile(string $fileName): array {
+ $path = $this->directory . '/' . $fileName;
+ $content = false;
+ if (is_readable($path) === true) {
+ $content = file_get_contents($path);
+ }
+
+ $decoded = null;
+ if (is_string($content) === true) {
+ $decoded = json_decode($content, true);
+ }
+
+ if (is_array($decoded) === false) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MAPPING_UNAVAILABLE,
+ message: 'CMDB import file ' . $fileName . ' is missing or not valid JSON'
+ );
+ }
+
+ return $decoded;
+ }//end decodeFile()
+}//end class
diff --git a/lib/Service/Cmdb/CmdbImportReport.php b/lib/Service/Cmdb/CmdbImportReport.php
new file mode 100644
index 000000000..730365fde
--- /dev/null
+++ b/lib/Service/Cmdb/CmdbImportReport.php
@@ -0,0 +1,299 @@
+
+ * @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-export-import/tasks.md#task-7
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Cmdb;
+
+/**
+ * The per-row report of one CMDB import run.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+class CmdbImportReport {
+ public const CREATED = 'created';
+ public const UPDATED = 'updated';
+ public const UNCHANGED = 'unchanged';
+ public const SKIPPED = 'skipped';
+ public const FAILED = 'failed';
+
+ /**
+ * The row entries, in processing order.
+ *
+ * @var array>
+ */
+ private array $rows = [];
+
+ /**
+ * Import-level warnings.
+ *
+ * @var array
+ */
+ private array $importWarnings = [];
+
+ /**
+ * Whether the run stopped on a cancel.
+ *
+ * @var bool
+ */
+ private bool $cancelled = false;
+
+ /**
+ * The consuming municipality.
+ *
+ * @var array{uuid: string, name: string, created: bool}|null
+ */
+ private ?array $municipality = null;
+
+ /**
+ * Modules this run created without a publication date.
+ *
+ * @var int
+ */
+ private int $unpublished = 0;
+
+ /**
+ * Constructor.
+ *
+ * @param string $operationId The progress operation id.
+ * @param int $rowsRead Non-empty rows read from the workbook.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ public function __construct(
+ private readonly string $operationId,
+ private readonly int $rowsRead,
+ ) {
+ }//end __construct()
+
+ /**
+ * Add one row outcome.
+ *
+ * @param string $sheet The sheet name.
+ * @param int $row The 1-based sheet row number.
+ * @param string $appId The APPID ('' when missing).
+ * @param string $name The application name ('' when missing).
+ * @param string $outcome One of the outcome constants.
+ * @param array $reasons Why the row was skipped or failed.
+ * @param array $warnings Row warnings.
+ * @param string|null $moduleUuid The module, when there is one.
+ * @param string|null $usageUuid The usage, when there is one.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ public function addRow(
+ string $sheet,
+ int $row,
+ string $appId,
+ string $name,
+ string $outcome,
+ array $reasons = [],
+ array $warnings = [],
+ ?string $moduleUuid = null,
+ ?string $usageUuid = null,
+ ): void {
+ $this->rows[] = [
+ 'sheet' => $sheet,
+ 'row' => $row,
+ 'appId' => $appId,
+ 'name' => $name,
+ 'outcome' => $outcome,
+ 'reasons' => array_values($reasons),
+ 'warnings' => array_values($warnings),
+ 'moduleUuid' => $moduleUuid,
+ 'usageUuid' => $usageUuid,
+ ];
+ }//end addRow()
+
+ /**
+ * Add import-level warnings, such as a missing optional column.
+ *
+ * @param array $warnings The warnings.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ public function addImportWarnings(array $warnings): void {
+ foreach ($warnings as $warning) {
+ $this->importWarnings[] = ['sheet' => (string)$warning['sheet'], 'message' => (string)$warning['message']];
+ }
+ }//end addImportWarnings()
+
+ /**
+ * Record the consuming municipality.
+ *
+ * @param string $uuid The organisation uuid.
+ * @param string $name Its name.
+ * @param bool $created Whether this run created it.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ public function setMunicipality(string $uuid, string $name, bool $created): void {
+ $this->municipality = ['uuid' => $uuid, 'name' => $name, 'created' => $created];
+ }//end setMunicipality()
+
+ /**
+ * Count a module this run created without publishing it.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ public function countUnpublished(): void {
+ $this->unpublished++;
+ }//end countUnpublished()
+
+ /**
+ * Mark the run as stopped on a cancel.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ public function markCancelled(): void {
+ $this->cancelled = true;
+ }//end markCancelled()
+
+ /**
+ * The number of rows processed so far.
+ *
+ * @return int
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ public function processed(): int {
+ return count($this->rows);
+ }//end processed()
+
+ /**
+ * The summary counts.
+ *
+ * `unpublished` counts the modules created without a publication date;
+ * a row whose module was created but whose usage then failed counts too.
+ *
+ * @return array{rowsRead: int, processed: int, created: int, updated: int, unchanged: int, skipped: int, failed: int,
+ * warnings: int, unpublished: int}
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ public function summary(): array {
+ $summary = [
+ 'rowsRead' => $this->rowsRead,
+ 'processed' => count($this->rows),
+ self::CREATED => 0,
+ self::UPDATED => 0,
+ self::UNCHANGED => 0,
+ self::SKIPPED => 0,
+ self::FAILED => 0,
+ 'warnings' => 0,
+ 'unpublished' => $this->unpublished,
+ ];
+
+ foreach ($this->rows as $row) {
+ $summary[$row['outcome']]++;
+ $summary['warnings'] += count($row['warnings']);
+ }
+
+ return $summary;
+ }//end summary()
+
+ /**
+ * The report in the contract shape.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ public function toArray(): array {
+ return [
+ 'success' => true,
+ 'operationId' => $this->operationId,
+ 'cancelled' => $this->cancelled,
+ 'municipality' => $this->municipality,
+ 'summary' => $this->summary(),
+ 'importWarnings' => $this->importWarnings,
+ 'rows' => $this->rows,
+ ];
+ }//end toArray()
+
+ /**
+ * The report as it is stored with the operation: the counts, and at most
+ * $maxRows rows.
+ *
+ * The progress entry lives in the distributed cache, where a value of a
+ * few megabytes is refused without an error (memcached's default item
+ * limit is 1 MB). The stored copy keeps every count. Within the limit it
+ * keeps every row; over it, the rows that need attention: failed, then
+ * skipped, then rows with a warning, then the rest, each in processing
+ * order. `rowsStored` and `rowsTruncated` say how much it holds.
+ *
+ * @param int $maxRows The most rows to keep.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ public function toStoredArray(int $maxRows): array {
+ $stored = $this->toArray();
+ $stored['rowsStored'] = count($this->rows);
+ $stored['rowsTruncated'] = false;
+ if (count($this->rows) <= $maxRows) {
+ return $stored;
+ }
+
+ $rank = static function (array $row): int {
+ if ($row['outcome'] === self::FAILED) {
+ return 0;
+ }
+
+ if ($row['outcome'] === self::SKIPPED) {
+ return 1;
+ }
+
+ if ($row['warnings'] !== []) {
+ return 2;
+ }
+
+ return 3;
+ };
+
+ $ordered = [];
+ foreach ($this->rows as $index => $row) {
+ $ordered[] = [$rank($row), $index, $row];
+ }
+
+ usort($ordered, static fn (array $left, array $right): int => [$left[0], $left[1]] <=> [$right[0], $right[1]]);
+ $kept = array_column(array_slice($ordered, 0, max(0, $maxRows)), 2);
+
+ $stored['rows'] = $kept;
+ $stored['rowsStored'] = count($kept);
+ $stored['rowsTruncated'] = true;
+
+ return $stored;
+ }//end toStoredArray()
+}//end class
diff --git a/lib/Service/Cmdb/CmdbPartReferences.php b/lib/Service/Cmdb/CmdbPartReferences.php
new file mode 100644
index 000000000..ac5580546
--- /dev/null
+++ b/lib/Service/Cmdb/CmdbPartReferences.php
@@ -0,0 +1,279 @@
+
+ * @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-export-import/tasks.md#task-4
+ *
+ * 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;
+use XMLReader;
+
+/**
+ * Refuses a part beyond the per-part bound, or lists it for blanking when only unread sheets refer to it.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+class CmdbPartReferences {
+ /**
+ * Refuse the first part beyond the limit that the import may parse, and list the ones to blank.
+ *
+ * @param string $path The xlsx file.
+ * @param array $oversized Part name => unpacked size, for the parts beyond the limit.
+ * @param array $sourceSheets The sheet names the import reads.
+ * @param int $limit The profile's `maxPartBytes`.
+ * @param object $scanner PhpSpreadsheet's XmlScanner, which every part passes before it is read.
+ *
+ * @return array The names of the parts beyond the limit that only unread sheets refer to.
+ *
+ * @throws CmdbImportException WORKBOOK_TOO_LARGE for a part the import may parse, with `maxPartBytes`,
+ * `part`, `size` and, for a sheet, `sheet`.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function assertPartSizes(string $path, array $oversized, array $sourceSheets, int $limit, object $scanner): array {
+ if ($oversized === []) {
+ return [];
+ }
+
+ $references = self::references(path: $path, scanner: $scanner, sourceSheets: $sourceSheets);
+ $blank = [];
+ foreach ($oversized as $part => $size) {
+ $verdict = self::verdict(part: (string)$part, references: $references);
+ if ($verdict['unread'] === true) {
+ $blank[] = (string)$part;
+ continue;
+ }
+
+ $details = ['maxPartBytes' => $limit, 'part' => $part, 'size' => $size];
+ if ($verdict['sheet'] !== null) {
+ $details['sheet'] = $verdict['sheet'];
+ }
+
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::WORKBOOK_TOO_LARGE,
+ message: 'A part of the workbook the import reads unpacks to more bytes than the profile allows',
+ details: $details
+ );
+ }
+
+ return $blank;
+ }//end assertPartSizes()
+
+ /**
+ * Whether only unread sheets may name a part, and the sheet the refusal names.
+ *
+ * The refusal names the sheet of the relationship that keeps the limit,
+ * which is null when that relationship is not a sheet's.
+ *
+ * @param string $part The part's name in the package.
+ * @param array, sheet: ?string, unread: bool}> $references Every relationship.
+ *
+ * @return array{unread: bool, sheet: ?string}
+ */
+ private static function verdict(string $part, array $references): array {
+ $key = self::lastSegment(path: $part);
+ if (str_ends_with($key, '.rels') === true || str_ends_with($key, 'content_types].xml') === true) {
+ return ['unread' => false, 'sheet' => null];
+ }
+
+ $unread = false;
+ foreach ($references as $reference) {
+ if (in_array($key, $reference['keys'], true) === false) {
+ continue;
+ }
+
+ if ($reference['unread'] === false) {
+ return ['unread' => false, 'sheet' => $reference['sheet']];
+ }
+
+ $unread = true;
+ }
+
+ return ['unread' => $unread, 'sheet' => null];
+ }//end verdict()
+
+ /**
+ * Every relationship in the package: the names it may resolve to, its sheet, and whether that sheet is unread.
+ *
+ * Every part is read, whatever its name, for `Relationship` elements and
+ * for `sheet` elements. A worksheet relationship takes the names of the
+ * sheets that refer to its id from the part its relationships file
+ * belongs to (`dir/_rels/name.rels` belongs to `dir/name`).
+ *
+ * @param string $path The xlsx file.
+ * @param object $scanner PhpSpreadsheet's XmlScanner.
+ * @param array $sourceSheets The sheet names the import reads.
+ *
+ * @return array, sheet: ?string, unread: bool}>
+ */
+ private static function references(string $path, object $scanner, array $sourceSheets): array {
+ $relationships = [];
+ $sheets = [];
+ (new CmdbWorkbookBounds())->eachPart(
+ path: $path,
+ scanner: $scanner,
+ consume: static function (XMLReader $xml, string $name) use (&$relationships, &$sheets): void {
+ self::collect(xml: $xml, name: $name, relationships: $relationships, sheets: $sheets);
+ }
+ );
+
+ $read = array_map(static fn (string $sheet): string => mb_strtolower(trim($sheet)), $sourceSheets);
+ $references = [];
+ foreach ($relationships as $relationship) {
+ $names = [];
+ if ($relationship['worksheet'] === true) {
+ $names = $sheets[$relationship['owner']][$relationship['id']] ?? [];
+ }
+
+ $unread = ($names !== []);
+ $sheet = ($names[0] ?? null);
+ foreach ($names as $sheetName) {
+ if (in_array(mb_strtolower(trim($sheetName)), $read, true) === true) {
+ $unread = false;
+ $sheet = $sheetName;
+ }
+ }
+
+ $references[] = [
+ 'keys' => self::keys(target: $relationship['target']),
+ 'sheet' => $sheet,
+ 'unread' => $unread,
+ ];
+ }
+
+ return $references;
+ }//end references()
+
+ /**
+ * Collect the relationships and the sheet elements of one part.
+ *
+ * @param XMLReader $xml The reader, opened on the part.
+ * @param string $name The part's name in the package.
+ * @param array $relationships Added to.
+ * @param array>> $sheets Owner part => relationship id => sheet names; added to.
+ *
+ * @return void
+ */
+ private static function collect(XMLReader $xml, string $name, array &$relationships, array &$sheets): void {
+ $owner = self::owner(relsPart: $name);
+ $self = mb_strtolower($name);
+ while ($xml->read() === true) {
+ if ($xml->nodeType !== XMLReader::ELEMENT) {
+ continue;
+ }
+
+ if ($xml->localName === 'Relationship') {
+ $relationships[] = [
+ 'owner' => $owner,
+ 'id' => (string)$xml->getAttribute('Id'),
+ 'target' => (string)$xml->getAttribute('Target'),
+ 'worksheet' => str_ends_with(mb_strtolower((string)$xml->getAttribute('Type')), '/worksheet'),
+ ];
+ } elseif ($xml->localName === 'sheet' && $xml->getAttribute('name') !== null) {
+ $sheets[$self][self::idAttribute(xml: $xml)][] = (string)$xml->getAttribute('name');
+ }
+ }
+ }//end collect()
+
+ /**
+ * The value of the element's `id` attribute in any namespace (PhpSpreadsheet reads `r:id`).
+ *
+ * @param XMLReader $xml The reader, on the element; it is moved back to it.
+ *
+ * @return string
+ */
+ private static function idAttribute(XMLReader $xml): string {
+ $id = '';
+ if ($xml->moveToFirstAttribute() === true) {
+ do {
+ if ($xml->localName === 'id') {
+ $id = $xml->value;
+ }
+ } while ($xml->moveToNextAttribute() === true);
+
+ $xml->moveToElement();
+ }
+
+ return $id;
+ }//end idAttribute()
+
+ /**
+ * The part a relationships part belongs to, lower-cased: `dir/_rels/name.rels` belongs to `dir/name`.
+ *
+ * @param string $relsPart The relationships part's name.
+ *
+ * @return string An empty string for a part that is not named like a relationships part.
+ */
+ private static function owner(string $relsPart): string {
+ $path = mb_strtolower(str_replace('\\', '/', $relsPart));
+ if (preg_match('#^(?:(.*)/)?_rels/([^/]+)\.rels$#', $path, $match) !== 1) {
+ return '';
+ }
+
+ return ltrim($match[1] . '/' . $match[2], '/');
+ }//end owner()
+
+ /**
+ * The last path segments a relationship target may resolve to, lower-cased.
+ *
+ * @param string $target The relationship's `Target`.
+ *
+ * @return array
+ */
+ private static function keys(string $target): array {
+ $keys = [];
+ foreach ([$target, rawurldecode($target)] as $candidate) {
+ $segment = self::lastSegment(path: $candidate);
+ $keys[] = $segment;
+ $keys[] = substr($segment, 1);
+ }
+
+ return array_values(array_unique($keys));
+ }//end keys()
+
+ /**
+ * The last segment of a path, after `/` or `\`, lower-cased.
+ *
+ * @param string $path The path.
+ *
+ * @return string
+ */
+ private static function lastSegment(string $path): string {
+ $segments = preg_split('#[/\\\\]#', $path);
+ return mb_strtolower((string)end($segments));
+ }//end lastSegment()
+}//end class
diff --git a/lib/Service/Cmdb/CmdbReadFilter.php b/lib/Service/Cmdb/CmdbReadFilter.php
new file mode 100644
index 000000000..87672dbe9
--- /dev/null
+++ b/lib/Service/Cmdb/CmdbReadFilter.php
@@ -0,0 +1,79 @@
+
+ * @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-export-import/tasks.md#task-4
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Cmdb;
+
+use PhpOffice\PhpSpreadsheet\Reader\IReadFilter;
+
+/**
+ * Admits the header row, or the allowlisted columns of the data rows.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+class CmdbReadFilter implements IReadFilter {
+ /**
+ * Constructor.
+ *
+ * @param int $lastRow The last row number that is read; 1 reads the header row only.
+ * @param array> $columns Sheet name => column letters of the data rows.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function __construct(
+ private readonly int $lastRow,
+ private readonly array $columns = [],
+ ) {
+ }//end __construct()
+
+ /**
+ * Whether a cell is read.
+ *
+ * @param string $columnAddress The column letters.
+ * @param int $row The row number.
+ * @param string $worksheetName The sheet name.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function readCell(string $columnAddress, int $row, string $worksheetName = ''): bool {
+ if ($row === 1) {
+ return $this->lastRow === 1;
+ }
+
+ if ($row < 1 || $row > $this->lastRow) {
+ return false;
+ }
+
+ return in_array(strtoupper($columnAddress), ($this->columns[$worksheetName] ?? []), true);
+ }//end readCell()
+}//end class
diff --git a/lib/Service/Cmdb/CmdbRowNormaliser.php b/lib/Service/Cmdb/CmdbRowNormaliser.php
new file mode 100644
index 000000000..e2d54f5a9
--- /dev/null
+++ b/lib/Service/Cmdb/CmdbRowNormaliser.php
@@ -0,0 +1,219 @@
+ string` row OpenRegister's `MappingEngine` expects (design D4):
+ *
+ * - Date columns: an Excel serial number becomes `Y-m-d` (1900 date system,
+ * or 1904 when the workbook says so). A non-numeric value stays as it is, so
+ * the pack's `date` transform accepts it or reports a warning.
+ * - Id columns: a whole number becomes a string without a decimal part
+ * (`1234.0` becomes `"1234"`).
+ * - Every value is trimmed; an empty value becomes the empty string.
+ * - A value the profile lists as "empty" for its column (such as the "NB" a
+ * CMDB sheet writes for an unknown BNN classification) becomes the empty
+ * string, compared case-insensitively before any conversion.
+ *
+ * The conversion lives here and not in the packs, so every pack stays a plain
+ * OpenRegister migration pack.
+ *
+ * @category Service
+ * @package OCA\Stackiq\Service\Cmdb
+ * @author Conduction b.v.
+ * @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-export-import/tasks.md#task-4
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Cmdb;
+
+use DateInterval;
+use DateTimeImmutable;
+use DateTimeZone;
+
+/**
+ * Normalises reader rows into string rows for the mapping engine.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ *
+ * @SuppressWarnings(PHPMD.BooleanArgumentFlag) The date system (1900 or 1904) is a property
+ * of the workbook that the reader reports; it is data, not a mode switch.
+ */
+class CmdbRowNormaliser {
+ /**
+ * Highest serial Excel accepts (9999-12-31).
+ */
+ private const MAX_SERIAL = 2958465;
+
+ /**
+ * Normalise one row.
+ *
+ * @param array $cells Column name => raw cell value.
+ * @param array $dateColumns Columns holding Excel serial dates.
+ * @param array $idColumns Columns holding identifiers.
+ * @param bool $date1904 Whether the workbook uses the 1904 date system.
+ * @param array> $emptyValues Values that mean empty, per column.
+ *
+ * @return array Column name => normalised value.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function normalise(array $cells, array $dateColumns, array $idColumns, bool $date1904 = false, array $emptyValues = []): array {
+ $row = [];
+ foreach ($cells as $column => $value) {
+ $column = (string)$column;
+ if (self::meansEmpty(text: $this->toText(value: $value), empty: ($emptyValues[$column] ?? [])) === true) {
+ $row[$column] = '';
+ continue;
+ }
+
+ if (in_array($column, $dateColumns, true) === true) {
+ $row[$column] = $this->normaliseDate(value: $value, date1904: $date1904);
+ continue;
+ }
+
+ if (in_array($column, $idColumns, true) === true) {
+ $row[$column] = $this->normaliseId(value: $value);
+ continue;
+ }
+
+ $row[$column] = $this->toText(value: $value);
+ }
+
+ return $row;
+ }//end normalise()
+
+ /**
+ * An Excel serial date to `Y-m-d`; any other value trimmed as text.
+ *
+ * @param mixed $value The raw value.
+ * @param bool $date1904 Whether the workbook uses the 1904 date system.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function normaliseDate(mixed $value, bool $date1904 = false): string {
+ $text = $this->toText(value: $value);
+ if (is_numeric($text) === false) {
+ return $text;
+ }
+
+ $serial = (float)$text;
+ if ($serial < 1 || $serial > self::MAX_SERIAL) {
+ return $text;
+ }
+
+ $days = (int)floor($serial);
+ // 1900 system: 1899-12-30 plus the serial, which absorbs Excel's
+ // phantom 1900-02-29 for every serial after it. Before it (serial < 61)
+ // the base is one day later. 1904 system: serial 0 is 1904-01-01.
+ $base = '1899-12-30';
+ if ($days < 61) {
+ $base = '1899-12-31';
+ }
+
+ if ($date1904 === true) {
+ $base = '1904-01-01';
+ }
+
+ $base = new DateTimeImmutable($base, new DateTimeZone('UTC'));
+
+ return $base->add(new DateInterval('P' . $days . 'D'))->format('Y-m-d');
+ }//end normaliseDate()
+
+ /**
+ * A numeric identifier without a decimal part, as a string.
+ *
+ * @param mixed $value The raw value.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function normaliseId(mixed $value): string {
+ if (is_float($value) === true && floor($value) === $value && abs($value) < PHP_INT_MAX) {
+ return (string)(int)$value;
+ }
+
+ $text = $this->toText(value: $value);
+ if (preg_match('/^(\d+)\.0+$/', $text, $matches) === 1) {
+ return $matches[1];
+ }
+
+ return $text;
+ }//end normaliseId()
+
+ /**
+ * Whether a value is one of the column's "empty" values.
+ *
+ * @param string $text The trimmed value.
+ * @param array $empty The column's empty values.
+ *
+ * @return bool
+ */
+ private static function meansEmpty(string $text, array $empty): bool {
+ if ($text === '' || $empty === []) {
+ return false;
+ }
+
+ $needle = mb_strtolower($text);
+ foreach ($empty as $candidate) {
+ if (mb_strtolower(trim($candidate)) === $needle) {
+ return true;
+ }
+ }
+
+ return false;
+ }//end meansEmpty()
+
+ /**
+ * Any scalar as trimmed text; null as the empty string.
+ *
+ * Trimming removes Unicode whitespace too: a non-breaking space, a
+ * zero-width space or a byte-order mark around a value is not part of it.
+ *
+ * @param mixed $value The raw value.
+ *
+ * @return string
+ */
+ private function toText(mixed $value): string {
+ if ($value === null) {
+ return '';
+ }
+
+ if (is_bool($value) === true) {
+ if ($value === true) {
+ return 'TRUE';
+ }
+
+ return 'FALSE';
+ }
+
+ if (is_float($value) === true && floor($value) === $value && abs($value) < PHP_INT_MAX) {
+ return (string)(int)$value;
+ }
+
+ if (is_scalar($value) === false) {
+ return '';
+ }
+
+ $text = (string)$value;
+ $trimmed = preg_replace('/^[\s\x{00A0}\x{200B}\x{FEFF}]+|[\s\x{00A0}\x{200B}\x{FEFF}]+$/u', '', $text);
+ if ($trimmed === null) {
+ // Not valid UTF-8: trim the ASCII whitespace only.
+ return trim($text);
+ }
+
+ return $trimmed;
+ }//end toText()
+}//end class
diff --git a/lib/Service/Cmdb/CmdbWorkbookBounds.php b/lib/Service/Cmdb/CmdbWorkbookBounds.php
new file mode 100644
index 000000000..55826991e
--- /dev/null
+++ b/lib/Service/Cmdb/CmdbWorkbookBounds.php
@@ -0,0 +1,455 @@
+
+ * @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-export-import/tasks.md#task-4
+ *
+ * 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;
+use Throwable;
+use XMLReader;
+use ZipArchive;
+
+/**
+ * Streams a workbook's parts to bound what PhpSpreadsheet can be made to hold by its shared strings.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ *
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Each helper is one small step of a streamed check;
+ * together they pass the threshold, and splitting the two checks apart would duplicate the streaming.
+ */
+class CmdbWorkbookBounds {
+ /**
+ * Refuse a shared-strings table with more entries than the limit, without building it.
+ *
+ * The table's `count` and `uniqueCount` attributes are written by the
+ * producer and can be wrong, so the entries are counted, streamed with
+ * XMLReader. PhpSpreadsheet reads the `` children of whatever part the
+ * workbook's relationships name, whatever that part's name or root
+ * element, so every part of the package is counted. It also builds every
+ * formatting run of a rich-text entry as objects, kept for the whole
+ * load, and does the same for the runs of a cell's own text (an inline
+ * string, ``), so each `` inside an entry or an inline string
+ * counts as an entry too. The count runs over all parts together.
+ *
+ * @param string $path The xlsx file.
+ * @param int $limit The maximum number of shared strings.
+ * @param object $scanner PhpSpreadsheet's XmlScanner, which every part passes before it is parsed.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException WORKBOOK_TOO_LARGE above the limit.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function assertSharedStringCount(string $path, int $limit, object $scanner): void {
+ $count = 0;
+ self::streamParts(
+ path: $path,
+ scanner: $scanner,
+ consume: static function (XMLReader $xml) use ($limit, &$count): void {
+ $count = self::countSharedStrings(xml: $xml, limit: $limit, count: $count);
+ if ($count > $limit) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::WORKBOOK_TOO_LARGE,
+ message: 'The shared-strings table holds more entries than the profile allows',
+ details: ['maxSharedStrings' => $limit]
+ );
+ }
+ }
+ );
+ }//end assertSharedStringCount()
+
+ /**
+ * Add one part's `` children of the root element and the `` runs inside them or inside an ``, stopping past the limit.
+ *
+ * @param XMLReader $xml The reader, opened on one part.
+ * @param int $limit The maximum number of shared strings.
+ * @param int $count The entries counted so far.
+ *
+ * @return int
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ private static function countSharedStrings(XMLReader $xml, int $limit, int $count): int {
+ $textDepth = null;
+ while ($count <= $limit && $xml->read() === true) {
+ $count += self::entriesAt(xml: $xml, textDepth: $textDepth);
+ }
+
+ return $count;
+ }//end countSharedStrings()
+
+ /**
+ * The entries the node the reader is on adds: 1 for a shared string or a run inside a text, else 0.
+ *
+ * @param XMLReader $xml The reader, on a node.
+ * @param int|null $textDepth The depth of the shared or inline string the reader is inside, or null; kept up to date.
+ *
+ * @return int
+ */
+ private static function entriesAt(XMLReader $xml, ?int &$textDepth): int {
+ if ($xml->nodeType === XMLReader::END_ELEMENT && $xml->depth === $textDepth) {
+ $textDepth = null;
+ return 0;
+ }
+
+ if ($xml->nodeType !== XMLReader::ELEMENT) {
+ return 0;
+ }
+
+ if ($textDepth === null && self::opensText(xml: $xml) === true) {
+ $textDepth = self::depthUnlessEmpty(xml: $xml);
+ return (int)($xml->localName === 'si');
+ }
+
+ return (int)($textDepth !== null && $xml->localName === 'r');
+ }//end entriesAt()
+
+ /**
+ * The depth of the element the reader is on, or null for an empty element, which has no end to wait for.
+ *
+ * @param XMLReader $xml The reader, on an element.
+ *
+ * @return int|null
+ */
+ private static function depthUnlessEmpty(XMLReader $xml): ?int {
+ if ($xml->isEmptyElement === true) {
+ return null;
+ }
+
+ return $xml->depth;
+ }//end depthUnlessEmpty()
+
+ /**
+ * Whether the reader is on a text PhpSpreadsheet builds runs for: a shared string or an inline string.
+ *
+ * @param XMLReader $xml The reader, on an element.
+ *
+ * @return bool
+ */
+ private static function opensText(XMLReader $xml): bool {
+ return ($xml->depth === 1 && $xml->localName === 'si') || $xml->localName === 'is';
+ }//end opensText()
+
+ /**
+ * Refuse a workbook whose cells reference more shared-string text than the limit, before any sheet is parsed.
+ *
+ * PhpSpreadsheet gives every cell that references a shared string its own
+ * copy of the text, and clones every run of a rich-text string first, so a
+ * long or many-run string referenced by many cells costs memory and time
+ * per cell however small the file is. Each entry weighs the bytes of its
+ * text plus 16 per element in it (so a run weighs at least 32), and the
+ * weights of the entries every `t="s"` cell references are added up,
+ * streamed with XMLReader.
+ *
+ * The check charges at least what PhpSpreadsheet can build, never less:
+ * every part of the package is read, whatever its name, as a possible
+ * table and a possible sheet; entries are numbered per namespace and an
+ * index weighs the heaviest entry any table has there; every `` of a
+ * shared-string cell is charged, read both as its own text and as all
+ * the text inside it; every cell counts, read or not; and the sum counts
+ * once per source sheet, because two source sheets may point at the same
+ * part.
+ *
+ * @param string $path The xlsx file.
+ * @param int $limit The maximum weight of the referenced shared strings.
+ * @param int $sheetCount The number of source sheets the profile reads.
+ * @param object $scanner PhpSpreadsheet's XmlScanner, which every part passes before it is parsed.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException WORKBOOK_TOO_LARGE above the limit.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function assertReferencedStringBytes(string $path, int $limit, int $sheetCount, object $scanner): void {
+ $tables = [];
+ self::streamParts(
+ path: $path,
+ scanner: $scanner,
+ consume: static function (XMLReader $xml) use (&$tables): void {
+ $tables[] = self::sharedStringWeights(xml: $xml);
+ }
+ );
+ $weights = self::heaviestByIndex(tables: $tables);
+ if ($weights === []) {
+ return;
+ }
+
+ $budget = intdiv($limit, max(1, $sheetCount));
+ $total = 0;
+ self::streamParts(
+ path: $path,
+ scanner: $scanner,
+ consume: static function (XMLReader $xml) use ($weights, $budget, $limit, &$total): void {
+ $total = self::referencedWeight(xml: $xml, weights: $weights, total: $total, budget: $budget);
+ if ($total > $budget) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::WORKBOOK_TOO_LARGE,
+ message: 'The cells of the workbook reference more shared-string text than the profile allows',
+ details: ['maxReferencedStringBytes' => $limit]
+ );
+ }
+ }
+ );
+ }//end assertReferencedStringBytes()
+
+ /**
+ * The weight of each `` child of a part's root element, by its position among the entries of its namespace.
+ *
+ * PhpSpreadsheet numbers the entries of one namespace; which one depends
+ * on the workbook, so every namespace is numbered and the heaviest entry
+ * at a position counts.
+ *
+ * @param XMLReader $xml The reader, opened on one part.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ private static function sharedStringWeights(XMLReader $xml): array {
+ $byNamespace = [];
+ $positions = [];
+ $namespace = null;
+ while ($xml->read() === true) {
+ if ($xml->depth === 1 && $xml->nodeType === XMLReader::ELEMENT) {
+ $namespace = null;
+ if ($xml->localName === 'si') {
+ $namespace = $xml->namespaceURI;
+ $positions[$namespace] = (($positions[$namespace] ?? -1) + 1);
+ $byNamespace[$namespace][$positions[$namespace]] = 0;
+ }
+ }
+
+ if ($namespace !== null && $xml->depth >= 1) {
+ $byNamespace[$namespace][$positions[$namespace]] += self::nodeWeight(xml: $xml);
+ }
+ }
+
+ return self::heaviestByIndex(tables: $byNamespace);
+ }//end sharedStringWeights()
+
+ /**
+ * Merge weight tables, keeping the heaviest weight at each index.
+ *
+ * @param array> $tables The tables.
+ *
+ * @return array
+ */
+ private static function heaviestByIndex(array $tables): array {
+ $merged = [];
+ foreach ($tables as $table) {
+ foreach ($table as $index => $weight) {
+ $merged[$index] = max(($merged[$index] ?? 0), $weight);
+ }
+ }
+
+ return $merged;
+ }//end heaviestByIndex()
+
+ /**
+ * What one node inside a shared string adds to its weight: 16 for an element, the bytes of a text.
+ *
+ * @param XMLReader $xml The reader, on the node.
+ *
+ * @return int
+ */
+ private static function nodeWeight(XMLReader $xml): int {
+ if ($xml->nodeType === XMLReader::ELEMENT) {
+ return 16;
+ }
+
+ if (self::isText(xml: $xml) === true) {
+ return strlen($xml->value);
+ }
+
+ return 0;
+ }//end nodeWeight()
+
+ /**
+ * Add the weights of the shared strings the `t="s"` cells of one part reference, stopping past the budget.
+ *
+ * Every `` after a shared-string cell opens, up to the next cell, is
+ * charged: PhpSpreadsheet reads the cell's first `` of its namespace,
+ * and charging all of them never charges less.
+ *
+ * @param XMLReader $xml The reader, opened on one part.
+ * @param array $weights The weight of each shared string, by index.
+ * @param int $total The weight counted so far.
+ * @param int $budget The weight above which the workbook is refused.
+ *
+ * @return int
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ private static function referencedWeight(XMLReader $xml, array $weights, int $total, int $budget): int {
+ $shared = false;
+ while ($total <= $budget && $xml->read() === true) {
+ if ($xml->nodeType !== XMLReader::ELEMENT) {
+ continue;
+ }
+
+ if ($xml->localName === 'c') {
+ $shared = ($xml->getAttribute('t') === 's');
+ } elseif ($shared === true && $xml->localName === 'v') {
+ $total += self::valueWeight(xml: $xml, weights: $weights);
+ }
+ }
+
+ return $total;
+ }//end referencedWeight()
+
+ /**
+ * The weight of the shared string a `` element points at.
+ *
+ * PhpSpreadsheet casts the element's own text to int; all the text inside
+ * it can differ when it holds child elements, so both are read and the
+ * heavier entry counts.
+ *
+ * @param XMLReader $xml The reader, on the `` element; it is left on its end.
+ * @param array $weights The weight of each shared string, by index.
+ *
+ * @return int
+ */
+ private static function valueWeight(XMLReader $xml, array $weights): int {
+ $depth = $xml->depth;
+ $own = '';
+ $all = '';
+ if ($xml->isEmptyElement === false) {
+ while ($xml->read() === true && $xml->depth > $depth) {
+ if (self::isText(xml: $xml) === true) {
+ $all .= $xml->value;
+ if ($xml->depth === ($depth + 1)) {
+ $own .= $xml->value;
+ }
+ }
+ }
+ }
+
+ return max(($weights[(int)$own] ?? 0), ($weights[(int)$all] ?? 0));
+ }//end valueWeight()
+
+ /**
+ * Whether the reader is on a text node: text, CDATA or whitespace.
+ *
+ * @param XMLReader $xml The reader.
+ *
+ * @return bool
+ */
+ private static function isText(XMLReader $xml): bool {
+ return in_array($xml->nodeType, [XMLReader::TEXT, XMLReader::CDATA, XMLReader::WHITESPACE, XMLReader::SIGNIFICANT_WHITESPACE], true);
+ }//end isText()
+
+ /**
+ * Stream every part of the package through a callback, with the part's name, as PhpSpreadsheet would parse it.
+ *
+ * @param string $path The xlsx file.
+ * @param object $scanner PhpSpreadsheet's XmlScanner.
+ * @param callable(XMLReader, string): void $consume Reads one opened part; gets the reader and the part's name.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function eachPart(string $path, object $scanner, callable $consume): void {
+ self::streamParts(path: $path, scanner: $scanner, consume: $consume);
+ }//end eachPart()
+
+ /**
+ * Stream every part of the package through a callback, as PhpSpreadsheet would parse it.
+ *
+ * Parts are read by index, so a part counts whatever its name, and each
+ * passes PhpSpreadsheet's XmlScanner first, which also converts its
+ * encoding. A part the scanner refuses, or that is not XML, is skipped:
+ * PhpSpreadsheet cannot read it either. Network access and entity
+ * substitution stay off; libxml errors are kept from the log and
+ * cleared, also when the callback throws.
+ *
+ * @param string $path The xlsx file.
+ * @param object $scanner PhpSpreadsheet's XmlScanner.
+ * @param callable(XMLReader, string): void $consume Reads one opened part; gets the reader and the part's name.
+ *
+ * @return void
+ */
+ private static function streamParts(string $path, object $scanner, callable $consume): void {
+ $zip = new ZipArchive();
+ if ($zip->open($path, ZipArchive::RDONLY) !== true) {
+ return;
+ }
+
+ try {
+ for ($index = 0; $index < $zip->numFiles; $index++) {
+ $previous = libxml_use_internal_errors(true);
+ try {
+ $xml = self::openPart(zip: $zip, index: $index, scanner: $scanner);
+ if ($xml !== null) {
+ $consume($xml, (string)$zip->getNameIndex($index));
+ $xml->close();
+ }
+ } finally {
+ libxml_clear_errors();
+ libxml_use_internal_errors($previous);
+ }
+ }
+ } finally {
+ $zip->close();
+ }
+ }//end streamParts()
+
+ /**
+ * Open one part of the package with XMLReader, after PhpSpreadsheet's XmlScanner.
+ *
+ * @param ZipArchive $zip The open package.
+ * @param int $index The part's index.
+ * @param object $scanner PhpSpreadsheet's XmlScanner.
+ *
+ * @return XMLReader|null Null for an empty part, or one the scanner refuses.
+ */
+ private static function openPart(ZipArchive $zip, int $index, object $scanner): ?XMLReader {
+ $content = $zip->getFromIndex($index);
+ if (is_string($content) === false || $content === '') {
+ return null;
+ }
+
+ try {
+ $content = (string)$scanner->scan($content);
+ } catch (Throwable) {
+ return null;
+ }
+
+ $xml = new XMLReader();
+ if ($xml->XML($content, null, LIBXML_NONET) === false) {
+ return null;
+ }
+
+ return $xml;
+ }//end openPart()
+}//end class
diff --git a/lib/Service/Cmdb/CmdbWorkbookReader.php b/lib/Service/Cmdb/CmdbWorkbookReader.php
new file mode 100644
index 000000000..3ff3e9184
--- /dev/null
+++ b/lib/Service/Cmdb/CmdbWorkbookReader.php
@@ -0,0 +1,821 @@
+
+ * @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-export-import/tasks.md#task-4
+ *
+ * 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;
+use Throwable;
+use ZipArchive;
+
+/**
+ * Reads the allowlisted columns of the profile's source sheets.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ *
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The file checks before parsing and the
+ * header resolution are each a chain of small guards; together they pass the threshold.
+ */
+class CmdbWorkbookReader {
+ /**
+ * PhpSpreadsheet's Xlsx reader, shipped in OpenRegister's vendor directory.
+ */
+ public const READER_CLASS = 'PhpOffice\PhpSpreadsheet\Reader\Xlsx';
+
+ /**
+ * What an oversized unread sheet becomes in the copy the reader reads.
+ */
+ private const EMPTY_WORKSHEET = ''
+ . '';
+
+ /**
+ * The ZIP local-file-header signature every xlsx package starts with.
+ */
+ private const ZIP_SIGNATURE = "PK\x03\x04";
+
+ /**
+ * Check that an upload is an xlsx workbook, without parsing it.
+ *
+ * @param string $path The uploaded temporary file.
+ * @param string $fileName The original file name.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException NOT_XLSX when the name, signature or package is wrong.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function assertXlsx(string $path, string $fileName): void {
+ if (strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION)) !== 'xlsx') {
+ throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The file name does not end in .xlsx');
+ }
+
+ $head = false;
+ if (is_file($path) === true && is_readable($path) === true) {
+ $head = file_get_contents($path, false, null, 0, 4);
+ }
+
+ if ($head !== self::ZIP_SIGNATURE) {
+ throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The file is not a ZIP package');
+ }
+
+ $zip = new ZipArchive();
+ if ($zip->open($path, ZipArchive::RDONLY) !== true) {
+ throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The ZIP package cannot be opened');
+ }
+
+ $hasWorkbook = ($zip->locateName('xl/workbook.xml') !== false);
+ $zip->close();
+
+ if ($hasWorkbook === false) {
+ throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The package holds no xl/workbook.xml');
+ }
+ }//end assertXlsx()
+
+ /**
+ * Whether PhpSpreadsheet's Xlsx reader can be loaded.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function isAvailable(): bool {
+ return class_exists(static::READER_CLASS) === true;
+ }//end isAvailable()
+
+ /**
+ * Read the source sheets of an xlsx workbook.
+ *
+ * What the workbook can make PhpSpreadsheet hold is bounded before any
+ * part is parsed: the unpacked size of the package (the profile's
+ * `maxUncompressedBytes`) and of each part (`maxPartBytes`), the number of
+ * shared strings (`maxSharedStrings`), the shared-string text the cells
+ * reference (`maxReferencedStringBytes`), then the last used row of every
+ * source sheet.
+ * The sheets are then read twice through a read filter: once for the
+ * header row, once for the resolved columns of the data rows, so no other
+ * cell is ever materialised.
+ *
+ * @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}) and `date1904` (bool).
+ *
+ * @throws CmdbImportException WORKBOOK_TOO_LARGE, READER_UNAVAILABLE, NOT_XLSX, NO_SOURCE_SHEET,
+ * MISSING_COLUMN or TOO_MANY_ROWS.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public function read(string $path, CmdbImportProfile $profile): array {
+ $oversized = $this->assertUncompressedSize(path: $path, limit: $profile->maxUncompressedBytes(), partLimit: $profile->maxPartBytes());
+
+ if ($this->isAvailable() === false) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::READER_UNAVAILABLE,
+ message: 'PhpSpreadsheet Xlsx reader is not available'
+ );
+ }
+
+ $scanner = $this->newReader(sheetNames: null)->getSecurityScannerOrThrow();
+ $blank = (new CmdbPartReferences())->assertPartSizes(
+ path: $path,
+ oversized: $oversized,
+ sourceSheets: $profile->sheetNames(),
+ limit: $profile->maxPartBytes(),
+ scanner: $scanner
+ );
+ if ($blank === []) {
+ return $this->readBounded(path: $path, profile: $profile, scanner: $scanner);
+ }
+
+ $copy = self::blankedCopy(path: $path, parts: $blank);
+ try {
+ return $this->readBounded(path: $copy, profile: $profile, scanner: $scanner);
+ } finally {
+ self::removeCopy(path: $copy);
+ }
+ }//end read()
+
+ /**
+ * A copy of the package in which each named part is an empty worksheet; the caller removes it.
+ *
+ * Every entry under one of the names is replaced, so PhpSpreadsheet can
+ * reach at most an empty sheet through it, whichever way it resolves a
+ * target to that name.
+ *
+ * @param string $path The xlsx file.
+ * @param array $parts The names of the parts to blank.
+ *
+ * @return string The copy's path, ending in .xlsx.
+ *
+ * @throws CmdbImportException NOT_XLSX when the copy cannot be written.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ private static function blankedCopy(string $path, array $parts): string {
+ $copy = self::temporaryCopy(path: $path);
+ $zip = new ZipArchive();
+ if ($zip->open($copy) !== true) {
+ unlink($copy);
+ throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The ZIP package cannot be opened');
+ }
+
+ for ($index = ($zip->numFiles - 1); $index >= 0; $index--) {
+ if (in_array((string)$zip->getNameIndex($index), $parts, true) === true) {
+ $zip->deleteIndex($index);
+ }
+ }
+
+ foreach (array_unique($parts) as $part) {
+ $zip->addFromString($part, self::EMPTY_WORKSHEET);
+ }
+
+ if ($zip->close() === false) {
+ unlink($copy);
+ throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The workbook cannot be copied for reading');
+ }
+
+ return $copy;
+ }//end blankedCopy()
+
+ /**
+ * A copy of the file in the temporary directory, removed at the end of the request at the latest.
+ *
+ * The copy holds the upload, so it is also removed when the request dies
+ * (memory or time limit) before read() removes it.
+ *
+ * @param string $path The xlsx file.
+ *
+ * @return string The copy's path, ending in .xlsx.
+ *
+ * @throws CmdbImportException NOT_XLSX when the copy cannot be written.
+ */
+ private static function temporaryCopy(string $path): string {
+ $base = tempnam(sys_get_temp_dir(), 'cmdb-read-');
+ if ($base === false) {
+ throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The workbook cannot be copied for reading');
+ }
+
+ $copy = $base . '.xlsx';
+ if (rename($base, $copy) === false) {
+ unlink($base);
+ throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The workbook cannot be copied for reading');
+ }
+
+ register_shutdown_function(static function () use ($copy): void {
+ self::removeCopy(path: $copy);
+ });
+ if (copy($path, $copy) === false) {
+ unlink($copy);
+ throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The workbook cannot be copied for reading');
+ }
+
+ return $copy;
+ }//end temporaryCopy()
+
+ /**
+ * Remove a copy made by temporaryCopy(), if it is still there.
+ *
+ * Called by read() once the copy is read, and by the shutdown function
+ * when the request dies first; after read(), the latter finds nothing.
+ *
+ * @param string $path The copy's path.
+ *
+ * @return void
+ */
+ private static function removeCopy(string $path): void {
+ if (is_file($path) === true) {
+ unlink($path);
+ }
+ }//end removeCopy()
+
+ /**
+ * Read the source sheets of a package whose part sizes have been checked.
+ *
+ * @param string $path The xlsx file, or its copy with the oversized unread sheets blanked.
+ * @param CmdbImportProfile $profile The import profile.
+ * @param object $scanner PhpSpreadsheet's XmlScanner.
+ *
+ * @return array See read().
+ *
+ * @throws CmdbImportException WORKBOOK_TOO_LARGE, NOT_XLSX, NO_SOURCE_SHEET, MISSING_COLUMN or TOO_MANY_ROWS.
+ */
+ private function readBounded(string $path, CmdbImportProfile $profile, object $scanner): array {
+ $bounds = new CmdbWorkbookBounds();
+ $bounds->assertSharedStringCount(path: $path, limit: $profile->maxSharedStrings(), scanner: $scanner);
+ $bounds->assertReferencedStringBytes(
+ path: $path,
+ limit: $profile->maxReferencedStringBytes(),
+ sheetCount: count($profile->sheetNames()),
+ scanner: $scanner
+ );
+
+ $reader = $this->newReader(sheetNames: null);
+ try {
+ $available = $reader->listWorksheetNames($path);
+ } catch (Throwable $e) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::NOT_XLSX,
+ message: 'The workbook cannot be read: ' . get_class($e),
+ previous: $e
+ );
+ }
+
+ $expected = $profile->sheetNames();
+ $present = array_values(array_intersect($expected, $available));
+ if ($present === []) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::NO_SOURCE_SHEET,
+ message: 'The workbook holds none of the source sheets',
+ details: ['expected' => $expected]
+ );
+ }
+
+ $limit = $profile->maxRowsPerSheet();
+ $lastRow = self::lastReadableRow(limit: $limit);
+ $this->assertRowSpan(path: $path, sheetNames: $present, lastRow: $lastRow, limit: $limit);
+
+ $headers = $this->load(path: $path, sheetNames: $present, filter: new CmdbReadFilter(lastRow: 1));
+ try {
+ $resolved = $this->resolveSheets(spreadsheet: $headers, sheetNames: $present, profile: $profile);
+ } finally {
+ $headers->disconnectWorksheets();
+ }
+
+ $letters = array_map(static fn (array $columns): array => array_keys($columns), $resolved['columns']);
+ $spreadsheet = $this->load(path: $path, sheetNames: $present, filter: new CmdbReadFilter(lastRow: $lastRow, columns: $letters));
+ try {
+ $rows = [];
+ foreach ($present as $sheetName) {
+ $sheetRows = $this->readRows(
+ worksheet: $spreadsheet->getSheetByName($sheetName),
+ columns: $resolved['columns'][$sheetName],
+ sheetName: $sheetName,
+ limit: $limit
+ );
+ array_push($rows, ...$sheetRows);
+ }
+
+ $date1904 = false;
+ if (method_exists($spreadsheet, 'getExcelCalendar') === true) {
+ $date1904 = ((int)$spreadsheet->getExcelCalendar() === 1904);
+ }
+ } finally {
+ $spreadsheet->disconnectWorksheets();
+ }
+
+ return ['rows' => $rows, 'importWarnings' => $resolved['warnings'], 'date1904' => $date1904];
+ }//end readBounded()
+
+ /**
+ * The last row number the data pass reads.
+ *
+ * Twice the row limit plus the header row: a sheet may carry empty rows
+ * between its data rows (formatted rows, formulas whose cached value is 0),
+ * but not more of them than it has room for data rows.
+ *
+ * @param int $limit The profile's maximum number of non-empty rows.
+ *
+ * @return int
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public static function lastReadableRow(int $limit): int {
+ return ((2 * max(0, $limit)) + 1);
+ }//end lastReadableRow()
+
+ /**
+ * Refuse a package whose parts together unpack to more than the limit, and list the parts beyond the part limit.
+ *
+ * The sizes are the uncompressed sizes the ZIP directory declares; libzip
+ * never inflates a part beyond its declared size. A part beyond the part
+ * limit is refused later, by CmdbPartReferences, unless only sheets the
+ * import does not read refer to it.
+ *
+ * @param string $path The xlsx file.
+ * @param int $limit The maximum number of unpacked bytes of all parts together.
+ * @param int $partLimit The maximum number of unpacked bytes of one part the import parses.
+ *
+ * @return array Part name => unpacked size, for every part beyond the part limit.
+ *
+ * @throws CmdbImportException NOT_XLSX when the package cannot be opened, WORKBOOK_TOO_LARGE above the limit.
+ */
+ private function assertUncompressedSize(string $path, int $limit, int $partLimit): array {
+ $zip = new ZipArchive();
+ if ($zip->open($path, ZipArchive::RDONLY) !== true) {
+ throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The ZIP package cannot be opened');
+ }
+
+ $total = 0;
+ $readable = true;
+ $oversized = [];
+ for ($index = 0; $index < $zip->numFiles && $total <= $limit; $index++) {
+ $stat = $zip->statIndex($index);
+ if ($stat === false) {
+ $readable = false;
+ break;
+ }
+
+ if ((int)$stat['size'] > $partLimit) {
+ $oversized[(string)$stat['name']] = (int)$stat['size'];
+ }
+
+ $total += (int)$stat['size'];
+ }
+
+ $zip->close();
+
+ if ($readable === false) {
+ throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'A part of the ZIP package cannot be read');
+ }
+
+ if ($total > $limit) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::WORKBOOK_TOO_LARGE,
+ message: 'The workbook unpacks to more bytes than the profile allows',
+ details: ['maxUncompressedBytes' => $limit]
+ );
+ }
+
+ return $oversized;
+ }//end assertUncompressedSize()
+
+ /**
+ * Refuse a source sheet whose last used row lies beyond the rows that are read.
+ *
+ * PhpSpreadsheet's worksheet info streams the sheet and counts no cell
+ * objects, so this runs before a sheet is loaded.
+ *
+ * @param string $path The xlsx file.
+ * @param array $sheetNames The present source sheets.
+ * @param int $lastRow The last row number that is read.
+ * @param int $limit The profile's maximum number of non-empty rows, for the details.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException NOT_XLSX when the sheets cannot be listed, TOO_MANY_ROWS beyond the last row.
+ */
+ private function assertRowSpan(string $path, array $sheetNames, int $lastRow, int $limit): void {
+ try {
+ $info = $this->newReader(sheetNames: null)->listWorksheetInfo($path);
+ } catch (Throwable $e) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::NOT_XLSX,
+ message: 'The workbook cannot be read: ' . get_class($e),
+ previous: $e
+ );
+ }
+
+ foreach ($info as $sheet) {
+ $name = (string)($sheet['worksheetName'] ?? '');
+ if (in_array($name, $sheetNames, true) === true && (int)($sheet['totalRows'] ?? 0) > $lastRow) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::TOO_MANY_ROWS,
+ message: 'A source sheet has more rows than the profile allows',
+ details: ['sheet' => $name, 'limit' => $limit]
+ );
+ }
+ }
+ }//end assertRowSpan()
+
+ /**
+ * A PhpSpreadsheet Xlsx reader in read-data-only mode.
+ *
+ * @param array|null $sheetNames The sheets to load, or null for none set.
+ *
+ * @return object
+ */
+ private function newReader(?array $sheetNames): object {
+ $readerClass = static::READER_CLASS;
+ $reader = new $readerClass();
+ $reader->setReadDataOnly(true);
+ $reader->setReadEmptyCells(false);
+ if ($sheetNames !== null) {
+ $reader->setLoadSheetsOnly($sheetNames);
+ }
+
+ return $reader;
+ }//end newReader()
+
+ /**
+ * Load the source sheets through a read filter.
+ *
+ * @param string $path The xlsx file.
+ * @param array $sheetNames The present source sheets.
+ * @param CmdbReadFilter $filter Which cells are read.
+ *
+ * @return object The PhpSpreadsheet workbook.
+ *
+ * @throws CmdbImportException NOT_XLSX when the workbook cannot be loaded.
+ */
+ private function load(string $path, array $sheetNames, CmdbReadFilter $filter): object {
+ $reader = $this->newReader(sheetNames: $sheetNames);
+ $reader->setReadFilter($filter);
+
+ try {
+ return $reader->load($path);
+ } catch (Throwable $e) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::NOT_XLSX,
+ message: 'The workbook cannot be loaded: ' . get_class($e),
+ previous: $e
+ );
+ }
+ }//end load()
+
+ /**
+ * Normalise a header or column name for matching.
+ *
+ * @param string $header The raw header.
+ *
+ * @return string The normalised name.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-4
+ */
+ public static function normaliseHeader(string $header): string {
+ $header = (string)preg_replace('/\s+/u', ' ', trim($header));
+ $header = (string)preg_replace('/\s*(?::|⚡)+$/u', '', $header);
+
+ return mb_strtolower(trim($header));
+ }//end normaliseHeader()
+
+ /**
+ * Resolve the columns of every present source sheet from its header row.
+ *
+ * Every sheet is resolved before a single data row is read, so a missing
+ * required column stops the import first.
+ *
+ * @param object $spreadsheet The workbook, loaded with the header row only.
+ * @param array $sheetNames The present source sheets, in profile order.
+ * @param CmdbImportProfile $profile The import profile.
+ *
+ * @return array{columns: array>, warnings: array}
+ * Per sheet, column letter => referenced column name; and the import warnings.
+ *
+ * @throws CmdbImportException MISSING_COLUMN.
+ */
+ private function resolveSheets(object $spreadsheet, array $sheetNames, CmdbImportProfile $profile): array {
+ $referenced = $profile->referencedColumns();
+ $mapped = $this->packSources(profile: $profile);
+ $required = $profile->requiredColumns();
+
+ $columnsPerSheet = [];
+ $warnings = [];
+ foreach ($sheetNames as $sheetName) {
+ $worksheet = $spreadsheet->getSheetByName($sheetName);
+ $columns = $this->resolveColumns(worksheet: $worksheet, referenced: $referenced);
+
+ foreach ($required as $column) {
+ if (in_array($column, $columns, true) === false) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MISSING_COLUMN,
+ message: 'A source sheet lacks a required column',
+ details: ['sheet' => $sheetName, 'column' => $column]
+ );
+ }
+ }
+
+ $skip = array_merge($required, $profile->absentColumns(sheetName: $sheetName));
+ array_push($warnings, ...self::missingOptionalColumns(sheetName: $sheetName, mapped: $mapped, columns: $columns, skip: $skip));
+ $columnsPerSheet[$sheetName] = $columns;
+ }
+
+ return ['columns' => $columnsPerSheet, 'warnings' => $warnings];
+ }//end resolveSheets()
+
+ /**
+ * Map the header row to the referenced column names.
+ *
+ * @param object $worksheet The worksheet.
+ * @param array $referenced The allowlisted column names.
+ *
+ * @return array Column letter => referenced column name.
+ */
+ private function resolveColumns(object $worksheet, array $referenced): array {
+ $wanted = [];
+ foreach ($referenced as $column) {
+ $wanted[self::normaliseHeader(header: $column)] = $column;
+ }
+
+ $columns = [];
+ $lastColumn = self::columnIndex(letters: (string)$worksheet->getHighestDataColumn(1));
+ for ($index = 1; $index <= $lastColumn; $index++) {
+ $letters = self::columnLetters(index: $index);
+ $coordinate = $letters . '1';
+ if ($worksheet->cellExists($coordinate) === false) {
+ continue;
+ }
+
+ $header = $this->cellValue(cell: $worksheet->getCell($coordinate));
+ if (is_scalar($header) === false) {
+ continue;
+ }
+
+ $name = $wanted[self::normaliseHeader(header: (string)$header)] ?? null;
+ // The first column with a matching header wins.
+ if ($name !== null && in_array($name, $columns, true) === false) {
+ $columns[$letters] = $name;
+ }
+ }
+
+ return $columns;
+ }//end resolveColumns()
+
+ /**
+ * Read the non-empty data rows of one sheet.
+ *
+ * @param object $worksheet The worksheet.
+ * @param array $columns Column letter => column name.
+ * @param string $sheetName The sheet name.
+ * @param int $limit The maximum number of non-empty rows.
+ *
+ * @return array, uncached: array}>
+ *
+ * @throws CmdbImportException TOO_MANY_ROWS.
+ */
+ private function readRows(object $worksheet, array $columns, string $sheetName, int $limit): array {
+ $rows = [];
+ $lastRow = (int)$worksheet->getHighestDataRow();
+ for ($rowNumber = 2; $rowNumber <= $lastRow; $rowNumber++) {
+ ['cells' => $cells, 'uncached' => $uncached, 'empty' => $empty] = $this->readRow(
+ worksheet: $worksheet,
+ columns: $columns,
+ rowNumber: $rowNumber
+ );
+ if ($empty === true) {
+ continue;
+ }
+
+ if (count($rows) >= $limit) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::TOO_MANY_ROWS,
+ message: 'A source sheet has more rows than the profile allows',
+ details: ['sheet' => $sheetName, 'limit' => $limit]
+ );
+ }
+
+ $rows[] = ['sheet' => $sheetName, 'row' => $rowNumber, 'cells' => $cells, 'uncached' => $uncached];
+ }//end for
+
+ return $rows;
+ }//end readRows()
+
+ /**
+ * The kept cells of one row, the columns whose formula has no cached value,
+ * and whether every kept cell is empty.
+ *
+ * @param object $worksheet The worksheet.
+ * @param array $columns Column letter => column name.
+ * @param int $rowNumber The 1-based row number.
+ *
+ * @return array{cells: array, uncached: array, empty: bool}
+ */
+ private function readRow(object $worksheet, array $columns, int $rowNumber): array {
+ $cells = [];
+ $uncached = [];
+ $empty = true;
+ foreach ($columns as $letters => $name) {
+ $value = null;
+ $coordinate = $letters . $rowNumber;
+ if ($worksheet->cellExists($coordinate) === true) {
+ $cell = $worksheet->getCell($coordinate);
+ $value = $this->cellValue(cell: $cell);
+ if (self::isUncachedFormula(cell: $cell) === true) {
+ $uncached[] = $name;
+ }
+ }
+
+ $cells[$name] = $value;
+ if ($value !== null && (is_string($value) === false || trim($value) !== '')) {
+ $empty = false;
+ }
+ }
+
+ return ['cells' => $cells, 'uncached' => $uncached, 'empty' => $empty];
+ }//end readRow()
+
+ /**
+ * One import warning per pack column a sheet lacks, except the ones to skip.
+ *
+ * @param string $sheetName The sheet name.
+ * @param array $mapped The sheet-mapped pack sources.
+ * @param array $columns The sheet's resolved columns.
+ * @param array $skip Required columns and the columns the sheet is known to lack.
+ *
+ * @return array
+ */
+ private static function missingOptionalColumns(string $sheetName, array $mapped, array $columns, array $skip): array {
+ $warnings = [];
+ foreach ($mapped as $column) {
+ if (in_array($column, $columns, true) === false && in_array($column, $skip, true) === false) {
+ $warnings[] = ['sheet' => $sheetName, 'column' => $column, 'message' => sprintf('Optional column "%s" not found', $column)];
+ }
+ }
+
+ return $warnings;
+ }//end missingOptionalColumns()
+
+ /**
+ * Whether a cell holds a formula without a cached value.
+ *
+ * @param object $cell The PhpSpreadsheet cell.
+ *
+ * @return bool
+ */
+ private static function isUncachedFormula(object $cell): bool {
+ return $cell->getDataType() === 'f' && $cell->getOldCalculatedValue() === null;
+ }//end isUncachedFormula()
+
+ /**
+ * The stored value of a cell; for a formula, the value Excel cached.
+ *
+ * A formula without a cached value, and a formula whose cached value is
+ * the number 0 (Excel's result for a reference to an empty cell), yield
+ * null.
+ *
+ * @param object $cell The PhpSpreadsheet cell.
+ *
+ * @return mixed A scalar or null.
+ */
+ private function cellValue(object $cell): mixed {
+ $value = $cell->getValue();
+ if ($cell->getDataType() === 'f') {
+ // The value Excel cached; the formula itself is never evaluated.
+ $value = $cell->getOldCalculatedValue();
+ if ((is_int($value) === true || is_float($value) === true) && (float)$value === 0.0) {
+ return null;
+ }
+ }
+
+ if (is_object($value) === true && method_exists($value, 'getPlainText') === true) {
+ return (string)$value->getPlainText();
+ }
+
+ if (is_scalar($value) === false) {
+ return null;
+ }
+
+ return $value;
+ }//end cellValue()
+
+ /**
+ * The sources of every sheet-mapped pack field.
+ *
+ * @param CmdbImportProfile $profile The import profile.
+ *
+ * @return array
+ */
+ private function packSources(CmdbImportProfile $profile): array {
+ $constants = $profile->constantColumns();
+ $sources = [];
+ foreach (CmdbImportProfile::TARGETS as $target) {
+ if ($target === 'municipality') {
+ continue;
+ }
+
+ foreach (($profile->pack(target: $target)['fieldMappings'] ?? []) as $mapping) {
+ $sources[] = (string)($mapping['source'] ?? '');
+ }
+ }
+
+ return array_values(
+ array_unique(
+ array_filter(
+ $sources,
+ fn (string $source): bool => $source !== '' && in_array($source, $constants, true) === false
+ )
+ )
+ );
+ }//end packSources()
+
+ /**
+ * Column letters to a 1-based index ("A" = 1, "AA" = 27).
+ *
+ * @param string $letters The column letters.
+ *
+ * @return int
+ */
+ private static function columnIndex(string $letters): int {
+ $index = 0;
+ foreach (str_split(strtoupper($letters)) as $char) {
+ $index = (($index * 26) + (ord($char) - 64));
+ }
+
+ return $index;
+ }//end columnIndex()
+
+ /**
+ * A 1-based column index to its letters.
+ *
+ * @param int $index The column index.
+ *
+ * @return string
+ */
+ private static function columnLetters(int $index): string {
+ $letters = '';
+ while ($index > 0) {
+ $remainder = (($index - 1) % 26);
+ $letters = chr(65 + $remainder) . $letters;
+ $index = intdiv(($index - 1), 26);
+ }
+
+ return $letters;
+ }//end columnLetters()
+}//end class
diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php
new file mode 100644
index 000000000..1efc049d7
--- /dev/null
+++ b/lib/Service/CmdbExportImportService.php
@@ -0,0 +1,2051 @@
+:` (the
+ * APPID in lower case), so a
+ * second import of a newer export updates the same records.
+ *
+ * What each column becomes is declarative: the migration packs under
+ * `lib/Settings/cmdb-import/`, executed by OpenRegister's
+ * `MigrationPack\MappingEngine` (ADR-031). This class is the imperative glue
+ * around the file: reading it, splitting a row over four linked objects,
+ * resolving contacts, progress and cancel. Every read and write goes through
+ * OpenRegister's `ObjectServiceInterface` (ADR-022).
+ *
+ * Rules stated once and enforced here:
+ * - A module matches on `externalKey`, but only when the municipality
+ * uses it or no organisation does yet; a usage on (consumer, module); a
+ * manufacturer on its normalised name, a Municipality of that name before
+ * a Supplier, so a municipality that builds its own applications stays one
+ * organisation; a contact person on (contactsUid, organization). An organisation that was merged away
+ * (status `merged`) or is `Inactive` is never matched by name.
+ * - `publicationDate` is set to the import's start on create, unless the
+ * admin chose not to publish (`publish` false), and never written on
+ * update; neither is `depublicationDate`.
+ * - Records missing from a newer export are left untouched.
+ * - Owners become contact persons, never Nextcloud user accounts, and no
+ * report entry or log line carries an owner name or e-mail address.
+ * - One import runs per register at a time: every match is find-then-create,
+ * so two interleaved runs would each create the same records.
+ *
+ * @category Service
+ * @package OCA\Stackiq\Service
+ * @author Conduction b.v.
+ * @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-export-import/tasks.md#task-5
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use OCA\OpenRegister\Contract\ObjectServiceInterface;
+use OCA\Stackiq\Exception\CmdbImportException;
+use OCA\Stackiq\Service\Cmdb\CmdbImportProfile;
+use OCA\Stackiq\Service\Cmdb\CmdbImportReport;
+use OCA\Stackiq\Service\Cmdb\CmdbRowNormaliser;
+use OCA\Stackiq\Service\Cmdb\CmdbWorkbookReader;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IL10N;
+use OCP\IUserSession;
+use OCP\Lock\ILockingProvider;
+use OCP\Lock\LockedException;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Imports a TOPdesk CMDB export for one municipality.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ *
+ * @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
+ * same run state from class to class without making any one rule simpler to read.
+ * @SuppressWarnings(PHPMD.TooManyFields) The run caches are one field per matched kind.
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Reader, normaliser, profile, report,
+ * contacts, progress and OpenRegister are the import's collaborators by design.
+ * @SuppressWarnings(PHPMD.TooManyMethods) Each match rule and each step of a row is its own
+ * small method; merging them back would only make the steps longer.
+ * @SuppressWarnings(PHPMD.ExcessiveClassLength) Most of the length is docblocks that state
+ * the matching rules; the code itself is under the threshold.
+ */
+class CmdbExportImportService {
+ /**
+ * The ProgressTracker operation type of an import.
+ */
+ public const OPERATION_TYPE = 'cmdb_import';
+
+ /**
+ * Operation ids a client may choose; anything else gets a generated id.
+ *
+ * `\z`, not `$`: `$` also matches before a trailing newline, which would
+ * let "cmdb-12345678\n" through as a cache key.
+ */
+ public const OPERATION_ID_PATTERN = '/^cmdb-[A-Za-z0-9-]{8,64}\z/';
+
+ /**
+ * OpenRegister's migration-pack mapping engine (not a public contract).
+ */
+ public const ENGINE_CLASS = 'OCA\OpenRegister\Service\MigrationPack\MappingEngine';
+
+ /**
+ * OpenRegister's schema mapper (not a public contract).
+ */
+ public const SCHEMA_MAPPER_CLASS = 'OCA\OpenRegister\Db\SchemaMapper';
+
+ /**
+ * The properties every match search and the module key rely on, per schema.
+ *
+ * OpenRegister answers a filter on a property its schema does not declare
+ * with no rows, not an error; without these the import would create a
+ * duplicate of every record instead of matching it. `applicationType` is
+ * not matched on, but it arrives with the same module version (0.3.8) as
+ * the write rule below, so its absence marks an outdated module schema.
+ *
+ * @var array>
+ */
+ private const MATCH_PROPERTIES = [
+ 'module' => ['externalKey', 'externalId', 'externalNumber', 'applicationType'],
+ 'organization' => ['name', 'type'],
+ 'usage' => ['consumer', 'module'],
+ 'contactPerson' => ['contactsUid', 'organization'],
+ ];
+
+ /**
+ * Wall-clock seconds an import may run, below the default 3600 s lifetime of a Nextcloud lock.
+ */
+ public const TIME_LIMIT_SECONDS = 3000;
+
+ /**
+ * The update rule a property must carry, per schema: property => the group that alone may change it.
+ *
+ * Only an admin may change `module.externalKey` outside the import; the
+ * conflict and ownership model relies on that, so a module schema without
+ * the rule (before 0.3.8) is outdated.
+ *
+ * @var array>
+ */
+ private const REQUIRED_UPDATE_RULES = [
+ 'module' => ['externalKey' => 'admin'],
+ ];
+
+ /**
+ * The most report rows stored with the operation in the distributed cache; the counts are always kept.
+ */
+ public const STORED_REPORT_ROWS = 500;
+
+ /**
+ * What the progress entry of a failed run says: a code and a generic message.
+ *
+ * The entry is readable by everyone who may follow the operation, and an
+ * exception message can quote cell values or person data, so it is never stored.
+ */
+ public const FAILED_RUN_MESSAGE = 'IMPORT_FAILED: The import stopped unexpectedly. The details are in the Nextcloud log.';
+
+ /**
+ * The URI of the importing admin's address book that new owner contacts go into.
+ */
+ public const OWNER_ADDRESS_BOOK_URI = 'stackiq-cmdb-owners';
+
+ /**
+ * The lock an import holds for its register, so imports never interleave.
+ */
+ private const LOCK_PREFIX = 'stackiq/cmdb-import/register-';
+
+ /**
+ * Organisation statuses a name never matches: a merge tombstone, and a retired organisation.
+ *
+ * @var array
+ */
+ private const UNMATCHED_STATUSES = ['merged', 'Inactive'];
+
+ /**
+ * Page size for loading the organisations a name may match.
+ */
+ 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.
+ *
+ * @var object|null
+ */
+ private ?object $engine = null;
+
+ /**
+ * OpenRegister and the register/schema ids of the current run.
+ *
+ * @var array{objectService: ObjectServiceInterface, register: int, module: int, organization: int, usage: int, contactPerson: int}|null
+ */
+ private ?array $coordinates = null;
+
+ /**
+ * Manufacturer organisations by normalised name, loaded once per run.
+ *
+ * @var array|null
+ */
+ private ?array $suppliers = null;
+
+ /**
+ * APPIDs seen in this upload.
+ *
+ * @var array
+ */
+ private array $seenKeys = [];
+
+ /**
+ * Per APPID match key, the sheet that wins when the APPID is on more than one sheet.
+ *
+ * @var array
+ */
+ private array $winningSheets = [];
+
+ /**
+ * Contact UIDs by e-mail or display name, per run.
+ *
+ * @var array
+ */
+ private array $contactUids = [];
+
+ /**
+ * Contact person uuids by contactsUid and organisation, per run.
+ *
+ * @var array
+ */
+ private array $contactPersons = [];
+
+ /**
+ * Constructor.
+ *
+ * @param ContainerInterface $container Resolves OpenRegister services.
+ * @param SettingsService $settingsService Register and schema ids.
+ * @param StackiqContactSyncService $contactSync Nextcloud Contacts bridge.
+ * @param ProgressTracker $progressTracker Progress and cancel.
+ * @param CmdbImportProfile $profile The import profile and packs.
+ * @param CmdbWorkbookReader $reader The xlsx reader.
+ * @param CmdbRowNormaliser $normaliser Dates and ids to strings.
+ * @param IL10N $l10n Translates report reasons and warnings.
+ * @param LoggerInterface $logger Logger; never handed person data.
+ * @param ILockingProvider $lockingProvider Serialises imports per register.
+ * @param IUserSession $userSession Names the admin who ran an import in its audit log lines.
+ *
+ * @SuppressWarnings(PHPMD.ExcessiveParameterList) Each collaborator is one concern of the
+ * import (the file, the packs, OpenRegister, Contacts, progress, the lock); grouping them
+ * into a parameter object would only move the same list one class further.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ public function __construct(
+ private readonly ContainerInterface $container,
+ private readonly SettingsService $settingsService,
+ private readonly StackiqContactSyncService $contactSync,
+ private readonly ProgressTracker $progressTracker,
+ private readonly CmdbImportProfile $profile,
+ private readonly CmdbWorkbookReader $reader,
+ private readonly CmdbRowNormaliser $normaliser,
+ private readonly IL10N $l10n,
+ private readonly LoggerInterface $logger,
+ private readonly ILockingProvider $lockingProvider,
+ private readonly IUserSession $userSession,
+ ) {
+ }//end __construct()
+
+ /**
+ * The upload limit in bytes (the profile's `maxFileBytes`).
+ *
+ * @return int
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-8
+ */
+ public function maxFileBytes(): int {
+ return $this->profile->maxFileBytes();
+ }//end maxFileBytes()
+
+ /**
+ * Check that an upload is an xlsx workbook, without parsing it.
+ *
+ * @param string $path The uploaded temporary file.
+ * @param string $fileName The original file name.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException NOT_XLSX.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-8
+ */
+ public function assertXlsx(string $path, string $fileName): void {
+ $this->reader->assertXlsx(path: $path, fileName: $fileName);
+ }//end assertXlsx()
+
+ /**
+ * Whether a `missingRecords` value is supported (only `keep`).
+ *
+ * @param string $mode The requested value.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-8
+ */
+ public function supportsMissingRecords(string $mode): bool {
+ return $mode === 'keep';
+ }//end supportsMissingRecords()
+
+ /**
+ * Ask a running import to stop between rows.
+ *
+ * @param string $operationId The operation id.
+ *
+ * @return bool False when no running `cmdb_import` operation has this id.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ public function requestCancel(string $operationId): bool {
+ if (preg_match(self::OPERATION_ID_PATTERN, $operationId) !== 1) {
+ return false;
+ }
+
+ $progress = $this->progressTracker->getProgress(operationId: $operationId);
+ if (is_array($progress) === false
+ || ($progress['operation_type'] ?? null) !== self::OPERATION_TYPE
+ || ($progress['status'] ?? null) !== 'running'
+ ) {
+ return false;
+ }
+
+ $this->progressTracker->setCancelRequested(operationId: $operationId);
+ return true;
+ }//end requestCancel()
+
+ /**
+ * Import an export for one municipality.
+ *
+ * Validation that can fail the whole import runs before any object is
+ * written: the packs and the engine, the configuration, the workbook and
+ * the municipality uuid. After that every row is processed in its own
+ * error boundary. The import holds an exclusive lock on its register from
+ * before the file is read until it returns or throws.
+ *
+ * @param string $path The xlsx file, already checked by assertXlsx().
+ * @param array $options municipalityUuid, municipalityName, updateExisting, publish,
+ * operationId, and fileName (the upload's name, for the audit log line).
+ *
+ * @return array The report (contract.md).
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE, NOT_CONFIGURED, SCHEMA_OUTDATED, IMPORT_IN_PROGRESS,
+ * WORKBOOK_TOO_LARGE, READER_UNAVAILABLE, NOT_XLSX, NO_SOURCE_SHEET,
+ * MISSING_COLUMN, TOO_MANY_ROWS, MUNICIPALITY_REQUIRED,
+ * MUNICIPALITY_INVALID or MUNICIPALITY_AMBIGUOUS.
+ * @throws \Exception An unexpected OpenRegister error outside a row, such as
+ * creating the municipality; rows catch their own.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ public function import(string $path, array $options): array {
+ $this->resetRun();
+ $this->keepRunning();
+ $startedAt = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format(DATE_ATOM);
+
+ $this->profile->load();
+ $this->engine = $this->resolveEngine();
+ $this->coordinates = $this->resolveCoordinates();
+
+ $lock = $this->acquireImportLock(register: $this->coordinates['register']);
+ try {
+ return $this->runImport(path: $path, options: $options, startedAt: $startedAt);
+ } finally {
+ $this->lockingProvider->releaseLock($lock, ILockingProvider::LOCK_EXCLUSIVE);
+ }
+ }//end import()
+
+ /**
+ * Let the import finish when the browser goes away, within a bounded time.
+ *
+ * A closed tab or a proxy that gives up would otherwise stop PHP after
+ * some rows, with no report and the operation left running. The time
+ * limit is only ever raised to TIME_LIMIT_SECONDS, never lowered, and an
+ * unlimited one (the CLI) stays unlimited.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ private function keepRunning(): void {
+ ignore_user_abort(true);
+
+ $current = (int)ini_get('max_execution_time');
+ if ($current > 0 && $current < self::TIME_LIMIT_SECONDS && function_exists('set_time_limit') === true) {
+ set_time_limit(self::TIME_LIMIT_SECONDS);
+ }
+ }//end keepRunning()
+
+ /**
+ * Take the register's import lock, or refuse because another import holds it.
+ *
+ * @param int $register The register id.
+ *
+ * @return string The lock path, to release.
+ *
+ * @throws CmdbImportException IMPORT_IN_PROGRESS.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ private function acquireImportLock(int $register): string {
+ $lock = self::LOCK_PREFIX . $register;
+ try {
+ $this->lockingProvider->acquireLock($lock, ILockingProvider::LOCK_EXCLUSIVE, 'CMDB import');
+ } catch (LockedException $e) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::IMPORT_IN_PROGRESS,
+ message: 'Another CMDB import is running for this register',
+ previous: $e
+ );
+ }
+
+ return $lock;
+ }//end acquireImportLock()
+
+ /**
+ * Read the workbook and import its rows, under the register's lock.
+ *
+ * An import is audited by two info log lines, "import started" and
+ * "import finished", naming the operation, the admin's user id, the
+ * file's base name, the municipality and updateExisting, and at the end
+ * the counts. No owner name or e-mail address is logged.
+ *
+ * @param string $path The xlsx file.
+ * @param array $options The import options.
+ * @param string $startedAt ISO start time of the import.
+ *
+ * @return array The report.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ private function runImport(string $path, array $options, string $startedAt): array {
+ $workbook = $this->reader->read(path: $path, profile: $this->profile);
+ $municipality = $this->resolveMunicipality(options: $options);
+
+ $operationId = $this->operationIdFrom(options: $options);
+ $rows = array_values($workbook['rows']);
+ $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 ($municipality['created'] === true) {
+ $report->addImportWarnings(
+ warnings: [
+ [
+ 'sheet' => '',
+ 'message' => $this->l10n->t(
+ 'No municipality named "%s" was found, so it was created. Check the name if you meant an existing one.',
+ [$municipality['name']]
+ ),
+ ],
+ ]
+ );
+ }
+
+ $this->progressTracker->startOperation(
+ operationType: self::OPERATION_TYPE,
+ options: ['total_items' => count($rows)],
+ operationId: $operationId
+ );
+ $this->progressTracker->setPhase(phase: 'processing_elements', data: ['total_items' => count($rows)]);
+
+ $this->winningSheets = $this->winningSheets(rows: $rows);
+ $updateExisting = (($options['updateExisting'] ?? true) !== false);
+ $publish = (($options['publish'] ?? true) !== false);
+ $publicationDate = null;
+ if ($publish === true) {
+ $publicationDate = $startedAt;
+ }
+ $audit = [
+ 'operationId' => $operationId,
+ 'uid' => $this->userSession->getUser()?->getUID(),
+ 'fileName' => basename(str_replace('\\', '/', (string)($options['fileName'] ?? ''))),
+ 'municipality' => $municipality['uuid'],
+ 'municipalityCreated' => $municipality['created'],
+ 'updateExisting' => $updateExisting,
+ 'publish' => $publish,
+ ];
+ $this->logger->info('CmdbExportImportService: import started', array_merge($audit, ['rows' => count($rows)]));
+ try {
+ foreach ($rows as $index => $row) {
+ if ($this->progressTracker->isCancelRequested(operationId: $operationId) === true) {
+ $report->markCancelled();
+ break;
+ }
+
+ $this->processRow(
+ row: $row,
+ municipalityUuid: $municipality['uuid'],
+ options: ['updateExisting' => $updateExisting, 'publicationDate' => $publicationDate],
+ date1904: $workbook['date1904'],
+ report: $report
+ );
+ $this->progressTracker->updateProgress(processedItems: ($index + 1));
+ }
+
+ $result = $report->toArray();
+ $this->finishOperation(report: $report->toStoredArray(maxRows: self::STORED_REPORT_ROWS));
+ } catch (Throwable $e) {
+ // Rows catch their own errors; this is the run itself failing, so the
+ // operation stops as failed instead of staying running until it expires.
+ $this->logger->error(
+ 'CmdbExportImportService: import failed',
+ ['operationId' => $operationId, 'exception' => get_class($e), 'error' => self::logSafeMessage(step: 'import', e: $e, values: [])]
+ );
+ $this->progressTracker->failOperation(message: self::FAILED_RUN_MESSAGE);
+ throw $e;
+ }//end try
+
+ $this->logger->info(
+ 'CmdbExportImportService: import finished',
+ array_merge($audit, ['summary' => $result['summary'], 'cancelled' => $result['cancelled']])
+ );
+
+ return $result;
+ }//end runImport()
+
+ /**
+ * Process one row in its own error boundary and add its outcome.
+ *
+ * @param array{sheet: string, row: int, cells: array, uncached?: array} $row The reader row.
+ * @param string $municipalityUuid The consumer.
+ * @param array{updateExisting: bool, publicationDate: string|null} $options Whether matched rows are updated, and the
+ * publicationDate of a created module (null: unpublished).
+ * @param bool $date1904 The workbook's date system.
+ * @param CmdbImportReport $report The report.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ private function processRow(
+ array $row,
+ string $municipalityUuid,
+ array $options,
+ bool $date1904,
+ CmdbImportReport $report,
+ ): void {
+ $sheet = $row['sheet'];
+ $values = $this->normaliseRow(row: $row, date1904: $date1904);
+ $rowNumber = $row['row'];
+ $appId = ($values[$this->profile->keyColumn()] ?? '');
+ $name = ($values[$this->profile->nameColumn()] ?? '');
+
+ $entry = ['sheet' => $sheet, 'row' => $rowNumber, 'appId' => $appId, 'name' => $name];
+
+ $warnings = $this->uncachedWarnings(row: $row);
+
+ $matchKey = self::matchKey(appId: $appId);
+ if ($this->skipForWinningSheet(report: $report, entry: $entry, matchKey: $matchKey, warnings: $warnings) === true) {
+ return;
+ }
+
+ $skipReason = $this->skipReason(appId: $appId, matchKey: $matchKey);
+ if ($skipReason !== null) {
+ $this->addRow(report: $report, entry: $entry, outcome: CmdbImportReport::SKIPPED, reasons: [$skipReason], warnings: $warnings);
+ return;
+ }
+
+ $step = 'mapping';
+ $moduleUuid = null;
+ $usageUuid = null;
+
+ try {
+ $module = $this->map(target: 'module', values: $values, rowNumber: $rowNumber, warnings: $warnings);
+ if ($module['missing'] !== []) {
+ $reasons = array_map(fn (string $column): string => $this->l10n->t('missing %s', [$column]), $module['missing']);
+ $this->addRow(report: $report, entry: $entry, outcome: CmdbImportReport::SKIPPED, reasons: $reasons, warnings: $warnings);
+ return;
+ }
+
+ // Claimed only now: a row skipped for a missing value leaves its APPID to a later row.
+ $this->seenKeys[$matchKey] = true;
+
+ // The module is matched before the supplier is resolved, so a row that
+ // ends as a conflict or `exists` creates no Supplier organisation.
+ $step = 'module';
+ $externalKey = $this->profile->externalKeyPrefix() . ':' . $municipalityUuid . ':' . $matchKey;
+ $match = $this->matchModule(externalKey: $externalKey, municipalityUuid: $municipalityUuid, matchKey: $matchKey, options: $options);
+ if ($match['skipReason'] !== null) {
+ $this->addRow(
+ report: $report,
+ entry: $entry,
+ outcome: CmdbImportReport::SKIPPED,
+ reasons: [$match['skipReason']],
+ warnings: $warnings,
+ moduleUuid: $match['uuid']
+ );
+ return;
+ }
+
+ $step = 'manufacturer';
+ $providerUuid = $this->resolveManufacturer(values: $values, rowNumber: $rowNumber);
+
+ $step = 'module';
+ $moduleResult = $this->importModule(
+ data: $module['data'],
+ externalKey: $externalKey,
+ existing: $match['existing'],
+ providerUuid: $providerUuid,
+ publicationDate: $options['publicationDate'],
+ report: $report
+ );
+ $moduleUuid = $moduleResult['uuid'];
+
+ $step = 'owners';
+ $owners = $this->resolveOwners(values: $values, rowNumber: $rowNumber, municipalityUuid: $municipalityUuid, warnings: $warnings);
+
+ $step = 'usage';
+ $usage = $this->map(target: 'usage', values: $values, rowNumber: $rowNumber, warnings: $warnings);
+ $usageResult = $this->upsertUsage(
+ data: $usage['data'],
+ municipalityUuid: $municipalityUuid,
+ moduleUuid: $moduleUuid,
+ providerUuid: $providerUuid,
+ owners: $owners
+ );
+ $usageUuid = $usageResult['uuid'];
+ } catch (Throwable $e) {
+ $this->failRow(report: $report, entry: $entry, step: $step, e: $e, warnings: $warnings, uuids: [$moduleUuid, $usageUuid], values: $values);
+ return;
+ }//end try
+
+ $outcome = self::rowOutcome(module: $moduleResult['outcome'], usage: $usageResult['outcome']);
+ $this->addRow(report: $report, entry: $entry, outcome: $outcome, warnings: $warnings, moduleUuid: $moduleUuid, usageUuid: $usageUuid);
+ }//end processRow()
+
+ /**
+ * The row's cells plus its sheet's constants, normalised the way the profile says.
+ *
+ * @param array{sheet: string, cells: array} $row The reader row.
+ * @param bool $date1904 The workbook's date system.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ private function normaliseRow(array $row, bool $date1904): array {
+ return $this->normaliser->normalise(
+ cells: array_merge($row['cells'], $this->profile->sheetConstants(sheetName: $row['sheet'])),
+ dateColumns: $this->profile->dateColumns(),
+ idColumns: $this->profile->idColumns(),
+ date1904: $date1904,
+ emptyValues: $this->profile->emptyValues()
+ );
+ }//end normaliseRow()
+
+ /**
+ * The warning for each formula cell of a row that had no cached value.
+ *
+ * @param array{uncached?: array} $row The reader row.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ private function uncachedWarnings(array $row): array {
+ $warnings = [];
+ foreach (($row['uncached'] ?? []) as $column) {
+ $warnings[] = $this->l10n->t('Column "%s": formula without a cached value, read as empty', [(string)$column]);
+ }
+
+ return $warnings;
+ }//end uncachedWarnings()
+
+ /**
+ * Report a row as failed at a step, and log it without person data.
+ *
+ * The report names the step and the kind of exception, never its message:
+ * OpenRegister messages can quote the object data. The log line keeps the
+ * message for the administrator, with every cell value of the row and
+ * every e-mail address taken out (logSafeMessage()).
+ *
+ * @param CmdbImportReport $report The report.
+ * @param array{sheet: string, row: int, appId: string, name: string} $entry Where the row is.
+ * @param string $step The step that failed.
+ * @param Throwable $e The cause.
+ * @param array $warnings Row warnings so far.
+ * @param array{0: string|null, 1: string|null} $uuids Module and usage, when saved.
+ * @param array $values The normalised row, whose values are taken out of the log line.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ private function failRow(CmdbImportReport $report, array $entry, string $step, Throwable $e, array $warnings, array $uuids, array $values): void {
+ $this->logger->warning(
+ 'CmdbExportImportService: row failed',
+ array_merge(
+ ['sheet' => $entry['sheet'], 'row' => $entry['row'], 'appId' => $entry['appId']],
+ ['step' => $step, 'exception' => get_class($e), 'error' => self::logSafeMessage(step: $step, e: $e, values: $values)]
+ )
+ );
+
+ $kind = substr((string)strrchr('\\' . get_class($e), '\\'), 1);
+ $reason = $this->l10n->t('step "%1$s" failed (%2$s)', [$step, $kind]);
+
+ $this->addRow(
+ report: $report,
+ entry: $entry,
+ outcome: CmdbImportReport::FAILED,
+ reasons: [$reason],
+ warnings: $warnings,
+ moduleUuid: $uuids[0],
+ usageUuid: $uuids[1]
+ );
+ }//end failRow()
+
+ /**
+ * Translate the reader's import-level warnings.
+ *
+ * @param array $warnings The reader warnings.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ private function translateImportWarnings(array $warnings): array {
+ $translated = [];
+ foreach ($warnings as $warning) {
+ $message = $warning['message'];
+ if (isset($warning['column']) === true) {
+ $message = $this->l10n->t('Optional column "%s" not found', [$warning['column']]);
+ }
+
+ $translated[] = ['sheet' => $warning['sheet'], 'message' => $message];
+ }
+
+ return $translated;
+ }//end translateImportWarnings()
+
+ /**
+ * The row outcome from the module and usage outcomes.
+ *
+ * @param string $module The module outcome.
+ * @param string $usage The usage outcome.
+ *
+ * @return string created when the module was created, updated when anything was saved, else unchanged.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ private static function rowOutcome(string $module, string $usage): string {
+ if ($module === CmdbImportReport::CREATED) {
+ return CmdbImportReport::CREATED;
+ }
+
+ if ($module !== CmdbImportReport::UNCHANGED || $usage !== CmdbImportReport::UNCHANGED) {
+ return CmdbImportReport::UPDATED;
+ }
+
+ return CmdbImportReport::UNCHANGED;
+ }//end rowOutcome()
+
+ /**
+ * Store the report with the operation and close it, as completed or cancelled.
+ *
+ * @param array $report The report.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ private function finishOperation(array $report): void {
+ if ($report['cancelled'] === true) {
+ $this->progressTracker->updateStatistics(statistics: ['report' => $report]);
+ $this->progressTracker->cancelOperation();
+ return;
+ }
+
+ $this->progressTracker->completeOperation(finalStatistics: ['report' => $report]);
+ // A cancel that came in after the last row's check has nothing left to stop.
+ $this->progressTracker->clearCancelRequested(operationId: (string)$report['operationId']);
+ }//end finishOperation()
+
+ /**
+ * Add a row outcome to the report.
+ *
+ * @param CmdbImportReport $report The report.
+ * @param array{sheet: string, row: int, appId: string, name: string} $entry Where the row is.
+ * @param string $outcome The outcome.
+ * @param array $reasons Reasons.
+ * @param array $warnings Warnings.
+ * @param string|null $moduleUuid The module.
+ * @param string|null $usageUuid The usage.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ private function addRow(
+ CmdbImportReport $report,
+ array $entry,
+ string $outcome,
+ array $reasons = [],
+ array $warnings = [],
+ ?string $moduleUuid = null,
+ ?string $usageUuid = null,
+ ): void {
+ $report->addRow(
+ sheet: $entry['sheet'],
+ row: $entry['row'],
+ appId: $entry['appId'],
+ name: $entry['name'],
+ outcome: $outcome,
+ reasons: $reasons,
+ warnings: $warnings,
+ moduleUuid: $moduleUuid,
+ usageUuid: $usageUuid
+ );
+ }//end addRow()
+
+ /**
+ * Skip a row whose APPID another sheet wins, with a warning naming the APPID and that sheet.
+ *
+ * @param CmdbImportReport $report The report.
+ * @param array{sheet: string, row: int, appId: string, name: string} $entry Where the row is.
+ * @param string $matchKey The APPID's match key.
+ * @param array $warnings Row warnings so far.
+ *
+ * @return bool Whether the row was skipped.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ private function skipForWinningSheet(CmdbImportReport $report, array $entry, string $matchKey, array $warnings): bool {
+ $winner = ($this->winningSheets[$matchKey] ?? $entry['sheet']);
+ if ($entry['appId'] === '' || $winner === $entry['sheet']) {
+ return false;
+ }
+
+ $warnings[] = $this->l10n->t('APPID %1$s is also on sheet "%2$s", which wins; this row is not imported', [$entry['appId'], $winner]);
+ $this->addRow(
+ report: $report,
+ entry: $entry,
+ outcome: CmdbImportReport::SKIPPED,
+ reasons: [$this->l10n->t('duplicate %s in file', [$this->profile->keyColumn()])],
+ warnings: $warnings
+ );
+
+ return true;
+ }//end skipForWinningSheet()
+
+ /**
+ * Per APPID, the sheet whose row is imported when the APPID is on more than one sheet.
+ *
+ * The profile ranks the sheets ("Beheerde Applicaties CMDB" before
+ * "Onbeh Applicaties CMDB"): an application whose maintenance is arranged
+ * is imported as such, whichever sheet the export lists first.
+ *
+ * @param array}> $rows The reader rows.
+ *
+ * @return array APPID match key => sheet name.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ private function winningSheets(array $rows): array {
+ $key = $this->profile->keyColumn();
+ $winners = [];
+ foreach ($rows as $row) {
+ $values = $this->normaliser->normalise(cells: [$key => ($row['cells'][$key] ?? null)], dateColumns: [], idColumns: $this->profile->idColumns());
+ $matchKey = self::matchKey(appId: ($values[$key] ?? ''));
+ if ($matchKey === '') {
+ continue;
+ }
+
+ $current = ($winners[$matchKey] ?? null);
+ if ($current === null || $this->profile->sheetRank(sheetName: $row['sheet']) < $this->profile->sheetRank(sheetName: $current)) {
+ $winners[$matchKey] = $row['sheet'];
+ }
+ }
+
+ return $winners;
+ }//end winningSheets()
+
+ /**
+ * Why a row is skipped before mapping, or null when it is imported.
+ *
+ * An APPID an earlier row of the same sheet imported is a duplicate; across
+ * sheets winningSheets() decides. APPIDs compare by their match key, so `APP-1` and
+ * `app-1` are the same application.
+ *
+ * @param string $appId The APPID.
+ * @param string $matchKey The APPID's match key.
+ *
+ * @return string|null
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ private function skipReason(string $appId, string $matchKey): ?string {
+ if ($appId === '') {
+ return $this->l10n->t('missing %s', [$this->profile->keyColumn()]);
+ }
+
+ if (isset($this->seenKeys[$matchKey]) === true) {
+ return $this->l10n->t('duplicate %s in file', [$this->profile->keyColumn()]);
+ }
+
+ return null;
+ }//end skipReason()
+
+ /**
+ * The key an APPID is matched on: lower case, so a change of case in the export is the same application.
+ *
+ * Surrounding whitespace, including a non-breaking space, is already
+ * removed by the normaliser.
+ *
+ * @param string $appId The normalised APPID.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ public static function matchKey(string $appId): string {
+ return mb_strtolower($appId);
+ }//end matchKey()
+
+ /**
+ * Map a row through a target's pack.
+ *
+ * Errors on required mappings are returned as missing columns. Other
+ * errors drop the field and become a warning naming the column and the
+ * value, except for the owner and manufacturer packs, whose errors are
+ * silent: such a row simply has no owner or no manufacturer.
+ *
+ * @param string $target The pack target.
+ * @param array $values The normalised row.
+ * @param int $rowNumber The sheet row number, for the engine's errors.
+ * @param array $warnings Row warnings, appended to.
+ *
+ * @return array{data: array, missing: array}
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ private function map(string $target, array $values, int $rowNumber, array &$warnings): array {
+ $pack = $this->profile->pack(target: $target);
+ $result = $this->engine->mapRow($pack, $values, $rowNumber);
+
+ $required = [];
+ foreach (($pack['fieldMappings'] ?? []) as $mapping) {
+ if (($mapping['required'] ?? false) === true) {
+ $required[] = (string)($mapping['source'] ?? '');
+ }
+ }
+
+ $silent = in_array($target, ['manufacturer', 'businessOwner', 'municipality'], true);
+ $missing = [];
+ foreach (($result['errors'] ?? []) as $error) {
+ $source = (string)($error['source'] ?? '');
+ if (in_array($source, $required, true) === true) {
+ $missing[] = $source;
+ continue;
+ }
+
+ if ($silent === false) {
+ $warnings[] = $this->l10n->t('Column "%1$s": %2$s', [$source, (string)($error['message'] ?? '')]);
+ }
+ }
+
+ // A lookup whose default is null maps a known "no value" (an Applicatiesoort
+ // that is not a hosting model) to null; that field is left out, as an empty
+ // cell is, so an update never blanks what the field already holds.
+ $data = array_filter(($result['data'] ?? []), static fn ($value): bool => $value !== null);
+ unset($data['id']);
+
+ return ['data' => $data, 'missing' => array_values(array_unique($missing))];
+ }//end map()
+
+ /**
+ * Find or create the organisation of the row's manufacturer.
+ *
+ * A name matches one live organisation: a Municipality of that name (the
+ * municipality builds its own applications, and its Vendor is then its
+ * own name), else a Supplier. Only no match creates a Supplier.
+ *
+ * @param array $values The normalised row.
+ * @param int $rowNumber The sheet row number.
+ *
+ * @return string|null The organisation uuid, or null when the row names no manufacturer.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ private function resolveManufacturer(array $values, int $rowNumber): ?string {
+ $warnings = [];
+ $mapped = $this->map(target: 'manufacturer', values: $values, rowNumber: $rowNumber, warnings: $warnings);
+ $name = trim((string)($mapped['data']['name'] ?? ''));
+ if ($mapped['missing'] !== [] || $name === '') {
+ return null;
+ }
+
+ $key = self::normaliseName(name: $name);
+ $suppliers = $this->suppliers();
+ if (isset($suppliers[$key]) === true) {
+ return $suppliers[$key];
+ }
+
+ $data = $mapped['data'];
+ $data['name'] = (string)preg_replace('/\s+/u', ' ', $name);
+ $uuid = $this->save(schemaKey: 'organization', data: $data, uuid: null);
+ $this->suppliers[$key] = $uuid;
+
+ return $uuid;
+ }//end resolveManufacturer()
+
+ /**
+ * Find the row's module by its import key, and decide whether the row stops there.
+ *
+ * A module found by its import key that another organisation uses is a
+ * conflict: it is neither changed nor duplicated, and the row is skipped.
+ * A match the run may not update is skipped as `exists`. Nothing is
+ * written here, so a skipped row leaves no trace in the register. The log
+ * line of a conflict names the module and the APPID's match key, nothing
+ * else.
+ *
+ * @param string $externalKey The module's import key.
+ * @param string $municipalityUuid The consumer.
+ * @param string $matchKey The APPID's match key.
+ * @param array{updateExisting: bool} $options Whether a match is updated.
+ *
+ * @return array{existing: object|null, uuid: string|null, skipReason: string|null} The stored module, the uuid
+ * to report for a skipped row,
+ * and why the row is skipped.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ private function matchModule(string $externalKey, string $municipalityUuid, string $matchKey, array $options): array {
+ $existing = $this->findOne(schemaKey: 'module', filters: ['externalKey' => $externalKey]);
+ if ($existing === null) {
+ return ['existing' => null, 'uuid' => null, 'skipReason' => null];
+ }
+
+ $uuid = (string)$existing->getUuid();
+ if ($this->moduleBelongsTo(moduleUuid: $uuid, municipalityUuid: $municipalityUuid) === false) {
+ $this->logger->warning(
+ 'CmdbExportImportService: import key belongs to a module another organisation uses; row not imported',
+ ['module' => $uuid, 'municipality' => $municipalityUuid, 'appId' => $matchKey]
+ );
+ $reason = $this->l10n->t('conflict: the application with this import key is used by another organisation, so it is not changed');
+ return ['existing' => $existing, 'uuid' => null, 'skipReason' => $reason];
+ }
+
+ if ($options['updateExisting'] === false) {
+ return ['existing' => $existing, 'uuid' => $uuid, 'skipReason' => $this->l10n->t('exists')];
+ }
+
+ return ['existing' => $existing, 'uuid' => $uuid, 'skipReason' => null];
+ }//end matchModule()
+
+ /**
+ * Create or update the row's module, and count it when it was created unpublished.
+ *
+ * @param array $data The mapped module fields.
+ * @param string $externalKey The module's import key.
+ * @param object|null $existing The module matchModule() found, or null to create one.
+ * @param string|null $providerUuid The supplier, when there is one.
+ * @param string|null $publicationDate ISO start time of the import for a module that is published
+ * when created, or null to create it unpublished.
+ * @param CmdbImportReport $report The report.
+ *
+ * @return array{uuid: string, outcome: string} The module and the outcome of upsertModule().
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ private function importModule(
+ array $data,
+ string $externalKey,
+ ?object $existing,
+ ?string $providerUuid,
+ ?string $publicationDate,
+ CmdbImportReport $report,
+ ): array {
+ $result = $this->upsertModule(
+ data: $data,
+ externalKey: $externalKey,
+ existing: $existing,
+ providerUuid: $providerUuid,
+ publicationDate: $publicationDate
+ );
+
+ if ($result['outcome'] === CmdbImportReport::CREATED && $publicationDate === null) {
+ $report->countUnpublished();
+ }
+
+ return $result;
+ }//end importModule()
+
+ /**
+ * Whether a module found by its import key may be updated for this municipality.
+ *
+ * The import key is a property of the module, so it is only trusted
+ * together with the usages: the module must already have a usage whose
+ * consumer is this municipality, or, before the first import, no usage
+ * at all. A module only another organisation uses is never taken over.
+ *
+ * @param string $moduleUuid The module found by its import key.
+ * @param string $municipalityUuid The consumer of this import.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ private function moduleBelongsTo(string $moduleUuid, string $municipalityUuid): bool {
+ if ($this->findOne(schemaKey: 'usage', filters: ['consumer' => $municipalityUuid, 'module' => $moduleUuid]) !== null) {
+ return true;
+ }
+
+ return $this->findOne(schemaKey: 'usage', filters: ['module' => $moduleUuid]) === null;
+ }//end moduleBelongsTo()
+
+ /**
+ * Create the module, or merge the mapped fields onto the one matchModule() found.
+ *
+ * @param array $data The mapped module fields.
+ * @param string $externalKey The match key.
+ * @param object|null $existing The stored module, or null to create one.
+ * @param string|null $providerUuid The supplier, when there is one.
+ * @param string|null $publicationDate ISO start time of the import for a module that is published
+ * when created, or null to create it unpublished.
+ *
+ * @return array{uuid: string, outcome: string} Outcome created, updated or unchanged.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ private function upsertModule(
+ array $data,
+ string $externalKey,
+ ?object $existing,
+ ?string $providerUuid,
+ ?string $publicationDate,
+ ): array {
+ $data['externalKey'] = $externalKey;
+ if ($providerUuid !== null) {
+ $data['provider'] = $providerUuid;
+ }
+
+ if ($existing === null) {
+ $create = array_merge($this->profile->createOnlyDefaults(target: 'module'), $data);
+ unset($create['publicationDate']);
+ if ($publicationDate !== null) {
+ $create['publicationDate'] = $publicationDate;
+ }
+
+ return ['uuid' => $this->save(schemaKey: 'module', data: $create, uuid: null), 'outcome' => CmdbImportReport::CREATED];
+ }
+
+ $uuid = (string)$existing->getUuid();
+ $merged = $this->merge(target: 'module', stored: $existing->getObject(), mapped: $data);
+ if ($merged === null) {
+ return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UNCHANGED];
+ }
+
+ $this->save(schemaKey: 'module', data: $merged, uuid: $uuid);
+ return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UPDATED];
+ }//end upsertModule()
+
+ /**
+ * Create, update, or leave the usage of the module for the municipality.
+ *
+ * @param array $data The mapped usage fields.
+ * @param string $municipalityUuid The consumer.
+ * @param string $moduleUuid The module.
+ * @param string|null $providerUuid The supplier, when there is one.
+ * @param array{businessOwner: string|null} $owners The owner contact person.
+ *
+ * @return array{uuid: string, outcome: string}
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ private function upsertUsage(array $data, string $municipalityUuid, string $moduleUuid, ?string $providerUuid, array $owners): array {
+ if (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'])),
+ fn (string $part): bool => $part !== ''
+ );
+ $data['interneAnnotation'] = implode(self::NOTE_SEPARATOR, $parts);
+ }
+
+ $data['consumer'] = $municipalityUuid;
+ $data['module'] = $moduleUuid;
+ if ($providerUuid !== null) {
+ $data['provider'] = $providerUuid;
+ }
+
+ foreach ($owners as $field => $contactPersonUuid) {
+ if ($contactPersonUuid !== null) {
+ $data[$field] = $contactPersonUuid;
+ }
+ }
+
+ $existing = $this->findOne(schemaKey: 'usage', filters: ['consumer' => $municipalityUuid, 'module' => $moduleUuid]);
+ if ($existing === null) {
+ return ['uuid' => $this->save(schemaKey: 'usage', data: $data, uuid: null), 'outcome' => CmdbImportReport::CREATED];
+ }
+
+ $uuid = (string)$existing->getUuid();
+ $merged = $this->merge(target: 'usage', stored: $existing->getObject(), mapped: $data);
+ if ($merged === null) {
+ return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UNCHANGED];
+ }
+
+ $this->save(schemaKey: 'usage', data: $merged, uuid: $uuid);
+ return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UPDATED];
+ }//end upsertUsage()
+
+ /**
+ * Merge mapped fields onto a stored object.
+ *
+ * Fields the pack does not map stay as they are. Create-only fields are
+ * written only when the stored value is empty; never-written fields are
+ * never touched.
+ *
+ * @param string $target The profile target.
+ * @param array $stored The stored object data.
+ * @param array $mapped The mapped fields.
+ *
+ * @return array|null The merged object, or null when nothing changes.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ private function merge(string $target, array $stored, array $mapped): ?array {
+ $createOnly = $this->profile->createOnlyFields(target: $target);
+ $never = $this->profile->neverWrittenOnUpdate(target: $target);
+ $merged = $stored;
+ unset($merged['@self']);
+ $changed = false;
+
+ foreach ($mapped as $field => $value) {
+ if (in_array($field, $never, true) === true) {
+ continue;
+ }
+
+ $current = ($stored[$field] ?? null);
+ if (in_array($field, $createOnly, true) === true && self::isEmptyValue(value: $current) === false) {
+ continue;
+ }
+
+ if (self::sameValue(stored: $current, value: $value) === true) {
+ continue;
+ }
+
+ $merged[$field] = $value;
+ $changed = true;
+ }
+
+ if ($changed === false) {
+ return null;
+ }
+
+ return $merged;
+ }//end merge()
+
+ /**
+ * Resolve the owner of a row (Applicatie Eigenaar) as the usage's business owner.
+ *
+ * No technical owner is read: the functional administrator columns are
+ * not part of the mapping.
+ *
+ * @param array $values The normalised row.
+ * @param int $rowNumber The sheet row number.
+ * @param string $municipalityUuid The municipality the contact person belongs to.
+ * @param array $warnings Row warnings, appended to.
+ *
+ * @return array{businessOwner: string|null}
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-6
+ */
+ private function resolveOwners(array $values, int $rowNumber, string $municipalityUuid, array &$warnings): array {
+ $owners = ['businessOwner' => null];
+ $identities = [];
+ foreach (array_keys($owners) as $target) {
+ $silent = [];
+ $mapped = $this->map(target: $target, values: $values, rowNumber: $rowNumber, warnings: $silent);
+ $name = trim((string)($mapped['data']['name'] ?? ''));
+ if ($mapped['missing'] === [] && $name !== '') {
+ $identities[$target] = $mapped['data'];
+ }
+ }
+
+ if ($identities === []) {
+ return $owners;
+ }
+
+ if ($this->contactSync->isAvailable() === false) {
+ $warnings[] = $this->l10n->t('Owners skipped: Nextcloud Contacts is unavailable');
+ return $owners;
+ }
+
+ foreach ($identities as $target => $identity) {
+ $column = $this->ownerColumn(target: $target);
+ try {
+ $contactsUid = $this->resolveContactUid(identity: $identity);
+ if ($contactsUid === null) {
+ $warnings[] = $this->l10n->t('Owner from column "%s" could not be resolved in Nextcloud Contacts', [$column]);
+ continue;
+ }
+
+ $owners[$target] = $this->resolveContactPerson(
+ contactsUid: $contactsUid,
+ municipalityUuid: $municipalityUuid,
+ role: trim((string)($identity['role'] ?? ''))
+ );
+ } catch (Throwable $e) {
+ $this->logger->warning(
+ 'CmdbExportImportService: owner could not be resolved',
+ ['row' => $rowNumber, 'column' => $column, 'exception' => get_class($e)]
+ );
+ $warnings[] = $this->l10n->t('Owner from column "%s" could not be resolved', [$column]);
+ }
+ }//end foreach
+
+ return $owners;
+ }//end resolveOwners()
+
+ /**
+ * Resolve the Nextcloud contact of an owner identity.
+ *
+ * Contacts are matched, and created, only in the importing admin's
+ * dedicated "Stackiq CMDB owners" address book, never in the admin's
+ * other address books. With an e-mail address, StackiqContactSyncService
+ * matches on it there or creates the contact. Without one, only a contact
+ * there whose display name is exactly the owner's name (case-insensitive)
+ * is reused, so an owner known by name alone is not created again on
+ * every import.
+ *
+ * @param array $identity name, email and role from the owner pack.
+ *
+ * @return string|null The contact UID, or null.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-6
+ */
+ private function resolveContactUid(array $identity): ?string {
+ $parts = self::splitPersonName(name: (string)$identity['name']);
+ $displayName = trim($parts['voornaam'] . ' ' . $parts['achternaam']);
+ $email = trim((string)($identity['email'] ?? ''));
+
+ $cacheKey = 'name:' . mb_strtolower($displayName);
+ if ($email !== '') {
+ $cacheKey = 'email:' . mb_strtolower($email);
+ }
+
+ if (array_key_exists($cacheKey, $this->contactUids) === true) {
+ return $this->contactUids[$cacheKey];
+ }
+
+ $uid = null;
+ if ($email === '') {
+ $uid = $this->contactByDisplayName(displayName: $displayName);
+ }
+
+ if ($uid === null) {
+ $record = ['voornaam' => $parts['voornaam'], 'achternaam' => $parts['achternaam']];
+ if ($email !== '') {
+ $record['email'] = $email;
+ }
+
+ $role = trim((string)($identity['role'] ?? ''));
+ if ($role !== '') {
+ $record['role'] = $role;
+ }
+
+ $uid = $this->contactSync->syncToNamedAddressBook(
+ objectType: 'contactPerson',
+ record: $record,
+ addressBookUri: self::OWNER_ADDRESS_BOOK_URI,
+ displayName: $this->l10n->t('Stackiq CMDB owners')
+ );
+ if ($uid === '') {
+ $uid = null;
+ }
+ }
+
+ $this->contactUids[$cacheKey] = $uid;
+ return $uid;
+ }//end resolveContactUid()
+
+ /**
+ * The contact in the owners' address book whose display name is exactly this one, case-insensitive.
+ *
+ * Only the dedicated "Stackiq CMDB owners" address book is searched: a
+ * namesake in another address book of the admin, such as a personal
+ * contact, is never linked to an imported owner.
+ *
+ * @param string $displayName The display name.
+ *
+ * @return string|null The contact UID, or null.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-6
+ */
+ private function contactByDisplayName(string $displayName): ?string {
+ $needle = mb_strtolower($displayName);
+ $contacts = $this->contactSync->searchNamedAddressBook(query: $displayName, addressBookUri: self::OWNER_ADDRESS_BOOK_URI, properties: ['FN']);
+ foreach ($contacts as $contact) {
+ if (mb_strtolower(trim((string)($contact['name'] ?? ''))) === $needle) {
+ return (string)$contact['uid'];
+ }
+ }
+
+ return null;
+ }//end contactByDisplayName()
+
+ /**
+ * Find or create the contact person of a contact for the municipality.
+ *
+ * The object carries only `contactsUid`, `organization` and `role`: no
+ * e-mail and no username, so neither the contact-person listener nor
+ * OrganizationSyncService::performUserSync() provisions a user for it.
+ *
+ * @param string $contactsUid The Nextcloud contact UID.
+ * @param string $municipalityUuid The organisation.
+ * @param string $role The owner's function, or ''.
+ *
+ * @return string The contact person uuid.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-6
+ */
+ private function resolveContactPerson(string $contactsUid, string $municipalityUuid, string $role): string {
+ $cacheKey = $contactsUid . '|' . $municipalityUuid;
+ if (isset($this->contactPersons[$cacheKey]) === true) {
+ return $this->contactPersons[$cacheKey];
+ }
+
+ $existing = $this->findOne(schemaKey: 'contactPerson', filters: ['contactsUid' => $contactsUid, 'organization' => $municipalityUuid]);
+ $data = ['contactsUid' => $contactsUid, 'organization' => $municipalityUuid];
+ if ($role !== '') {
+ $data['role'] = $role;
+ }
+
+ $uuid = (string)$existing?->getUuid();
+ if ($existing === null) {
+ $uuid = $this->save(schemaKey: 'contactPerson', data: $data, uuid: null);
+ }
+
+ $this->contactPersons[$cacheKey] = $uuid;
+ return $uuid;
+ }//end resolveContactPerson()
+
+ /**
+ * Resolve the consuming municipality from the options.
+ *
+ * A name matches the one live Municipality with that normalised name.
+ * Several matches are refused rather than guessed: the uuid embedded in
+ * every external key would bind the whole catalogue to the guess. No
+ * match creates the municipality, which the report then warns about.
+ *
+ * @param array $options municipalityUuid or municipalityName.
+ *
+ * @return array{uuid: string, name: string, created: bool}
+ *
+ * @throws CmdbImportException MUNICIPALITY_REQUIRED, MUNICIPALITY_INVALID or MUNICIPALITY_AMBIGUOUS.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ private function resolveMunicipality(array $options): array {
+ $uuid = trim((string)($options['municipalityUuid'] ?? ''));
+ if ($uuid !== '') {
+ return $this->municipalityByUuid(uuid: $uuid);
+ }
+
+ $warnings = [];
+ $values = ['municipalityName' => trim((string)($options['municipalityName'] ?? ''))];
+ $mapped = $this->map(target: 'municipality', values: $values, rowNumber: 0, warnings: $warnings);
+ $name = trim((string)preg_replace('/\s+/u', ' ', (string)($mapped['data']['name'] ?? '')));
+ if ($mapped['missing'] !== [] || $name === '') {
+ throw new CmdbImportException(errorCode: CmdbImportException::MUNICIPALITY_REQUIRED, message: 'No municipality given');
+ }
+
+ $key = self::normaliseName(name: $name);
+ $matches = array_values(
+ array_filter(
+ $this->organisationsOfType(type: 'Municipality'),
+ static fn (array $organisation): bool => self::normaliseName(name: (string)($organisation['name'] ?? '')) === $key
+ )
+ );
+
+ if (count($matches) > 1) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MUNICIPALITY_AMBIGUOUS,
+ message: 'Several municipalities have this name',
+ details: ['matches' => array_column($matches, 'uuid')]
+ );
+ }
+
+ if ($matches !== []) {
+ return ['uuid' => $matches[0]['uuid'], 'name' => (string)$matches[0]['name'], 'created' => false];
+ }
+
+ $data = $mapped['data'];
+ $data['name'] = $name;
+ $created = $this->save(schemaKey: 'organization', data: $data, uuid: null);
+
+ return ['uuid' => $created, 'name' => $name, 'created' => true];
+ }//end resolveMunicipality()
+
+ /**
+ * Resolve a municipality uuid, which must be an organisation of type Municipality that was not merged away.
+ *
+ * @param string $uuid The organisation uuid.
+ *
+ * @return array{uuid: string, name: string, created: bool}
+ *
+ * @throws CmdbImportException MUNICIPALITY_INVALID when the uuid is unknown or not a live municipality.
+ * @throws Throwable When OpenRegister fails to look it up for another reason.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ private function municipalityByUuid(string $uuid): array {
+ $coordinates = $this->coordinates();
+ $organisation = null;
+ try {
+ $organisation = $coordinates['objectService']->find(
+ id: $uuid,
+ register: $coordinates['register'],
+ schema: $coordinates['organization'],
+ _rbac: false,
+ _multitenancy: false
+ );
+ } catch (DoesNotExistException $e) {
+ // OpenRegister throws DoesNotExistException for an unknown uuid.
+ $organisation = null;
+ } catch (Throwable $e) {
+ // Anything else is OpenRegister or the database failing, not a wrong uuid: no 422 for it.
+ $this->logger->error('CmdbExportImportService: the municipality could not be looked up', ['exception' => get_class($e)]);
+ throw $e;
+ }
+
+ $data = [];
+ if ($organisation !== null) {
+ $data = $organisation->getObject();
+ }
+
+ if ($organisation === null || ($data['type'] ?? null) !== 'Municipality') {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MUNICIPALITY_INVALID,
+ message: 'The municipality uuid is not an organisation of type Municipality'
+ );
+ }
+
+ // A merge tombstone points at the organisation that replaced it; data never goes to the tombstone.
+ if (($data['status'] ?? null) === 'merged') {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::MUNICIPALITY_INVALID,
+ message: 'The municipality uuid is an organisation that was merged into another'
+ );
+ }
+
+ return ['uuid' => (string)$organisation->getUuid(), 'name' => (string)($data['name'] ?? ''), 'created' => false];
+ }//end municipalityByUuid()
+
+ /**
+ * The organisations a manufacturer name matches, by normalised name, loaded once per run.
+ *
+ * Municipalities are loaded first, so a name both a Municipality and a
+ * Supplier carry is the Municipality.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ private function suppliers(): array {
+ if ($this->suppliers === null) {
+ $this->suppliers = [];
+ $organisations = array_merge($this->organisationsOfType(type: 'Municipality'), $this->organisationsOfType(type: 'Supplier'));
+ foreach ($organisations as $organisation) {
+ $key = self::normaliseName(name: (string)($organisation['name'] ?? ''));
+ if ($key !== '' && isset($this->suppliers[$key]) === false) {
+ $this->suppliers[$key] = $organisation['uuid'];
+ }
+ }
+ }
+
+ return $this->suppliers;
+ }//end suppliers()
+
+ /**
+ * Every organisation of a type that a name may match, as uuid and name.
+ *
+ * Merge tombstones and inactive organisations are left out: new data
+ * linked to them would never show where the live organisation is used.
+ *
+ * @param string $type Municipality or Supplier.
+ *
+ * @return array
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ private function organisationsOfType(string $type): array {
+ $coordinates = $this->coordinates();
+ $found = [];
+ $offset = 0;
+ do {
+ $page = $coordinates['objectService']->searchObjects(
+ query: [
+ '@self' => ['register' => $coordinates['register'], 'schema' => $coordinates['organization']],
+ 'type' => $type,
+ '_limit' => self::PAGE_SIZE,
+ '_offset' => $offset,
+ ],
+ _rbac: false,
+ _multitenancy: false
+ );
+ if (is_array($page) === false) {
+ $page = [];
+ }
+
+ foreach ($page as $entity) {
+ $data = $entity->getObject();
+ // The filter is checked again: a filter OpenRegister cannot apply must not widen the match.
+ if (($data['type'] ?? null) === $type
+ && $entity->getUuid() !== null
+ && in_array(($data['status'] ?? null), self::UNMATCHED_STATUSES, true) === false
+ ) {
+ $found[] = ['uuid' => (string)$entity->getUuid(), 'name' => ($data['name'] ?? '')];
+ }
+ }
+
+ $offset += self::PAGE_SIZE;
+ $pageSize = count($page);
+ } while ($pageSize === self::PAGE_SIZE);
+
+ return $found;
+ }//end organisationsOfType()
+
+ /**
+ * The one object matching every filter, or null.
+ *
+ * @param string $schemaKey The coordinates key of the schema.
+ * @param array $filters Field => exact value.
+ *
+ * @return object|null The entity (ObjectEntityInterface).
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ private function findOne(string $schemaKey, array $filters): ?object {
+ $coordinates = $this->coordinates();
+ $results = $coordinates['objectService']->searchObjects(
+ query: array_merge(
+ ['@self' => ['register' => $coordinates['register'], 'schema' => $coordinates[$schemaKey]], '_limit' => 10],
+ $filters
+ ),
+ _rbac: false,
+ _multitenancy: false
+ );
+ if (is_array($results) === false) {
+ return null;
+ }
+
+ foreach ($results as $entity) {
+ $data = $entity->getObject();
+ $matches = true;
+ foreach ($filters as $field => $value) {
+ if (self::relationUuid(value: ($data[$field] ?? null)) !== $value) {
+ $matches = false;
+ break;
+ }
+ }
+
+ if ($matches === true) {
+ return $entity;
+ }
+ }
+
+ return null;
+ }//end findOne()
+
+ /**
+ * Save an object through OpenRegister and return its uuid.
+ *
+ * @param string $schemaKey The coordinates key of the schema.
+ * @param array $data The object data.
+ * @param string|null $uuid The uuid to update, or null to create.
+ *
+ * @return string The uuid.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ private function save(string $schemaKey, array $data, ?string $uuid): string {
+ $coordinates = $this->coordinates();
+ $entity = $coordinates['objectService']->saveObject(
+ object: $data,
+ register: $coordinates['register'],
+ schema: $coordinates[$schemaKey],
+ uuid: $uuid,
+ _rbac: false,
+ _multitenancy: false
+ );
+
+ return (string)$entity->getUuid();
+ }//end save()
+
+ /**
+ * Resolve OpenRegister's mapping engine.
+ *
+ * @return object The engine, with `mapRow(array, array, int): array`.
+ *
+ * @throws CmdbImportException MAPPING_UNAVAILABLE.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-3
+ */
+ private function resolveEngine(): object {
+ $class = static::ENGINE_CLASS;
+ try {
+ if ($this->container->has($class) === true) {
+ $engine = $this->container->get($class);
+ if (is_object($engine) === true && method_exists($engine, 'mapRow') === true) {
+ return $engine;
+ }
+ }
+ } catch (Throwable $e) {
+ // Fall through to the class check below.
+ $engine = null;
+ }
+
+ if (class_exists($class) === true) {
+ $engine = new $class();
+ if (method_exists($engine, 'mapRow') === true) {
+ return $engine;
+ }
+ }
+
+ throw new CmdbImportException(errorCode: CmdbImportException::MAPPING_UNAVAILABLE, message: 'OpenRegister MappingEngine is not available');
+ }//end resolveEngine()
+
+ /**
+ * Resolve OpenRegister and the register and schema ids, failing closed.
+ *
+ * @return array{objectService: ObjectServiceInterface, register: int, module: int, organization: int, usage: int, contactPerson: int}
+ *
+ * @throws CmdbImportException NOT_CONFIGURED when OpenRegister or a schema is not configured,
+ * SCHEMA_OUTDATED when a schema lacks a match property.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ private function resolveCoordinates(): array {
+ try {
+ $objectService = $this->container->get(ObjectServiceInterface::class);
+ } catch (Throwable $e) {
+ $objectService = null;
+ }
+
+ $register = (int)($this->settingsService->getVoorzieningenConfig()['register'] ?? 0);
+ $schemas = [];
+ foreach (array_keys(self::MATCH_PROPERTIES) as $type) {
+ $schemas[$type] = (int)($this->settingsService->getSchemaIdForObjectType($type) ?? 0);
+ }
+
+ if ($objectService instanceof ObjectServiceInterface === false || $register <= 0 || in_array(0, $schemas, true) === true) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::NOT_CONFIGURED,
+ message: 'OpenRegister or the stackiq register and schemas are not configured'
+ );
+ }
+
+ $this->assertMatchProperties(schemas: $schemas);
+
+ return array_merge(['objectService' => $objectService, 'register' => $register], $schemas);
+ }//end resolveCoordinates()
+
+ /**
+ * Refuse the import when a schema does not declare the properties and write rules the import relies on.
+ *
+ * The module properties, and the admin-only update rule on `externalKey`,
+ * arrive with the register fragment (module 0.3.8 and later), which an
+ * installation gets only after its register configuration is imported
+ * again. A missing rule is reported as `.authorization.update`.
+ *
+ * @param array $schemas Schema key => schema id.
+ *
+ * @return void
+ *
+ * @throws CmdbImportException NOT_CONFIGURED when the schemas cannot be read,
+ * SCHEMA_OUTDATED when one lacks a match property.
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ private function assertMatchProperties(array $schemas): void {
+ try {
+ $mapper = $this->container->get(static::SCHEMA_MAPPER_CLASS);
+ } catch (Throwable $e) {
+ $mapper = null;
+ }
+
+ if (is_object($mapper) === false || method_exists($mapper, 'find') === false) {
+ throw new CmdbImportException(errorCode: CmdbImportException::NOT_CONFIGURED, message: 'OpenRegister SchemaMapper is not available');
+ }
+
+ foreach (self::MATCH_PROPERTIES as $type => $required) {
+ try {
+ $properties = $mapper->find(id: $schemas[$type], _rbac: false, _multitenancy: false)->getProperties();
+ } catch (Throwable $e) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::NOT_CONFIGURED,
+ message: 'The ' . $type . ' schema cannot be read: ' . get_class($e),
+ previous: $e
+ );
+ }
+
+ $properties = (array)$properties;
+ $missing = array_values(array_diff($required, array_keys($properties)));
+ array_push($missing, ...self::missingUpdateRules(type: $type, properties: $properties));
+ if ($missing !== []) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::SCHEMA_OUTDATED,
+ message: 'The ' . $type . ' schema does not declare the properties the import matches on',
+ details: ['schema' => $type, 'missing' => $missing]
+ );
+ }
+ }
+ }//end assertMatchProperties()
+
+ /**
+ * The declared properties of a schema that lack the update rule the import relies on.
+ *
+ * A property that is not declared at all is already reported as missing.
+ *
+ * @param string $type The schema key.
+ * @param array $properties The schema's property definitions.
+ *
+ * @return array `.authorization.update` per property without the rule.
+ */
+ private static function missingUpdateRules(string $type, array $properties): array {
+ $missing = [];
+ foreach ((self::REQUIRED_UPDATE_RULES[$type] ?? []) as $property => $group) {
+ if (array_key_exists($property, $properties) === false) {
+ continue;
+ }
+
+ $definition = json_decode((string)json_encode($properties[$property]), true);
+ $update = ($definition['authorization']['update'] ?? null);
+ if (is_array($update) === false) {
+ $update = [];
+ }
+
+ $groups = [];
+ foreach ($update as $rule) {
+ if (is_array($rule) === true) {
+ $rule = ($rule['group'] ?? null);
+ }
+
+ $groups[] = $rule;
+ }
+
+ if (in_array($group, $groups, true) === false) {
+ $missing[] = $property . '.authorization.update';
+ }
+ }
+
+ return $missing;
+ }//end missingUpdateRules()
+
+ /**
+ * The coordinates of the current run.
+ *
+ * @return array{objectService: ObjectServiceInterface, register: int, module: int, organization: int, usage: int, contactPerson: int}
+ *
+ * @throws CmdbImportException NOT_CONFIGURED.
+ */
+ private function coordinates(): array {
+ if ($this->coordinates === null) {
+ $this->coordinates = $this->resolveCoordinates();
+ }
+
+ return $this->coordinates;
+ }//end coordinates()
+
+ /**
+ * The operation id the client chose, or a new one.
+ *
+ * The client's id is replaced when it does not match the pattern, and
+ * also when an operation with that id is still running: reusing it would
+ * overwrite that run's progress and owner, and a cancel would stop both.
+ * An id that is taken drops any cancel request left over from an earlier
+ * run with the same id, so the new run is not stopped before row 1.
+ *
+ * @param array $options The import options.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ private function operationIdFrom(array $options): string {
+ $operationId = $options['operationId'] ?? null;
+ if (is_string($operationId) === true
+ && preg_match(self::OPERATION_ID_PATTERN, $operationId) === 1
+ && ($this->progressTracker->getProgress(operationId: $operationId)['status'] ?? null) !== 'running'
+ ) {
+ $this->progressTracker->clearCancelRequested(operationId: $operationId);
+ return $operationId;
+ }
+
+ return 'cmdb-' . bin2hex(random_bytes(16));
+ }//end operationIdFrom()
+
+ /**
+ * Clear the run caches.
+ *
+ * @return void
+ */
+ private function resetRun(): void {
+ $this->engine = null;
+ $this->coordinates = null;
+ $this->suppliers = null;
+ $this->seenKeys = [];
+ $this->winningSheets = [];
+ $this->contactUids = [];
+ $this->contactPersons = [];
+ }//end resetRun()
+
+ /**
+ * The source column of an owner target, for warnings.
+ *
+ * @param string $target businessOwner.
+ *
+ * @return string
+ */
+ private function ownerColumn(string $target): string {
+ foreach (($this->profile->pack(target: $target)['fieldMappings'] ?? []) as $mapping) {
+ if (($mapping['target'] ?? null) === 'name') {
+ return (string)($mapping['source'] ?? $target);
+ }
+ }
+
+ return $target;
+ }//end ownerColumn()
+
+ /**
+ * An exception message that is safe for the log: no cell value and no e-mail address.
+ *
+ * Only the first line is kept, at most 300 characters. Every value of the
+ * row of three characters or more is replaced by "…", longest first, and
+ * every e-mail address by "". The owner step logs no message at
+ * all, so no contact data can reach the log. Public so the controller logs
+ * an unexpected failure the same way.
+ *
+ * @param string $step The step that failed.
+ * @param Throwable $e The exception.
+ * @param array $values The normalised row.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-7
+ */
+ public static function logSafeMessage(string $step, Throwable $e, array $values): string {
+ if ($step === 'owners') {
+ return '';
+ }
+
+ $lines = preg_split('/\R/u', $e->getMessage());
+ $message = mb_substr(trim((string)($lines[0] ?? '')), 0, 300);
+ $message = (string)preg_replace('/[^\s@<>"\'(),;:=]+@[^\s@<>"\'(),;:]+/u', '', $message);
+ $secrets = array_filter(array_unique(array_map('strval', $values)), static fn (string $value): bool => mb_strlen($value) >= 3);
+ usort($secrets, static fn (string $left, string $right): int => (mb_strlen($right) <=> mb_strlen($left)));
+ foreach ($secrets as $secret) {
+ $message = str_ireplace($secret, '…', $message);
+ }
+
+ return $message;
+ }//end logSafeMessage()
+
+ /**
+ * Split a TOPdesk person name ("Achternaam, Voornaam").
+ *
+ * @param string $name The name.
+ *
+ * @return array{voornaam: string, achternaam: string}
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-6
+ */
+ public static function splitPersonName(string $name): array {
+ $name = trim((string)preg_replace('/\s+/u', ' ', $name));
+ if (str_contains($name, ',') === true) {
+ [$last, $first] = array_map('trim', explode(',', $name, 2));
+ return ['voornaam' => $first, 'achternaam' => $last];
+ }
+
+ return ['voornaam' => '', 'achternaam' => $name];
+ }//end splitPersonName()
+
+ /**
+ * Normalise an organisation name for matching: trim, collapse whitespace, lower case.
+ *
+ * @param string $name The name.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ */
+ public static function normaliseName(string $name): string {
+ return mb_strtolower(trim((string)preg_replace('/\s+/u', ' ', $name)));
+ }//end normaliseName()
+
+ /**
+ * A relation value (uuid string, or array/object with uuid or id) as a string.
+ *
+ * @param mixed $value The stored value.
+ *
+ * @return string|null
+ */
+ private static function relationUuid(mixed $value): ?string {
+ if (is_scalar($value) === true) {
+ return (string)$value;
+ }
+
+ if (is_array($value) === true) {
+ $uuid = ($value['uuid'] ?? ($value['id'] ?? null));
+ if (is_scalar($uuid) === true) {
+ return (string)$uuid;
+ }
+ }
+
+ return null;
+ }//end relationUuid()
+
+ /**
+ * Whether a stored value equals a mapped value.
+ *
+ * @param mixed $stored The stored value.
+ * @param mixed $value The mapped value.
+ *
+ * @return bool
+ */
+ private static function sameValue(mixed $stored, mixed $value): bool {
+ if (is_scalar($value) === true && (is_scalar($stored) === true || is_array($stored) === true)) {
+ return self::relationUuid(value: $stored) === (string)$value;
+ }
+
+ return $stored === $value;
+ }//end sameValue()
+
+ /**
+ * Whether a stored value counts as empty.
+ *
+ * @param mixed $value The stored value.
+ *
+ * @return bool
+ */
+ private static function isEmptyValue(mixed $value): bool {
+ return $value === null || $value === '' || $value === [];
+ }//end isEmptyValue()
+}//end class
diff --git a/lib/Service/ConnectionReportService.php b/lib/Service/ConnectionReportService.php
new file mode 100644
index 000000000..1e058c974
--- /dev/null
+++ b/lib/Service/ConnectionReportService.php
@@ -0,0 +1,555 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @link https://conduction.nl
+ *
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service;
+
+use OCA\Stackiq\AppInfo\Application;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventDispatcher;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Sends connection refresh requests and reports to integriq.
+ *
+ * A save refreshes before it reports. Under hydra#674 a refresh retires the
+ * observations older than itself, so a report sent before the refresh would
+ * be retired by it. A pull or a sync run reports without a refresh: it
+ * changes no settings.
+ *
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
+ */
+class ConnectionReportService {
+
+ /**
+ * Integriq's report event (ADR-041). Named by string so stackiq stays
+ * installable without integriq: the class is only there when integriq is.
+ *
+ * @var string
+ */
+ public const STATUS_EVENT = 'OCA\Integriq\Event\ConnectionStatusReportedEvent';
+
+ /**
+ * Integriq's refresh event. Same reason for the string as above.
+ *
+ * @var string
+ */
+ public const REFRESH_EVENT = 'OCA\Integriq\Event\ConnectionRefreshRequestedEvent';
+
+ /**
+ * The email connection key in lib/Settings/connections.json.
+ *
+ * @var string
+ */
+ public const KEY_EMAIL = 'email';
+
+ /**
+ * The catalog federation connection key in lib/Settings/connections.json.
+ *
+ * @var string
+ */
+ public const KEY_FEDERATION = 'federation';
+
+ /**
+ * The end-of-life feed connection key in lib/Settings/connections.json.
+ *
+ * @var string
+ */
+ public const KEY_EOL = 'eol-feed';
+
+ /**
+ * The service desk exchange connection key in lib/Settings/connections.json.
+ *
+ * @var string
+ */
+ public const KEY_ITSM = 'itsm';
+
+ /**
+ * The pull reason FederationService records for switched-off federation, which is never reported.
+ *
+ * The `federation` switch in lib/Settings/connections.json reads
+ * `federation_enabled`, so integriq shows `disabled` without a report
+ * (hydra connection-registry D4 rule 2b).
+ *
+ * @var string
+ */
+ public const PULL_REASON_SWITCHED_OFF = 'federation disabled';
+
+ /**
+ * The EOL sync degrade reason for a switched-off sync, which is never reported.
+ *
+ * The `eol-feed` switch reads `enabled` inside `eol_sync_config` itself.
+ *
+ * @var string
+ */
+ public const EOL_REASON_SWITCHED_OFF = 'disabled';
+
+ /**
+ * The longest failure reason a message carries.
+ *
+ * @var int
+ */
+ public const REASON_LIMIT = 160;
+
+ /**
+ * What each EOL sync degrade reason means for the row, as status and message.
+ *
+ * The reasons are the ones EolSyncService::degrade() records, apart from
+ * `disabled`: the `eol-feed` switch in lib/Settings/connections.json reads
+ * a switched-off sync itself, so that reason reports nothing.
+ *
+ * @var array
+ */
+ public const EOL_REASONS = [
+ 'openregister-not-installed' => [
+ 'unavailable',
+ 'The end-of-life sync needs OpenRegister, and it is not installed.',
+ ],
+ 'object-service-unavailable' => [
+ 'error',
+ 'OpenRegister did not answer the last end-of-life sync.',
+ ],
+ 'module-schema-not-configured' => [
+ 'unconfigured',
+ 'Stackiq has no module or module version schema configured, so the sync has nothing to stamp.',
+ ],
+ 'eol-register-or-schema-not-found' => [
+ 'unconfigured',
+ 'The end-of-life register or schemas are missing. Install the endoflife.date source in integriq, '
+ . 'or fix the names in the End-of-life feed sync section.',
+ ],
+ ];
+
+ /**
+ * Constructor.
+ *
+ * @param IEventDispatcher $eventDispatcher Sends the integriq events (ADR-041).
+ * @param SymfonyEmailService $emailService Tells whether the saved email settings are complete.
+ * @param LoggerInterface $logger Records what could not be sent.
+ *
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
+ */
+ public function __construct(
+ private readonly IEventDispatcher $eventDispatcher,
+ private readonly SymfonyEmailService $emailService,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * After an email settings save: refresh, then report what the saved settings say.
+ *
+ * The `null` transport is left to integriq's rule 3, which outranks any
+ * report. Never throws, and does nothing without integriq.
+ *
+ * @return bool True when the report was sent.
+ *
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
+ */
+ public function emailSettingsSaved(): bool {
+ if ($this->refresh(key: self::KEY_EMAIL) === false) {
+ return false;
+ }
+
+ try {
+ [$status, $message] = $this->describeEmail(
+ configStatus: $this->emailService->isEmailSystemConfigured(),
+ transportLabels: $this->emailService->getAvailableTransports()
+ );
+ } catch (Throwable $e) {
+ $this->logger->warning(
+ 'Stackiq: could not read the email settings for a connection report',
+ ['key' => self::KEY_EMAIL, 'exception' => $e->getMessage()]
+ );
+ return false;
+ }
+
+ return $this->report(key: self::KEY_EMAIL, status: $status, message: $message);
+ }//end emailSettingsSaved()
+
+ /**
+ * What the email configuration status says about the connection.
+ *
+ * @param array $configStatus The result of SymfonyEmailService::isEmailSystemConfigured().
+ * @param array $transportLabels Transport type to its label.
+ *
+ * @return array{0: string, 1: string} The status and the message.
+ *
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
+ */
+ public function describeEmail(array $configStatus, array $transportLabels): array {
+ if (array_key_exists('transportType', $configStatus) === false) {
+ // SymfonyEmailService answers without a transport only when email is off.
+ return ['unconfigured', 'Email is switched off, so stackiq sends no mail.'];
+ }
+
+ $transport = (string) $configStatus['transportType'];
+ $label = ($transportLabels[$transport] ?? $transport);
+
+ if (($configStatus['hasCredentials'] ?? false) !== true) {
+ return ['unconfigured', 'Email is on, and the ' . $label . ' transport misses a setting it needs.'];
+ }
+
+ if (($configStatus['hasTemplates'] ?? false) !== true) {
+ return ['unconfigured', 'Email is on, and a required mail template is empty.'];
+ }
+
+ return ['configured', 'Email is on and the ' . $label . ' transport settings are filled. No test mail was sent.'];
+ }//end describeEmail()
+
+ /**
+ * After a peer was added or removed: refresh, then report a state that blocks federation.
+ *
+ * A ready federation gets no report: only a pull can tell whether the peers
+ * answer, so the row reads the declared "Not checked yet" until then.
+ * Switched-off federation gets none either: the `federation` switch in
+ * lib/Settings/connections.json reads `federation_enabled` itself, and
+ * integriq shows `disabled`.
+ *
+ * @param array $status The result of FederationService::getStatus().
+ *
+ * @return bool True when a report was sent.
+ *
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
+ */
+ public function federationPeersChanged(array $status): bool {
+ if ($this->refresh(key: self::KEY_FEDERATION) === false) {
+ return false;
+ }
+
+ $available = ($status['available'] ?? false) === true;
+ if ($available === true && ($status['enabled'] ?? false) !== true) {
+ return false;
+ }
+
+ $blocked = $this->federationBlocker(
+ available: $available,
+ peerCount: count((array) ($status['peers'] ?? []))
+ );
+ if ($blocked === null) {
+ return false;
+ }
+
+ return $this->report(key: self::KEY_FEDERATION, status: $blocked[0], message: $blocked[1]);
+ }//end federationPeersChanged()
+
+ /**
+ * After a federation pull: report what the peers answered.
+ *
+ * @param array $pull The result of FederationService::pullAllPeers().
+ *
+ * @return bool True when a report was sent.
+ *
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
+ */
+ public function federationPulled(array $pull): bool {
+ $described = $this->describePull(pull: $pull);
+ if ($described === null) {
+ return false;
+ }
+
+ [$status, $message] = $described;
+
+ return $this->report(key: self::KEY_FEDERATION, status: $status, message: $message);
+ }//end federationPulled()
+
+ /**
+ * What a federation pull says about the connection.
+ *
+ * @param array $pull The result of FederationService::pullAllPeers().
+ *
+ * @return array{0: string, 1: string}|null The status and the message, or null when federation is switched off.
+ *
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
+ */
+ public function describePull(array $pull): ?array {
+ $reason = (string) ($pull['reason'] ?? '');
+ if (($pull['ok'] ?? false) !== true) {
+ if ($reason === self::PULL_REASON_SWITCHED_OFF) {
+ return null;
+ }
+
+ $blocked = $this->federationBlocker(
+ available: $reason !== 'OpenCatalogi unavailable',
+ peerCount: 1
+ );
+
+ return ($blocked ?? ['error', 'The last federation pull failed: ' . $this->shorten(text: $reason)]);
+ }
+
+ $peers = array_values((array) ($pull['peers'] ?? []));
+ if ($peers === []) {
+ return ['unconfigured', 'Federation is on, and no peer catalog is added yet.'];
+ }
+
+ $failed = array_values(
+ array_filter($peers, static fn (mixed $peer): bool => is_array($peer) === true && ($peer['ok'] ?? false) !== true)
+ );
+ if ($failed === [] && count($peers) === 1) {
+ return ['configured', 'The peer catalog answered the last pull.'];
+ }
+
+ if ($failed === []) {
+ return ['configured', 'All ' . count($peers) . ' peer catalogs answered the last pull.'];
+ }
+
+ $first = $this->peerHost(url: (string) ($failed[0]['peer'] ?? ''))
+ . ' did not: ' . $this->shorten(text: (string) ($failed[0]['reason'] ?? ''));
+ if (count($failed) === count($peers)) {
+ return ['error', 'No peer catalog answered the last pull. ' . $first];
+ }
+
+ $answered = (count($peers) - count($failed));
+
+ return ['limited', $answered . ' of ' . count($peers) . ' peer catalogs answered the last pull. ' . $first];
+ }//end describePull()
+
+ /**
+ * After an EOL sync settings save: refresh, and send no report.
+ *
+ * The save may have switched the sync on or off, and the `eol-feed` switch
+ * in lib/Settings/connections.json reads `enabled` inside
+ * `eol_sync_config` itself. A run reports what the sync met.
+ *
+ * @return bool True when the refresh was sent.
+ *
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
+ */
+ public function eolSyncConfigSaved(): bool {
+ return $this->refresh(key: self::KEY_EOL);
+ }//end eolSyncConfigSaved()
+
+ /**
+ * After an EOL sync run: report the outcome the run recorded.
+ *
+ * @param array $runStatus The status EolSyncService::run() recorded.
+ *
+ * @return bool True when a report was sent.
+ *
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
+ */
+ public function eolSyncRan(array $runStatus): bool {
+ $described = $this->describeEolRun(runStatus: $runStatus);
+ if ($described === null) {
+ return false;
+ }
+
+ [$status, $message] = $described;
+
+ return $this->report(key: self::KEY_EOL, status: $status, message: $message);
+ }//end eolSyncRan()
+
+ /**
+ * What an EOL sync run says about the connection.
+ *
+ * @param array $runStatus The status EolSyncService::run() recorded.
+ *
+ * @return array{0: string, 1: string}|null The status and the message, or null when the sync is switched off.
+ *
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
+ */
+ public function describeEolRun(array $runStatus): ?array {
+ if (($runStatus['available'] ?? false) === true) {
+ return [
+ 'configured',
+ 'The last sync stamped ' . (int) ($runStatus['matched'] ?? 0) . ' module versions and skipped '
+ . (int) ($runStatus['skipped'] ?? 0) . '.',
+ ];
+ }
+
+ $reason = (string) ($runStatus['reason'] ?? '');
+ if ($reason === self::EOL_REASON_SWITCHED_OFF) {
+ return null;
+ }
+
+ return (self::EOL_REASONS[$reason] ?? ['error', 'The last end-of-life sync stopped: ' . $this->shorten(text: $reason)]);
+ }//end describeEolRun()
+
+ /**
+ * After the service desk exchange was set up: report what the set-up met.
+ *
+ * @param bool $created Whether every flow was created.
+ * @param string $message What the set-up found.
+ *
+ * @return bool True when a report was sent.
+ *
+ * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-007-the-cmdb-page-says-what-stackiq-is
+ */
+ public function itsmSetUp(bool $created, string $message): bool {
+ $status = 'error';
+ if ($created === true) {
+ $status = 'configured';
+ }
+
+ return $this->report(key: self::KEY_ITSM, status: $status, message: $this->shorten(text: $message));
+ }//end itsmSetUp()
+
+ /**
+ * Ask integriq to resolve one connection again.
+ *
+ * @param string $key The connection key.
+ *
+ * @return bool True when the event was dispatched.
+ *
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
+ */
+ private function refresh(string $key): bool {
+ $eventClass = $this->resolveEventClass(eventClass: self::REFRESH_EVENT);
+ if ($eventClass === null) {
+ return false;
+ }
+
+ return $this->send(
+ key: $key,
+ build: static fn (): object => new $eventClass(app: Application::APP_ID, key: $key)
+ );
+ }//end refresh()
+
+ /**
+ * Report one status for one connection.
+ *
+ * @param string $key The connection key.
+ * @param string $status One of the six registry statuses.
+ * @param string $message What stackiq observed.
+ *
+ * @return bool True when the event was dispatched.
+ *
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
+ */
+ private function report(string $key, string $status, string $message): bool {
+ $eventClass = $this->resolveEventClass(eventClass: self::STATUS_EVENT);
+ if ($eventClass === null) {
+ return false;
+ }
+
+ return $this->send(
+ key: $key,
+ build: static fn (): object => new $eventClass(app: Application::APP_ID, key: $key, status: $status, message: $message)
+ );
+ }//end report()
+
+ /**
+ * The event class to instantiate, or null when integriq does not ship it.
+ *
+ * @param string $eventClass The fully qualified class name, without a leading backslash.
+ *
+ * @return string|null The class name to instantiate, or null when absent.
+ *
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
+ */
+ protected function resolveEventClass(string $eventClass): ?string {
+ $qualified = '\\' . $eventClass;
+ if (class_exists($qualified) === false) {
+ return null;
+ }
+
+ return $qualified;
+ }//end resolveEventClass()
+
+ /**
+ * The state that keeps federation from pulling at all, or null when none does.
+ *
+ * Switched-off federation is not a blocker here: the callers leave it to the
+ * row's switch, which integriq reads itself.
+ *
+ * @param bool $available Whether OpenCatalogi is installed.
+ * @param int $peerCount How many peers are configured.
+ *
+ * @return array{0: string, 1: string}|null The status and the message, or null.
+ */
+ private function federationBlocker(bool $available, int $peerCount): ?array {
+ if ($available === false) {
+ return ['unavailable', 'Federation needs the OpenCatalogi app, and it is not installed.'];
+ }
+
+ if ($peerCount === 0) {
+ return ['unconfigured', 'Federation is on, and no peer catalog is added yet.'];
+ }
+
+ return null;
+ }//end federationBlocker()
+
+ /**
+ * The host of a peer URL, so a message never carries its path, query or credentials.
+ *
+ * @param string $url The peer URL.
+ *
+ * @return string The host, or "A peer" when the URL has none.
+ */
+ private function peerHost(string $url): string {
+ $host = parse_url($url, PHP_URL_HOST);
+ if (is_string($host) === false || $host === '') {
+ return 'A peer';
+ }
+
+ return $host;
+ }//end peerHost()
+
+ /**
+ * A reason cut to REASON_LIMIT characters.
+ *
+ * @param string $text The reason.
+ *
+ * @return string The reason, cut and trimmed.
+ */
+ private function shorten(string $text): string {
+ $text = trim($text);
+ if (mb_strlen($text) <= self::REASON_LIMIT) {
+ return $text;
+ }
+
+ return rtrim(mb_substr($text, 0, self::REASON_LIMIT)) . '...';
+ }//end shorten()
+
+ /**
+ * Build and dispatch one event, swallowing anything a listener throws.
+ *
+ * @param string $key The connection the event is about, for the log.
+ * @param callable(): object $build Builds the event.
+ *
+ * @return bool True when the event was dispatched without an exception.
+ */
+ private function send(string $key, callable $build): bool {
+ try {
+ $event = $build();
+ if (($event instanceof Event) === false) {
+ return false;
+ }
+
+ $this->eventDispatcher->dispatchTyped($event);
+ return true;
+ } catch (Throwable $e) {
+ $this->logger->warning(
+ 'Stackiq: could not send a connection event to integriq',
+ ['key' => $key, 'exception' => $e->getMessage()]
+ );
+ return false;
+ }
+ }//end send()
+}//end class
diff --git a/lib/Service/DemoDataService.php b/lib/Service/DemoDataService.php
index ac68ddefc..d5fd1a921 100644
--- a/lib/Service/DemoDataService.php
+++ b/lib/Service/DemoDataService.php
@@ -225,7 +225,10 @@ public function install(): array {
$objects = count($components['objects']);
}
- $result = $this->configurationService()->importFromApp(
+ $importer = $this->configurationService();
+ $data = $this->pinSchemasToTheirRegisters(data: $data);
+
+ $result = $importer->importFromApp(
appId: self::CONFIG_APP_ID,
data: $data,
version: $this->appManager->getAppVersion(Application::APP_ID),
@@ -249,6 +252,74 @@ public function install(): array {
return $imported;
}//end install()
+ /**
+ * Point every demo object at its schema in its own register, by id.
+ *
+ * The descriptor carries no schema definitions (the live register's are the
+ * ones the objects must satisfy), so each object names its schema by slug.
+ * OpenRegister resolves a slug it has no definition for across the whole
+ * instance, and a slug such as `organization` is also another app's
+ * (OpenCatalogi's), whose schema the importer then skips as foreign. The
+ * register the object names lists the right schema, so the slug is
+ * resolved there and replaced by that schema's id. A slug the register does
+ * not list, or a register that cannot be read, is left for the importer.
+ *
+ * @param array $data The descriptor.
+ *
+ * @return array The descriptor with resolved schema ids.
+ *
+ * @spec exclude Demo-data import; ADR-111 rule 1 has no per-app behavioural spec.
+ */
+ private function pinSchemasToTheirRegisters(array $data): array {
+ $objects = ($data['components']['objects'] ?? null);
+ if (is_array($objects) === false) {
+ return $data;
+ }
+
+ $bySlug = [];
+ foreach ($objects as $index => $object) {
+ $register = (string) ($object['@self']['register'] ?? '');
+ $schema = (string) ($object['@self']['schema'] ?? '');
+ if ($register === '' || $schema === '') {
+ continue;
+ }
+
+ $bySlug[$register] ??= $this->schemaIdsOfRegister(registerSlug: $register);
+ if (isset($bySlug[$register][$schema]) === true) {
+ $data['components']['objects'][$index]['@self']['schema'] = (string) $bySlug[$register][$schema];
+ }
+ }
+
+ return $data;
+ }//end pinSchemasToTheirRegisters()
+
+ /**
+ * The schemas a register lists, as slug => id.
+ *
+ * @param string $registerSlug The register.
+ *
+ * @return array The schema ids by slug; empty when the register cannot be read.
+ */
+ private function schemaIdsOfRegister(string $registerSlug): array {
+ try {
+ $register = $this->container->get('OCA\OpenRegister\Db\RegisterMapper')->find($registerSlug, _rbac: false, _multitenancy: false);
+ $schemas = $this->container->get('OCA\OpenRegister\Db\SchemaMapper')->findMultiple($register->getSchemas(), _rbac: false, _multitenancy: false);
+ } catch (\Throwable $e) {
+ $this->logger->warning(
+ '[DemoDataService] could not read the schemas of a demo register; its objects are resolved by slug',
+ ['register' => $registerSlug, 'error' => $e->getMessage()]
+ );
+ return [];
+ }
+
+ $ids = [];
+ foreach ($schemas as $schema) {
+ $ids[(string) $schema->getSlug()] = (int) $schema->getId();
+ }
+
+ return $ids;
+ }//end schemaIdsOfRegister()
+
/**
* Absolute path to the shipped descriptor.
*
diff --git a/lib/Service/EolSyncService.php b/lib/Service/EolSyncService.php
index 7a138f9bb..86be572b3 100644
--- a/lib/Service/EolSyncService.php
+++ b/lib/Service/EolSyncService.php
@@ -57,12 +57,16 @@ class EolSyncService {
* @param EolMatcherService $matcher The pure matching/stamping logic.
* @param ITimeFactory $timeFactory The time factory (sync-run timestamp).
* @param LoggerInterface $logger The logger.
+ * @param ConnectionReportService|null $connectionReports Tells integriq what a save or a run met.
+ *
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
*/
public function __construct(
private readonly SettingsService $settingsService,
private readonly EolMatcherService $matcher,
private readonly ITimeFactory $timeFactory,
private readonly LoggerInterface $logger,
+ private readonly ?ConnectionReportService $connectionReports = null,
) {
}//end __construct()
@@ -82,12 +86,19 @@ public function getConfig(): array {
*
* @param array $data The submitted configuration fields.
*
+ * The save asks integriq to resolve the end-of-life feed connection again
+ * (adopt-connection-registry).
+ *
* @return array The persisted configuration result.
*
* @spec openspec/specs/eol-feed-integration/spec.md#requirement-products-are-mapped-to-endoflife-date-via-per-module-config
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
*/
public function updateConfig(array $data): array {
- return $this->settingsService->updateEolSyncConfig($data);
+ $result = $this->settingsService->updateEolSyncConfig($data);
+ $this->connectionReports?->eolSyncConfigSaved();
+
+ return $result;
}//end updateConfig()
/**
@@ -226,7 +237,7 @@ public function run(): array {
'skipped' => $totalSkipped,
'lastRunAt' => $fetchedAt,
];
- $this->settingsService->setEolSyncStatus($status);
+ $this->recordStatus(status: $status);
return $status;
}//end run()
@@ -476,8 +487,22 @@ private function degrade(string $reason): array {
'skipped' => 0,
'lastRunAt' => $this->timeFactory->getDateTime()->format(\DateTimeInterface::ATOM),
];
- $this->settingsService->setEolSyncStatus($status);
+ $this->recordStatus(status: $status);
return $status;
}//end degrade()
+
+ /**
+ * Record a run's status, and tell integriq what the run met.
+ *
+ * @param array{available: bool, reason: string|null, matched: int, skipped: int, lastRunAt: string|null} $status The run status.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
+ */
+ private function recordStatus(array $status): void {
+ $this->settingsService->setEolSyncStatus($status);
+ $this->connectionReports?->eolSyncRan(runStatus: $status);
+ }//end recordStatus()
}//end class
diff --git a/lib/Service/FacetService.php b/lib/Service/FacetService.php
index fc8094afe..a2af905d3 100644
--- a/lib/Service/FacetService.php
+++ b/lib/Service/FacetService.php
@@ -108,6 +108,15 @@ class FacetService {
*/
private const DIMENSIONS = ['referenceComponent', 'standard', 'applicationService', 'domain'];
+ /**
+ * The element property the domain dimension reads: the register's
+ * `element` schema declares it as `domein`, and the GEMMA import stores
+ * the "Domein" property under that lower-cased name.
+ *
+ * @var string
+ */
+ public const ELEMENT_DOMAIN_PROPERTY = 'domein';
+
/**
* Constructor for FacetService.
*
@@ -701,7 +710,7 @@ private function buildDimensionValueMap(array $modulesByObjectId): array {
fallbackIdentifier: $refCompId
);
- $domain = $element['domain'] ?? null;
+ $domain = $element[self::ELEMENT_DOMAIN_PROPERTY] ?? null;
if (is_string($domain) === true && trim($domain) !== '') {
$domeinValues[] = trim($domain);
}
diff --git a/lib/Service/Federation/FederationService.php b/lib/Service/Federation/FederationService.php
index 2bd60c109..3bb5174b7 100644
--- a/lib/Service/Federation/FederationService.php
+++ b/lib/Service/Federation/FederationService.php
@@ -27,6 +27,7 @@
namespace OCA\Stackiq\Service\Federation;
+use OCA\Stackiq\Service\ConnectionReportService;
use OCA\Stackiq\Service\SettingsService;
use OCP\App\IAppManager;
use Psr\Container\ContainerInterface;
@@ -63,6 +64,9 @@ class FederationService {
* @param FederationMerger $merger The merge/staleness reconciler.
* @param SettingsService|null $settingsService Resolves the mirror register/schema (lazy/optional).
* @param LoggerInterface $logger Logger.
+ * @param ConnectionReportService|null $connectionReports Tells integriq what a peer change or a pull met.
+ *
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
*/
public function __construct(
private readonly ContainerInterface $container,
@@ -71,6 +75,7 @@ public function __construct(
private readonly FederationMerger $merger,
private readonly ?SettingsService $settingsService,
private readonly LoggerInterface $logger,
+ private readonly ?ConnectionReportService $connectionReports = null,
) {
}//end __construct()
@@ -141,9 +146,13 @@ public function getStatus(): array {
*
* @param string $peerUrl The peer base URL.
*
+ * A new peer asks integriq to resolve the federation connection again
+ * (adopt-connection-registry).
+ *
* @return array{ok:bool, reason:string} Result for the settings UI.
*
* @spec openspec/specs/federated-catalog-sync/spec.md
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
*/
public function addPeer(string $peerUrl): array {
$peerUrl = trim($peerUrl);
@@ -162,6 +171,7 @@ public function addPeer(string $peerUrl): array {
$peers[] = $peerUrl;
$this->config->setPeers(array_values($peers));
+ $this->connectionReports?->federationPeersChanged(status: $this->getStatus());
return ['ok' => true, 'reason' => 'peer added'];
}//end addPeer()
@@ -170,9 +180,13 @@ public function addPeer(string $peerUrl): array {
*
* @param string $peerUrl The peer base URL.
*
+ * A removed peer asks integriq to resolve the federation connection again
+ * (adopt-connection-registry).
+ *
* @return array{ok:bool, reason:string} Result for the settings UI.
*
* @spec openspec/specs/federated-catalog-sync/spec.md
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
*/
public function removePeer(string $peerUrl): array {
$peerUrl = trim($peerUrl);
@@ -184,6 +198,7 @@ public function removePeer(string $peerUrl): array {
$this->config->setPeers($filtered);
$this->config->setPeerFailures($peerUrl, 0);
+ $this->connectionReports?->federationPeersChanged(status: $this->getStatus());
return ['ok' => true, 'reason' => 'peer removed'];
}//end removePeer()
@@ -278,11 +293,29 @@ public function discoverPeers(): array {
* independently so one unreachable peer cannot block the rest. Returns a
* per-peer result summary for logging / the admin UI.
*
+ * Tells integriq what the pull met, from the Pull now button and from
+ * FederationSyncJob alike (adopt-connection-registry).
+ *
* @return array{ok:bool, reason:string, peers:array>}
*
* @spec openspec/specs/federated-catalog-sync/spec.md
+ * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met
*/
public function pullAllPeers(): array {
+ $result = $this->pullEveryPeer();
+ $this->connectionReports?->federationPulled(pull: $result);
+
+ return $result;
+ }//end pullAllPeers()
+
+ /**
+ * Pull every subscribed peer, one at a time.
+ *
+ * @return array{ok:bool, reason:string, peers:array>}
+ *
+ * @spec openspec/specs/federated-catalog-sync/spec.md
+ */
+ private function pullEveryPeer(): array {
if ($this->config->isEnabled() === false) {
return ['ok' => false, 'reason' => 'federation disabled', 'peers' => []];
}
@@ -297,7 +330,7 @@ public function pullAllPeers(): array {
}
return ['ok' => true, 'reason' => 'ok', 'peers' => $results];
- }//end pullAllPeers()
+ }//end pullEveryPeer()
/**
* Pull one peer's published catalog and reconcile it into local mirrors.
diff --git a/lib/Service/Itsm/ItsmFlowGateway.php b/lib/Service/Itsm/ItsmFlowGateway.php
new file mode 100644
index 000000000..f8abc9642
--- /dev/null
+++ b/lib/Service/Itsm/ItsmFlowGateway.php
@@ -0,0 +1,223 @@
+
+ * @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/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Itsm;
+
+use Psr\Container\ContainerInterface;
+use RuntimeException;
+use Throwable;
+
+/**
+ * Validates, saves, publishes and runs flows through OpenRegister.
+ *
+ * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential
+ */
+class ItsmFlowGateway {
+
+ /**
+ * OpenRegister's preflight, which checks a flow against the live node registry.
+ *
+ * @var string
+ */
+ private const PREFLIGHT = 'OCA\OpenRegister\Service\Flow\FlowNodePreflight';
+
+ /**
+ * OpenRegister's flow store.
+ *
+ * @var string
+ */
+ private const FLOWS = 'OCA\OpenRegister\Service\Flow\FlowService';
+
+ /**
+ * OpenRegister's flow versions, which publish a draft.
+ *
+ * @var string
+ */
+ private const VERSIONS = 'OCA\OpenRegister\Service\Flow\FlowVersionService';
+
+ /**
+ * OpenRegister's object service.
+ *
+ * @var string
+ */
+ private const OBJECTS = 'OCA\OpenRegister\Service\ObjectService';
+
+ /**
+ * Constructor.
+ *
+ * @param ContainerInterface $container The server container.
+ */
+ public function __construct(
+ private readonly ContainerInterface $container,
+ ) {
+ }//end __construct()
+
+ /**
+ * Whether OpenRegister's flow engine is there.
+ *
+ * @return bool True when the flow classes resolve.
+ *
+ * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential
+ */
+ public function available(): bool {
+ return class_exists('\\' . self::FLOWS) === true && class_exists('\\' . self::PREFLIGHT) === true;
+ }//end available()
+
+ /**
+ * Check a flow document against the live node registry.
+ *
+ * @param array $flow The flow document.
+ *
+ * @return array{blocking: list>, warnings: list>} The findings.
+ *
+ * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential
+ */
+ public function inspect(array $flow): array {
+ $result = $this->service(class: self::PREFLIGHT)->inspect($flow);
+
+ return [
+ 'blocking' => array_values((array) ($result['blocking'] ?? [])),
+ 'warnings' => array_values((array) ($result['warnings'] ?? [])),
+ ];
+ }//end inspect()
+
+ /**
+ * Save a flow, publish it and switch it on.
+ *
+ * @param array $flow The flow document.
+ * @param string|null $uuid The flow to update, or null to create one.
+ *
+ * @return string The flow's uuid.
+ *
+ * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential
+ */
+ public function saveAndPublish(array $flow, ?string $uuid): string {
+ $flows = $this->service(class: self::FLOWS);
+ if ($uuid !== null && $this->exists(uuid: $uuid) === false) {
+ $uuid = null;
+ }
+
+ // A published version is immutable: open a draft first, so a second
+ // set-up edits the flow instead of being refused.
+ if ($uuid !== null) {
+ $existing = $flows->find($uuid);
+ if ((string) $existing->getLifecycleStatus() !== 'draft') {
+ $this->service(class: self::VERSIONS)->createDraft($existing);
+ }
+ }
+
+ $saved = $flows->save($flow, $uuid);
+ $this->service(class: self::VERSIONS)->publish($saved);
+
+ $enabled = $flows->save(['enabled' => true], (string) $saved->getUuid());
+
+ return (string) $enabled->getUuid();
+ }//end saveAndPublish()
+
+ /**
+ * Start a run of a flow with a payload, which seeds the run's first item.
+ *
+ * @param string $uuid The flow.
+ * @param array $payload The payload.
+ *
+ * @return string The run's uuid.
+ *
+ * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-006-a-file-feeds-the-same-import
+ */
+ public function run(string $uuid, array $payload): string {
+ $run = $this->service(class: self::FLOWS)->run($uuid, [], ['payload' => $payload], false);
+
+ return (string) $run->getUuid();
+ }//end run()
+
+ /**
+ * Read one object of a register and schema by id, uuid or slug.
+ *
+ * @param string $register The register slug.
+ * @param string $schema The schema slug.
+ * @param string $id The id, uuid or slug.
+ *
+ * @return array|null The object, or null when there is none.
+ *
+ * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential
+ */
+ public function findObject(string $register, string $schema, string $id): ?array {
+ try {
+ $found = $this->service(class: self::OBJECTS)->find($id, [], false, $register, $schema, false, false);
+ } catch (Throwable $e) {
+ return null;
+ }
+
+ if (is_object($found) === false) {
+ return null;
+ }
+
+ $object = (array) $found->jsonSerialize();
+ $object['uuid'] = (string) $found->getUuid();
+
+ return $object;
+ }//end findObject()
+
+ /**
+ * Whether a flow still exists.
+ *
+ * @param string $uuid The flow.
+ *
+ * @return bool True when it does.
+ */
+ private function exists(string $uuid): bool {
+ try {
+ $this->service(class: self::FLOWS)->find($uuid);
+ } catch (Throwable $e) {
+ return false;
+ }
+
+ return true;
+ }//end exists()
+
+ /**
+ * Resolve one OpenRegister service.
+ *
+ * @param string $class The class name.
+ *
+ * @return object The service.
+ *
+ * @throws RuntimeException When OpenRegister does not provide it.
+ */
+ private function service(string $class): object {
+ try {
+ $service = $this->container->get($class);
+ } catch (Throwable $e) {
+ throw new RuntimeException('OpenRegister does not provide ' . $class . ': ' . $e->getMessage(), 0, $e);
+ }
+
+ if (is_object($service) === false) {
+ throw new RuntimeException('OpenRegister does not provide ' . $class . '.');
+ }
+
+ return $service;
+ }//end service()
+}//end class
diff --git a/lib/Service/ItsmExchangeService.php b/lib/Service/ItsmExchangeService.php
new file mode 100644
index 000000000..e14991f39
--- /dev/null
+++ b/lib/Service/ItsmExchangeService.php
@@ -0,0 +1,528 @@
+
+ * @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/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service;
+
+use InvalidArgumentException;
+use OCA\Stackiq\AppInfo\Application;
+use OCA\Stackiq\Service\Itsm\ItsmFlowGateway;
+use OCP\IAppConfig;
+use OCP\IURLGenerator;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Fills, checks and creates the service desk exchange flows.
+ *
+ * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential
+ */
+class ItsmExchangeService {
+
+ /**
+ * App setting that holds the desk, the organisation and the flow uuids.
+ *
+ * @var string
+ */
+ public const CONFIG_KEY = 'itsm_exchange';
+
+ /**
+ * App setting the Integrations page reads as the exchange's switch.
+ *
+ * @var string
+ */
+ public const ENABLED_KEY = 'itsm_exchange_enabled';
+
+ /**
+ * The register every stackiq flow writes to.
+ *
+ * @var string
+ */
+ public const REGISTER = 'stackiq';
+
+ /**
+ * The nightly import time, as a five-field cron expression.
+ *
+ * @var string
+ */
+ public const CRON = '0 2 * * *';
+
+ /**
+ * One profile per service desk: integriq's slugs and the outbound endpoints.
+ *
+ * The slugs, endpoints and answer paths are the ones integriq's change
+ * connectors-service-desk-templates ships and its mocks answer. Endpoints
+ * are relative to the source's location; `%BASE%` becomes the tenant's
+ * scheme and host, for record links. TOPdesk lists asset links one asset
+ * at a time, so its relations use the per-application template.
+ *
+ * @var array>
+ */
+ public const DESKS = [
+ 'topdesk' => [
+ 'label' => 'TOPdesk',
+ 'source' => 'topdesk',
+ 'relationsTemplate' => 'itsm-inbound-relations-per-application.json',
+ 'createEndpoint' => '/assetmgmt/assets',
+ 'createMethod' => 'POST',
+ 'updateEndpoint' => '/assetmgmt/assets/{{ usage.recordId }}',
+ 'updateMethod' => 'POST',
+ 'callQuery' => [],
+ 'responseId' => 'data.id',
+ 'recordUrl' => '%BASE%/tas/secure/assetmgmt/card.html?unid={{ response.body.data.id }}',
+ ],
+ 'servicenow' => [
+ 'label' => 'ServiceNow',
+ 'source' => 'servicenow',
+ 'relationsTemplate' => 'itsm-inbound-relations.json',
+ 'createEndpoint' => '/api/now/table/cmdb_ci_appl',
+ 'createMethod' => 'POST',
+ 'updateEndpoint' => '/api/now/table/cmdb_ci_appl/{{ usage.recordId }}',
+ 'updateMethod' => 'PATCH',
+ 'callQuery' => ['sysparm_input_display_value' => 'true'],
+ 'responseId' => 'result.sys_id',
+ 'recordUrl' => '%BASE%/nav_to.do?uri=cmdb_ci_appl.do?sys_id={{ response.body.result.sys_id }}',
+ ],
+ ];
+
+ /**
+ * The flows the set-up creates: key => template file, feed and preset suffix.
+ *
+ * @var array
+ */
+ public const FLOWS = [
+ 'applications' => ['template' => 'itsm-inbound-applications.json', 'feed' => 'applications', 'preset' => 'application-inbound'],
+ 'relations' => ['template' => 'itsm-inbound-relations.json', 'feed' => 'relations', 'preset' => 'relation-inbound'],
+ 'licences' => [
+ 'template' => 'itsm-inbound-contracts.json',
+ 'feed' => 'licences',
+ 'preset' => 'licence-inbound',
+ 'contractType' => 'Licence',
+ 'feedLabel' => 'licences',
+ ],
+ 'contracts' => [
+ 'template' => 'itsm-inbound-contracts.json',
+ 'feed' => 'contracts',
+ 'preset' => 'contract-inbound',
+ 'contractType' => 'SLA',
+ 'feedLabel' => 'contracts',
+ ],
+ 'outbound' => ['template' => 'itsm-outbound-applications.json', 'feed' => 'outbound', 'preset' => 'application-outbound'],
+ 'file' => ['template' => 'itsm-file-applications.json', 'feed' => 'file', 'preset' => 'file'],
+ ];
+
+ /**
+ * Constructor.
+ *
+ * @param ItsmFlowGateway $gateway OpenRegister's flow store.
+ * @param IAppConfig $appConfig The app settings.
+ * @param IURLGenerator $urlGenerator Builds the catalogue link sent to the desk.
+ * @param LoggerInterface $logger The logger.
+ * @param ConnectionReportService|null $connectionReports Tells integriq what the set-up met.
+ * @param string|null $templateDir Where the flow templates live; tests point it elsewhere.
+ */
+ public function __construct(
+ private readonly ItsmFlowGateway $gateway,
+ private readonly IAppConfig $appConfig,
+ private readonly IURLGenerator $urlGenerator,
+ private readonly LoggerInterface $logger,
+ private readonly ?ConnectionReportService $connectionReports = null,
+ private readonly ?string $templateDir = null,
+ ) {
+ }//end __construct()
+
+ /**
+ * What is set up today.
+ *
+ * @return array The desk, the organisation, the flows and the desks on offer.
+ *
+ * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-007-the-cmdb-page-says-what-stackiq-is
+ */
+ public function status(): array {
+ $config = $this->config();
+ $desks = [];
+ foreach (self::DESKS as $key => $desk) {
+ $desks[] = ['id' => $key, 'label' => $desk['label']];
+ }
+
+ return [
+ 'available' => $this->gateway->available(),
+ 'enabled' => $this->appConfig->getValueBool(Application::APP_ID, self::ENABLED_KEY, false),
+ 'desk' => ($config['desk'] ?? null),
+ 'organisation' => ($config['organisation'] ?? null),
+ 'flows' => ($config['flows'] ?? []),
+ 'setUpAt' => ($config['setUpAt'] ?? null),
+ 'desks' => $desks,
+ ];
+ }//end status()
+
+ /**
+ * Fill every template for one desk and one organisation.
+ *
+ * @param string $desk The desk key (topdesk, servicenow).
+ * @param string $organisation The uuid of the organisation whose applications are exchanged.
+ * @param string $runAs The user the scheduled imports run as.
+ * @param string $location The desk source's location; its scheme and host make the record links.
+ * @param string $templateId The desk's asset template for a new record (TOPdesk needs one), or empty.
+ *
+ * @return array> The filled flow documents, by flow key.
+ *
+ * @throws InvalidArgumentException When the desk is unknown or a template is missing.
+ *
+ * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential
+ */
+ public function buildFlows(string $desk, string $organisation, string $runAs, string $location, string $templateId = ''): array {
+ if (isset(self::DESKS[$desk]) === false) {
+ throw new InvalidArgumentException('Unknown service desk "' . $desk . '". Choose one of: ' . implode(', ', array_keys(self::DESKS)) . '.');
+ }
+
+ $profile = self::DESKS[$desk];
+ $appUrl = rtrim($this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APP_ID), '/');
+ $base = self::baseOf(location: $location);
+ $flows = [];
+ foreach (self::FLOWS as $key => $flow) {
+ $deskKey = $desk;
+ if ($key === 'file') {
+ $deskKey = 'file';
+ }
+
+ $values = [
+ 'REGISTER' => self::REGISTER,
+ 'CONSUMER' => $organisation,
+ 'RUN_AS' => $runAs,
+ 'CRON' => self::CRON,
+ 'DESK' => $deskKey,
+ 'DESK_LABEL' => $profile['label'],
+ 'SOURCE' => $profile['source'],
+ 'SYNC' => 'itsm-' . $deskKey . '-' . $flow['feed'],
+ 'PRESET' => 'itsm-' . $deskKey . '-' . $flow['preset'],
+ 'CONTRACT_TYPE' => ($flow['contractType'] ?? 'Licence'),
+ 'FEED_LABEL' => ($flow['feedLabel'] ?? $flow['feed']),
+ 'APP_URL' => $appUrl,
+ 'CREATE_ENDPOINT' => $profile['createEndpoint'],
+ 'CREATE_METHOD' => $profile['createMethod'],
+ 'UPDATE_ENDPOINT' => $profile['updateEndpoint'],
+ 'UPDATE_METHOD' => $profile['updateMethod'],
+ 'RESPONSE_ID' => $profile['responseId'],
+ 'RECORD_URL' => str_replace('%BASE%', $base, $profile['recordUrl']),
+ 'DESK_BASE' => $base,
+ 'TEMPLATE_ID' => $templateId,
+ 'CALL_QUERY' => $profile['callQuery'],
+ ];
+ if ($key === 'file') {
+ $values['SYNC'] = 'itsm-file-applications';
+ $values['PRESET'] = 'itsm-file-application-inbound';
+ }
+
+ $template = $flow['template'];
+ if ($key === 'relations') {
+ $template = $profile['relationsTemplate'];
+ }
+
+ $flows[$key] = self::fill(value: $this->template(file: $template), values: $values);
+ }//end foreach
+
+ return $flows;
+ }//end buildFlows()
+
+ /**
+ * Set up the exchange: check every flow, then create or update them all.
+ *
+ * Nothing is saved unless every flow passes preflight, so the instance never
+ * holds half an exchange. Running it again updates the flows it created.
+ *
+ * @param string $desk The desk key.
+ * @param string $organisation The uuid of the organisation whose applications are exchanged.
+ * @param string $runAs The user the scheduled imports run as.
+ * @param string $templateId The desk's asset template for new records, when the desk needs one.
+ *
+ * @return array `created`, and either `flows` or `blocking` per flow key.
+ *
+ * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential
+ */
+ public function setUp(string $desk, string $organisation, string $runAs, string $templateId = ''): array {
+ $found = $this->preconditions(desk: $desk, organisation: $organisation);
+ if (is_string($found) === true) {
+ return $this->refuse(message: $found);
+ }
+
+ // The flows compare this with a usage's consumer uuid, so store the uuid
+ // even when the admin gave an id or a slug.
+ $organisation = $found['organisation'];
+ try {
+ $flows = $this->buildFlows(
+ desk: $desk,
+ organisation: $organisation,
+ runAs: $runAs,
+ location: (string) ($found['source']['location'] ?? ''),
+ templateId: $templateId
+ );
+
+ $blocking = $this->blockingFindings(flows: $flows);
+ } catch (Throwable $e) {
+ $this->logger->error('[ItsmExchangeService] Checking the exchange flows failed', ['exception' => $e]);
+ return $this->refuse(message: 'Nothing was created. The flows could not be checked: ' . $e->getMessage());
+ }
+
+ if ($blocking !== []) {
+ $first = (array) reset($blocking);
+ $entry = (array) ($first[0] ?? []);
+ return $this->refuse(
+ message: 'Nothing was created. OpenRegister refused flow "' . (string) array_key_first($blocking) . '": step '
+ . (string) ($entry['step'] ?? '?') . ', ' . (string) ($entry['reason'] ?? 'unknown reason') . '.',
+ blocking: $blocking
+ );
+ }
+
+ $stored = (array) ($this->config()['flows'] ?? []);
+ $saved = [];
+ try {
+ foreach ($flows as $key => $flow) {
+ $saved[$key] = $this->gateway->saveAndPublish(flow: $flow, uuid: $this->storedUuid(stored: $stored, key: $key));
+ }
+ } catch (Throwable $e) {
+ $this->logger->error('[ItsmExchangeService] Saving the exchange flows failed', ['exception' => $e]);
+ $this->storeConfig(desk: $desk, organisation: $organisation, flows: array_merge($stored, $saved));
+ return $this->refuse(message: 'Saving the flows failed after ' . count($saved) . ' of ' . count($flows) . ': ' . $e->getMessage());
+ }
+
+ $this->storeConfig(desk: $desk, organisation: $organisation, flows: $saved);
+ $this->appConfig->setValueBool(Application::APP_ID, self::ENABLED_KEY, true);
+ $this->connectionReports?->itsmSetUp(
+ created: true,
+ message: count($saved) . ' flows set up for ' . self::DESKS[$desk]['label'] . '. The first import runs tonight.'
+ );
+
+ return ['created' => true, 'desk' => $desk, 'flows' => $saved];
+ }//end setUp()
+
+ /**
+ * What must be there before any flow is built: the engine, the desk, the organisation and integriq's source.
+ *
+ * @param string $desk The desk key.
+ * @param string $organisation The organisation uuid.
+ *
+ * @return array{source: array, organisation: string}|string The integriq source and the
+ * organisation's uuid, or why the
+ * set-up cannot start.
+ */
+ private function preconditions(string $desk, string $organisation): array|string {
+ if ($this->gateway->available() === false) {
+ return 'OpenRegister\'s flow engine is not available, so no exchange can be set up.';
+ }
+
+ if (isset(self::DESKS[$desk]) === false) {
+ return 'Unknown service desk "' . $desk . '".';
+ }
+
+ $found = $this->gateway->findObject(register: self::REGISTER, schema: 'organization', id: $organisation);
+ if ($found === null || (string) ($found['uuid'] ?? '') === '') {
+ return 'The organisation ' . $organisation . ' does not exist in stackiq.';
+ }
+
+ $profile = self::DESKS[$desk];
+ $source = $this->gateway->findObject(register: 'integriq', schema: 'source', id: $profile['source']);
+ if ($source === null) {
+ return 'Integriq has no source "' . $profile['source'] . '". Add the ' . $profile['label'] . ' source in integriq first.';
+ }
+
+ return ['source' => $source, 'organisation' => (string) $found['uuid']];
+ }//end preconditions()
+
+ /**
+ * Preflight every flow and keep the blocking findings.
+ *
+ * @param array> $flows The filled flows by key.
+ *
+ * @return array>> The blocking findings by flow key; empty when all pass.
+ */
+ private function blockingFindings(array $flows): array {
+ $blocking = [];
+ foreach ($flows as $key => $flow) {
+ $findings = $this->gateway->inspect(flow: $flow);
+ if ($findings['blocking'] !== []) {
+ $blocking[$key] = $findings['blocking'];
+ }
+ }
+
+ return $blocking;
+ }//end blockingFindings()
+
+ /**
+ * The uuid a flow was stored under by an earlier set-up, or null.
+ *
+ * @param array $stored The stored flow uuids.
+ * @param string $key The flow key.
+ *
+ * @return string|null The uuid, or null when there is none.
+ */
+ private function storedUuid(array $stored, string $key): ?string {
+ $previous = ($stored[$key] ?? null);
+ if (is_string($previous) === false || $previous === '') {
+ return null;
+ }
+
+ return $previous;
+ }//end storedUuid()
+
+ /**
+ * Replace every `%KEY%` placeholder in the strings of a value.
+ *
+ * A string that is exactly one placeholder takes the value as it is, so a
+ * list or an object can be filled in; any other string gets text.
+ *
+ * @param mixed $value The template, or a part of it.
+ * @param array $values The placeholder values, by key without the percent signs.
+ *
+ * @return mixed The filled value.
+ *
+ * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential
+ */
+ public static function fill(mixed $value, array $values): mixed {
+ if (is_array($value) === true) {
+ $filled = [];
+ foreach ($value as $key => $item) {
+ $filled[$key] = self::fill(value: $item, values: $values);
+ }
+
+ return $filled;
+ }
+
+ if (is_string($value) === false) {
+ return $value;
+ }
+
+ if (preg_match('/^%([A-Z_]+)%$/', $value, $whole) === 1 && array_key_exists($whole[1], $values) === true) {
+ return $values[$whole[1]];
+ }
+
+ $search = [];
+ $replace = [];
+ foreach ($values as $key => $text) {
+ if (is_array($text) === true) {
+ continue;
+ }
+
+ $search[] = '%' . $key . '%';
+ $replace[] = (string) $text;
+ }
+
+ return str_replace($search, $replace, $value);
+ }//end fill()
+
+ /**
+ * The scheme, host and port of a location, without its path.
+ *
+ * @param string $location The source's location.
+ *
+ * @return string The base, or an empty string when the location has no host.
+ *
+ * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential
+ */
+ public static function baseOf(string $location): string {
+ $parts = parse_url(trim($location));
+ if (is_array($parts) === false || isset($parts['host']) === false) {
+ return '';
+ }
+
+ $base = ($parts['scheme'] ?? 'https') . '://' . $parts['host'];
+ if (isset($parts['port']) === true) {
+ $base .= ':' . $parts['port'];
+ }
+
+ return $base;
+ }//end baseOf()
+
+ /**
+ * Read one template.
+ *
+ * @param string $file The file name.
+ *
+ * @return array The template.
+ *
+ * @throws InvalidArgumentException When it is missing or not JSON.
+ */
+ private function template(string $file): array {
+ $path = ($this->templateDir ?? __DIR__ . '/../Settings/flows') . '/' . $file;
+ $content = '';
+ if (is_file($path) === true) {
+ $content = (string) file_get_contents($path);
+ }
+
+ $decoded = json_decode($content, true);
+ if (is_array($decoded) === false) {
+ throw new InvalidArgumentException('The flow template ' . $file . ' is missing or not JSON.');
+ }
+
+ return $decoded;
+ }//end template()
+
+ /**
+ * The stored set-up.
+ *
+ * @return array The stored set-up, or an empty array.
+ */
+ private function config(): array {
+ $decoded = json_decode($this->appConfig->getValueString(Application::APP_ID, self::CONFIG_KEY, '{}'), true);
+ if (is_array($decoded) === false) {
+ return [];
+ }
+
+ return $decoded;
+ }//end config()
+
+ /**
+ * Store the set-up.
+ *
+ * @param string $desk The desk key.
+ * @param string $organisation The organisation uuid.
+ * @param array $flows The flow uuids by key.
+ *
+ * @return void
+ */
+ private function storeConfig(string $desk, string $organisation, array $flows): void {
+ $this->appConfig->setValueString(
+ Application::APP_ID,
+ self::CONFIG_KEY,
+ (string) json_encode(['desk' => $desk, 'organisation' => $organisation, 'flows' => $flows, 'setUpAt' => date(DATE_ATOM)])
+ );
+ }//end storeConfig()
+
+ /**
+ * A refused set-up, reported to the Integrations page.
+ *
+ * @param string $message What stopped it.
+ * @param array $blocking The preflight findings, by flow key.
+ *
+ * @return array The answer.
+ */
+ private function refuse(string $message, array $blocking = []): array {
+ $this->connectionReports?->itsmSetUp(created: false, message: $message);
+
+ return ['created' => false, 'message' => $message, 'blocking' => $blocking];
+ }//end refuse()
+}//end class
diff --git a/lib/Service/ItsmFileImportService.php b/lib/Service/ItsmFileImportService.php
new file mode 100644
index 000000000..7e53c9dba
--- /dev/null
+++ b/lib/Service/ItsmFileImportService.php
@@ -0,0 +1,318 @@
+
+ * @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/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-006-a-file-feeds-the-same-import
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.