diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index dd777f44d3..90bf933223 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -325,3 +325,23 @@ jobs: # The fleet had 30,459 such strings, so this records the current count and # fails only when it GROWS — burning it down stays an ordinary PR. frontend-checks: '["check:specs", "test:l10n", "format", "check:schema-l10n", "check:l10n-js"]' + # ── Cost controls (see ConductionNL/.github#596, #599) ─────────────── + # + # The fleet's CI was not slow, it was QUEUED. A Code Quality run does + # ~38 job-minutes of work and its longest job is 16 minutes, but the + # median run took 78.5 minutes wall clock: one poll on 2026-08-27 found + # 53 jobs running against the account's 60-job ceiling, 1,528 queued + # behind them, and a run that had waited 425 minutes to start. ~96% of + # the wall clock was queueing, so the levers below remove DEMAND rather + # than making any check faster. + # Run PHPUnit and Playwright only when the diff could change their + # verdict. They are 29 of the ~38 job-minutes a run spends, and 58% of + # sampled fleet commits touched no .php/.js/.ts/.vue file at all. Fails + # safe TOWARDS running, and a filtered skip is a declared state in the + # Quality Report, distinct from both a pass and a missing verdict. + enable-path-filtering: true + # PR-time PHPUnit runs the primary PHP against the newest declared + # Nextcloud; the full matrix still runs on every push to a default + # branch and on the release PR into beta. Breadth moves from + # per-commit to per-merge, it is not dropped. + reduce-pr-matrix: true diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 774f762851..35d0ba20ec 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -1,13 +1,35 @@ name: Documentation +# Publishes the docs site to the Cloudflare Worker that serves it. +# +# TRIGGERS ON `development`, NOT ON A `documentation` BRANCH. This file used to +# listen on a branch called `documentation`; nobody has pushed to one since +# 2026-05-25, so the site simply stopped being rebuilt while every docs change +# merged to development satisfied its review and published nothing. on: push: - branches: [documentation] + branches: [development] pull_request: - branches: [documentation] + branches: [development] jobs: deploy: 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 + # `if:` guard, and the run finishes GREEN having changed nothing -- the + # failure that left the fleet's docs sites on May builds. The names are the + # same on both sides; the org secrets really are CF_API_TOKEN/CF_ACCOUNT_ID. + secrets: + CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} with: - cname: openregisters.app + cname: openregister.conduction.nl + # EVERY host this worker answers on, in FULL: wrangler reconciles the + # worker's triggers against this list, so a host left out is REMOVED and + # goes dark. + docs-hosts: openregister.conduction.nl + # PINNED. Deriving the name is how a deploy goes green and reaches + # nobody: wrangler creates the derived worker and publishes there while + # the custom domains keep routing to the real one. + worker-name: openregister-docs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c22d6a155..bf9027fff1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,13 @@ name: Release on: push: branches: [main, beta, development] + # Lets a human force a release without pushing — the shared workflow already + # expects this: its skip guard is written `github.event_name != 'push' || …` + # precisely so a dispatch is "deliberately never skipped … including of a + # commit this guard would refuse". 18 of the 21 fleet apps already declare it; + # this repo was one of the three that did not, so the only way to trigger a + # release here was to push. + workflow_dispatch: jobs: unstable: diff --git a/appinfo/info.xml b/appinfo/info.xml index f9a23814ff..901baeb2ce 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata Vrij en open source onder de EUPL-licentie. ]]> - 1.1.6-beta.20260820205738 + 1.1.9-unstable.20260829214947 EUPL-1.2 Conduction OpenRegister @@ -194,6 +194,9 @@ Vrij en open source onder de EUPL-licentie. OCA\OpenRegister\Repair\MigrateRenamedFlowNodeTypes + + OCA\OpenRegister\Repair\BackfillFlowVersions OCA\OpenRegister\Repair\BackfillFlowTriggerIndex OCA\OpenRegister\Repair\InitializeFlowActions OCA\OpenRegister\Repair\MigrateNotificationSubscriptionsToUserConfig @@ -227,6 +230,16 @@ Vrij en open source onder de EUPL-licentie. column while every read looks at the new one and finds null — no error, no data loss, invisible to the suite. --> OCA\OpenRegister\Repair\RenameDutchColumns + + OCA\OpenRegister\Repair\RechainAuditTrailForFlowAttribution OCA\OpenRegister\Repair\ReconcileDeclaredBackgroundJobs @@ -240,6 +253,9 @@ Vrij en open source onder de EUPL-licentie. OCA\OpenRegister\Repair\MigrateRenamedFlowNodeTypes + + OCA\OpenRegister\Repair\BackfillFlowVersions OCA\OpenRegister\Repair\BackfillFlowTriggerIndex OCA\OpenRegister\Repair\InitializeFlowActions OCA\OpenRegister\Repair\SeedDirectoryVirtualSchemas @@ -275,6 +291,7 @@ Vrij en open source onder de EUPL-licentie. OCA\OpenRegister\Command\RematerialiseCalculationsCommand OCA\OpenRegister\Command\BackfillSystemOwnerCommand + OCA\OpenRegister\Command\ImportSkosCsvCommand OCA\OpenRegister\Command\MigrateSchemaApplicationCommand diff --git a/appinfo/routes.php b/appinfo/routes.php index ded64c7e03..6f4b5ab5ba 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -16,6 +16,9 @@ // Federation (cross-instance OCM sharing) — token-scoped serving endpoints. // #[PublicPage]: the caller is a remote instance authenticated by the // bearer share token in the URL, not a local session. + // First-time setup wizard (ADR-042) - the standard CnSetupWizard contract. + ['name' => 'setup#status', 'url' => '/api/setup/status', 'verb' => 'GET'], + ['name' => 'setup#runAction', 'url' => '/api/setup/action/{actionId}', 'verb' => 'POST', 'requirements' => ['actionId' => '[a-z0-9\\-]+']], ['name' => 'federation#objects', 'url' => '/api/federation/{shareToken}/objects', 'verb' => 'GET', 'requirements' => ['shareToken' => '[^/]+']], ['name' => 'federation#object', 'url' => '/api/federation/{shareToken}/objects/{id}', 'verb' => 'GET', 'requirements' => ['shareToken' => '[^/]+', 'id' => '[^/]+']], ['name' => 'federation#meta', 'url' => '/api/federation/{shareToken}/meta', 'verb' => 'GET', 'requirements' => ['shareToken' => '[^/]+']], @@ -538,6 +541,20 @@ // for the tenants it exists for. The authorisation that matters is the // organisation scoping and per-flow guard inside FlowService. ['name' => 'flow#run', 'url' => '/api/flows/{id}/run', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], + + // Lifecycle. Declared BEFORE the bare `{id}` routes for the same reason + // `{id}/run` is: `id` matches `[^/]+`, so a uuid can never swallow a + // trailing literal segment, but keeping the specific paths first means + // a future looser requirement cannot silently start capturing them. + // + // The VERSION number is `\d+`, not `[^/]+`. Without that, + // `/versions/publish` would match `version` with the literal string + // "publish" and return a 404 for a route that exists. + ['name' => 'flow#versions', 'url' => '/api/flows/{id}/versions', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], + ['name' => 'flow#version', 'url' => '/api/flows/{id}/versions/{version}', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+', 'version' => '\d+']], + ['name' => 'flow#publish', 'url' => '/api/flows/{id}/publish', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], + ['name' => 'flow#draft', 'url' => '/api/flows/{id}/draft', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], + ['name' => 'flow#deprecate', 'url' => '/api/flows/{id}/deprecate', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], ['name' => 'flow#index', 'url' => '/api/flows', 'verb' => 'GET'], ['name' => 'flow#create', 'url' => '/api/flows', 'verb' => 'POST'], ['name' => 'flow#show', 'url' => '/api/flows/{id}', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], @@ -900,6 +917,9 @@ ['name' => 'files#batch', 'url' => '/api/objects/{register}/{schema}/{id}/files/batch', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], ['name' => 'files#preview', 'url' => '/api/objects/{register}/{schema}/{id}/files/{fileId}/preview', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+', 'fileId' => '\d+']], ['name' => 'files#updateLabels', 'url' => '/api/objects/{register}/{schema}/{id}/files/{fileId}/labels', 'verb' => 'PUT', 'requirements' => ['id' => '[^/]+', 'fileId' => '\d+']], + // Description and category had NO surface before this: only labels did, + // which is why the gap was easy to miss. `file-actions` specifies all three. + ['name' => 'files#updateMetadata', 'url' => '/api/objects/{register}/{schema}/{id}/files/{fileId}/metadata', 'verb' => 'PUT', 'requirements' => ['id' => '[^/]+', 'fileId' => '\d+']], // Direct file access by ID (authenticated). ['name' => 'files#downloadById', 'url' => '/api/files/{fileId}/download', 'verb' => 'GET', 'requirements' => ['fileId' => '\d+']], @@ -1296,6 +1316,12 @@ ['name' => 'transfer#index', 'url' => '/api/transfers', 'verb' => 'GET'], ['name' => 'transfer#show', 'url' => '/api/transfers/{id}', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], ['name' => 'transfer#create', 'url' => '/api/transfers', 'verb' => 'POST'], + // The archivist's decision. A literal trailing segment, so `{id}` — which + // matches [^/]+ — can never swallow them. Without these two the whole + // e-Depot flow was unreachable: `transfer#create` refuses to dispatch + // anything that is not `approved`, and nothing could set that status. + ['name' => 'transfer#approve', 'url' => '/api/transfers/{id}/approve', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], + ['name' => 'transfer#reject', 'url' => '/api/transfers/{id}/reject', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], // Features & Roadmap menu — GitHub issues proxy (add-features-roadmap-menu). // GET is a cached read (NoCSRFRequired set via controller attribute, pure read). @@ -1313,6 +1339,7 @@ // answered by `show('active')` → 404 for every request. ['name' => 'flowRun#active', 'url' => '/api/flow-runs/active', 'verb' => 'GET'], ['name' => 'flowRun#show', 'url' => '/api/flow-runs/{uuid}', 'verb' => 'GET', 'requirements' => ['uuid' => '[^/]+']], + ['name' => 'flowRun#objects', 'url' => '/api/flow-runs/{uuid}/objects', 'verb' => 'GET', 'requirements' => ['uuid' => '[^/]+']], ['name' => 'flowRun#retry', 'url' => '/api/flow-runs/{uuid}/retry', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], ['name' => 'flowRun#resume', 'url' => '/api/flow-runs/{uuid}/resume', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], // Interactive test run (or-flow-partial-run): run synchronously with optional startAt + pins + seed. diff --git a/composer.json b/composer.json index 573d49e189..34a03185be 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,11 @@ "OCA\\OpenRegister\\": "lib/" } }, + "autoload-dev": { + "psr-4": { + "OCA\\OpenRegister\\Tests\\": "tests/" + } + }, "repositories": [ { "type": "vcs", @@ -101,7 +106,7 @@ "ddn/sapp": "dev-feat/chained-filter-text-replace#5c406e91254d6936f44372db35f1cc15e5a06c56", "dompdf/dompdf": "^3.1", "dragonmantank/cron-expression": "^3.3", - "elasticsearch/elasticsearch": "^v8.14.0", + "elasticsearch/elasticsearch": "^v9.5.0", "guzzlehttp/guzzle": "^7.0", "jwadhams/json-logic-php": "^1.5", "minishlink/web-push": "^9.0", @@ -121,7 +126,7 @@ "symfony/yaml": "^6.4 || ^7.0", "theodo-group/llphant": "^0.9.3", "twig/twig": "^3.27.0", - "web-token/jwt-library": "^3.4.10", + "web-token/jwt-library": "^4.1.9", "webonyx/graphql-php": "^15.0", "zbateson/mail-mime-parser": "^3.0" }, diff --git a/composer.lock b/composer.lock index 2152b2359a..782815d742 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "afcdeab8bd2a2678bfc97c9368dddee9", + "content-hash": "c54554a5f7c891978d85cc5fa0611a00", "packages": [ { "name": "adbario/php-dot-notation", @@ -119,25 +119,24 @@ }, { "name": "brick/math", - "version": "0.12.3", + "version": "0.19.1", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba" + "reference": "a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/866551da34e9a618e64a819ee1e01c20d8a588ba", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba", + "url": "https://api.github.com/repos/brick/math/zipball/a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce", + "reference": "a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.2" }, "require-dev": { - "php-coveralls/php-coveralls": "^2.2", - "phpunit/phpunit": "^10.1", - "vimeo/psalm": "6.8.8" + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" }, "type": "library", "autoload": { @@ -167,7 +166,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.12.3" + "source": "https://github.com/brick/math/tree/0.19.1" }, "funding": [ { @@ -175,7 +174,7 @@ "type": "github" } ], - "time": "2025-02-28T13:11:00+00:00" + "time": "2026-08-08T23:03:16+00:00" }, { "name": "composer/pcre", @@ -568,36 +567,36 @@ }, { "name": "elastic/transport", - "version": "v8.11.0", + "version": "v9.0.1", "source": { "type": "git", "url": "https://github.com/elastic/elastic-transport-php.git", - "reference": "1d476af5dc0b74530d59b67d5dd96ee39768d5a4" + "reference": "3488b9d070e220f2a1ebdb500e6c588069f1897f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/elastic/elastic-transport-php/zipball/1d476af5dc0b74530d59b67d5dd96ee39768d5a4", - "reference": "1d476af5dc0b74530d59b67d5dd96ee39768d5a4", + "url": "https://api.github.com/repos/elastic/elastic-transport-php/zipball/3488b9d070e220f2a1ebdb500e6c588069f1897f", + "reference": "3488b9d070e220f2a1ebdb500e6c588069f1897f", "shasum": "" }, "require": { "composer-runtime-api": "^2.0", + "nyholm/psr7": "^1.8", "open-telemetry/api": "^1.0", - "php": "^7.4 || ^8.0", + "php": "^8.1", "php-http/discovery": "^1.14", - "php-http/httplug": "^2.3", + "php-http/httplug": "^2.4", "psr/http-client": "^1.0", "psr/http-factory": "^1.0", - "psr/http-message": "^1.0 || ^2.0", - "psr/log": "^1 || ^2 || ^3" + "psr/http-message": "^2.0", + "psr/log": "^2.0 || ^3.0" }, "require-dev": { - "nyholm/psr7": "^1.5", "open-telemetry/sdk": "^1.0", "php-http/mock-client": "^1.5", "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^9.5", - "symfony/http-client": "^5.4" + "phpunit/phpunit": "^10", + "symfony/http-client": "^6.0" }, "type": "library", "autoload": { @@ -620,43 +619,44 @@ ], "support": { "issues": "https://github.com/elastic/elastic-transport-php/issues", - "source": "https://github.com/elastic/elastic-transport-php/tree/v8.11.0" + "source": "https://github.com/elastic/elastic-transport-php/tree/v9.0.1" }, - "time": "2025-04-02T08:20:33+00:00" + "time": "2025-05-08T12:31:50+00:00" }, { "name": "elasticsearch/elasticsearch", - "version": "v8.19.0", + "version": "v9.5.0", "source": { "type": "git", "url": "https://github.com/elastic/elasticsearch-php.git", - "reference": "1771284cb43a7b653634d418b6f5f0ec84ff8a6d" + "reference": "6fe5f99ea3a8b697f8c098399cc84d2339a9508d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/elastic/elasticsearch-php/zipball/1771284cb43a7b653634d418b6f5f0ec84ff8a6d", - "reference": "1771284cb43a7b653634d418b6f5f0ec84ff8a6d", + "url": "https://api.github.com/repos/elastic/elasticsearch-php/zipball/6fe5f99ea3a8b697f8c098399cc84d2339a9508d", + "reference": "6fe5f99ea3a8b697f8c098399cc84d2339a9508d", "shasum": "" }, "require": { - "elastic/transport": "^8.11", - "guzzlehttp/guzzle": "^7.0", - "php": "^7.4 || ^8.0", + "elastic/transport": "^9.0", + "php": "^8.1", "psr/http-client": "^1.0", - "psr/http-message": "^1.1 || ^2.0", - "psr/log": "^1|^2|^3" + "psr/http-message": "^2.0", + "psr/log": "^2.0|^3.0" }, "require-dev": { "ext-yaml": "*", "ext-zip": "*", - "mockery/mockery": "^1.5", - "nyholm/psr7": "^1.5", - "php-http/mock-client": "^1.5", + "guzzlehttp/guzzle": "^7.0", + "mockery/mockery": "^1.6", + "nette/php-generator": "^4.0", + "nyholm/psr7": "^1.8", + "php-http/mock-client": "^1.6", "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^9.5", + "phpunit/phpunit": "^10.0", "psr/http-factory": "^1.0", - "symfony/finder": "~4.0", - "symfony/http-client": "^5.0|^6.0|^7.0" + "symfony/finder": "^6.0", + "symfony/http-client": "^6.0|^7.0" }, "type": "library", "autoload": { @@ -677,9 +677,9 @@ ], "support": { "issues": "https://github.com/elastic/elasticsearch-php/issues", - "source": "https://github.com/elastic/elasticsearch-php/tree/v8.19.0" + "source": "https://github.com/elastic/elasticsearch-php/tree/v9.5.0" }, - "time": "2025-08-06T16:58:06+00:00" + "time": "2026-08-04T14:31:01+00:00" }, { "name": "guzzlehttp/guzzle", @@ -1435,18 +1435,96 @@ }, "time": "2025-12-10T14:00:12+00:00" }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, { "name": "open-telemetry/api", - "version": "1.9.0", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be" + "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/6f8d237ce2c304ca85f31970f788e7f074d147be", - "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/7c029c4a6fd457094a20569bf98f93d95e9a7559", + "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559", "shasum": "" }, "require": { @@ -1503,7 +1581,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2026-02-25T13:24:05+00:00" + "time": "2026-07-06T12:28:04+00:00" }, { "name": "open-telemetry/context", @@ -1845,171 +1923,6 @@ }, "time": "2021-05-22T15:57:08+00:00" }, - { - "name": "paragonie/constant_time_encoding", - "version": "v3.1.3", - "source": { - "type": "git", - "url": "https://github.com/paragonie/constant_time_encoding.git", - "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", - "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", - "shasum": "" - }, - "require": { - "php": "^8" - }, - "require-dev": { - "infection/infection": "^0", - "nikic/php-fuzzer": "^0", - "phpunit/phpunit": "^9|^10|^11", - "vimeo/psalm": "^4|^5|^6" - }, - "type": "library", - "autoload": { - "psr-4": { - "ParagonIE\\ConstantTime\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com", - "homepage": "https://paragonie.com", - "role": "Maintainer" - }, - { - "name": "Steve 'Sc00bz' Thomas", - "email": "steve@tobtu.com", - "homepage": "https://www.tobtu.com", - "role": "Original Developer" - } - ], - "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", - "keywords": [ - "base16", - "base32", - "base32_decode", - "base32_encode", - "base64", - "base64_decode", - "base64_encode", - "bin2hex", - "encoding", - "hex", - "hex2bin", - "rfc4648" - ], - "support": { - "email": "info@paragonie.com", - "issues": "https://github.com/paragonie/constant_time_encoding/issues", - "source": "https://github.com/paragonie/constant_time_encoding" - }, - "time": "2025-09-24T15:06:41+00:00" - }, - { - "name": "paragonie/sodium_compat", - "version": "v2.5.1", - "source": { - "type": "git", - "url": "https://github.com/paragonie/sodium_compat.git", - "reference": "e4d4933af9463a96a1a3cbaeb94327b90e3350ca" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/e4d4933af9463a96a1a3cbaeb94327b90e3350ca", - "reference": "e4d4933af9463a96a1a3cbaeb94327b90e3350ca", - "shasum": "" - }, - "require": { - "php": "^8.1", - "php-64bit": "*" - }, - "require-dev": { - "infection/infection": "^0", - "nikic/php-fuzzer": "^0", - "phpunit/phpunit": "^7|^8|^9|^10|^11", - "vimeo/psalm": "^4|^5|^6" - }, - "suggest": { - "ext-sodium": "Better performance, password hashing (Argon2i), secure memory management (memzero), and better security." - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "files": [ - "autoload.php" - ], - "psr-4": { - "ParagonIE\\Sodium\\": "namespaced/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "ISC" - ], - "authors": [ - { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com" - }, - { - "name": "Frank Denis", - "email": "jedisct1@pureftpd.org" - } - ], - "description": "Pure PHP implementation of libsodium; uses the PHP extension if it exists", - "keywords": [ - "Authentication", - "BLAKE2b", - "ChaCha20", - "ChaCha20-Poly1305", - "Chapoly", - "Curve25519", - "Ed25519", - "EdDSA", - "Edwards-curve Digital Signature Algorithm", - "Elliptic Curve Diffie-Hellman", - "Poly1305", - "Pure-PHP cryptography", - "RFC 7748", - "RFC 8032", - "Salpoly", - "Salsa20", - "X25519", - "XChaCha20-Poly1305", - "XSalsa20-Poly1305", - "Xchacha20", - "Xsalsa20", - "aead", - "cryptography", - "ecdh", - "elliptic curve", - "elliptic curve cryptography", - "encryption", - "libsodium", - "php", - "public-key cryptography", - "secret-key cryptography", - "side-channel resistant" - ], - "support": { - "issues": "https://github.com/paragonie/sodium_compat/issues", - "source": "https://github.com/paragonie/sodium_compat/tree/v2.5.1" - }, - "time": "2026-08-18T15:39:41+00:00" - }, { "name": "php-di/invoker", "version": "2.3.7", @@ -2651,55 +2564,6 @@ }, "time": "2025-06-05T10:32:36+00:00" }, - { - "name": "psr/cache", - "version": "3.0.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Cache\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for caching libraries", - "keywords": [ - "cache", - "psr", - "psr-6" - ], - "support": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" - }, - "time": "2021-02-03T23:26:27+00:00" - }, { "name": "psr/clock", "version": "1.0.0", @@ -3574,20 +3438,20 @@ }, { "name": "spomky-labs/pki-framework", - "version": "1.5.0", + "version": "1.6.0", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/pki-framework.git", - "reference": "e0d61661962560c1cedfef02b51b431e720aae78" + "reference": "80778a25426288acd2e3a7cde2def41a3d59cddf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/e0d61661962560c1cedfef02b51b431e720aae78", - "reference": "e0d61661962560c1cedfef02b51b431e720aae78", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/80778a25426288acd2e3a7cde2def41a3d59cddf", + "reference": "80778a25426288acd2e3a7cde2def41a3d59cddf", "shasum": "" }, "require": { - "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19", "ext-mbstring": "*", "php": ">=8.1" }, @@ -3667,7 +3531,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/pki-framework/issues", - "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.5.0" + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.6.0" }, "funding": [ { @@ -3679,7 +3543,7 @@ "type": "patreon" } ], - "time": "2026-07-16T10:28:45+00:00" + "time": "2026-08-06T16:21:11+00:00" }, { "name": "symfony/console", @@ -4097,189 +3961,6 @@ ], "time": "2026-06-05T06:23:12+00:00" }, - { - "name": "symfony/http-client", - "version": "v7.4.15", - "source": { - "type": "git", - "url": "https://github.com/symfony/http-client.git", - "reference": "817bb83ef06717f67ab72a3f03e3b63fbe138de1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/817bb83ef06717f67ab72a3f03e3b63fbe138de1", - "reference": "817bb83ef06717f67ab72a3f03e3b63fbe138de1", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "psr/log": "^1|^2|^3", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-client-contracts": "~3.4.4|^3.5.2", - "symfony/polyfill-php83": "^1.29", - "symfony/service-contracts": "^2.5|^3" - }, - "conflict": { - "amphp/amp": "<2.5", - "amphp/socket": "<1.1", - "php-http/discovery": "<1.15", - "symfony/http-foundation": "<6.4" - }, - "provide": { - "php-http/async-client-implementation": "*", - "php-http/client-implementation": "*", - "psr/http-client-implementation": "1.0", - "symfony/http-client-implementation": "3.0" - }, - "require-dev": { - "amphp/http-client": "^4.2.1|^5.0", - "amphp/http-tunnel": "^1.0|^2.0", - "guzzlehttp/promises": "^1.4|^2.0", - "nyholm/psr7": "^1.0", - "php-http/httplug": "^1.0|^2.0", - "psr/http-client": "^1.0", - "symfony/amphp-http-client-meta": "^1.0|^2.0", - "symfony/cache": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/rate-limiter": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\HttpClient\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", - "homepage": "https://symfony.com", - "keywords": [ - "http" - ], - "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.15" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-07-29T07:12:33+00:00" - }, - { - "name": "symfony/http-client-contracts", - "version": "v3.7.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\HttpClient\\": "" - }, - "exclude-from-classmap": [ - "/Test/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to HTTP clients", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-06-05T06:23:12+00:00" - }, { "name": "symfony/http-foundation", "version": "v6.4.41", @@ -4984,16 +4665,16 @@ }, { "name": "symfony/polyfill-php82", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", - "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59" + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/34808efe3e68f69685796f7c253a2f1d8ea9df59", - "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", "shasum": "" }, "require": { @@ -5040,7 +4721,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1" }, "funding": [ { @@ -5060,7 +4741,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-05-26T12:45:58+00:00" }, { "name": "symfony/polyfill-php83", @@ -6156,16 +5837,16 @@ }, { "name": "twig/twig", - "version": "v3.27.1", + "version": "v3.28.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74" + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/ae2071bffb38f04847fc0864d730c94b9cb8ab74", - "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", "shasum": "" }, "require": { @@ -6220,7 +5901,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.27.1" + "source": "https://github.com/twigphp/Twig/tree/v3.28.0" }, "funding": [ { @@ -6232,37 +5913,27 @@ "type": "tidelift" } ], - "time": "2026-05-30T17:09:26+00:00" + "time": "2026-07-03T20:44:34+00:00" }, { "name": "web-token/jwt-library", - "version": "3.4.10", + "version": "4.1.9", "source": { "type": "git", "url": "https://github.com/web-token/jwt-library.git", - "reference": "8c38010d971c2313406fa1376d763cee745a833b" + "reference": "cbd52c8e96b835498f0f9c3a70a94d9f0e9e6c69" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-token/jwt-library/zipball/8c38010d971c2313406fa1376d763cee745a833b", - "reference": "8c38010d971c2313406fa1376d763cee745a833b", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/cbd52c8e96b835498f0f9c3a70a94d9f0e9e6c69", + "reference": "cbd52c8e96b835498f0f9c3a70a94d9f0e9e6c69", "shasum": "" }, "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12", - "ext-json": "*", - "ext-mbstring": "*", - "paragonie/constant_time_encoding": "^2.6|^3.0", - "paragonie/sodium_compat": "^1.20|^2.0", - "php": ">=8.1", - "psr/cache": "^2.0|^3.0", + "brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19", + "php": ">=8.2", "psr/clock": "^1.0", - "psr/http-client": "^1.0", - "psr/http-factory": "^1.0", - "spomky-labs/pki-framework": "^1.2.1", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/polyfill-mbstring": "^1.12" + "spomky-labs/pki-framework": "^1.2.1" }, "conflict": { "spomky-labs/jose": "*" @@ -6273,7 +5944,8 @@ "ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)", "ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", "paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", - "spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (A128KW, A192KW, A256KW, A128GCMKW, A192GCMKW, A256GCMKW, PBES2-HS256+A128KW, PBES2-HS384+A192KW, PBES2-HS512+A256KW...)", + "spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)", + "symfony/console": "Needed to use console commands", "symfony/http-client": "To enable JKU/X5U support." }, "type": "library", @@ -6318,7 +5990,7 @@ ], "support": { "issues": "https://github.com/web-token/jwt-library/issues", - "source": "https://github.com/web-token/jwt-library/tree/3.4.10" + "source": "https://github.com/web-token/jwt-library/tree/4.1.9" }, "funding": [ { @@ -6330,20 +6002,20 @@ "type": "patreon" } ], - "time": "2026-06-06T16:30:13+00:00" + "time": "2026-08-26T09:47:42+00:00" }, { "name": "webonyx/graphql-php", - "version": "v15.32.3", + "version": "v15.37.2", "source": { "type": "git", "url": "https://github.com/webonyx/graphql-php.git", - "reference": "993bf0bea17f870412ad8a90f60c41cb8d5f1145" + "reference": "f2a5d802213eb231d17a21badbda22b01435f177" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/993bf0bea17f870412ad8a90f60c41cb8d5f1145", - "reference": "993bf0bea17f870412ad8a90f60c41cb8d5f1145", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/f2a5d802213eb231d17a21badbda22b01435f177", + "reference": "f2a5d802213eb231d17a21badbda22b01435f177", "shasum": "" }, "require": { @@ -6356,14 +6028,14 @@ "amphp/http-server": "^2.1 || ^3", "dms/phpunit-arraysubset-asserts": "dev-master", "ergebnis/composer-normalize": "^2.28", - "friendsofphp/php-cs-fixer": "3.95.1", + "friendsofphp/php-cs-fixer": "3.95.18", "mll-lab/php-cs-fixer-config": "5.13.0", "nyholm/psr7": "^1.5", "phpbench/phpbench": "^1.2", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "2.1.51", - "phpstan/phpstan-phpunit": "2.0.16", - "phpstan/phpstan-strict-rules": "2.0.10", + "phpstan/phpstan": "2.2.8", + "phpstan/phpstan-phpunit": "2.0.18", + "phpstan/phpstan-strict-rules": "2.0.12", "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", "psr/http-message": "^1 || ^2", "react/http": "^1.6", @@ -6398,7 +6070,7 @@ ], "support": { "issues": "https://github.com/webonyx/graphql-php/issues", - "source": "https://github.com/webonyx/graphql-php/tree/v15.32.3" + "source": "https://github.com/webonyx/graphql-php/tree/v15.37.2" }, "funding": [ { @@ -6410,7 +6082,7 @@ "type": "open_collective" } ], - "time": "2026-04-24T13:49:35+00:00" + "time": "2026-08-14T06:29:42+00:00" }, { "name": "zbateson/mail-mime-parser", @@ -8611,16 +8283,16 @@ }, { "name": "nextcloud/ocp", - "version": "v34.0.2", + "version": "v34.0.3", "source": { "type": "git", "url": "https://github.com/nextcloud-deps/ocp.git", - "reference": "81cbb2c594afe0fa978885bf7accd0440199520a" + "reference": "3fb764be792476e4dcf1593101d978fc1dc8ac9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/81cbb2c594afe0fa978885bf7accd0440199520a", - "reference": "81cbb2c594afe0fa978885bf7accd0440199520a", + "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/3fb764be792476e4dcf1593101d978fc1dc8ac9a", + "reference": "3fb764be792476e4dcf1593101d978fc1dc8ac9a", "shasum": "" }, "require": { @@ -8654,9 +8326,9 @@ "description": "Composer package containing Nextcloud's public OCP API and the unstable NCU API", "support": { "issues": "https://github.com/nextcloud-deps/ocp/issues", - "source": "https://github.com/nextcloud-deps/ocp/tree/v34.0.2" + "source": "https://github.com/nextcloud-deps/ocp/tree/v34.0.3" }, - "time": "2026-07-16T01:28:13+00:00" + "time": "2026-08-07T02:03:36+00:00" }, { "name": "nikic/php-parser", @@ -9547,16 +9219,16 @@ }, { "name": "phpmetrics/phpmetrics", - "version": "v2.9.1", + "version": "v2.11.0", "source": { "type": "git", "url": "https://github.com/phpmetrics/PhpMetrics.git", - "reference": "e2e68ddd1543bc3f44402c383f7bccb62de1ece3" + "reference": "55c5e23b34afb8fa9b6c6ea95cbd59b710160235" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpmetrics/PhpMetrics/zipball/e2e68ddd1543bc3f44402c383f7bccb62de1ece3", - "reference": "e2e68ddd1543bc3f44402c383f7bccb62de1ece3", + "url": "https://api.github.com/repos/phpmetrics/PhpMetrics/zipball/55c5e23b34afb8fa9b6c6ea95cbd59b710160235", + "reference": "55c5e23b34afb8fa9b6c6ea95cbd59b710160235", "shasum": "" }, "require": { @@ -9605,7 +9277,7 @@ ], "support": { "issues": "https://github.com/PhpMetrics/PhpMetrics/issues", - "source": "https://github.com/phpmetrics/PhpMetrics/tree/v2.9.1" + "source": "https://github.com/phpmetrics/PhpMetrics/tree/v2.11.0" }, "funding": [ { @@ -9613,7 +9285,7 @@ "type": "github" } ], - "time": "2025-09-25T05:21:02+00:00" + "time": "2026-08-09T06:58:42+00:00" }, { "name": "phpowermove/docblock", @@ -9716,11 +9388,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.8", + "version": "2.2.9", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e285254e60f33c21902efef4a926ca0987c06804", - "reference": "e285254e60f33c21902efef4a926ca0987c06804", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/13d6b4f347bad222da436580c8304fa6f83e6bd0", + "reference": "13d6b4f347bad222da436580c8304fa6f83e6bd0", "shasum": "" }, "require": { @@ -9776,7 +9448,7 @@ "type": "github" } ], - "time": "2026-08-04T22:21:45+00:00" + "time": "2026-08-22T07:38:16+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10208,6 +9880,55 @@ ], "time": "2026-01-27T05:48:37+00:00" }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, { "name": "roave/security-advisories", "version": "dev-latest", diff --git a/css/main.css b/css/main.css index f511315a43..98848f2bc5 100644 --- a/css/main.css +++ b/css/main.css @@ -569,7 +569,7 @@ .tableColumnTitle { min-width: 120px; max-width: 200px; - word-wrap: break-word; + overflow-wrap: break-word; overflow: hidden; } @@ -588,7 +588,7 @@ .value-cell { width: 70%; - word-break: break-word; + overflow-wrap: break-word; border-radius: 4px; } @@ -869,7 +869,7 @@ } .detail-value { - word-break: break-word; + overflow-wrap: break-word; color: var(--color-text-default); line-height: 1.4; } diff --git a/docs/Installation/demo-environment.md b/docs/Installation/demo-environment.md new file mode 100644 index 0000000000..df984647bc --- /dev/null +++ b/docs/Installation/demo-environment.md @@ -0,0 +1,130 @@ +# Run a local demo + +This page gets a working OpenRegister running on your own machine in two commands. You end with a registry with registers and schemas you can model against. + +It is a **demo**, not a development environment. Nothing is mounted from a checkout, and that is deliberate — see [What this is not](#what-this-is-not). + +## What you need + +Docker, with Compose v2.23 or newer. Nothing else — no PHP, no Node, no Nextcloud. + +```bash +docker --version +docker compose version +``` + +If `docker compose version` prints v2.22 or older, upgrade first. The compose file declares its scripts inline via `configs`, and older versions ignore the `content:` field **silently** — which produces an instance with no apps installed and nothing in the logs to explain why. + +## Step 1 — get the compose file + +```bash +curl -fsSLO https://raw.githubusercontent.com/ConductionNL/openregister/development/openregister-compose.yaml +``` + +A single self-contained file. There is nothing else to fetch and nothing to edit. + +## Step 2 — start it + +```bash +docker compose -f openregister-compose.yaml up -d +``` + +The first run takes a few minutes: it pulls three images and downloads the application archives. Watch it work if you like: + +```bash +docker compose -f openregister-compose.yaml logs -f app-installer +``` + +You are looking for: + +``` +==> installing openregister +==> installing thematiq +==> installing integriq +==> apps present: integriq openregister thematiq +``` + +Then Nextcloud installs itself and enables the apps **in dependency order**. OpenRegister goes first: it owns the registers and schemas the others declare against, and a leaf app enabled before it finds no register to attach to. + +That is done when this returns `"installed":true`: + +```bash +curl -s http://localhost:8601/status.php +``` + +## Step 3 — open the demo + +| What | Where | +| --- | --- | +| **OpenRegister** | [http://localhost:8601/apps/openregister/](http://localhost:8601/apps/openregister/) | +| Admin interface | [http://localhost:8601](http://localhost:8601) — `admin` / `admin` | + +## What gets installed, and why more than one app + +| App | Why | +| --- | --- | +| `openregister` | **Required.** Every Connext app declares its registers and schemas against OpenRegister. | +| `thematiq` | Optional. Government theming. Absent, the UI renders unthemed rather than wrong. | +| `integriq` | Optional. The connector, for feeding in data from systems you do not control. | +| `openregister` | The app this page is about. | + +That OpenRegister dependency is **not declared** in `appinfo/info.xml` — no app in the fleet declares an `` dependency — so nothing stops the App Store from installing openregister without it. It would then load, find no register to attach to, and show you an empty app rather than an error. The compose file encodes the dependency the manifest does not. + +## Verifying it actually worked + +A page loading is not the same as a page working. Nextcloud serves its shell before the app decides whether it has anything to render, so an app URL returns HTTP 200 even when it resolves to nothing at all. A smoke test that checks for a 200 would call that a success. + +Check content instead: + +```bash +# The app answers. Note the credentials: an app page requires a login, so the +# SAME request without -u returns 401, which is not a broken demo — measured +# on a booted demo while writing this page. +curl -s -o /dev/null -w '%{http_code}\n' -u admin:admin -L "http://localhost:8601/apps/openregister/" + +# OpenRegister has registers — an empty list means the configuration +# was never imported, which is not the same as "nothing configured yet" +curl -s -u admin:admin "http://localhost:8601/apps/openregister/api/registers" | head -c 300 +``` + +## Changing the defaults + +The port and every version are overridable: + +```bash +DEMO_PORT=9000 \ +OPENREGISTER_VERSION=1.2.3 \ +docker compose -f openregister-compose.yaml up -d +``` + +Leaving a version empty resolves the newest release for that app, pre-releases included — which is what most Connext apps still ship, so that is the default. + +## Tearing it down + +```bash +# Stop, keep the data +docker compose -f openregister-compose.yaml down + +# Stop and delete everything, including the database +docker compose -f openregister-compose.yaml down -v +``` + +## What this is not + +**It is not a development environment, and it cannot be turned into one by adding a bind mount.** + +Nextcloud installs and updates an app by deleting the app directory and extracting a fresh archive over it. Point that at a checkout and an app-store update will delete your working tree — measured on a development machine on 27 August 2026, where `\OC\Updater::upgradeAppStoreApp` fired on a container restart and removed every top-level file from a bind-mounted checkout, including its `.git` directory. Only the subdirectories it lacked permission to unlink survived. + +So this compose keeps its apps in a named volume and installs them from release archives. That also happens to be the only thing that works: a release archive is a **complete** app carrying `vendor/` and the built `js/` bundle, while a `git clone` carries neither — and a Nextcloud app with no `vendor/` does not fail loudly. It warns once and keeps loading, so the app appears installed while every service that needs a dependency is quietly absent. + +To work *on* these apps rather than *with* them, use the development environment instead. + +## Troubleshooting + +**`app-installer` exits non-zero.** It could not download an archive. Check the log for the URL it tried; the most common cause is a pinned version with no matching release. + +**It stops with `openregister missing; aborting`.** Deliberate. Every other app declares registers against OpenRegister, so a stack without it would start and then fail in a dozen confusing ways instead of one clear one. + +**The UI renders unthemed.** Thematiq is not installed or not enabled. Expected, and cosmetic — the theme resolver renders unthemed rather than wrong when it is absent. + +**Everything returns 404 or a maintenance page after a restart.** Nextcloud is waiting for an upgrade. Run `docker compose -f openregister-compose.yaml exec -u www-data nextcloud php occ upgrade`. diff --git a/docs/l10n-ui-translation.md b/docs/l10n-ui-translation.md new file mode 100644 index 0000000000..b2740c0d31 --- /dev/null +++ b/docs/l10n-ui-translation.md @@ -0,0 +1,1934 @@ +# Translating a UI locale + +How to take one `l10n/.js` from partial to complete. Scope is **frontend UI +strings only** — the `l10n/*.js` set read by `t()` / `n()`. Backend `l10n/*.json` +and the translatable-object-property machinery in `docs/i18n.md` are separate +concerns. + +The hard rules (never `value === key`; plural arity per locale; don't overwrite +real translations) are in `CLAUDE.md`. **The workflow — the pass in order, what each +gate refusal means, and the traps catalogue — is `docs/l10n-workflow.md`; read that +first.** This document is the per-locale *linguistic* reference behind it: the +judgement calls that no tooling can make for you, and what each finished locale +decided. + +## Why `value === key` is the cardinal sin + +Worth restating, because every shortcut in this work leads back to it. A missing +key falls back to the English source: the UI is correct and the gap stays +*visible*. A key written as `"Slug": "Slug"` renders identically but is +indistinguishable from finished work — so it is never revisited. Bulk-filling a +locale from the English source therefore produces a bundle that is 100% "complete" +and permanently untranslated. That approach has been tried here and rejected. + +The corollary: **absent is a legitimate, deliberate state.** Cognates and +technical tokens belong absent, with the reasoning recorded in the commit. + +## Per-locale workflow + +1. **Measure the register** against Nextcloud core (below). Do not assume. +2. **Validate any formality detector** with must-fire and must-not-fire controls, + then sweep core — a detector that flags core is wrong, since core defines the + convention. +3. **Review harvested values at the call site** (below). Never bulk-apply. +4. **Split placeholders** into real translations vs deliberate identity keys. +5. **Translate in batches** of roughly 150–170 keys. +6. **Verify**: `npm run test:l10n:parity`, `node --check l10n/.js`, a runtime + `OC.L10N.register` load, and a diff against `HEAD` classifying every key + added / removed / changed. The number that matters is *real translations + altered: 0* — anything else means you overwrote someone's work. +7. **Commit that one language.** + +## Register is measured, never inherited + +Formality is a per-language fact, and neighbouring languages disagree. Measure +by counting formal vs informal markers across core (`server/core`, `lib`, +`apps/files`, `apps/settings`, `apps/dav`, …) for that locale. + +Measured results: informal for `nl`, `de`, `sv`, `da`, `nb`, `pl`, `fi`, `hu`, +`et`, `lv`, `ga`, `mt`, `is`; formal for `fr`, `cs`, `ru`, `uk`, `tr`, `el`, `sr`, `bg`, `ca`, `hr`, +`lt`, `sk`, `sl`, `rm`, `lb`, `sq`, `mk`, `be`. + +**`be` is the first verdict resting on an asserted ABSENCE rather than a ratio.** Core's 14 +Belarusian catalogues carry 334 formal markers across 1873 values and **zero** informal; the +bundle's own pre-existing half runs 156 to zero. Neither corpus contains a single `ты`, +`цябе`, `табе` or `тво-` form anywhere. That is the `lv` shape reached from the opposite +end — there the assertion that mattered was zero *formal* markers, because correct informal +Latvian is mostly undetectable by design. Read a one-sided count for which side is the +assertion. + +**`cs` is measured as of this pass, not inherited.** It sat in the formal column from before +any of the tooling existed (it is one of the sixteen pre-rule locales), and re-measuring it +gave **828 formal markers against zero informal** over core's 32 catalogues / 5005 values, +plus 243-vs-0 in the bundle itself. It is also the *plainest* entry in either column: Czech +has a live `vy`/`ty` distinction in ordinary current use, so unlike `ga`, `mt` and `is` there +is no structural story behind the verdict. Those three were the exceptions; `cs` is the +baseline they were exceptions to. + +**"Informal" does not mean the same thing in every row of that list**, and the three +low-resource locales done in sequence make the point better than any argument: they +measured identically and are three different situations. `ga` — Irish never had a T-V +distinction, so the label names the only address form that exists. `mt` — Maltese has one +and it is current (`intom` as a polite singular, `Is-Sinjur` with third-person agreement); +it is simply unused, so the verdict is an ordinary choice. `is` — Icelandic *had* one +(`þér` plus a 2pl verb, possessive `yðar`) and abandoned it during the 20th century, so its +V-forms are archaic rather than absent or merely unfashionable. That third state changes +what a slip looks like: for `is` the realistic error is not `yðar` at all but the plain +modern plural `þið`/`ykkar`, because a translator importing a politeness plural from +de/fr/nl reaches for the form that is actually current. Never copy a verdict without its +reason. **`sk` is the least ambiguous of any locale measured for this app**: +1001 formal markers against 1 informal over 4991 values in 31 catalogues, beating +`lt`'s 689 vs 0 on volume. Russian is next: 328 formal pronouns and 164 formal +imperatives against zero informal. + +That single Slovak informal hit is worth keeping rather than explaining away. It is +`Zruš prihlasovanie` in `core/sk.json` — a bare 2sg imperative where core uses the +infinitive for all 27 of its other short labels. One deviation in 4991 values is a +slip in core, not a second register; a detector that returned **zero** here would be +the more suspicious result, because it would mean the 2sg imperative was invisible +to it. + +**Core can contradict the bundle's own pre-existing keys, and core wins.** This has +now happened twice, both times with the same shape: core measured *informal* while +the 1053 keys the file arrived with measured *formal*. + +| Locale | Core | Pre-existing bundle | Outcome | +| --- | --- | --- | --- | +| `et` | informal, 420 vs 2 | **formal**, 26 vs 1 | followed core; 24 pre-existing values corrected | +| `lv` | informal, 44 vs 3 | **formal**, 85 vs 1 | followed core; 78 pre-existing values corrected | +| `ro` | **MIXED**, 124 vs 66 | formal, 84 vs 0 | followed the BUNDLE — core gave no verdict | + +`ro` is why the rule is "measure core", not "obey core". Core ro is the first +genuinely mixed catalogue in this project: 124 formal markers against 66 informal +over 1078 values, with the informal strings concentrated in `core/ro.json` and +reading as newer work (`Te rugăm să alegi un fișier`, `Conectează-te la contul tău`) +while the formal ones dominate the email templates. A 2:1 split is not a verdict, so +the tiebreaker was the bundle's own 1053 keys — 25 formal pronouns and 59 formal 2pl +verb forms against **zero** informal pronouns — and, independently, the project +owner's native-speaker advice that Romanian users expect formal address in a web UI. +Note the direction: for `et` and `lv` core was one-sided and overruled the file; here +core was inconclusive and the file won. Both follow from the same rule. + +The corrections are not optional cleanup — leaving them makes the bundle mix +registers inside a single dialog, which reads worse than either choice made +consistently. They go through `apply.js --allow-replace` with a reason recorded per +key in `locales/.json`, so the change is auditable rather than a silent side +effect of a bulk apply. For `lv` that means `Izvēlieties reģistru` → `Izvēlies +reģistru`, `jūsu filtriem` → `taviem filtriem`, and `Lūdzu, uzgaidiet` → the +impersonal `Lūgums uzgaidīt` that core lv itself prefers. + +Latvian's evidence base is small (890 values in 9 catalogues — core ships far less +Latvian than Lithuanian) but one-sided: **zero** `jūs`-series pronouns anywhere in +core, 44 `tu`-series markers spread across `core`, `lib`, `settings`, `oauth2` and +`files_trashbin`, and the informal usage appears in UI prose (`Kuras datnes vēlies +paturēt?`) rather than only in notification email. The three formal hits are two +imperatives: `Skatiet dokumentāciju` twice, plus the tagline `Turiet savus kolēģus +un draugus vienuviet`. + +**Button labels follow their own convention, and there are five patterns.** +Measure the *prose* register, then establish the button style separately from +core's own short labels — a single verdict for the locale is usually meaningless: + +| Pattern | Locales | Buttons | +| --- | --- | --- | +| same register throughout | `tr`, `ru` | formal imperative | +| bare 2sg imperative, whatever the prose | `ca`, `et`, `hr`, `sl`, `sr`, `ga`, `mt` | `Desa`, `Salvesta`, `Spremi`, `Shrani`, `Сачувај`, `Sábháil`, `Issejvja` | +| **infinitive — register-neutral** | `cs`, `lt`, `lv`, `sk`, `rm`, `is` | `Zobrazit`/`Smazat`, `Įrašyti`/`Ištrinti`/`Atsisakyti`, `Saglabāt`/`Dzēst`/`Atcelt`, `Uložiť`/`Odstrániť`/`Zrušiť`, `Memorisar`/`Stizzar`/`Annullar`, `Vista`/`Eyða`/`Hætta við` | +| **verbal noun — register-neutral** | `ro`, `bg` | `Salvare`/`Ștergere`/`Anulare`/`Adăugare endpoint`; `Запазване`/`Изтриване`/`Отказ`/`Добавяне на крайна точка` | +| **2sg imperative for a label, 2pl once it is a sentence — GRADED BY LENGTH** | `sq`, `mk` | `Ruaj`/`Fshi`/`Anulo`/`Eksporto`, but `Menaxhoni regjistrat …`/`Filtroni dhe analizoni …`; and `Zgjidhni …` for any `Select`/`Choose` prompt. `Зачувај`/`Избриши`/`Додај`/`Откажи`, but `Управувајте со вашите апликации и модули`; and `Изберете …` / `Внесете …` for any prompt | + +The fifth row is the only non-categorical one, and it has now been measured twice — which is +what makes it a shape to check for rather than one locale's quirk. `sq` slides between two +forms with string length (219:1 in favour of 2sg at ≤14 characters, 7:35 the other way at 80+, +crossover ~40) and `mk` does the same, with the crossover in the same place: core `mk` runs +128:1 at ≤14 and 6:12 at 80+, the siblings 139:3 and 1:26, the bundle 41:2 and 7:14. +The reading is that the long end is not a label convention at all but formal prose that +opens with a verb, so the register governs it and the button rule covers only the short +end — see §7.3 of `docs/l10n-workflow.md`. **Check whether a locale's apparent button +convention is really two populations before recording one answer.** + +`bg` reaches the same form as `ro` by the ordinary route rather than by divergence, and +for a reason no Latin-script locale here has: **Bulgarian has no infinitive at all.** It +lost the form, so the verbal noun (отглаголно съществително, `-не`) is the only +register-neutral option available — it does the job the infinitive does in +`cs`/`lt`/`lv`/`sk`, and the "infinitive" row is simply not reachable for this language. +Core `bg` is genuinely mixed (44 catalogue-weighted verbal nouns, 23 bare 2sg +imperatives, 4 formal 2pl `Потвърдете`, 4 plain nouns), so the measurement alone does not +decide it; the file does, with 1053 pre-existing values that are verbal nouns or plain +nouns and not one imperative label. + +`ro` is the one locale where the button convention is a **project decision that +knowingly diverges from core**, rather than a measurement. Core ro uses the bare 2sg +imperative for 15 of its 16 short labels (`Salvează`, `Adaugă`, `Șterge`), but the +project chose no-familiar-address anywhere, so a bare 2sg imperative in label +position is a *deviation* for `ro` and `detectors/ro.js` counts it as informal — the +exact opposite of how `ca`/`et`/`hr` treat theirs. Within that, the split is by +string role, which is what the bundle already did: action labels take a verbal noun +(`Salvare`, `Adăugare endpoint`, matching core's own `Adăugare punct final`), while +anything addressing the user takes formal 2pl (`Selectați un registru`, `Sigur doriți +să ștergeți...`). + +Uniform 2pl imperatives on buttons were measured to be **unavailable** without a +source fix: `Create`, `Read`, `Update` and `Delete` are single keys rendered both as +`` column headers and audit-action filter labels (`AvgIndex.vue`, +`AuditTrailSideBar.vue` `actionOptions`) *and* as buttons. A header reading +`Ștergeți` — "delete!" — above a count column is wrong, so those four force the +verbal noun and the rest follow them. Same open defect class as `Url` vs `URL`. + +Latvian makes the infinitive convention load-bearing for the *detector*, not just for +the translations: the Latvian infinitive ends in `-t`/`-āt`/`-at`, which is also the +2pl present ending. Every one of the 12 distinct `-at`/`-āt` words in core lv is an +infinitive or an adverb (`atjaunināt`, `saglabāt`, `turpināt`, `turklāt`) and **not +one** is a 2pl verb, so a suffix rule would score every button label in the language +as formal and invert the whole measurement. + +Infinitive buttons must **not** be "corrected" to imperatives. Bare 2sg +imperatives must be **excluded from the detector**, because they are also +homographs — of the Catalan/Croatian 3sg present indicative (`uredi` = "edit!" / +"he edits") or of Estonian nouns and names (`Lisa`, `Ava`) — so counting them +would flag every button in the app. Lithuanian is the reason the table exists: its +informal count is a clean 0 precisely *because* core never produces a 2sg +imperative at all. + +**`sk` is the exception, and it earns it.** Slovak's 2sg imperative is *not* a 3sg +homograph (`ulož` vs `uloží`), and its labels are infinitives, so `detectors/sk.js` +counts a bare imperative as informal where `ca`/`et`/`hr` must not. Before adding +that to any new locale, check both halves: the imperative must be distinguishable +from the third person *and* must not be the locale's own label convention. See the +`sk` section below. + +**`sl` is the control that proves the rule is not about language family.** Slovenian +is Slovak's neighbour and fails *both* halves of that test — its imperative is the +label convention AND a 3sg homograph across the whole `-iti` class — so its detector +excludes what `sk`'s counts. Two adjacent Slavic locales, opposite answers, from the +same test applied honestly. + +Three consecutive locales came out three different ways (`tr` formal 841:0, `ca` +formal 491:32, `et` **informal** 415:3). Carrying an answer over from the previous +locale would pass every automated check while being wrong in every string that +addresses the user. + +Two traps make pronoun-counting insufficient on its own: + +**Formality often lives in verb morphology, not pronouns.** Spanish, Czech and +Russian all address the reader formally through the imperative ending with no +pronoun present at all. Czech formal `Vyberte` vs informal `Vyber`; Russian +formal `Выберите` vs informal `Выбери`. A pronoun-only check sees nothing. +Beware the opposite error too: Czech *infinitives* (`Zobrazit`, `Smazat`) are +register-neutral button labels and must not be "corrected" to imperatives. + +**Formal pronouns are frequently homographs.** Each needs its own resolution: + +| Language | Collision | Resolution | +| --- | --- | --- | +| `da` `nb` `nn` | `De`/`Dem`/`Deres` = formal *you* **and** everyday *they/them/their*; `de` is also the definite article | require a **mid-sentence capital**; exclude positions where a capital is explained by orthography (string start, after sentence punctuation, after an opening quote) | +| `pl` | `Państwo` = formal plural *you* **and** the noun *state/country* | mid-sentence capital for `Państwo`; `Pan`/`Pani` match anywhere | +| `cs` | `ty` = informal *you* **and** the plural demonstrative *those* | unsolvable by position — same case, same slot. Deliberately left unmatched; the possessives and imperatives are unambiguous, so nothing is lost | +| `ru` | `вы`/`ваш` are the ordinary polite address, not a plural-only form | not evidence of anything — don't match them at all | +| `hr` | `ti` = informal *you* **and** the masculine nominative plural of `taj` (`ti objekti` = *those objects*) — the same collision as `cs` `ty` | leave bare `ti` unmatched; the oblique forms (`tebe`/`tebi`/`tobom`) and the `tvoj-` possessive are unambiguous | +| `hr` | `si` = 2sg of *biti* **and** the reflexive dative clitic, which occurs in formal sentences (`možete si odabrati`) | leave bare `si` unmatched; use `nisi`/`jesi` | +| `sl` | `te` = accusative of informal `ti`, **and** the accusative plural of `ta` (`te datoteke` = *these files*), **and** the 2pl verb ending — three readings for two letters | leave bare `te` unmatched, like `ti`. The `tvoj-` possessive and `tebe`/`tebi`/`tabo` are unambiguous | +| `sl` | `vas` = formal *you* (acc/gen) **and** the noun *village* | kept anyway: a village is implausible in this app's domain. Recorded in `UNDETECTABLE` rather than silently accepted | +| `et` | `teist` = elative of `teie` (*of you*) **and** partitive of `teine` (*another*) — `Proovi teist otsingut` is *informal* 2sg | exclude `teist` entirely. Half of core's apparent formal signal was this one word: removing it took the count 6 → 3 | +| `tr` | 2sg possessive is spelled identically to the plural genitive (`dosyaların` = *your files* / *of the files*), and 3sg-possessive+accusative collides too (`hesabını`) | all 35 first-pass "informal" hits in core were this. Only `şifre`/`parola` are safe anchors | +| `lt` | **`gali`, `turi`, `nori` are 2sg *and* 3sg/3pl** — Lithuanian third person makes no number distinction, so these mean *you can/have/want* and *he/they can/have/want* alike. They are the three commonest modals in UI prose (`Registras gali turėti kelias schemas`) | exclude all three. Use only 2sg forms whose 3sg differs: `žinai` (3sg `žino`), `matai`, `gauni`, `esi` | + +**Never use suffix patterns for these languages.** Lithuanian `-ai` ends the +nominative plural of every masculine noun (`objektai`, `failai`) *and* a large class +of adverbs (`gerai`, `automatiškai`). Croatian is the clearest case: +`-te` looks like the 2pl ending but is the accusative plural of every masculine +noun (`dokumente`, `objekte`, `atribute`), and `-š` looks like the 2sg ending but +ends `naš` (*our*) and **`vaš` (*your*-FORMAL)** — so a `-š` rule scores the formal +possessive as informal and inverts the polarity outright. Estonian `-ge`/`-ke` is +not a 2pl marker either (`selge`, `märge`). Use closed word lists. + +Two JavaScript traps that silently weaken any detector: `\b` is ASCII-only, so use +`(?=2 && n<=4) ? 1 : 2`. That is the load-bearing + difference from `hr`/`ru`/`pl`/`lt`, which all key on `n%10`/`n%100`. So **22 + selects form 2 in Slovak and form 1 in Croatian** — and form 2 is right, because + standard Slovak takes the genitive plural on compound numerals (`22 objektov`, + not `22 objekty`). An array copied between two `nplurals=3` Slavic locales is + wrong at every compound number, not just at the obvious 5–9 boundary. Note also + that form 2 carries **zero** as well as 5+, so `0 objektov` must read correctly + from the same string as `100 objektov` +- `hr` — `nplurals=3`, same expression as `ru`; the three forms are Croatian + nominative singular / genitive singular / genitive plural + (`1 objekt` / `3 objekta` / `7 objekata`) +- `be` — `nplurals=3`, expression byte-identical to `ru`'s, boundaries identical to `ru`'s, + and **the noun form in form 1 is still different**. Belarusian takes the **nominative + plural** after 2–4 where Russian takes the genitive singular: `2 запісы`, `%n файлы`, + `тры дні` — never Russian's `2 записа`. Core `be` is one-sided on this across all 30 of + its plural arrays, and the agreeing adjective and verb go plural with it + (`{count} файлавыя канфлікты`, `%n запісы яшчэ не маюць хэша`). Form 2 is the genitive + plural and shifts the predicate with it (`Выбраны {count}` at forms 0–1 against + `Выбрана {count}` at form 2). Core's own catalogues ship **four**-element arrays; that is + a Transifex artefact, not a fourth Belarusian form +- `lt` — `nplurals=3`, and **the boundaries are not Croatian's**: form 1 covers + **2–9** (not 2–4) and 10–20 falls to form 2. Nominative singular / nominative + plural / genitive plural (`1 objektas` / `5 objektai` / `10 objektų`). A Croatian + array pasted here is wrong for every count 5–9 — the two locales share + `nplurals=3` and disagree on where the forms switch + +- `lv` — `nplurals=3`, and the third form is **a dedicated zero form**, not a + "many" form: the categories are zero / numerals ending in 1 except 11 / everything + else, which is exactly Latvian numeral agreement (genitive plural after 0, + singular after 1 and 21, plural otherwise). See the order warning below +- `ro` — `nplurals=3` with boundaries no other locale here uses: form 0 is `n==1` + **only**, form 1 covers **0 and 2–19**, form 2 is 20+. Zero joins the teens branch + rather than having its own form, the mirror image of Latvian. The third form exists + because Romanian inserts `de` before the noun from 20 up, which the runtime + confirms: `1 email` / `2 emailuri` / `20 de emailuri` + +- `sl` — `nplurals=4`, the **only** four-form locale in the finished set and the only + one with a **dual**. Modular on `n%100`: form 0 is `n%100==1`, form 1 is `n%100==2` + (the dual), form 2 is `n%100==3||4`, form 3 is everything else including **zero**. + The dual is not a spelling variant of the plural — it governs noun case, adjective + agreement **and verb number** at once, so a count of 2 needs `Objekta sta bila + uspešno izbrisana` where the plural needs `Objekti so bili uspešno izbrisani`. In an + accusative context the four forms are `objekt` / `objekta` / `objekte` / `objektov`; + in the nominative, `objekt` / `objekta` / `objekti` / `objektov`. The pre-existing + `_%n entry has no hash yet_` array already had the dual verb right (`nimata` vs + `nimajo`) and is the model to copy + +- `bg` — `nplurals=2; plural=(n != 1)`, the simplest header in the set, and the one + locale where the **arity is not the problem**. Bulgarian masculine non-person nouns + have a separate counting form (числителна форма, `-а`/`-я`) used after a numeral, + distinct from the ordinary plural: `обект` → `обекти` bare but `обекта` after a + number, likewise `запис`/`записа`, `файл`/`файла`, `регистър`/`регистъра`. So the + noun form is chosen by **the sentence, not the form index** — + `_Delete {count} object_` takes `Изтриване на {count} обекта` because a numeral + precedes, while `_Object successfully deleted_` takes `Обектите са изтрити успешно` + because none does. Two keys, one form index, two different words. Masculine person + nouns keep the plural (`{count} членове`); feminine and neuter have no count form + (`схема` → `схеми` either way) + +- `rm` — `nplurals=2; plural=(n != 1)` in the header, but `@nextcloud/l10n` has **no + entry for Romansh at all**, so its `getPlural` returns index 0 at *every* count + (verified over 0, 1, 2, 3, 5, 11, 21, 100, 101). Form 1 is unreachable. The runbook + files this with `tr` and `ga` as a harmless NOTE, and **for `rm` it is not harmless**: + the reason one form is fine for Turkish is that Turkish does not pluralise after a + numeral, whereas Romansh pluralises regularly with `+s`, so a bare singular in form 0 + renders `5 datoteca`. Form 0 therefore has to be acceptable at every count. The answer + is the `(s)` parenthetical the bundle already used for its own `(s)` sibling keys — + `Stizzar {count} object(s)` — which is never wrong at any count. The one plural key + with no numeral takes a **number-neutral** phrasing instead (`Stizzà cun success`), + because a parenthetical cannot be spread across three agreeing words. Form 1 is still + written as the true plural so the array becomes correct if the library ever gains an + `rm` entry. This is the mirror of the `lv` problem in §"The header and the library can + disagree on ORDER": there the library reorders the forms, here it collapses them + +- `ga` — `nplurals=5`, **the largest declared form count in the set**, and the library + uses only **three** of them: measured over counts 0–120, index 0 takes `n==1`, index 1 + takes `n==2`, and index 2 takes `0` and every `n>=3`. Forms 3 and 4 are unreachable, so + the header and the library disagree at every `n>=7`. Unlike `rm`, that collapse **is** + harmless, and it is measured rather than assumed: across core `ga`'s 101 fully-translated + five-form arrays, form 2 differs from form 3 in **zero** cases and form 3 from form 4 in + **zero** cases — core writes the last three identically without exception. What actually + decides these arrays is not the form index but the **counted-noun rule**: Irish takes the + *singular* after a numeral (An Caighdeán Oifigiúil — numerals 1–19 govern the singular), + so a value containing `{count}`/`%n` needs the same counted singular in all five forms + while a value with **no** numeral needs a real singular/plural split. Core's own data + separates on exactly that axis — of 73 numeral-bearing arrays, 53 keep the singular + throughout against 20 that pluralise (the calqued minority), while **28 of 28** arrays + without a numeral pluralise. Hence five of this bundle's seven arrays are all-forms- + identical (`Scrios {count} réad`, the `hu`/`tr` shape) and only + `_Object successfully deleted_::_Objects successfully deleted_`, which carries no + numeral, splits (`Scriosadh an réad` / `Scriosadh na réada`). **Initial mutation is not + applied after a digit**, which is also core's measured practice and not a guess: core + writes form 0 as `%n comhad`, `%n beart`, `%n carachtar`, `%n fógra`, `%n soicind` — + never `chomhad`/`bheart`/`charachtar` — even though `aon` lenites in a spelled-out + numeral phrase. A **fixed** numeral in a label is different and does take the mutation + (`Last 3 months` → `3 mhí anuas`, from `trí mhí`); it is only the `{count}` placeholder, + whose value is unknown, that stays unmutated + +- `mt` — `nplurals=4`, and **the header and the library agree exactly**, verified + index-by-index over counts 0–130 with all four forms reachable. That makes it the first + low-resource locale here with no plural surprise at all: no rotation as in `lv`, no + collapsed form count as in `rm`/`ga`/`tr`. The arrays are still the hardest in the set, + because Maltese counting is **Semitic rather than European**: the noun is PLURAL only + after 2–10 (and 0), and SINGULAR after 1, after 11–19 (`ħdax-il ktieb`) and after 20+ + (`għoxrin ktieb`). So forms 0, 2 and 3 normally carry the same string and only form 1 + differs — which reads as three duplicated forms and is correct. §2.3 had flagged this + bundle's one pre-existing array as *suspect, forms 2 and 3 fall back to the singular*; + verification cleared it and it was left untouched. The harvest corroborated the rule + independently: `Last 7 days` → `L-aħħar 7 ijiem` (plural) against `Last 30 days` → + `L-aħħar 30 jum` (singular). One further trap: **the whole predicate agrees, not just the + noun** — `%n entrata għadha m'għandhiex hash` against `%n entrati għadhom m'għandhomx + hash` — so an array cannot be built by swapping the noun alone + +All are mutually incompatible. `npm run test:l10n:parity` catches +wrong *length*; nothing can catch a Polish array pasted into Czech, and nothing at all +catches the Bulgarian count form or a Romansh form 0 that only reads correctly at 1. Verify the +forms are reachable by driving the real `@nextcloud/l10n`: call `unregister()` and +`setLanguage(loc)` first, because `register(app, bundle)` **ignores** a plural +function passed to it and installs the library's own `getPlural`. For `hr`, +counts 1/3/7 must select three *different* indices. + +### The header and the library can disagree on ORDER, not just count + +Writing an array to match the file's own `plural=` header is the obvious thing to +do, and for Latvian it is wrong at **every** count. Measured against the real +library over counts 0–1001: + +| | index 0 | index 1 | index 2 | +| --- | --- | --- | --- | +| `lv.js` header (legacy gettext) | 1, 21, 101 | 2–20, 22–100 | 0 | +| `@nextcloud/l10n` (what renders) | **0** | **1, 21, 101** | **everything else** | + +Same three categories, rotated. An array ordered by the header therefore renders +the singular for zero, the plural for one, and the zero form for two — while +`nplurals` matches, every array has three forms, nothing renders blank and nothing +renders English. Arity, parity and the not-English assertions all pass. It was found +only by asking which array *index* the library picks per count, which +`runtime-check.mjs` now does for every locale. + +So `lv` arrays are ordered **by the library**, and `locales/lv.json` records +`"pluralOrder": "library"` to say so. Without that record the runtime check fails, +which is deliberate: the next reader's instinct will be to "fix" the order back to +the header. `hr`, `lt`, `ru`, `cs`, `pl`, `uk` and the rest agree with their headers +exactly, and `tr`/`rm`/`ga` disagree only on form *count*, where the unreachable extra +forms cannot be mis-ordered. + +**But `lv` is only one of two kinds of disagreement, and the other one takes the opposite +remedy.** `lv`'s is a **permutation**: the header and the library carve the counts into the +same three groups and merely label them differently, so reordering the arrays makes the +locale completely correct. A **boundary** disagreement puts the lines in different *places*, +and then no reordering exists that helps — you choose which counts to be correct for and +record the residue. `runtime-check.mjs` now classifies which one you have and names the +right field: `pluralOrder: "library"` for a permutation, `pluralBoundary: "library"` for a +boundary. + +Both remaining flagged locales turned out to be boundary cases, **in opposite directions**: + +- `is` — the library files Icelandic under its coarse `number === 1 ? 0 : 1` group, so form 0 + is reachable only at exactly 1. The header is correct CLDR Icelandic + (`n%10!=1 || n%100==11`), under which 21, 31 … 191 also take the *singular* (`21 hlutur`). + So 17 counts in 0–200 render a plural where the language wants a singular. Accepted rather + than worked around, and the reasoning is the mirror of `rm`'s: form 1 is correct for 0 and + 2–20, the overwhelming majority of real counts, so contorting it into a number-neutral + shape would trade 17 wrong counts for roughly 180 unidiomatic ones. `rm` needed the + contortion because its collapsed form was wrong at *every* count but 1. +- `mk` — same modular header, but the library implements the modular rule and merely **drops + the `n%100 != 11` guard** (`number % 10 === 1 ? 0 : 1`), so only 11 and 111 disagree, and + they go the other way: the library picks the *singular* where Macedonian takes the plural. + Because form 0 is still reached correctly at 1, 21, 31 …, the call is the opposite of + `is`'s: form 0 is the plain counted singular, form 1 the true plural, and the residue is + just those two counts. **And Macedonian has NO productive counted form**, which is the trap + for anyone arriving from the `bg` note below: the избројана форма in `-а` exists for + masculine non-person nouns, so `5 објекта` looks right, but core writes `%n бајти`, + `{count} известувања`, `%n датотеки` and the app family `{count} објекти` / `{count} записи`. + The `-а` form survives in core only for a closed set of measure nouns (`%n дена`, `%n часа`, + while `месец` takes plain `месеци`). + +The general signal: **a two-form header whose expression is modular rather than `n != 1`** +is the shape to check, because the library's coarse groups are mostly written `n === 1`. +`lb` and `sq` carry plain `n != 1` and agree exactly; `bs` and `be` are three-form Slavic +and also agree. + +## Known source-side defect: `object{plural}` + +Keys like `object{plural}`, `file{plural}`, `register{plural}` exist because a +caller interpolates a literal `"s"` or `""`. This is an English-only trick. It +degrades with morphological complexity — harmless in `es`/`pt`, a parenthetical +approximation in `da`/`nb`/`sv`, and genuinely lossy in `pl`/`cs`/`ru` where three +forms mean a parenthetical cannot cover the genitive. Current locales use +approximations like `объект(ы)`. + +What each finished locale actually does, so the next one has a precedent to pick +from rather than reinventing it: + +| Shape | Locales | Example | +| --- | --- | --- | +| keep the placeholder — the plural really is `+s` | `es`, `ca` (4 of 5) | `fitxer{plural}` | +| parenthetical | `nl` `de` `fi` `ru` `pl` `cs` `et` | `bestand(en)`, `fail(i)` | +| slash, where the stem changes | `fr`, `ca` (`schema`), `et` (`register`) | `journal/journaux`, `esquema/esquemes` | +| bare noun — no plural suffix after a numeral | `hu`, `tr` | `fájl`, `dosya` | +| the form correct for the most counts | `hr` | see below | +| genitive plural, the conventional invariant counter | `lt` | `failų`, `objektų`, `registrų` | + +Catalan learned this the hard way: masculine nouns in `-a` pluralise in `-es`, so +`schema{plural}` rendered **`esquemas`** until the runtime harness caught it. +Estonian drops the placeholder for all five keys, because a numeral above one takes +the *partitive singular* (`5 faili`, not `5 failid`), so an appended `s` is wrong at +every count. + +Croatian has **three** numeral cases (1 → nom.sg, 2–4 → gen.sg, 5+ → gen.pl), so no +invariant string is right everywhere. Each value picks the form correct for the most +counts, which depends on the noun's gender: `datoteka` and `shema` (feminine — +correct for 1, 0 and 5+) but `zapisnika`, `objekata`, `registara` (masculine, where +gen.sg and gen.pl coincide — correct for 2–4 *and* 5+). Always runtime-assert that +no `{plural}` residue and no stray trailing `-s` survives. + +The real fix is in the source: use `n()` instead of interpolating a literal. +Worth a separate issue rather than more translation workarounds. + +## Locale conventions already established + +Per-language decisions that later work should stay consistent with, and which are +*not* derivable by analogy from a related language: + +- **Domain-term capitalisation** — `da` lowercases (1:15), `sv` capitalises + (54:2). `pl` follows `sv` but keeps `organizacja` lowercase (0:18). `cs`, `nb` + and `ru` follow `da`. `sr` is the strongest case and the only one that splits by + *term*: six concepts capitalised, everything else lowercase — see the `sr` section. +- **Imperative accent** — `da` `Aktivér` (13:0) vs `nb` `Aktiver` (13:0), despite + the two agreeing on capitalisation. +- **Error voice** — `da` passive; `nb` active `Kunne ikke`; `ru` `Не удалось`. +- **Ellipsis spacing** — `nb` puts a space before `...` (`Laster inn ...`); `ru` + does not. +- **Quotes** — `ru` uses guillemets `«…»` for interpolated names; `da` opens with + `”`. +- **Terminology** — `nb` `endepunkt` vs `da` `endpoint`; `ru` `Реестр` / `Схема` / + `Объект` / `Дашборд` / `Поиск` / `конечная точка`, `Редактировать` for + `Edit`-prefixed keys. +- **`hr`** — `trag revizije` for *audit trail* (measured 24:4 against + `revizijski trag` in the file's own existing values), `prikaz` for the *view* + noun but `Pregledaj` for the *View* button, `vektorska ugrađivanja` for *vector + embeddings*, `Pravo` for the RBAC *Right*, `ispitanik` for the GDPR *data + subject* (the term used by the official Croatian text of the regulation), + `Razred` for a histogram *Bucket*, and `Mapiranja` for *Mappings* — **not** + openconnector's `Mappingi`, a non-standard transliteration. Two app-internal + conventions deliberately override core: `Tip` for *Type* (core says `Vrsta`) and + `lozinka` for *password* (core prefers `zaporka` 195:62, but the file already + shipped `Lozinka` and it is valid Croatian, so it was not churned). + +- **`lt`** — buttons are **infinitives** (see the register section). `audito + žurnalas` for *audit trail* (24:3 in the file's own values), `rodinys` for the + *view* noun but `Peržiūrėti` for the *View* button, `esybė` for *entity* — chosen + so it does not collide with `duomenų subjektas` (*data subject*) — `užtemdymas` + for *redaction*, because the obvious `redagavimas` is the same word this app uses + for **Edit** (`Redaguoti`). `žiniatinklio kabliukas` for *webhook*: all 40 + existing webhook keys translate it, so do not keep the English. `Ataskaitos` for + *Reports*, distinct from `Pranešimai` (*Notifications*). `minkštai pašalinti` for + *soft-deleted*. `Delete` → `Pašalinti` but `Remove` → `Šalinti`: the file splits + those two senses deliberately. Bare `Name` → `Vardas`, but name-in-compound → + `pavadinimas` (`Failo pavadinimas`). GDPR is **`BDAR`**, not `GDPR`. + +**Check the existing values before coining a term.** For `hr` the audit-trail, +view, embeddings and `Tip`/`Filtri` conventions were all already present in the +1053 keys the file arrived with; deriving them from core instead would have split +the locale's own terminology. `lt` was the same, and stronger: `webhook` looked +like an obvious keep-the-English case until the file showed 40 keys translating it. + +`lv` proves the rule by breaking it mid-pass, twice. The pass coined `šķautne` for +*facet* and wrote `AI aģentus`; the bundle already had `Iespējot fasetēšanu` and +`MI aģents` at HEAD. Both were reverted to the file's own terms through the audited +`--allow-replace` path — the split was invisible until the pre-existing values were +read side by side with the new ones, which is why that read belongs *before* the +first batch, not after the last. Latvian's own established terms, followed here: +`Reģistrs`, `Shēma`, `Objekts`, `Fails`, `Entītijas`, `Tīmekļa āķis` (webhook *is* +translated), `Informācijas panelis` for dashboard (not the harvest's `Vadības +panelis`), `Fragments` for chunk, and `fasete`/`fasetēšana`. + +### `ro` traps + +Four, and every one of them is live in this app's data: + +1. **`vă` (formal you) vs `va` (3sg future auxiliary).** One diacritic apart, and + `va` is everywhere — `Acest proces va genera embeddings` is an ordinary future + tense, not formal address. Match only `vă`. +2. **`ai` is unusable and is excluded.** It is the 2sg of *avea*, but also the + masculine plural possessive article (`ai tăi`), and under case folding it collides + with the acronym **AI**, which this bundle carries in `Setări chat Fireworks AI`. +3. **CEDILLA vs COMMA diacritics.** Romanian ș/ț exist as two codepoint pairs: + ș U+0219 / ț U+021B (correct) and ş U+015F / ţ U+0163 (legacy Turkish cedilla). + Core ro mixes them — 362 values comma-form, 5 legacy strings cedilla + (`contactaţi`, `fişiere`) — so `detectors/ro.js` normalises cedilla to comma in + `fold()`, or its closed lists silently miss those strings. Write the comma form: + this bundle is already consistent at 419 values, 0 cedilla. +4. **`-ați` / `-eți` is not a 2pl suffix.** It also ends the masculine plural of a + large class of adjectives and nouns: `curați` (clean), `bogați` (rich), `pereți` + (walls), `băieți` (boys). Likewise `-ești` ends `povești` (stories) as well as 2sg + verbs. Closed lists only. + +Also note the 3sg/imperative homograph that decided the correction scope: `Creează +automat o organizație implicită` is *"automatically creates"*, not *"create!"*. A +string-initial 2sg-imperative scan reports 11 hits in `ro.js`, but only the 4 short +ones are buttons; the other 7 are long toggle descriptions where the same form is +third person. The detector's 40-character label-position bound is what separates +them, and `Copiază` in a table cell would still be a false positive — see +`UNDETECTABLE` in the detector. + +Terminology worth keeping: `ro` **borrows** where `lv`/`lt` translate — `Webhook` / +`Webhook-uri`, `Endpoint` / `Endpoint-uri`, `Driver`, `Token`, and `AI` (not `MI`). +GDPR is `RGPD`, and *data subject* is the official `persoana vizată`. `Bucket` became +`Segment` because `Interval` was already taken by its own key. + +### `lv` homograph traps + +Latvian has two *systematic* homograph classes, not a handful of exceptions, and both +make the obvious 2sg markers unusable: + +1. **For `-ēt` and `-āt` verbs the 2sg form is spelled exactly like the third + person**, because Latvian third person makes no number distinction: `meklē` is both + "you search" and "it searches"; likewise `saglabā`, `aizver`, `atver`. Counting + them turns ordinary third-person prose (`Sistēma saglabā izmaiņas`) into informal + hits. +2. **Feminine nouns in `-e` have an accusative singular in `-i`, colliding with the + 2sg imperative**: `pārbaudi` is both "check!" and the accusative of `pārbaude` + (a check); `redzi` collides with `redze` (vision), `atlasi` with `atlase` (a + selection). `ievadi` is the same trap from the masculine side — nominative plural + of `ievads` (an input). + +What is left and genuinely unambiguous is the pronoun series plus 2sg forms whose +third person differs: `vari` (vs `var`), `zini` (vs `zina`), `esi` (vs `ir`), `spied` +(vs `spiež`). Also note `-i` is the nominative plural of masculine nouns (`faili`, +`objekti`, `lietotāji`) — the same trap Lithuanian has with `-ai` — and `-iet` is not +a 2pl marker either: of the four distinct `-iet` words in core lv, `skatiet` and +`turiet` are 2pl imperatives while `vienuviet` is an adverb and `nešķiet` is third +person. + +Two consequences worth keeping: the detector's informal count for `lv` is *low* +(109 markers across 2052 values) because most correct informal Latvian is +undetectable by design, so **zero formal markers is the assertion that matters**, not +a high informal count. And the `{plural}` source hack takes the **nominative plural** +(`faili`, `žurnāli`, `objekti`, `reģistri`, `shēmas`): Latvian needs the plural after +every numeral except those ending in 1, so it is right for the large majority of +counts, and those keys render as a `countLabel` beside a figure rather than inside a +sentence. + +### `sk` is the one locale where the 2sg imperative IS detectable + +Every other locale so far has had to leave the bare 2sg imperative *unmatched*, for +one of two reasons: it is the correct button convention (`hr`, `ca`, `et`), or it is a +homograph of the third person (`ro`'s `Creează`, `lv`'s `meklē`, `hr`'s `uredi`). +Slovak has neither problem, and `detectors/sk.js` therefore counts it as **informal** +— the opposite of how `ca`/`et`/`hr` treat theirs. That is not a policy difference; it +follows from three measured facts: + +1. the label convention is the **infinitive** — 27 of 30 short action keys in core sk + resolve to one (`Uložiť`, `Zmazať`, `Pridať`, `Vybrať`), and **zero** to an + imperative, so an imperative label is a deviation rather than the house style; +2. the 2sg imperative is **not** a homograph of the 3sg present — `ulož` vs `uloží`, + `pridaj` vs `pridá`, `zmeň` vs `zmení` — so no `automatically creates` description + can be mistaken for a command; +3. the infinitive ends in `-ť`, which no imperative does. + +Because of (2), `sk` needs **no label-position bound**. `ro.js` carries a +40-character one only because Romanian's imperative *is* a 3sg homograph. + +So `sk` pairs `lv`'s infinitive labels with `ro`'s formal 2pl prose — the fourth +distinct combination in this app. The role split is the same as `ro`'s: infinitive for +buttons, menu items, dialog titles and bare field captions; formal 2pl for anything +addressing the reader. The English source marks the boundary reliably here, which it +did not for `ro`: `Select backend` is a field label (`Vybrať backend`) while +`Select a branch` is a prompt (`Vyberte vetvu`), and the indefinite article is the +tell. + +### `sk` traps + +1. **`vy-` is the most productive verbal prefix in the language.** `vybrať`, + `vymazať`, `vytvoriť`, `vyhľadať`, `vypnúť`, `vyčistiť`, `vypočítať` — an unguarded + `vy` pronoun marker matches most of the buttons in this app. `Vybrať všetko` is a + must-not-fire control for exactly this. +2. **DIACRITICS MUST NOT BE FOLDED.** Three of the detector's distinctions are + carried by the acute alone, so `fold()` only lowercases — the opposite of `ro`, + where `fold()` *must* normalise cedilla to comma: + - `ti` (dative of `ty`, informal) vs **`tí`** (masculine animate nominative plural + of `ten` — `tí používatelia` = *those users*); + - `vyber` (2sg imperative) vs **`výber`** (*selection*, which this bundle uses in + five keys — `Výber typu súboru`); + - `uprav` (2sg imperative) vs **`úprav`** (genitive plural of *edit*). +3. **`-te` is not a 2pl suffix.** It is the locative singular of every hard masculine + noun (`v dokumente`, `v objekte`, `v elemente`) and it ends **`ešte`** (*still*, + *yet*), one of the commonest adverbs in the language — which appears in this + bundle's own `notify_push je nainštalovaný, ale ešte nie je aktívny`. +4. **`-š` inverts polarity, exactly as in `hr`.** It looks like the 2sg ending but + ends `váš` (*your*-FORMAL), `náš` (*our*) and `kôš` (*basket*). +5. **Bare `si` is unusable.** Besides the 2sg of *byť* it is the reflexive dative + clitic, which is at its most common in *formal* prose — this bundle's own + `Pred rozhodnutím si záznam prečítajte` and `môžete si vybrať`. + +Terminology: `sk` sits between `ro` (borrow) and `lv`/`lt` (translate). It keeps +`Webhook`/`Webhooky`, `Slug`, `Audit`, `Avatar` and `Register` (which genuinely *is* +the Slovak word — `ID registra`, `Všetky registre`), but translates `Driver` → +`Ovládač`, `Mappings` → `Mapovania` (**not** openconnector's `Mappingy`), `Right` → +`Právo`, and `Bucket` → `Pásmo`, since `Interval` was already taken by its own key — +the same collision `ro` resolved with `Segment`. **`auditná stopa` for *audit trail* +(the re-audit re-coined this from `audítny záznam` — see below)**, `záznam auditu` for +an *audit entry*, `úsek` for *chunk*, `vloženie` for *embedding*, +`nástenka` for *dashboard*, `tok` for *flow* but `pracovný postup` for *workflow*, +`riešiteľ` for a DSAR *handler* (not `spracovateľ`, which is the GDPR *processor* and +would collide), and `Osoby` for `People` — core sk says `Ľudia`, but the bundle's own +`Person` → `Osoba` wins on lexicon. The `{plural}` hack reuses the bundle's own +pre-existing parenthesised style: `register(s)` was already `register(-tre)` and +`schema(s)` already `schéma(-y)`, so those two keys are literally the existing values. + +### `sk` is the first Tier 2 locale re-audited, and a `0` count meant unverified, not clean + +`sk` had a measured register, a detector, a reviewed cognate set and a terminology +record, but `corrections` was **empty**. The audit of all 2052 values found **57** +defects — so a zero means nobody looked, exactly as the re-audit handoff assumed. But +it does **not** mean a quarter of the file is waiting: 2.8% sits below `cs`'s 5.5% and +far below `is`'s 22%, with **zero** agreement failures, **zero** case errors and +**zero** wrong plural arrays. `sk` is the closest sibling to `cs` in the set, which was +why it was picked, and the two agree: the defect rate tracks upstream health, not the +length of time un-audited. **Tier 2 is cheaper than the `is` numbers made it look.** + +**37 of the 57 were one term.** *Audit trail* was `audítny záznam` — "audit record" — +which forced *audit trail entry* to render `záznam audítneho záznamu`, "record of the +audit record", in 8 keys. Escalated (first-class entity noun, 30+ keys, no core +authority) and re-coined by the owner to **`auditná stopa`**, so *trail* is `stopa`, +*entry* is `záznam`, and the stutter dissolves by construction. The adjective was also +misspelled in all 35 keys carrying it: Slovak adds `-ný` to the stem with no vowel +change (`kredit → kreditný`, `limit → limitný`), and the long `í` of `audítny` belongs +to the separate lexeme `audítor` (Latin *audītor*, adjective `audítorský`). The bundle +already had the distinction — it writes `audit`, `auditu`, `auditovanie`, `auditujte` +short, matching core's `Auditovanie`, and `Audítori` long — and applied it correctly to +the noun and the verb while getting the adjective wrong. `Audítori` is preserved. + +**A NEW DEFECT CLASS: an opposite-direction key pair carrying each other's meaning.** +`Uses` → `Používa sa` ("it is used") and `Used by` → `Používa` ("it uses") were +**inverted**. Both are `AppTab` titles, one over outgoing relations and one over +incoming. Neither value is ill-formed Slovak and neither is empty, identical or +wrong-arity, so nothing but reading the pair against its call sites finds it. Every +other locale marks the direction (`cs` `Použití` / `Používáno v`, `de` `Verwendungen` / +`Verwendet von`, `pl` `Używa` / `Używane przez`), which is what made `sk` visibly the +outlier. **Look for this wherever the English ships a converse pair** — Uses/Used by, +Parent/Child, Source/Target, Merged from/Merged into. Fixing `Uses` also resolved its +byte-identical collision with `In use`, whose `Používa sa` is correct as a card badge. + +**`Delete`/`Remove` was escalated and deliberately LEFT.** Both render `Odstrániť` +across 122 values (88 Delete, 41 Remove). Core `sk` splits them — Delete → `Zmazať`(6) +or `Vymazať`(3), **never** `Odstrániť`; Remove → `Odstrániť`/`Odobrať` — and `Zmazať` +was the only free slot, because the bundle has already spent `Vymazať` on *Clear* and +on the GDPR *Erase*/`vymazanie` family, where it is the standard term for the Art 17 +right and cannot move. The owner chose to leave it: both are valid Slovak, the +collision is soft, and §3.8 protects taste. `locales/sk.json` records it as a +**deliberate** divergence with the counts, so it is not re-litigated or mistaken for an +oversight. This is the second worked example of the escalation shape after `cs`'s +`Configuration`, and the first where the answer was "change nothing". + +### `sl` looks like `sk` on the map and behaves like `hr` in the data + +The two are neighbours, both West-adjacent South Slavic, both formal in core. They +agree on nothing else that matters here, and taking `sk`'s answers across would have +been wrong on every count: + +| | `sk` | `sl` | +| --- | --- | --- | +| Button labels | infinitive (`Uložiť`) | bare 2sg imperative (`Shrani`) | +| 2sg imperative in the detector | counted as informal | **excluded** | +| Plural forms | 3, absolute boundaries | **4**, modular, with a dual | +| Borrowing | keeps `Webhook`, `Slug`, `Port`, `Audit` | translates all four | + +Core sl uses the bare 2sg imperative for 23 of its 26 short labels, with **zero** 2pl +and zero infinitive; the remaining three are nouns (`Souporaba`, `Izbor`) or the +adverb `Nazaj`. So `sl` joins `ca`/`et`/`hr` in the imperative-buttons row, and its +imperative must be **excluded** from the detector for the ca/et/hr reason *plus* one +of its own: across the whole `-iti` verb class the 2sg imperative is spelled exactly +like the 3sg present indicative — `uredi`, `shrani`, `osveži`, `obnovi`, `posodobi`, +`preveri` are each both "do X!" and "he does X", and all six are button labels in +this bundle. (`-ati` and `-irati` verbs do differ — `dodaj` vs `doda` — but the +collision class is far too large to carve out.) + +### `sl` traps + +1. **`ti` and `te` both point in two directions.** `ti` is informal *you* **and** the + masculine nominative plural of `ta` (`ti objekti` = *those objects*) — the `cs`/`hr` + collision again. `te` is worse: the accusative of informal `ti`, **and** the + accusative plural of `ta` (`te datoteke` = *these files*), **and** the 2pl verb + ending. Both are left unmatched; the oblique forms (`tebe`/`tebi`/`tabo`) and the + `tvoj-` possessive carry the signal instead. +2. **Bare `si` is the reflexive dative clitic**, commonest in *formal* prose + (`lahko si izberete`), as in `hr` and `sk`. +3. **`-š` inverts polarity**, exactly as in `hr` and `sk`: `vaš` (your-FORMAL) and + `naš` both end in it. Note the asymmetry the closed lists exploit — the 2sg + *present* (`spremeniš`, `shraniš`) is safe to enumerate because the 3sg drops the + `-š`, while the 2sg *imperative* is not, because it collides with the 3sg. +4. **`vas` is also the noun "village".** Kept as a formal marker anyway, since a + village is implausible in this app's domain, but it is a real false-positive path + and is recorded in `UNDETECTABLE`. +5. **The dual is a third address form** (`vidva želita`) that no other locale here + has. It addresses exactly two people, never appears in UI prose, and is not + matched — but it is the reason `nplurals=4`. + +Terminology: `sl` translates where `ro` and `sk` borrow — `spletni kljuk` for webhook, +`koristni tovor` for payload, `Vrata` for Port, `Oznaka` for Slug, `žeton` for token, +`del` for chunk, `vložitev` for embedding, `zgoščena vrednost` for hash, `nadzorna +plošča` for dashboard, `revizijska sled` for audit trail against `revizijski vnos` for +an audit entry, `potek dela` for workflow but `tok` for flow. **AI is rendered `UI`** +(*umetna inteligenca*) — so generic AI strings take `UI` while product names keep the +English (`Fireworks AI`, `OpenAI`, `Dolphin AI`), following the pre-existing values. +Two deliberate divergences from the harvest: `Revoke` is `Odvzemi`, not core's +`Prekliči`, because this bundle already uses `Prekliči` for **Cancel** and the two +share the account screen; and `Right` is `Pravica`, not core's `Desno`. `Quota` takes +core's `Količinska omejitev` even though it is long, because settings *is* the quota +domain. Ellipses take a **space before** in both spellings (`Poteka nalaganje ...`, +`Preverjanje …`), and progressive states use the impersonal `Poteka X ...`. + +Two pre-existing values were corrected, both single occurrences against a large +consistent majority, and both worth noting as a *class*: `Audit trail #{id}` read +`Revizijski trag` — **`trag` is Croatian**, Slovenian is `sled`, which the bundle's +own 14 other audit-trail keys already used — and one confirm dialog opened with +`Predmeti` against 74 values using `objekt`. Given that openbuild ships a Croatian +catalogue under `sl.json`, a Croatian word turning up in a Slovenian bundle is not a +coincidence to shrug at. A cheap check that caught nothing else: scan the finished +bundle for `ć đ ě ř ů ą ę ł ń ś ź ż`, none of which exist in Slovenian orthography. + +### `sr` capitalises its domain terms, and that is the convention most easily broken + +Serbian is the `hr`/`sl` shape on register and buttons — formal 2pl prose, bare 2sg +imperative labels, split by string role — so the interesting part of the pass is elsewhere. +This bundle capitalises the **six first-class register concepts** mid-sentence, like proper +nouns, and lowercases everything else. Measured rather than assumed: + +| Capitalised mid-sentence | Lowercase | +| --- | --- | +| `Шема` 33:1 · `Регистар` 30:0 · `Објекат` 62:0 · `Својство` 25:0 · `Датотека` 43:0 · `Извор` 5:1 | `приказ` 0:41 · `ентитет` 0:13 · `ток` 0:25 | + +So `Обриши све Објекте у овој Шеми` but `Обриши приказ`, and `Управљајте вашим Шемама +података и њиховим Својствима`. It touches roughly a third of all values, no other locale +here does it, and **nothing automated checks it** — which makes it the single most likely +thing to get wrong at scale. Measure it before the first batch, the same way you measure +register: count capitalised-mid-sentence against lowercase per term. The two 1-of-N outliers +(`Додај извор`, `по овој шеми`) were normalised rather than left to explain later. + +Register is a clean **formal**: 911 markers against **zero** informal over 4631 values in 32 +catalogues, one-sided like `lt`, `sk` and `sl`. That zero is what made the two register +corrections unarguable rather than a judgement call — core never produces a 2sg present at +all, so `имаш` and `сачуваш` in the `Select …` family had no defence. The split point matters +though, and the bundle had already drawn it: instruction sentences take 2pl (`Изаберите које +Својство…`), bare labels and dropdown placeholders take 2sg (`Изабери све`, `Изабери грану`). +The corrections moved two long strings from the label side to the sentence side, which is +where `sl` puts the same strings. + +**The trap that nearly produced a wrong correction.** A third value in that same family, +`Изабери модел или унеси прилагођени назив модела`, looks identical in kind and is +**correct**: `унеси` is a 2sg *imperative*, so it is the label convention, where its siblings +carried 2sg *presents*. Serbian spells the 2sg present with a `-ш` the 3sg drops (`сачуваш` +vs `сачува`), so the presents are safe to enumerate in a detector while the imperatives are +not — and that asymmetry is exactly what separates a style choice from an address slip here. +Recorded in `UNDETECTABLE` in `detectors/sr.js` rather than "fixed". + +Terminology: `ревизорски траг` for audit trail (24 uses; the three `ревизијски` outliers were +normalised — both adjectives are formable Serbian, so this is consistency, not a fix), +`приказ` for view, `део` for chunk, `уграђивање` for embedding, `веб-кукица` for webhook, +`ток` for flow against `радни ток` for workflow, `корисни терет` for payload, `ентитет` for +entity, `контролна табла` for dashboard, `привремена меморија` for clipboard, `печат` for +seal, `поузданост` for confidence, `предмет` for a DSAR case, `субјекат података` for the +GDPR data subject, `Рок чувања` for `Bewaartermijn`, `активност обраде` for +`verwerkingsactiviteit`, `Одговорност` for `Verantwoording`. Initialisms are **not** handled +uniformly and that is deliberate pre-existing practice: `ID` → `ИД` and `Slug` → `Слаг` are +transliterated, while `URL`, `API`, `HTTP`, `LLM`, `PDF`, `CSV`, `RBAC`, `JSON` and `OAS` +stay in Latin and take a hyphenated Cyrillic case ending where they inflect (`URL-у`, +`OpenRegister-у`). Two coinages avoided collisions: **Revoke** → `Повуци` so that `Опозови` +stays free for **Reverse** (a merge), and **Bucket** → `Сегмент`, never "интервал", since +`Interval` is its own key. + +### `bg` is the first locale where the imperative is detectable in *part* of the verb system + +Every locale before this one was all-or-nothing about the bare 2sg imperative: `sk` +counts it, `ca`/`et`/`hr`/`sl` exclude it. Bulgarian splits by conjugation class, and +`detectors/bg.js` is the first detector to enumerate half a paradigm: + +- **и-conjugation (`-я`/`-иш`) imperatives are unusable, for two reasons rather than + one.** `запази` is simultaneously the 2sg imperative, the 3sg present (`може да + запази`) and the 3sg **aorist** (`той запази`). The aorist collision is the novel + part — no other locale's imperative homograph reaches into the *past* tense — and it + is live in this bundle's own prose: `Анализът завърши:` and `Изтриването завърши` are + aorists spelled exactly like the imperatives of `завърша`. Same for `провери`, + `добави`, `отвори`, `потвърди`. +- **а-conjugation (`-ай`/`-вай`/`-й`) imperatives are unambiguous**, because the 3sg + present of that class ends in `-а`/`-ва`: `опитай` vs `опитва`, `изпълнявай` vs + `изпълнява`, `записвай` vs `записва`, `копирай` vs `копира`. + +That split earned its keep immediately. The two informal values this bundle already +shipped — `Изпълнявай надзорни проверки преди всяка стъпка` and `Записвай одитен запис +за всяка стъпка`, both settings-toggle labels the English source phrases imperatively — +are **both** а-conjugation, so excluding the whole paradigm would have found neither. +Corrected to the verbal nouns `Изпълняване на…` and `Вписване на…`; `вписване` also +removes the `Записвай … запис` repetition that the direct fix would have kept. + +One thing to say out loud about counting any imperative: the detector measures against +**this bundle's** label convention, not core's. Core `bg` uses `-й` imperatives as +labels in 29 places, so pointed at core it would report noise — which is also why the +core register scan's raw informal figure needs splitting before it is read at all (see +below). + +### `bg` traps + +1. **`-те` is the definite plural article** as well as the 2pl verb ending, and the + article is the single commonest morpheme in the language. `файловете`, `обектите`, + `потребителите`, `Членовете`, `настройките` are all nouns. A `-те` suffix rule scores + essentially every plural noun phrase in the app as formal prose and the measurement + becomes noise — a *louder* version of the same failure `hr`, `sk` and `sl` have. +2. **Bare `те` is the 3pl pronoun *they*,** not just the 2sg accusative clitic. This + bundle says `Те могат да бъдат възстановени по-късно` — *of objects*. Left unmatched. +3. **Bare `си` is the reflexive possessive clitic**, commonest in formal prose + (`за да прецизирате търсенето си`), as in `hr`/`sk`/`sl`. +4. **`-ш` inverts polarity**, the third locale in a row: `ваш` (your-FORMAL) and `наш` + both end in it. +5. **`трябва` is not a person marker.** It is impersonal 3sg; in `Трябва да сте влезли` + the register is carried by `сте`. Matching `трябва` would score impersonal + requirement text as formal address. +6. **The useful negative: bare `ти` IS usable here.** Bulgarian lost its case system and + its plural demonstrative is `тези`/`тия`, so `ти` has none of the demonstrative + reading that makes it unusable in `cs`, `hr` and `sl`. Do not port that exclusion + across the family by analogy — it costs real recall for nothing. +7. **`брой` is the noun *count* as well as an imperative**, and it is a noun in every + place this bundle uses it (`Брой обекти за обработка`, `Максимален брой резултати`). + +**Split the core informal count by what each hit is.** `bg` measured 699 formal markers +against 43 informal over 3451 values in 27 catalogues — but 29 of the 43 are core using a +2sg imperative as a *button label* (`Копирай`, `Актуализирай`, `Преименувай`, `Запиши`), +which is a label-style choice, not prose address. Only 11 values carry informal **prose**, +and they cluster in `core/bg.json` and `encryption/bg.json` (`Можеш да затвориш този +прозорец`, `старата ти парола`). So the prose ratio is 699:11. Unsplit, 43 reads like a +minority position worth weighing; split, it is a style choice plus eleven legacy strings. +Worth doing wherever the scan comes out close — `ro`'s MIXED 124 vs 66 is exactly that +shape. + +**The count form is a plural hazard no gate can see.** `bg` has the simplest header in the +project (`nplurals=2; plural=(n != 1)`) and still needs care, because Bulgarian masculine +non-person nouns take a separate counting form (числителна форма, `-а`/`-я`) after a +numeral: `обект` → `обекти` as a bare plural but `обекта` after a number, likewise +`запис`/`записа`, `файл`/`файла`, `регистър`/`регистъра`, `имейл`/`имейла`. Which form a +plural array needs therefore depends on **whether the string contains a numeral**, not on +the form index — `_Delete {count} object_` needs `Изтриване на {count} обекта` while +`_Object successfully deleted_` needs `Обектите са изтрити успешно`. Masculine *person* +nouns keep the plural (`{count} членове`); feminine and neuter have no count form at all +(`схема` → `схеми` either way). The pre-existing `_%n entry has no hash yet_` array already +had `%n записа` and was the model to follow. + +Terminology: `bg` translates domain prose fully but keeps Latin-script initialisms +unchanged rather than transliterating them into Cyrillic — `CSV`, `PDF`, `RBAC`, `URL`, +`ID`, `DSAR`, `JSON`, `OAS`, `Slug`, and `push` in `Push известия` (all pre-existing +practice). Coinages: `уебхук` for webhook, `полезен товар` for payload, `фрагмент` for +chunk, `вграждане` for embedding, `одитна следа` for audit trail, `изглед` for view, +`табло` for dashboard, `запечатване` for seal, `достоверност` for confidence, `меко +изтрит` for soft-deleted, `Срок на съхранение` for `Bewaartermijn`, `дейност по +обработване` for `verwerkingsactiviteit`, `Отчетност` for `Verantwoording`. + +Three collisions the language forced, all resolved in favour of the pre-existing value: +**Connections** → `Свързвания`, because `Връзки` was already **Relations** (and the +`Links` entity type); **Subject** (the GDPR data subject) → `Субект на данните` spelled +out, because bare `субект` was already **entity** in twenty-odd keys — the `lt` fix of +coining a different word for *entity* was not available, since that value had shipped; +and **Bucket** → `Сегмент`, never "интервал", because `Interval` is its own key. One +collision was left standing deliberately: **Cancel** and **Denial** are both `Отказ`, +which is the right Bulgarian word for each (the dialog button, and the GDPR refusal of a +request — and what core `bg` uses for Cancel in ten catalogues). They never share a +screen, and forcing a distinction would have made one of them wrong. + +Orthography: ellipsis follows the **key's own** punctuation (`...` where the source has +`...`, ` …` where it has ` …`), ranges take a plain hyphen matching the source, long prose +takes an em dash with spaces, and the dative clitic is written `ѝ` with the grave accent — +never `и`. That last one is a marker of careful Bulgarian and the pre-existing bundle +already got it right (`може вече да не ѝ съответстват`). + +### `rm` is the first locale with no core evidence at all + +Romansh is where the standard procedure runs out of inputs. Nextcloud ships **zero `rm` +catalogues** — none in `core/l10n`, none in `lib/l10n`, none in any bundled app — so +`coreCatalogues('rm')` throws by design and §5 step 2 cannot be run as written. The +§6.4 fallback applies and the verdict comes from the bundle's own pre-existing half: +**81 formal markers against 0 informal across 995 translated values.** That is +one-sided enough to settle it without core. + +Check this before assuming a locale is measurable. Four of the nine low-resource +locales cannot be decided from core: `rm` and `mt` have **no catalogues**, and `bs` and +`lb` have one each carrying 55 and 72 values, which is not evidence of anything. + +Romansh has a real T–V distinction, so unlike Russian the polite pronoun **is** a +register marker: `Vus` / `voss` (the German `Sie` model) against `ti` / `tiu`. Buttons +are **infinitives** — `Memorisar`, `Stizzar`, `Annullar`, `Modifitgar`, `Crear`, +`Tscherner` — the register-neutral `cs`/`lt`/`lv`/`sk` pattern, which also defuses the +dual-role `Create`/`Read`/`Update`/`Delete` keys that forced `ro` onto verbal nouns. +Progressive states are the bare infinitive plus an ellipsis (`Analyzing...` → +`Analisar...`). + +**Two whole paradigms are undetectable, and `rm` is the exact mirror of `sk`.** Both +languages label buttons with the infinitive, so §6.5 test 1 comes out the same for +both; they diverge on test 2: + +- the **2sg imperative** of every `-ar` verb is spelled identically to the 3sg present + *and* the feminine singular past participle. `stizza` is "delete!", "it deletes" and + "deleted-f.sg" at once, and the 3sg reading is live in this bundle's own prose + (`Quai stizza las endataziuns`, `Ferma mintga flux`, `Elavurescha ils chunks`). Where + Slovak has `ulož` ≠ `uloží`, Romansh has `stizza` = `stizza`. Same button convention, + opposite detector decision — which is why §6.5 insists both tests be run per locale. + Unlike `bg` there is no conjugation class to carve out: every `-ar` and `-escha` verb + collides. +- the **2sg present of regular verbs** ends in `-as`, which is also the feminine plural + of every noun and adjective — the commonest inflection in the language. `controllas` + is "you check" *and* the noun "checks" (`Controllas da surveglianza` is a real value); + `empruvas` is "you try" *and* "attempts" (`Max empruvas`); `tschernas` is "you choose" + *and* the plural of the noun `tscherna`. Detection therefore rests on the ten + irregular verbs, whose 2sg ends in a bare `-s`: `has`, `es`, `pos`, `stos`, `vuls`, + `sas`, `vas`, `fas`, `das`, `vegns`. + +The useful **negative**: bare `ti` **is** usable, unlike `cs` `ty`, `hr`/`sl` `ti` and +`sr` `ти`, because Romansh demonstratives are `quel`/`quest`, so there is no +demonstrative reading to collide with. Same result as `bg`, and again reached from the +data rather than from the family. + +### `rm` traps + +Suffix rules fail in both directions here, which is why the lists are closed: + +- **`-ai`** looks like the polite 2pl imperative, and mostly is — but `quai` + ("this/that") ends in it and occurs **23 times**, the single most common word such a + rule would hit, along with `perquai` ("therefore"), `mai` ("never"), bare `ai` + (a + ils) and `hai`/`sai` (1sg "I have"/"I know"). +- **`-ais`** looks like the 2pl present, and mostly is — but `mais` is "months" + (`Mintga mais`) and the nationality adjectives `ollandais`/`englais`/`franzais` end + in it. +- **`-as`**: see above. Fatal in the other direction. + +**Diacritics must not be folded.** `tscherni` is the 2pl imperative ("choose!", a +formal marker) while `tschernì` is the past participle "selected" — this bundle's value +for both `Selected` and `register(s) selected`. They differ by the grave accent and +nothing else, so folding would score every `Tschernì` label as polite address. Same for +`e` ("and") against `è` ("is"). `fold()` only lowercases. + +Capitalisation is the `sr`-shaped convention with a **different set**, which is the +point: the practice is per-locale and so is the list. `rm` capitalises exactly three +domain terms mid-sentence — `Schema` 34:1, `Register` 30:0, `Datoteca` 43:0 — and +lowercases every other one, including `object` at 0:63, plus `vista`, `webhook`, +`colliaziun`, `tschertga`, `endataziun`, `caracteristica`, `utilisader`, `chunk`, +`flux`, `entitad`, `gruppa`, `funtauna`, `roll`, `token`. Both rules apply inside one +value: `naginas relaziuns cun objects u Datotecas`. The polite pronoun and possessive +are **always** capitalised mid-sentence (`Vus` 13:0, `Voss*` 21:0), on the German +`Sie`/`Ihr` model. + +Terms, all taken from the bundle rather than coined: audit trail → `colliaziun +d'audit` (literally "audit link", a loose rendering of *trail* but the file's own +consistent term in 25 values — three `tratga da revisiun` outliers were normalised to +it), view → `vista`, property → `caracteristica`, source → `funtauna`, file → +`Datoteca`, field → `chomp`, workflow → `process da lavur`, flow → `flux`, log → +`protocol`, clipboard → `archivet provisoric`, dashboard → `panel`, owner → +`possessur`, password → `pled-clav`, lock → `serradira`, `Bewaartermijn` → `Temp da +conservaziun`, `Rechtsgrond` → `Basa giuridica`, `Verantwoording` → `Rendaquint`, +`AVG / Verwerkingsregister` → `RGPD / Register da las activitads da tractament`, +**Subject** (the GDPR data subject) → `Persuna pertutgada`, **Golden record** → +`Endataziun da referenza`, **Bucket** → `Segment` (never "interval", because +`Interval` is its own key). Technical terms are borrowed throughout — webhook, chunk, +embedding, payload, token, hash, batch, trigger, backend, endpoint, `Slug`, `Branch` — +which is why `Slug` is a recorded cognate rather than a coinage even though core `lt` +and `sl` translate it. + +Three near-synonyms are kept deliberately apart, the §8.5 pattern: the pre-existing +`revocar` is **undo** (`na po betg vegnir revocada`, 5 values), `Inversar` is +**reversing a merge** (`ro` uses `Inversare` likewise; `Annullar` was unavailable +because it is already **Cancel**), and `Revocar` is **revoking a token**, which is what +`ca`, `es`, `fr`, `it` and `ro` all use. The last two share a stem — an unavoidable +overlap recorded rather than worked around, since a token row action and a +delete-confirmation dialog never share a screen. + +One trap worth stating because it would have been silent: **`Handler` is a person.** +Every locale renders it `Responsable`/`Gestor`/`Bearbeiter`, and the call site confirms +it — a `` in the DSAR cases table beside `Type`, `Status`, `Deadline`, with +`handlerFilterOptions` mapping people's names. It is not an event handler. `rm` → +`Respunsabel`. + +Twelve pre-existing defects were corrected, spanning six classes: three +`tratga da revisiun` terminology outliers and three `endataziun da revisiun` ones; +`siwa` for *follows* (**the only non-loanword `w` in the bundle**, and `w` is not a +letter of Romansh — corrected to `suonda`); `d'spetga`, the only `d'` elided before a +consonant; `lescha` where the 3sg of *leger* is `leja` (`lescha` is the noun "law"); +`endataziuns sigillads` with a masculine participle on a feminine noun; one lowercase +`quest schema`; and a `…` ellipsis with a missing article against 20 consistent +siblings. A thirteenth was the plural array above. + +### `ga` is the first locale with no T-V distinction at all + +Every locale before this one had a register *choice* to measure. Irish does not. +`sibh` is strictly the second-person **plural** in modern standard Irish — it is not a +polite singular the way German `Sie`, Romansh `Vus` or Croatian `Vi` are — so there is +exactly one way to address a user, and it is `tú`. + +The measurement is more one-sided than any other locale in the set, and in a different +way: **434 second-person singular markers against 0 plural** across core's 33 `ga` +catalogues (5395 values). Not one occurrence of `sibh`, `sibhse`, `bhur`, `agaibh`, +`daoibh`, `libh`, `oraibh`, `uaibh`, `chugaibh`, `díbh`, or an `-aigí`/`-igí` 2pl +imperative — confirmed by raw grep as well as by the detector, and the bundle's own +1036 pre-existing values agree (15 singular, 0 plural). + +`locales/ga.json` records `"register": "informal"`, and that needs reading correctly: +it does **not** mean Irish core preferred the familiar register over a polite one. It +sets the gate's polarity so `patchcheck` refuses second-person *plural* address. That +is the one register defect this locale can have, and it is a live risk rather than a +theoretical one — a translator working down a list of European locales imports the +politeness plural by analogy from de/fr/nl and writes `An bhfuil sibh cinnte…` for a +single-user dialog. Nothing else in the project can see that. + +The corollary is that **a low informal count is not evidence of a problem here.** Most +of this bundle is written with the autonomous/impersonal verb (`Scriosadh an rian +iniúchta`, `Níor aimsíodh aon chláir`), which addresses nobody and scores zero in both +directions. The load-bearing figure is that the *formal* count is zero. + +### `ga` traps + +**The 2sg imperative fails both §6.5 tests at once**, so it is excluded — the first +locale where the two tests agree rather than pulling apart. It is the label convention +(core: `Sábháil`, `Scrios`, `Cealaigh`, `Cruthaigh`, `Deimhnigh`, `Cóipeáil`, +`Roghnaigh`, `Bain`, `Dún`, `Bog`, `Athnuaigh`, `Athchóirigh`), **and** for the whole +`-áil` class it is spelled identically to the verbal noun, which is live in this +bundle's prose as the progressive: `Ag sábháil...`, `Ag cóipeáil sonraí...`, +`Ag tástáil...`, `Ag próiseáil...` are all real values whose second word is the +imperative form. Several stems are ordinary nouns besides — `Scrios` is also +"destruction", `Dún` also "a fort". + +**`do` is the single largest recall loss in any detector here, and it is unavoidable.** +It is at once the 2sg possessive "your", the preposition "to/for", the past-tense verbal +particle, and half of `le do thoil` ("please"). It occurs in **527 of 6431** corpus +values, overwhelmingly not as a possessive, so it is excluded wholesale — which means +`D'eochair API OpenAI` ("your OpenAI API key") and `do chuardach` ("your search") carry +no detectable marker. Note the polarity: the loss is on the *correct*-register side, so +it thins the evidence without ever producing a false formal hit. + +**`-ibh` must never be a suffix rule.** `díbh` is "off you (pl)" and `díobh` is "off +them" — one letter apart, and it is the **third**-person one that occurs in this corpus, +twice, both times genuinely "of them" (`gach ball díobh seo`, `gach ceann díobh a +shárú`). Same for `-igí`: it is the 2pl imperative ending and also the plural of every +noun in `-ig` (`oifig` → `oifigí`). No such noun happens to occur in the 6431-value +corpus, which is precisely why a suffix rule would have looked safe and shipped. + +**Casing has no independent convention here — it mirrors the English source.** This is a +third outcome for the §8.10 trap, distinct from `sr` (six terms capitalised +mid-sentence) and `rm` (three). Measured over 14 domain terms: where the English key is +title-cased the `ga` value capitalises **76 times against 1**; where the English key is +prose it capitalises **0 times against 193**. The lone apparent exception is not one — +`Update register OAS: ...` → `Nuashonraigh OAS cláir: ...` has lowercase `register` in +the English too. So follow the source per key. This also explains why a naive per-term +count reads MIXED for every single term (`clár` 3:5, `scéimre` 9:21, `réad` 11:19, +`comhad` 6:11, `amharc` 3:22) and would have looked like "no convention" — the +convention is real, it is just conditioned on the source rather than on the term. + +Ellipsis mirrors the source glyph too: 41 of 42 keys carrying `...` keep `...`, and 2 of +2 carrying `…` keep `…`. The bundle has 8 em dashes, **no** en dash, no non-breaking +space, no guillemets or curly quotes, and `%` never appears outside a placeholder. + +Terminology, fixed by the pre-existing half and kept: Register `clár`/`cláir`, Schema +`scéimre`/`scéimrí`, Object `réad`/`réada` (gen sg `réid`), Property `airí` (gen pl +`airíonna`), File `comhad`/`comhaid`, View `amharc`/`amhairc`, Audit trail +`rian iniúchta`, Flow `sruth`, Workflow `sreabhadh oibre`, Entity `aonán`, Chunk +`smután`, Embedding `leabú`/`leabuithe`, Endpoint `críochphointe`, Token `comhartha`, +Hash `hais`, Chain `slabhra`, Seal `séala`, Dashboard `deais`, Repository `stórlann`, +soft delete `boigscriosadh`. Four deliberate divergences from core: **Webhook** stays the +English loan (28 pre-existing uses) where core's `webhook_listeners` has +`Crúcaí gréasáin` — the file wins on lexicon, as `hr` kept `lozinka`; **Flow** is `sruth` +and Workflow `sreabhadh oibre`, where core's `Sreabhadh` for Flow would collapse two +concepts the app distinguishes; **Update** is the imperative `Nuashonraigh`, not core's +verbal noun `Nuashonrú`; and **Revoke** is `Cúlghair`, not core's `Chúlghairm`, which +carries a stray lenition on a citation form and is a typo of the kind to leave alone +(cf. core `tr`'s `Yenlle`). + +Two §8.4 wrong-sense traps resolved at the call site rather than from the harvest: +**Bucket** is a histogram bin in `QualityIndex.vue` (a `` beside `Count`), so +`Banda` — never core's `Buicéad`, a literal pail — kept distinct from `Interval` +`Eatramh` and `Range` `Raon`; and **Labels** is the file-tag column in +`UploadFiles.vue`, so `Clibeanna`, while the singular **Label** is a facet range caption +in `EditSchemaProperty.vue` and takes `Lipéad`. That is the split §8.4 predicts for that +pair, and here it is real. `Right` is the RBAC `` in `EditOrganisation.vue`'s +`special-rights-table`, so `Ceart`, not `Deas` (right-hand side). `Apply` is +`Cuir i bhFeidhm`, not core's `Cuir iarratas isteach`, which is a *job* application. + +One pre-existing defect was corrected: `Loading organisations...` → +`Eagraíochtaí á luchtú…` was the only one of the bundle's 35 `Loading X...` values not +built as `Ag ...` (the other 34 are), and it also carried `…` +against the source's `...`. Normalised to `Ag luchtú eagraíochtaí...`. + +### `mt` has a politeness system and simply does not use it + +`ga` came out 2sg-only because Irish has no T-V distinction at all. `mt` came out 2sg-only +for a completely different reason, and the two sitting next to each other is exactly the +trap: **Maltese does have the politeness options.** `intom` serves as a polite singular the +way French `vous` does, and `Is-Sinjur` / `Is-Sinjura` with third-person agreement is the +deferential register. Both are available; both are measured absent. So `mt`'s `informal` is +an ordinary measured choice like `nl`'s, and its gate catches genuine deference rather than +an impossible form. Two consecutive locales measuring the same way is not evidence they are +the same case. + +Core ships **zero** `mt` catalogues, so this is the §6.4 fallback — the second locale after +`rm` with no core evidence at all. The fallback was widened to the sibling apps' **frontend** +`mt.js` files, which tripled the corpus from 1015 values to 3422 and turned a thin 26-vs-0 +into **128 vs 0**. Two constraints made that sound: only `.js` bundles, because the backend +`.json` is a separate catalogue with a separate consumer — openregister's own `mt.json` +contains an `int` that would otherwise have been miscredited to the frontend — and the +sibling scan excludes byte-identical mislabelled catalogues the way `harvest.js` does. + +The markers split as 75 `tiegħek`, **35 `jekk jogħġbok`**, 15 `int`/`inti` and 4 +prepositional pronouns. That middle figure is the transferable finding: Maltese's politeness +formula carries a 2sg **object suffix**, which makes it a real address marker rather than a +courtesy word, and at 35 uses it was the second commonest marker in the bundle. The first +draft of the detector omitted it and a must-fire control caught the omission. Any locale +whose "please" inflects for the addressee has the same free signal — `ga`'s `le do thoil` +does not, so check rather than assume. + +One method note worth keeping: the first probe run scored **zero** for the pronoun and would +have gone into the record that way. It omitted the `/i` flag, and all three of the bundle's +`Inti ċert li trid…` values are sentence-initial. A measurement that comes out at exactly +zero deserves a second look before it is written down. + +### `mt` traps + +**Two whole paradigms are undetectable**, both ordinary Maltese morphology, and between them +they cost most of the theoretical recall: + +- **The `t-` prefix.** The 2sg imperfect and the 3sg **feminine** imperfect are spelled + identically across the entire verb system, so `tista'` is at once "you can" and "she/it + can" — and both readings are live here: `Hawn tista' tara jekk dik il-katina hijiex sħiħa` + (2sg) against `Il-Proprjetà tista' tittejjeb`, `Din l-analiżi tista' tieħu ftit ħin` and + `Qabel ma tista' taħdem il-vettorizzazzjoni` (3sg f). 23 occurrences of `tista'` split both + ways, plus 24 of `trid` that are mostly genuine 2sg and still cannot be counted. This is + the Latvian shape — systematic, not exceptional. +- **The `-u` ending.** The 2pl imperative and 2pl present both end in `-u`, and so does the + 3pl of everything. `nstabu` ("they were found") occurs 24 times in `Ma nstabu l-ebda X`, + `għandhom` 19 times, plus `jistgħu` and `jappartjenu`. A `-u` rule would score the + commonest sentence shape in the file as deference, so 2pl imperatives are excluded and the + formal side rests entirely on the pronoun, the possessive, the `-kom` prepositional + pronouns and `Sinjur` — narrow, but unambiguous. + +Note the polarity of both losses: they fall on the **correct**-register side, so they thin +the evidence without ever producing a false formal hit. And because most of this bundle is +written with impersonal or passive verbs (`It-traċċa tal-awditjar tħassret`, `Ma nstabu +l-ebda Reġistri`), which address nobody, a low informal count is not evidence of a problem +here. The load-bearing figure is that the formal count is zero. + +Also: `tagħhom` (3pl "their") is one paradigm slot from `tagħkom` (2pl "your"), and it is +the 3pl that occurs — `mar-ringieli tagħhom`, `il-konfigurazzjonijiet tagħhom`. Match only +`tagħkom`. And `à è ì ò ù` **are** Maltese, marking stress on Romance-derived nouns +(`attività`, `kwalità`, `entità`, `Proprjetà`) — 92 legitimate uses, so a foreign-diacritic +sweep that flags them is measuring its own list, not the data. + +**This bundle keeps English IT loans**, consistently, and that is the house style rather +than laziness: Logs, Repository, Path, Branch, Email, Format, Headers, Password, Username, +Timestamp, Status, Settings, Total, Serial, Parallel, Port, Slug, Webhook, Dashboard, +Endpoint, token, triggers, payload, timeout, clipboard, hash, embedding, soft delete. The +siblings disagree on two and **the file wins** (§3.5): they render Repository as +`Repożitorju` and Logs as `Reġistri`, but this bundle writes `Repository` and `Path +fir-repository`, and keeps `Logs` in four independent places. `Reġistri` is unavailable for +Logs in any case — it is already this bundle's plural of Register, which is the §8.5 +collision pattern showing up in a loan decision. + +Terminology: Register `Reġistru`/`Reġistri`, Schema `Skema`/`Skemi`, Object +`Oġġett`/`Oġġetti`, Property `Proprjetà`/`Proprjetajiet`, File `Fajl`/`Fajls`, View +`Veduta`/`Vedute`, Source `Sors`/`Sorsi`, Flow `Fluss`/`Flussi`, Entity +`Entità`/`Entitajiet`, Chunk `Biċċa`/`Biċċiet`, Audit trail `Traċċa/Traċċi tal-Awditjar`, +Chain `Katina`, Seal `Siġill`, Count `Għadd`, Field `Kamp`, Error `Żball`, Owner `Sid`. +Phrase patterns: `No X found` → `Ma nstabu l-ebda X`, `Failed to X` → `Naqas milli X`, +progressives → `Qed jitilgħu l-X...`, `Please X` → `Jekk jogħġbok X`. + +Domain-term capitalisation is a **partial, per-term list** — the `sr`/`rm` shape, not `ga`'s +mirror-the-source. Four families are capitalised mid-sentence (`Oġġett` 56:2, `Reġistru` +55:0, `Proprjetà` 21:0, `Fajl`/`Fajls` 71:0) and the rest are lowercase (`skema` 4:17, +`iskema` 6:49, `veduta` 3:26, `entità` 2:7, `biċċiet` 3:14, `utent` 4:14). The asymmetry to +notice is that `Reġistru` is capitalised while `skema` is not, though they are the app's two +paired core concepts — so the list cannot be inferred from what a term means, even within +one locale. **This pass broke that convention and the check caught it:** measuring the +pre-existing half separately from the newly written half showed HEAD capitalising +`Fajl`/`Fajls` 43:0 while the new values had lowercased it 24:4, and 21 values were +normalised. Whole-bundle counts read "CAPITALISED" either way, because the pre-existing +majority outvoted the drift — split the corpus at `HEAD` and compare the two columns. + +**Nine pre-existing defects were corrected, in three classes.** Six were the same one: +values whose English key says AUDIT but whose Maltese said `verifika` (verification) — a +different concept in this app that appears on the same screen (`Verifika tal-katina`, +`Ivverifika l-katina`). 24 values use `awditjar` for audit against those 6, so the 6 were +normalised. A seventh, `Audit trail #{id}`, had been left as untranslated English (`Audit +Trail #{id}`, merely re-cased) — the same key that was the outlier in `sr`, where it was +wrong Croatian in the wrong alphabet. An eighth, `Log integrity`, read `Integrità +tar-reġistru`, i.e. "integrity of the REGISTER", on a screen that lists Reġistri. And +`Loading organisations...` was the 1-of-19 outlier on both its verb and its ellipsis glyph — +the same key that was the 1-of-35 outlier in `ga`, on the same two counts. + +One further inconsistency was **recorded rather than changed**: the bundle renders +`entry/entries` as `entrata/entrati` 19 times and `annotazzjoni/annotazzjonijiet` 18 times +for the same rows. It splits by screen rather than randomly, `entrata` is the accurate word +but `annotazzjoni` may well be idiomatic for a log row in Maltese IT, and normalising 18 +values is a larger intervention than the pass warranted (§6.9). New keys follow the adjacent +existing value on their own screen, so no screen was made self-inconsistent. + +### `is` had a T-V distinction and abandoned it + +Icelandic is the third state of the three (see "Register is measured, never inherited" +above): it once had `þérun` — nominative `þér` with a 2pl verb, possessive `yðar` — and +dropped it during the 20th century. The forms survive in legal, liturgical and deliberately +archaic register, so unlike Irish 2pl-as-polite they are not *impossible*; they are merely +obsolete. `Hafið þér aðgang?` in a 2026 admin UI reads as parody. + +Measured: **626 informal (2sg) markers against zero formal**, over core's 28 `is` catalogues +(3610 values), with the bundle's own 1054 pre-existing values adding zero formal. Because +that is a zero, it was re-checked by raw grep over the 28 catalogues rather than trusted from +the detector alone — `yður`, `yðar`, `yðvar`, `yðr`, `þið`, `ykkur`, `ykkar`, `þéra` and +`þérun` are all literally absent, nine tokens at zero occurrences. + +`detectors/is.js` therefore gates on **two different defects** under one polarity, and they +are worth keeping apart when you read a hit: + +1. **archaic deference** — `yður`/`yðar`, or nominative `þér` with a 2pl verb; +2. **plain wrong number** — `þið`/`ykkur`/`ykkar`, the ordinary modern 2nd person plural, + entirely correct Icelandic for several addressees and simply wrong for one user. + +The second is the likelier slip, because it is the plural that is actually current in the +language; reaching for `yðar` would take a knowledge of Icelandic philology. + +**Buttons are infinitives** — `Vista`, `Eyða`, `Breyta`, `Afrita`, `Staðfesta`, `Virkja`, +`Endurstilla`, `Endurheimta`, `Skoða`, `Loka`, `Búa til`, `Bæta við`, `Hætta við` — 29 of 35 +distinct core values, zero verbal nouns, and exactly one enclitic imperative (`Choose` = +`Veldu`, against `Select` = `Velja`, so an outlier not a pattern). The bundle's own 21 action +keys agree unanimously. `is` therefore joins `cs`/`lt`/`lv`/`sk`/`rm` on the register-neutral +infinitive, and that is what makes the imperative countable as a marker here (§6.5 test 1 is +the only NO in the set so far). + +### `is` traps + +- **`þér` is both the 2sg dative and the archaic polite nominative**, and the dative is what + actually occurs — all 54 core hits (`þér er ekki heimilt`, `gefur þér`, `Beini þér til`). + Calling it formal would misclassify ordinary informal prose. The polite reading is + recovered by a **bigram**: `þér` plus a finite 2pl verb, matched in both orders since a + question inverts it. Neither token is decidable alone; the pair is. This is the reusable + idea from the pass. +- **`-ið` is the neuter definite article** as well as the 2pl verb ending, so there is no + suffix rule at all — `lykilorðið`, `tölvupóstfangið`, `skjalið`, `safnið`, `nafnið` are + nouns. Worse, five individual 2pl forms are themselves homographs of common words: `hafið` + is "the ocean" *and* the past participle of `hefja` ("hefur hafið ferli" — "has begun a + process"), `getið` is "mentioned", `verðið` is "the price", `vitið` is "the wit", `eigið` + is the neuter adjective "own" ("þitt eigið Nextcloud"). Two of the five occur in core in + the non-verb reading; none occurs as a 2pl verb. +- **The enclitic imperative splits by conjugation class.** Class 1 is safe (`notaðu` vs 3pl + past `notuðu`), class 2 is not (`settu` is both "enter!" and "they put"). The collision is + attested, not hypothetical: `komu` occurs 4× as a 3pl past and `völdu` twice as the weak + adjective *selected*, never as imperatives. +- **`vinsamlegast` ("please") carries no address marker** — it is an adverb and inflects for + nothing, so unlike Maltese `jekk jogħġbok` there is no free signal here. +- **`skrá` means both *file* and *register*.** The single biggest issue in the bundle, and + the reason for its large correction set. Four pairs of distinct keys rendered identically + and one value was unreadable (`No register objects reference this file` → `Engin + skrárhlutar vísa í þessa skrá`). Core locks `skrá` = file, so *register* moved — to + `gagnaskrá`, which the bundle already used in one place. The owner decided this, since it + is the app's primary noun and touched ~76 keys. +- **`sía` ("filter") is feminine, and the bundle had it masculine** in 21 values: `Virkir + síar`, `Ítarlegir síar`, `Engir virkir síar`, `Hreinsa alla síar`. Correct is `Virkar + síur`, and core `is` contains that exact phrase, plus `Filters` → `Síur`; core's only + `sí-` forms anywhere are `síur` and `síu`, never `síar`. Both the noun and every adjective + were corrected. The 5 pre-existing dative-plural `síum` values were already right. +- **`Slug` collided with `ID`** (both `Auðkenni`, both field labels on the same + `EditSchemaProperty` screen) → `Stuttheiti`. **`Refresh` collided with `Update`** (both + `Uppfæra`) → `Endurnýja`, core's own word. **`Configurations` and `Settings` both stay + `Stillingar`** — correct for each, and the unavoidable-collision case. +- **No domain-term capitalisation whatsoever**, and unlike `ga` this is *not* mirroring: it + holds regardless of the English key's casing (`skema` 0:9 under title-cased keys, 0:14 + under prose). A flat lowercase rule. +- **`skema` is treated as indeclinable** — 25 bare forms at HEAD against one `skemanu`. This + pass initially introduced `skemað`/`skemans` and they were normalised back; see the + declension note in the runbook's §8.10. +- Typography: `...` over `…` (41:4 at HEAD) and **matching the English source's glyph per + key** is the rule — 44 of 45 already did, and the one exception was `Loading + organisations...`, which was *also* the only `Loading` value not using the `Hleð + ...` pattern. That same key was the outlier in `ga` and `mt` too, on the same + two counts. Em dash `—`, never en dash. No percentage values and no quote glyphs exist in + the bundle, so source quotes are mirrored rather than converted to `„ “`. +- **The pre-existing half was badly defective, and the audit is the story of this pass.** + 291 of the 1052 pre-existing values were corrected in total — 63 in the first round and + 228 more in the audit. The largest + classes: 41 acronym/product-name compounds missing the hyphen Icelandic requires + (`API lykill` → `API-lykill`), 33 malformed compounds (`Hámarks framkvæmdatími` → + `Hámarksframkvæmdartími`, `Gagnagrunnupplýsingar` → `Gagnagrunnsupplýsingar`, + `Yfirlitvilla` → `Yfirlitsvilla`), 23 wrong senses, 20 keys using `stofnun` (institution) + where the bundle's word for *organisation* is `skipulagsheild`, 19 wrong cases, 13 + singular-for-plural, 11 keys using `skráning` where the bundle's word for *log* is + `annáll`, and 11 agreement errors. Notable individual finds: **`Stav`** — Slavic for + "status", a straight wrong-language contamination of the kind §6.6 warns about but *inside* + the committed bundle; **`skrivaðgang`** with a Danish/Norwegian stem for `skrifaðgang`; + **`fersla`** ×5, which is not an Icelandic word at all (it is `færsla`) and which I + propagated into my own values before catching it; **`Búningaaðgerðir`** for *Create + Operations*, where `búningur` means a costume; and `levranir`, `búsum`, `tökmörkun`, + `kerfisssstillingum`, `Misheppnaðst`, `Endurtaki`, `Grunvefslóð`, `Mynsturnvandamál`, all + simply not words. +- Terms: object `hlutur`, schema `skema` (pl `skemu`), property `eiginleiki`, view + `yfirlit`, entity `eining`, source `uppspretta`, chunk `bútur`, audit trail + `endurskoðunarferlatal`, webhook `vefkrókur`, dashboard `mælaborð`, organisation + `skipulagsheild`, token `teikn`, flow `flæði` but workflow `vinnuflæði`. Note `Overview` + had to become `Yfirsýn` rather than core's `Yfirlit`, because `yfirlit` is this app's word + for **View**. + +### `cs` is the first of the sixteen pre-rule locales to be re-audited, and it came out healthy + +The sixteen locales finished before any of this tooling existed (`cs da de el es fi fr hu it +nb nl pl pt ru sv uk`) are *doubly* un-audited: their identical values were never cognate- +reviewed **and** their translations never had a register measurement, a detector, an +orthography sweep or a grammar pass. The expectation going in — set by `is`, where 22% of the +pre-existing half was defective — was that these would be as bad or worse. **They are not, at +least not the mature ones.** `cs` came in at 113 defects across all 2052 values (5.5%), with +**zero** garbled words, **zero** foreign stems, **zero** agreement failures and **zero** wrong +plural arrays. Czech is an actively maintained locale with a real translator community. + +What that means for the remaining fifteen: budget the pass for **terminology counting**, not +grammar repair. Counting competing renderings per English term produced about 70 of the 113 +`cs` corrections and needs no knowledge of the language at all. + +### `cs` traps + +- **Every Czech 2sg imperative is a proper prefix of its 2pl counterpart**, because the 2pl is + the 2sg plus `-te`: `vyber`/`vyberte`, `zadej`/`zadejte`, `nastav`/`nastavte`, + `zvol`/`zvolte`. This bundle holds 64 `vyberte`. Without the trailing `(?!\p{L})` guard the + detector scores the commonest **formal** shape in the corpus as informal and inverts the + verdict outright. Several stems are also prefixes of the app's own nouns — + `nastav`⊂`nastavení`, `zobraz`⊂`zobrazení`, `ulož`⊂`uložené`. The informal possessive `tvá` + is likewise a substring of `vytvářet`/`vytváření` ("to create"), which a raw scan finds 48 + times, every one inside that verb. +- **Bare `ty` and `ti` are unmatched** (§8.2) — both are the plural demonstrative as well as + the pronoun, and Czech has no diacritic to split them the way Slovak's `ti`/`tí` does. + Measured cost of the exclusion: `ty` occurs 6 times in the corpus, all demonstrative, and + `ti` zero times. +- **Bare `si` carries no register information at all**, which is a *different* situation from + `hr`/`sk`/`sl`. There, `si` is the 2sg of "to be" as well as the reflexive clitic, so it is + ambiguous. Czech's 2sg of `být` is `jsi`, so `si` is only ever reflexive — empty rather than + ambiguous. `jsi` itself is unambiguous and is matched. +- **`prosím` ("please") gives no free signal.** It is a 1sg present verb, so it inflects for + the speaker, not the addressee — unlike Maltese `jekk jogħġbok`. In `Počkejte prosím` the + register is carried by `počkejte` alone. +- **Core overturned four candidate corrections**, and this is the trap worth internalising: + a majority inside the bundle is not authority on its own. `Loading…` → `Načítání…` looks + like the outlier against 37 sibling `Načítá se` values and is **core's own form**, so the + one value I was about to "fix" was the only one matching core. `Current password` → + `Dosavadní heslo` looked like a wrong sense and is core verbatim. `Bucket` is untranslated + in core and there means an S3 bucket, so core is no authority for this app's histogram-bin + sense and the value was left alone. +- **Check the call site before calling a form wrong.** `folder` → `složky` looks like a + genitive where a nominative belongs, and is correct: the key is a button in the middle of a + split sentence whose preceding fragment ends `přejděte do`, which governs the genitive. +- **The Configuration/Settings split was the owner's call** and is the largest correction set. + The bundle rendered the app's `Configuration` entity `nastavení` in 33 values and + `konfigurace` in 31, and the split ran *through* individual features: the Configurations + screen was titled `Konfigurace` with buttons reading `Nové nastavení`/`Upravit nastavení`, + and `Failed to save configuration` was byte-identical to `Failed to save settings`. Core + `cs` renders the generic heading `Configuration` → `Nastavení`, pointing the other way, so + the decision went to the owner: **`konfigurace` for the noun, `nastavení` for Settings**, + knowingly diverging from core because core has no Configuration *entity*. Verbs were not + touched — `Configure X` → `Nastavte X` cannot collide with either noun. +- Genuine grammatical errors were few but real: `No register objects reference this file` had + lost the `na` that `odkazovat` governs; `Queue / sync health` → `Stav frontu` used the + genitive of the masculine `front` (a front line) where a queue is the feminine `fronta`, + genitive `fronty`; `událost, kterou naslouchat` put an accusative on `naslouchat`, which + governs the dative, with no modal verb; and `Search GitHub` → `Hledat na GitHub` dropped the + locative the bundle gets right elsewhere in `na GitHubu`. +- Wrong senses: `Commit message` → `Zpráva potvrzení` read *commit* as *confirm* in a bundle + that is unambiguously about Git (`Branch` → `Větev`, `Repository` → `Repozitář`); `Complex` + → `Komplexní` is a false friend (Czech `komplexní` means comprehensive, not complicated — + the bundle's own `Complex queries` → `Složité dotazy` had it right); `Uses` → `Používá` used + a 3sg verb for an `AppTab` title. +- Terms: object `objekt`, schema `schéma` (pl `schémata`), register `registr`, property + `vlastnost`, view `pohled`, log `protokol`, audit **trail** `auditní záznam` but audit + **entry** `záznam auditu` (a distinction the bundle already drew and worth keeping), + chunk `úsek`, embedding `vnoření`, facet `faseta`, flow `tok`, workflow `pracovní postup`, + dashboard `nástěnka`, payload `datová část`, handler `řešitel`, hash `otisk`. Refresh was + decided by core: `Refresh` and `Restore` both rendered `Obnovit`, and core distinguishes + them exactly — `Znovu načíst` vs `Obnovit`. +- **`nplurals=3` with ABSOLUTE boundaries** (`1` / `2–4` / else incl. 0), agreeing with the + library, so no boundary or ordering note is needed. All 7 plural arrays were already + correct, including `_Successfully restored {count} object_`, which switches the participle's + agreement per form (`Obnoven`/`Obnoveny`/`Obnoveno`) exactly as Czech requires. + +**All thirty-six locales are complete**: `nl`, `de`, `fr`, `es`, `it`, `pt`, `sv`, `da`, +`nb`, `pl`, `cs`, `ru`, `uk`, `el`, `fi`, `hu`, `tr`, `ca`, `et`, `hr`, `lt`, `lv`, +`ro`, `sk`, `sl`, `bg`, `sr`, `rm`, `ga`, `mt`, `is`, `lb`, `sq`, `mk`, `be`, `bs` — the +whole high-confidence group and every low-resource one. There is no translation queue +left; the remaining work is the re-audit of the finished bundles and the source-side +defects, and the §9.1 closing task is now due. + +For non-Latin locales (`ru`, `uk`, `bg`, `be`, `mk`, `sr`, `el`) a script-coverage +check replaces the English-leftover check, and it is now a committed script: +`npm run l10n:script -- `. Note it cannot distinguish an +untranslated string from a correct one built around a literal identifier — `ru` +legitimately retains 11 such values (`conversationId`, `fileCollection`, +`Zookeeper`, file paths), where every word of prose *is* translated. `bg` retains +16, and all 16 are recorded: the 14 cognates plus the two case normalisations +(`Id` → `ID`, `Url` → `URL`). The script prints Latin runs found *inside* otherwise +translated values too, which is the half the older ad-hoc checks missed. + +**On `sr` it earned the whole exercise.** Of 2052 values, exactly two Latin-only ones +were not recorded cognates, and both were genuine defects that had passed every gate for +the life of the file: `NO ACTION` → `NEMA RADNJE`, which is correct Serbian in the +**wrong alphabet**, and `Audit trail #{id}` → `Revizijski trag #{id}`, which is the wrong +alphabet *and* Croatian *and* inconsistent with the bundle's 24 other audit-trail values. +Neither is empty, identical to English, or a bad plural, so nothing else could see them. +Run the sweep before assuming a non-Latin bundle's pre-existing half is sound. + +### `ca` traps + +Register is **formal** (491 vs 32 against core) with **bare 2sg imperative buttons**, so a single +value legitimately mixes `Desa` on the button with `Deseu`-style 2pl in the prose beside it. An +imperative on an action label is the convention, not a slip. + +- **The interpunct `·` (U+00B7) is word-internal.** `col·lecció`, `paral·lel`, `Cancel·la`, + `instal·lada`, `sol·licitud`, `excel·lent`. Any tokeniser, spell check or regex sweep that + treats it as a separator will split these in half and report the halves — this actually + happened to `spell.js` and is written up in runbook §8.3. It also makes the verb `instal·la` + look like the article `la` to an elision check. +- **`la` does NOT contract before an unstressed initial `i-` or `u-`.** `la informació`, + `la identitat`, `la integració`, `la interfície`, `la UE` are all correct; `l'` is required + only before a stressed vowel (`l'alternativa`, `l'API`, `l'objecte`, `l'esquema`). This makes + a naive elision sweep about 1-in-12 accurate here (runbook §6.9). +- **`registre` collapses three English words** — `Register` (the app's primary entity), `log`, + and `record`. That is not avoidable at the stem, but it *is* avoidable per key, and two of the + collisions were on-screen: `Logs`/`Registers` were the two tabs of one tab bar in + `ViewSource.vue`. `Logs` took the qualifier the bundle's other 16 log keys already use + (`Registres d'activitat`), and *record* in the MDM screens moved to the app's own `objecte`. + Spanish and Portuguese have the identical three-way collapse and have not been audited, so + expect this to recur in both — and do **not** read their agreement with `ca` as evidence. +- **`Configuració` covers both `Settings` and the app's `Configuration` entity.** Core `ca` splits + them (`Paràmetres`/`Configuració`) and so did the bundle's own one key containing both words. + 35 keys; the owner chose to normalise them all. Compare `cs`, where the same shape came out the + other way round because core pointed at the losing option. +- **False friends that pass every gate**: `inconsistent` means *flimsy* in Catalan, not + *contradictory* (use `incoherent`); `citació` is a judicial summons, not a citation; + `desplaçament` is scrolling, not moving an item; `serial` is a broadcast serial, not + *sequential* (`en sèrie`); `amigable` describes a person's manner, not a UI. And `autoritzat` + is *authorised*, which is not *authoritative*. +- **`Fins a la data` is an idiom meaning "up to now"**, so it is wrong as the label of a `To Date` + field — a sense error that reads perfectly well in isolation. +- **`(s)` parentheticals only work where the plural is a bare suffix.** `dia(es)` and + `problema(es)` expand to the non-words *diaes* and *problemaes* because both stems change, so + those took the slash (`dia/dies`). `cas(os)`, `tauler(s)`, `giny(s)` are fine. Same rule `is` + arrived at, for the same reason. +- **Two useful negatives, both checked rather than assumed.** `Refresh` and `Update` both render + `Actualitza` and that is **core `ca` verbatim for both**, so the collision is not a defect. + `Remove` and `Delete` both land in the `suprimir` family, and core collapses them too — the + same answer `sk` reached. Neither was "fixed". + +### `lb` is where thin core coverage proved more dangerous than none + +Register is **formal** (200 vs 9 over 3321 translated values) with **infinitive buttons** +(64 vs 0), so `lb` sits beside `cs`, `lt`, `lv`, `sk`, `rm` and `is`. Luxembourgish builds +its V-form from the 2pl on the German `Sie` model — `Dir` / `Iech` / `Ären` — and it is +live, current, ordinary usage. That makes it a fifth situation behind the same "formal" +label and the plainest one since `cs`: not archaic (`is`), not available-but-unused (`mt`), +not absent (`ga`), not structurally undetectable (`lv`). + +**The thing to carry forward is how nearly the measurement went wrong.** `rm` and `mt` had +**zero** core catalogues, so `coreCatalogues()` threw and §5 step 2 could not be run by +accident. `lb` has exactly **one** catalogue with 72 values, so the call *succeeded* — and +those 72 values contain no address form at all, so the scan would have reported a verdict +computed from **0 formal and 0 informal markers**. A thin core is the shape that lets a pass +record a measurement it never made. `detectors/lb.js` therefore counts core's markers and +prints "too thin to decide anything" rather than trusting the catalogue count, and it falls +back to the app family's own frontend bundles (1054 own + 2386 sibling values) the way `mt` +did. **`bs`, at 55 values in one catalogue, is the same trap.** + +- **`fold()` MUST NOT lowercase, and this is the only locale so far where that is true.** + Lowercase `dir` is the informal 2sg **dative**; capitalised `Dir` is the polite 2pl + **nominative**. Both occur here — `dir` once, in "Dashboards ... déi dir gehéieren" + alongside `Du kanns`, and `Dir` 82 times in "Sidd Dir sécher…". A `toLowerCase()` merges + all 83 and inverts the verdict. This is the `da`/`nb` `De`/`Dem`/`Deres` problem with a + sharper edge: there the collision is with a third-person pronoun, here it is with the + familiar form of the same paradigm. The residue is honest and recorded in `UNDETECTABLE` — + a value *opening* with the informal dative takes a sentence-initial capital and cannot be + distinguished. All 11 sentence-initial `Dir` in the corpus are polite, so the cost is + currently theoretical. +- **The modals syncretise 1sg/2sg/3sg**, so `muss` and `weess` carry no address information + whatever, while the regularly inflected 2sg of the same verbs (`kanns`, `wëlls`, `sollst`) + does. Measured, not reasoned: all 15 bare `muss` in the corpus are 3sg. That makes this a + third kind of partial detectability — split by **lexical class**, where `bg` and `is` split + by conjugation class. +- **The 2sg imperative is excluded on §6.5 test 2 alone.** Test 1 comes out NO, because + labels are infinitives — so unlike `ca`/`et`/`hr`/`sl`/`sr`/`ga`/`mt`, counting it would + not have flagged every button. It is excluded because the bare stem is an ordinary noun for + the productive verbs: `Späicher` is "storage/loft", `Filter` and `Test` are nouns this + bundle uses as labels. +- **Capitalisation is grammatically FORCED, not a house convention** — Luxembourgish + capitalises every noun. A fifth §8.10 outcome, and the one that ends the question instead of + answering it. Two false positives to expect anywhere: `{register}`/`{schema}` are + *placeholders*, and lowercase `filteren` is the *verb* (the noun `Filter` is 25:0). +- **The Eifeler Regel is the whole story of this audit.** Word-final `-n` deletes before any + consonant but `n d t z h`, and is kept before those and before vowels. Obligatory, fires + several times per sentence, invisible to every gate, and broken in **both** directions here. + It is the one mechanical morphology check that has ever paid off in this project; the method + is §8.11. It also caught 44 violations in the half written during the pass — more own-drift + than any other locale has produced — and then 48 more in a follow-up round, for the reason + below. +- **The per-lemma consistency check is necessary but NOT sufficient, and believing otherwise + cost a round.** Comparing each lemma only against itself excused every lemma that occurs in + a single environment: `Lueden` appears only *with* the `-n`, `Deele` only *without* it, so + both looked internally consistent. 26 values were then left uncorrected on a "16:0 with no + counter-example" count that was really **one copy-pasted phrase repeated thirteen times**. + Aggregating by **word class** shows the family does both — 118 kept against 108 deleted — + with directly parallel pairs: `Lueden vum` / `Deele vun`, `Späicheren feelgeschloen` / + `bäisetze wëllt`, `erstellen wann` / `zesummeféiere wann`. A uniform lemma is evidence of + one decision, not of a rule. Run both cuts. +- **Terminology was healthy and both cheap reports were structurally blind to it.** + `corediff` had only 4 shared keys; `termdrift` produced almost nothing actionable, because + a heavily compounding language buries the shared stem inside `Lëschtenusiicht` and + `Webhook-Liwwerung` where a stem-prefix heuristic cannot see it; and the spell report does + not exist, `lb` being one of the six locales with no hunspell dictionary. **Where all three + reports come back thin, ask what rule the language has that they cannot check.** +- **Lexicon settled against core, and recorded.** `Files` → `Datei`/`Dateien`, against core + `lb`'s French-derived `Fichieren`: the app family is 111:0 for `Datei*` and 0 for + `Fichier*`, so §3.5 settles it without an owner ask. `Email` → `E-Mail` likewise. +- **Two filler traps caught by §3.3 rather than by eye.** `GitHub Personal Access Token` and + its GitLab sibling looked like product names to keep, and 32 of the 37 locales translate + them — the exact shape the `cs` pass classified as filler. Both were translated. Against + that, `Mappings` was *kept* on the app family's own evidence: openconnector, which owns the + concept, uses it unchanged across six keys and compounds it as `Mapping-Detailer`. +- **`Driver` is the weakest cognate in the set and is flagged as such** in `locales/lb.json`. + Luxembourgish IT register borrows it and `Dreiwer` would be a coinage, but §3.3 finds 21 + distinct values and the `cs` pass called its own identical `Driver` filler. Challenge it if + a native speaker disagrees. + +### `sq` traps + +Albanian was the least ambiguous **register** verdict in the set and one of the more +interesting **convention** ones. Core is usable here (five catalogues, 603 values), so §5 +step 2 ran as written and needed no fallback. + +- **Register is formal beyond argument.** Core scores 218 polite markers to 1; the four + sibling frontends 556 to 2. The 2sg pronoun `ti` occurs **zero** times across 3980 values, + and the three informal values in the entire corpus all live outside this bundle — one in + core/encryption (`Vendos fjalëkalimin tënd`) and two in openconnector (`Nuk ke ende + kredenciale…`, `…në vend që të ruash…`). openregister's own 1019 pre-existing values carry + none. Both openconnector slips are §11 reciprocal work. +- **`ju lutem` is register-bearing, which makes Albanian only the second locale where the + politeness formula pays.** Albanian's "please" inflects for the **addressee** — `ju lutem` + is "I beg you(pl)", `të lutem` "I beg you(sg)" — so the choice of object clitic *is* the + T-V choice. Measured 83 to 0. Contrast `lb` `wann ech glift`, `is` `vinsamlegast` and `ga` + `le do thoil`, all of which inflect for the speaker and are useless. Four of six checked + now come back empty; keep checking, because when it lands it is free and high-frequency. +- **The button convention is a fifth pattern and the first non-categorical one.** 2sg + imperative for a label, 2pl once the string is a sentence, sliding monotonically with + length: 219:1 at ≤14 characters, 32:42 at 40–79, 7:35 at 80+. The crossover is ~40 + characters. The right reading is that the long end is not a label convention at all but + ordinary formal prose opening with a verb — so §7.2 governs it and the button rule governs + only the short end. A single ratio over all action keys reports "476:145, mostly 2sg" and + would have produced `Menaxho regjistrat e të dhënave tuaja`, which no sibling writes. + On top of the gradient, `Select …`/`Choose …` prompts take 2pl at any length (67:26). +- **Four detector exclusions, every one measured, and two of them are total.** `-ni` is the + 2pl ending *and* the definite singular of every masculine `-n` stem (≈45 ordinary nouns in + the corpus: `aplikacioni`, `pozicioni`, `versioni`, `tani`, `php.ini`). `-sh` is the 2sg + subjunctive *and* the ablative plural of every noun. Bare `do` is 2sg **and** 3sg of `dua` + **and** the future particle — 101 occurrences of `do të` in third-person prose, so counting + it inverts the verdict. And the whole `-oj` class is unusable because its 2sg and 3sg + present are homographs; unlike `bg` there is no conjugation split to rescue part of it, so + informal recall rests on three irregulars, the possessives, the imperfect and a closed + subjunctive list. `tij`/`tyre` are excluded as third-person despite looking like `tënd`. +- **THE LEFT GUARD NEEDED A HYPHEN, which is new in this set.** Albanian attaches the + definite/case ending to acronyms after a hyphen — `UUID-je`, `URL-je`, `Token-i`, `PHP-ja`, + `email-it` — so the standard `(?` in `EditOrganisation.vue`, so it is + `E drejta`. `Display Name` offered `Trego Emrin` ("show the name!") for what is a field + label, `Emri i Shfaqur`. `View`, by contrast, really *is* an action button here and + `Shiko` was right — the harvest is not always the one that is wrong. +- **`Read` is the only permission-matrix key that could be fixed.** §8.7 wants verbal nouns, + and `Read`'s two call sites are both nouns, so it became `Lexim`. `Create`/`Update`/`Delete` + are buttons in 8, 2 and 16 other files and cannot be, which leaves that header row mixed — + the same unresolved shape `sl` and `sr` shipped, and the same source-side defect `ro` had to + diverge from core over (§10). + +### `mk` traps + +Macedonian came out **formal** (core 565 vs 59 over 24 catalogues / 3424 values, the +bundle's own 1053 values 31 vs 0), with the bare **2sg imperative** for short labels and +the **2pl** once a value becomes a sentence — the `sq` gradient, replicated, crossover in +the same place at ~40 characters. Core and the file agreed, so unlike `et` and `lv` there was +nothing to overrule, and unlike `ga`/`mt`/`is` there is no structural story: the T-V +distinction is live and ordinary, which makes this the plain `cs`-style case. + +**`ВИ` is the Macedonian acronym for AI** — вештачка интелигенција — and a homograph of the +formal dative clitic `ви`. `ВИ-агент` and `ВИ-функции` are real values in this bundle, so a +detector that folds case scores the app's AI vocabulary as deference. Case is the whole of the +discriminator, since the acronym is always all-caps and the pronoun is `ви` or sentence-initial +`Ви`, so `detectors/mk.js` **consumes the all-caps form in `fold()` before lowercasing**. That +is the `lb` `dir`/`Dir` problem from the other end: `lb` had to preserve case throughout, `mk` +needs it for one token and can spend it up front, which keeps the word lists readable. The +hyphen belongs in the **left** guard only — Macedonian attaches acronyms to the following noun +with one (`API-клуч`, `LLM-дејства`, `push-известувања`), so the acronym must still match with +a hyphen after it. + +Suffix rules that fail here, all measured: **`-те`** is the 2pl ending *and* the definite +plural article, so it scores every plural noun phrase as formal prose (`објектите`, +`датотеките`, `филтрите` — the `bg` situation exactly); **`-ш`** is the 2sg present ending +*and* the end of `ваш`, your-FORMAL, so it inverts the polarity, and this bundle also carries +the nouns `хеш` and `кеш`. Bare **`си`** is excluded as both the 2sg of `сум` and the reflexive +clitic — but it is *absent outright*, 0 of 6864 corpus values, so the exclusion costs nothing. +**`треба`** is impersonal and **`молиме`** is 1pl; in `Ве молиме` the register is carried by +`Ве`. + +Two useful negatives. Bare **`ти` is usable** — Macedonian's demonstratives are тој/таа/тоа/тие, +so there is no demonstrative reading to collide with. And bare **`те` is usable, where `bg`'s +is not**: Bulgarian `те` is the 3pl pronoun *they*, Macedonian's is `тие`, so `те` here is only +ever the 2sg accusative clitic. Do not port `bg`'s `те` exclusion across. + +The 2sg imperative is **excluded**, and §6.5 test 1 alone forces it — the bare imperative *is* +the short-label convention, so counting it would flag every button. Test 2 comes out the other +way and is worth recording rather than inferring: Macedonian imperatives are **not** 3sg +homographs the way Bulgarian's и-conjugation is (`избриши` against the 3sg aorist `избриша`, +`зачувај` against `зачува`/`зачувува`). So `mk` gives the paradigm up to convention rather than +to ambiguity, and the recall lost is real. + +**Orthography: flat lowercase, and the bundle broke it 118 times.** Macedonian does not +capitalise common nouns mid-sentence *or* in a heading, and the corpus is unanimous — the +sibling frontends run 1:273 in prose, core 2:~480, and under Title-Cased keys core is 7:122 and +the siblings 40:518. The bundle's own half ran 41:146 in prose and 65:507 in headings, with all +41 prose hits inside four terms (`Датотека` 20:0, `Шема` 9:5, `Регистар` 7:6, `Извор` 3:0, +`Својство` 2:5) while `објект` — the app's most central noun — was 0:26. That inconsistency is +what makes it a defect rather than an `sr`-style convention, and §8.11's rule settles the +uniform terms: a uniform lemma is one decision copied, not a rule. + +**Clitic doubling is the rule that looks mechanical and is not.** Macedonian obligatorily +doubles a definite direct object with a resumptive accusative clitic (`Избриши ГО објектот`), +which is the obvious §8.11 candidate here. It scored **0 of 5**, because the trigger is +*semantic* (definiteness, invisible to a regex) and the clitic's position depends on clause +type. Recorded so the next pass does not rebuild it. + +Terminology settled by the bundle's own values against core (§3.5), all of which core +disagreed with: `Create` → `Создај` (core `Креирај`), `Settings` → `Поставки` (core +`Параметри`), `Actions` → `Дејства` (core `Акции`), `Type` → `Тип` (core `Вид`), `Views` → +`Прикази` (core `Прегледи`). The sibling frontends agree with the bundle in every case. +`termdrift`'s minority was right once: `прегледи` in `Avg Object Views/Session` is a *page-view +count*, not the View entity, so it correctly does not use `прикази`. + +`Slug` → `Кратко име`, following the descriptive shape `lt` (`Trumpinys`), `is` (`Stuttheiti`) +and `hu` take rather than the `Слаг` transliteration `be`/`ru`/`sr`/`uk` use — the bundle needs +`Ознака` for `Label` and `Етикети` for `Labels` (§8.4), so a transliteration would have been +the only way to avoid a collision and it reads as nothing to a Macedonian user. `ID` and `URL` +stay Latin because the bundle already writes them that way in ten of its own values. + +### `be` is where an alphabet gap made wrong-language contamination deterministic + +Belarusian's alphabet has **no `и`, no `щ`, no `ъ`** — it writes `і` and `ў` instead — so a +single grep separates Belarusian from Russian with no knowledge of either language: + +```bash +node -e '…' # /[ищъЩИЪ]/u over every value +``` + +That found **two committed Russian values** in the pre-existing half (`Audit trail #{id}` → +`Запись журнала аудита #{id}`, `NO ACTION` → `НЕТ ДЕЙСТВИЯ`) and it settled the harvest at a +glance: **openbuild's entire `be.json` is Russian**, 426 of its lines carrying a letter +Belarusian does not have. `harvest.js`'s byte-identity guard cannot see that, because the +file is not byte-identical to openbuild's `ru` — it is a *separate* Russian translation. Every +one of its eight candidates (`Документация`, `Уведомления`, `Открыть`, `Обязательно`, +`Кратно`, `Жизненный цикл`, `объект`, `Открыть в OpenRegister`) had to be rejected by hand, +while the other five sibling `be` catalogues came back clean at zero. + +**The generalisation is the useful part**: where the target's script omits a letter its +likeliest contaminant has, that gap is a free, exact contamination detector — for the harvest +*and* for the committed bundle, which is where the guard cannot help. `uk` (no `ы`, `ъ`, `э`), +`sr` and `mk` (no `й`, `ы`, `щ`, `ъ`) all have one. Ask what letters the target does **not** +have before reading values one at a time. Note the `Audit trail #{id}` key: it is the same key +that has twice reached a committed bundle carrying Croatian under `bs`/`sr`. Check it first. + +**The obligatory sandhi question (§8.11) came back YES here, and the bundle already obeyed +it.** Belarusian writes `у` after a consonant or a pause and `ў` after a vowel, both for the +standalone preposition and word-initially inside a phrase — orthographic, deterministic, firing +several times per sentence, invisible to every gate. The pre-existing half scored 98 correct +word-initial `ў` and 47 correct `у` with **zero** violations in either direction, and the half +written during the pass scored 169 and 58 with zero. So the answer to the `lb` question is +sometimes "the rule exists and the locale is already right" — which is a measurement, not a +skip, and is only worth anything because it was run against **both halves** separately. + +Watch it interact with morphology: the genitive plural of `дастаўка` is `даставак`, not +`дастаўкаў`, and the `ў` becomes `в` because the epenthetic vowel puts it before a vowel. + +**Casing: flat lowercase, the `is`/`mk` outcome.** Domain terms mid-sentence run 0 capitalised +against 458 lowercase, and conditioning on the English key's own casing changes *nothing* +(0:117 even under Title-Case keys). A Title-Cased English heading takes sentence case here. + +**Ellipsis mirrors the source key** rather than following core: a key ending `...` takes +`...` (39 of 40) and a key ending ` …` takes ` …`, where core `be` uses the `…` character +exclusively and never the three dots. The one mismatch was a slip, not a second convention. + +### `be` traps + +- **The 2sg imperative IS counted** — §6.5 tests 1 and 2 both come out usable, which puts `be` + with `sk`/`rm` rather than with the larger exclusion group. Labels are **infinitives** + (`Захаваць`, `Выдаліць`, `Дадаць`, `Скасаваць` — 44 core action keys, not one imperative), + so counting imperatives flags no button. The catch is that Belarusian builds the 2pl + imperative as the 2sg **plus `-це`** (`выберы` → `выберыце`, `захавай` → `захавайце`), so + the trailing `(?!\p{L})` guard is the only thing separating the two polarities. That is the + West Slavic hazard of §8.1 turning up in a different branch — **it is not a family + property.** One form is given up: `май`, 2sg of `мець` and also the month name. +- **`-це` is the locative singular of every hard-stem masculine noun**, not just the 2pl + ending: `фармаце`, `праекце`, `тэксце`, `запыце`, `пакеце`, `стандарце`, `пошце`, `даце` + all occur in the corpora, two of them live in this bundle. A `-це` rule scores ordinary + prepositional phrases as deferential address. +- **`-ш` inverts the verdict**, as everywhere: `ваш` is the formal possessive, and the corpora + also carry `больш` (17), `менш`, `перш`, `найбольш`, `клавіш` and the app's own `хэш` and + `кэш`. +- **`ты` is fully usable** — Belarusian's demonstrative is `гэты`, never bare `ты`, so unlike + `cs`/`hr`/`sl` there is no reading to give up. +- **`ШІ` is the AI acronym** (штучны інтэлект), written hyphen-attached as `ШІ-агент`, and it + is **not** a homograph of any register marker — so the `mk` `ВИ`/`ви` problem does not + replicate and `fold()` can lowercase freely. Worth recording as the measured negative it is. +- Terminology settled against core: `Cancel` → `Скасаваць` (core in six catalogues, against + the bundle's `Адмяніць`, which stays for *cannot be undone*); `Share` → `Абагуліць`, which + the bundle's own noun `Абагульванні` already implied while its verb said `Падзяліцца`; + *default* → `прадвызначан-`, not the Russian calque `па змаўчанні`. Collisions avoided: + `Bucket` → `Сегмент` (never *interval*, which is its own key), `redaction` → `зацямненне` + (never *рэдагаванне*, this app's *Edit* — the `lt` trap), `Totals` → `Разам` (it was + byte-identical to `Results` on the dashboard sidebar). + +### `bs` is where byte-identity with a sibling language stopped being evidence + +Bosnian, Croatian and Serbian share most of their morphology and much of their lexicon, so +the contamination test that works everywhere else — a value byte-identical to another +locale's — returns almost nothing here. `Revizijski trag #{id}` matches `hr` exactly and is +correct Bosnian. §2.3 predicted the openbuild Croatian catalogue would be found *inside* this +committed bundle, as it was in `sr` and `mk`; it was not, because for `bs` that catalogue is +filed under `bs.json`, the **backend** set, which §1 puts out of scope and `harvest.js` drops. + +**What to measure instead is standard-language lexicon, per term.** Bosnian is ijekavian like +Croatian (`Osvježi`, `Sljedeće`, where `sr` is ekavian) while its lexicon leans to the +Eastern and international side (`Sačuvaj` not `Spremi`, `Obriši` not `Izbriši`, `Kreiraj` not +`Stvori`, `Nazad` not `Natrag`). A sweep of the whole known bs/hr divergence set over the +pre-existing half found exactly two Croatianisms, both agent-noun or term choices rather than +grammar: `pružatelj` for *provider* in 7 values, where Bosnian forms this class in `-lac` +(`pružalac`, gen. `pružaoca`, as BiH legal usage has `pružalac usluga`), and `predložak` for +*template* in 3, where Bosnian uses `šablon`. Both had the pass's own half on the other side, +so the bundle was split against itself in both cases. + +**Two false leads worth not repeating.** The `-irati` / `-ovati` split on internationalist +verbs is *not* drift: `analizirati` is `-ir-` and `vektorizovati`, `sinhronizovati`, +`serijalizovati`, `konfigurisati` are `-ov-`/`-is-`, each consistent across two independent +sibling apps. Cross-app agreement is what upgrades a uniform lemma from "one decision copied" +(§8.11) to a convention. And the ekavian check needs care in both directions: `vremena` and +`vremenu` are the correct ijekavian *oblique* forms, since the root shortens outside the +nominative in every BCS variant, and any such sweep must run over values only — over the raw +`.js` file the English key `Delete` scores as an ekavian `del-` root. + +### `bs` traps + +- **Register is formal (Vi) but core cannot say so.** One core catalogue, 55 values, **0 + markers of either polarity** — the §2.3 thin-core trap, where the scan succeeds rather than + throwing. The §6.4 fallback over this bundle plus three sibling frontends gives 367 formal + against 1 informal over 3416 values. The single informal hit is not in this app: it is + openconnector's `Još nemaš posredovanih vjerodajnica…`, the same defect class the `sq` pass + found in the same sibling bundle (§11). +- **The prompt override is lexically bounded and the English VERB decides it**, which is a + third distinct partition of the same verb set after `sq`'s length grading and `mk`'s + "Select/Choose/Enter all take 2pl". Here `Select …` takes the 2sg (`Odaberi`, 20 of 20, at + 14–45 characters) while `Choose …` and `Enter …` take the 2pl (`Odaberite`, `Unesite`, 5 of + 5 each, at 15–128 characters). Length is not the variable. Measure it per verb. +- **`svoj-` is excluded from the detector on two independent counts**: it is person-neutral, + and it is the stem of `svojstvo`, the app's own word for *property*, which occurs 30+ times. +- **The hyphen question comes out the opposite way from Albanian.** Bosnian attaches case + endings to Latin-script loanwords across a hyphen (`webhook-a`, `webhook-ovi`, `VAPID-om`), + but those endings are only case endings, none of which is a register marker — so the plain + `(?.js` from partial to complete, correctly, and to +hand the work to someone else mid-stream. This is the **operational** document: order of +operations, which command to run, and what to do when a gate refuses. Two companions: + +| Document | Covers | When to read it | +| --- | --- | --- | +| **this file** | the whole pass, in order; every refusal and what it means; the traps catalogue | start here, every time | +| `scripts/l10n/README.md` | the tooling layout, and what each script refuses | when a script surprises you | +| `docs/l10n-ui-translation.md` | the per-locale **linguistic** reference — register verdicts with their evidence, button conventions, plural boundaries, homograph traps per language | before translating a specific locale | + +**Keep this file operational.** A pass's durable output is the *rule* it learned, not the +retelling of how it learned it — the worked examples belong in the commit message, in +`locales/.json`, or in the companion's per-locale section. This document was allowed to +grow to 2284 lines by appending a case study per pass. **Ceiling: 1600 lines.** If a pass +needs room here, it has to earn it by deleting something, not by appending. + +`CLAUDE.md` holds the short version of the hard rules, plus a counts snapshot under an +explicit "re-measure before trusting any number here" — treat it that way, and see §2. + +--- + +## 1. Goal and scope + +Give the Nextcloud app **openregister** real, correct **frontend** translations in all 36 +non-English locales. The repo is its own git checkout at +`/apps-custom/openregister`, working branch **`feature/l10n-fixes`**. + +**Scope is `l10n/*.js` only** — the frontend catalogue, read by `OC.L10N.register` → +`t('openregister', …)` / `n(…)`. Every string reached that way needs a genuine translation. + +**Out of scope:** `l10n/*.json` is the **backend** catalogue with a different consumer +(PHP `IL10N`) and no scanner. A change to one implies nothing about the other. Never harvest +`.json` into `.js` — 31% of shared keys disagree, and some `.json` files carry the wrong +language outright (§8.6). + +Also out of scope: the translatable-object-property machinery in `docs/i18n.md`. Different +system, same word. + +--- + +## 2. Reading the current state, and the invariants + +**Do not write counts into documentation.** They go stale silently, and this project has +already been bitten: seven consecutive passes bumped `CLAUDE.md` and left +`docs/l10n-ui-translation.md` claiming twelve finished locales while the gate enforced +nineteen. Measure instead — every number you need is one command away. + +### 2.1 How to check the counts + +**One command gives the whole picture.** `npm run test:l10n:parity` prints, in order: + +``` +36 required locales; checked 2 translation set(s) +all 36 required locale(s) are held at key-for-key parity, unconditionally: +M of those also hold every English-identical value to a recorded justification: +K locale(s) predate the cognate rule and are NOT yet held to it: +OK — every finished locale is at full parity +``` + +Read all five lines, not just the last. **A green run does not mean everything is +translated** — the backlog prints on passing runs precisely so it cannot be read that way, +and the "NOT yet held to it" line names the locales whose identical values nobody has +checked. + +| To find out… | Run | +| --- | --- | +| the whole state: finished / enforced / unreviewed / backlog | `npm run test:l10n:parity` | +| one locale in detail — absent, identical, arity, empty, register | `npm run l10n:status -- ` | +| how `en.js` compares to `src/` — missing, unused, unwrapped | `npm run check:l10n` | +| which locales are **enforced** for cognates | `ls scripts/l10n/locales/` | +| which locales have a register **detector** | `ls scripts/l10n/detectors/` | +| which locales the gate holds to parity | all of them, unconditionally — §9.1 | +| every locale's size at a glance | `wc -l l10n/*.js` | + +The last one is the fastest smell test there is — see invariant 2. + +### 2.2 The invariants + +These are the rules the counts have to satisfy. Each is enforced by a gate; knowing them +is how you tell "in progress" from "broken". + +1. **Every finished locale has exactly the same key count as `en.js`.** Not approximately. + `test:l10n:parity` fails a finished locale that is one key short. This is the parity + guarantee, and §3.1 is why you never work around it. + +2. **Therefore every finished locale has the same *line* count as `en.js`, exactly.** + `serializeJs` emits one line per key plus a fixed wrapper, so `wc -l l10n/*.js` is a + one-second check on the whole set: the finished locales are all identical, anything in + progress is visibly shorter, and a finished locale whose line count differs means a + duplicated key or a hand edit even when the key count matches. + +3. **`absent === 0` and `extra === 0`** for a finished locale. Extra keys matter as much as + missing ones: a key no longer in `en.js` is dead weight that `check:l10n` reports as + unused forever. + +4. **Empty values and wrong plural arity are fatal for *every* locale**, finished or not, + because both render **blank** to the user. This is the one class of defect you cannot + see by reading the file. + +5. **Plural array length must equal that locale's own `nplurals`**, taken from its own + header — not the neighbour's, not the previous locale's. §7.1. + +6. **`value === key` is correct in `en.js` and nowhere else.** `en` *is* the source. In any + other locale it is either a recorded cognate or a defect (§3.2). + +7. **For the enforced locales, every identical value carries a written justification** — + and no justification is left behind for a value that is no longer identical. Both + directions are checked. + +### 2.3 Order of work + +**All 36 locales are at full parity. There is no translation queue left**, and the only +remaining streams are the re-audit of the already-finished bundles (§9.2 and +`handoff_re-auditing.txt`) and the source-side defects of §10. + +Parity is held unconditionally for every locale, and §9.1 says why that must not be +loosened. + +**Check whether core can decide the register at all before planning a pass**, because §5 +step 2 assumes it can. **Measure the marker COUNT, not the catalogue count.** Zero +catalogues is the safe failure — `coreCatalogues()` throws, so step 2 cannot run by +accident. A *thin* core is the dangerous one, and both shapes have now been met: `lb` had one +catalogue with 72 values and `bs` one with 55, so the scan **succeeded** in each case and +would have reported a verdict computed from **0 markers of either polarity**. That is the +shape that lets a pass record a measurement it never made, and it stays relevant to the +re-audit stream: a Tier 1 locale needs its register measured for the first time, so the same +question applies before trusting core for it. + +--- + +## 3. Hard rules — all binding + +### 3.1 Every locale is key-for-key identical to `en.js`, always + +Enforced, not aspirational: `npm run test:l10n:parity` fails the build when any locale is +missing a key, and it runs in CI as its own leg. Add an English string and you **translate it +or unwrap it**. You never leave a locale one key short, and you **never add an exemption to +get a green build** — that is the one move the gate exists to prevent (§9.1). + +### 3.2 The cognate rule + +Two cases, and getting the split wrong is the easy mistake: + +- **Untranslatable / non-prose** — an input placeholder or example value (`sk-…`, `myapp`, + `https://example.com/webhook`). Do not wrap it in `t()` at all. If it is already wrapped, + unwrap it in `src/` **and** delete the key from all 37 bundles including `en.js`, in one + commit. Deleting it from the locales alone leaves `check:l10n` reporting it unused forever. +- **Translatable but identical in this language** — a genuine cognate (`CSV`, `PDF`, `RBAC`, + `Register` in Slovak). **Write it out** so the locale keeps parity, and record a written + reason (≥15 chars) per key in `scripts/l10n/locales/.json` under `"cognates"`. + +Writing `value === key` for anything that is **not** a recorded cognate is the worst option +available. Absent falls back to English and stays *visibly* untranslated to tooling; +identical renders the same characters while being indistinguishable from finished work, so +nobody ever revisits it. + +**`cognates` is a permission list for `value === key` and nothing else.** Do not park notes +about *rejected* cognates there — a future reader will read the entry as permission to leave +the key untranslated, and the gate fails a record whose value is not actually identical. Put +those notes in a free-form field (`lexiconNote`) or the commit message. + +### 3.3 Measure the untranslatable/cognate split — never eyeball it + +A key is untranslatable only if **no locale has ever carried a value differing from it**, +and that is a one-command test rather than a judgement: + +```bash +node scripts/l10n-ai.js get "UUID:" | awk -F'\t' '{print $2}' | sort -u +# UUID : <- fr, French spacing before a colon +# UUID: +``` + +More than one distinct value means it is **translatable**, so it cannot be unwrapped — it +is at most a cognate. Strings that look like pure punctuation or pure branding keep failing +this test: `UUID:` (above), `{property} - {other}` (`ru` em dash, `hr` en dash), `Slug` +(core `lt` translates it, `sl` renders it `Oznaka`), `Ollama URL` (29 locales translate it). + +The reverse also holds: a single distinct value across all 37, for a string that is a bare +product name, is evidence it was never translatable prose to begin with (§6.15). + +### 3.4 Follow Nextcloud core per language for register — measure it, never assume + +Every locale so far has come out differently. Carrying an answer over from the previous +locale passes every automated check while being wrong in every string that addresses the +user. §5 step 2 is how you measure it; §7.2 records the verdicts. + +**A verdict is a label over a reason, and the reasons differ — never copy one without its +reason.** `informal` has meant *the language has no T-V distinction at all* (`ga`), *it has +one and does not use it* (`mt`), and *it had one and abandoned it* (`is`). Those differ in +what a slip would look like, which is what the detector has to gate: where the V-forms are +merely archaic, the likelier error is not the archaic form but the plain modern plural, and +the detector must catch both. §7.2 has the taxonomy. + +### 3.5 Read the locale's own existing values before coining any term + +Where the file's own pre-existing values and core disagree on **lexicon** (not register), the +file usually wins — splitting the app's own terminology is worse than diverging from core. +The cautionary cases: `lt` translates `webhook` in all 40 of its keys (it looked like an +obvious keep-the-English); the `lv` pass coined `šķautne` for *facet* and `AI aģentus` when +the bundle already had `fasete` and `MI aģents`, and both had to be reverted. + +Do this read **before the first batch**, not after the last. + +### 3.6 Use the committed tooling in `scripts/l10n/`. Do not write your own + +Every script a pass needs is in the repo. It lived in a scratchpad for five passes and got +rebuilt from a handoff each time, which is how it kept re-acquiring the same bugs — one +silently harvested the *previous* language for a whole round. **If you catch yourself +writing a script to load, diff, patch or verify `l10n/*.js`, it already exists.** Extend the +committed one so the next pass inherits the fix. + +`apply.js` is the **only** writer. Never hand-edit a bundle. + +Two narrow exceptions, both fine: generating the **patch JSON** you feed to `l10n:apply` +(that is data, and it never lands in the repo), and a throwaway scratchpad script for a +one-off negative test that *calls the committed library* rather than reimplementing it. + +### 3.7 When adding a NEW English string, `en` is required and the rest are optional + +This is the rule for ordinary development, not for a locale pass. Demanding a hand-written +value in 37 locales for every new string invites exactly the placeholder-shaped filler +§3.2 forbids. So: add `en` — where `value === key` is correct, because `en` **is** the +source — plus any locale you can genuinely do well, narrowing with `--locales=`. + +The catch, and it is the one that breaks CI: **a new English string puts every finished +locale one key short**, which the parity gate treats as fatal. See §6.15 for the procedure. + +### 3.8 Never overwrite an existing real translation + +`l10n-ai.js` refuses without `--force` and `apply.js` refuses without `--allow-replace`. +**Trust the refusal.** Only replace a real value when it is genuinely wrong, and say why — +in `corrections` for a pre-existing value (§6.3), in the commit message otherwise. Someone +translated that string; the burden of proof is on the replacement. + +**This guards against changes of taste, not against fixing grammar.** Do not cite it to skip +the §6.9 audit. + +### 3.9 A `t()` call belongs in `en.js`, never in `en.json` + +The two sets have different consumers (§1). There is **no scanner for the backend set** — +it would have to walk `lib/` for PHP `$l->t()` — so `en.json` is maintained by hand, and +nothing will tell you if you put a frontend string there. It simply never renders. + +### 3.10 Process rules + +- **One commit per language.** So a bad locale can be reverted alone. Tooling fixes + discovered during a pass go in their **own** commit, before the locale. +- **No approval needed for translation batches.** The owner explicitly declined to review: + "reviewing thousands of lines of translations is too much for me to begin with." Do not + ask for sign-off on translation content; do ask when a *convention* decision is the + owner's to make (§6.4). +- **No `Co-Authored-By` trailers.** +- **No sed/awk/Python on repo code files** — use Edit/Write. Running the repo's own + `prettier --write` is sanctioned; it is the fix for the `format` gate. +- **Never browser-test.** The owner verifies manually. Never run Playwright or any browser + automation. +- **Verify before claiming.** Read the code path that consumes a value before calling it a + bug. When the owner pushes back, re-derive rather than just agree. +- **Never `git checkout --` a bundle mid-pass.** See §6.10 — this destroyed a whole pass. + +--- + +## 4. The tooling + +### 4.1 Two tool families — pick the right one + +They cut the same 37 files along different axes, and reaching for the wrong one is how +people end up hand-editing bundles. + +| | `scripts/l10n-ai.js` | `scripts/l10n/*` | +| --- | --- | --- | +| Works on | **one key, across all locales** | **one locale, across all keys** | +| Use it for | a source change added/renamed/removed a string; a single wrong value | a translation pass | +| Writes | in place, per key | only via `apply.js`, in gated batches | + +**`node scripts/l10n-ai.js `** — the per-key tool. Operates on `l10n/*.js` only; +never touches the backend `.json`. + +| Subcommand | What | +| --- | --- | +| `has [--ignore-case]` | does this key exist? | +| `get ` | print its value in every locale — the fastest way to answer "has any locale ever translated this differently?" (§3.3) | +| `find ` | list keys containing a substring, case-insensitive | +| `add --value = …` | add a key. One `--value` per locale, `--locales=a,b` to narrow | +| `set --locale= --value=` | update a single locale | +| `rm [--force]` | remove a key everywhere | +| `rename [--force]` | rename a key everywhere | +| `list-locales` | the shipped locale list | + +Three gotchas, all load-bearing: + +- **`rename` does not rewrite call sites.** Grep `src/` afterwards, or `test:l10n` will + fail on the call site that still uses the old string. +- **`set` refuses pluralised (array) keys.** Those go through `apply.js` with a full array. +- **It refuses to overwrite an existing real value without `--force`. Trust the refusal** + (§3.8). + +### 4.2 Commands + +| Command | What it does | Notes | +| --- | --- | --- | +| `npm run l10n:status -- ` | absent / identical / arity / empty counts, plural header, measured register | start and end of every batch | +| `npm run l10n:worklist -- ` | the worklist as JSON on stdout: **`absent ∪ unjustified-identical`** | redirect to a scratchpad file | +| `npm run l10n:harvest -- [todo.json]` | candidate values from core + sibling apps | 2–7% hit rate. **Candidates, never answers** | +| `npm run l10n:patchcheck -- patch.json` | runs the locale's register detector over a patch **before** it lands | catches a register slip while it is still cheap | +| `npm run l10n:apply -- patch.json [--apply]` | the **only** writer. Six gates; refuses the whole patch rather than landing half | dry-run by default | +| `npm run l10n:selfcheck -- ` | 16 assertions, incl. a diff against `HEAD` and a serializer round-trip | must be all-pass before committing | +| `npm run l10n:runtime -- ` | drives the real `@nextcloud/l10n` against the real bundle | the only thing that catches a wrong plural boundary | +| `npm run l10n:gatetest -- ` | proves the parity gate really fails when this locale loses a key | last step of a pass; restores the bundle itself | +| `npm run l10n:script -- ` | script coverage for a **non-Latin** locale: values carrying no target-script character, plus every Latin run | §5 step 8, in place of the English-leftover scan. A reading aid — never fails | +| `npm run l10n:corediff -- ` | every key the bundle shares with core, split AGREE / DISAGREE | **first thing in an audit.** The AGREE list is what you must not "fix" | +| `npm run l10n:termdrift -- ` | English words the bundle renders two ways | the §6.9 term count, over *all* words instead of a guessed list | +| `npm run l10n:spell -- --suggest` | words absent from a hunspell dictionary | wrong-language stems and typos. Needs `l10n:fetchdicts` | +| `npm run l10n:fetchdicts` | hunspell dictionaries into `scripts/l10n/dicts/` (gitignored) | one-time per machine, and needs the `hunspell` binary first (`pacman -S hunspell`, or the apt/brew equivalent). 30 of 36 locales exist — `fi ga lb mk mt rm` have none, so a quiet spell report there is a **gap, not a clean bill of health** | +| `npm run check:l10n` | developer audit: missing / unused / unwrapped | **`0 missing, 0 unused`** is the invariant; the unwrapped count is a known backlog (§10) | +| `npm run test:l10n` | CI gate: `en.js` covers every `t()`/`n()` call | the coverage gate. `check:l10n` is the richer audit of the same extraction — it adds unused + unwrapped and has no write mode | +| `npm run test:l10n:parity` | CI gate: parity, empty, arity, cognates | | +| `npm run test:l10n:write` | extract new keys into `en.js` | after a source change adds strings | +| `npm run clean:l10n` | keys no source file references — **dry run on purpose** | never run blind; see §8.9 | + +`l10n:status` and `l10n:worklist` are both `batch.js` (`status` / `absent`); it is read-only. + +Environment overrides, for a checkout that differs from the default layout: +`L10N_WORKSPACE`, `L10N_SERVER_DIR` and `L10N_APPS_DIR`. + +### 4.3 The four CI legs + +`check:specs`, `test:l10n`, `test:l10n:parity`, `format`. **All green, and must stay green.** + +`npm run lint` is **not** one of the four, and it currently exits non-zero on pre-existing +errors in `src/` unrelated to l10n (`vue/attribute-hyphenation`, `l10n-enforce-ellipsis`). +It also only covers `src/`, so it never sees `scripts/` or `tests/`. Before blaming your own +change, check whether it was already failing: + +```bash +git stash && npm run lint; echo "at HEAD: $?"; git stash pop +``` + +`npm run format` covers `**/*.{js,ts,vue,css,scss}` — which **excludes** `l10n/` via +`.prettierignore`, deliberately. **Never reformat `l10n/*.js`.** `serializeJs` emits the +exact on-disk Nextcloud/Transifex layout; `eslint --fix` would rewrite all ~4400 lines and +diverge from what Transifex regenerates. It also excludes `.md`, so a prettier warning on +`CLAUDE.md` does not fail the leg. + +### 4.4 Per-locale files the gates read + +Neither of these is documentation. The gates read both. + +**`scripts/l10n/locales/.json`** — write it **before** the first batch. + +```json +{ + "register": "formal", + "registerEvidence": "…how it was measured, with the counts…", + "buttons": "…the convention, and how it was measured…", + "pluralNote": "…the boundaries, and any header/library disagreement…", + "cognates": { "CSV": "reason, ≥15 chars" }, + "corrections": { "Some key": "why the pre-existing value was wrong" } +} +``` + +`register` and `cognates` are consumed by the gates. `pluralOrder: "library"` and +`pluralBoundary: "library"` are the two recognised plural acknowledgements, and they are +**not** interchangeable — §6.7. Any other field is free-form documentation and is ignored: +`registerEvidence`, `buttons`, `orthographyNote`, `lexiconNote`, `siblingParentheticalNote` +are all in use. `registerNotMeasured` is the exception among the note-shaped fields: it IS +functional — a written reason this locale has a config without a measured register, which +`selfcheck` reads to SKIP the register check instead of failing it. See §6.7's neighbours +and `loadLocaleConfig`. + +**A functional field must be added to `loadLocaleConfig`'s whitelist or it is silently +dropped.** This has happened three times — `pluralOrder`, `spellAllow`, `pluralBoundary` — +and it survives because it usually fails in the *safe* direction: a dropped `spellAllow` +makes a report noisier, never wrong, so nothing goes red and nobody looks. **Writing the +rationale in prose is not setting the field.** So verify a new field by making it do +something observable, not by reading the code or the JSON back. + +**`scripts/l10n/detectors/.js`** — the register detector. The gates call exactly two +things, so those are the required exports: **`score(s) -> {f, i}`** and +**`runControls() -> {fail, total}`**. Also export `fold` and `CONTROLS` by convention, and +**`UNDETECTABLE`** — a list of `[example, why]` pairs for informal styling the detector +*cannot* see. That last one is documentation rather than interface, but write it: it is the +honest record of the detector's blind spots, and without it the next reader assumes a clean +scan means clean data. Running the file directly executes its own controls **and** scans +core: + +```bash +node scripts/l10n/detectors/.js +``` + +Templates: `hr.js` and `sl.js` for formal-prose/imperative-buttons, `et.js` if the locale +turns out informal (its polarity is inverted), `sk.js` if the 2sg imperative is detectable +(§6.5), `bg.js` if it is detectable for only **part** of the verb system, `ro.js` if you need +a label-position bound, and **`rm.js` if core ships nothing for this locale** — it is the +only detector whose `main` block cannot call `scanCoreRegister`, so it re-checks that core +is still empty and scans the bundle instead. + +--- + +## 5. The pass, in order + +Twelve steps. Skipping step 2, 5 or 8 is what produces a locale that passes every gate and +is wrong. + +```bash +LOC=sr +SCRATCH=/tmp/…/scratchpad # anywhere outside the repo +``` + +**1. Status.** `npm run l10n:status -- $LOC`. Note `nplurals`, the plural header, `absent`, +and the identical count. Read the identical list: it usually splits into a block of +genuinely untranslated English (a recently added feature) plus a handful of real cognates. + +**2. Measure the register against core.** Build the detector first (step 4) or a throwaway +probe, then: + +```bash +node scripts/l10n/detectors/$LOC.js # controls + core scan in one run +``` + +Counts markers across `server/core/l10n`, `server/lib/l10n`, `server/apps/*/l10n`. Region +variants are found by matching the directory, so Estonian's `et_EE` and Lithuanian's `lt_LT` +are picked up without guessing the region code. Record the verdict and the counts in +`locales/$LOC.json`. If core comes out **MIXED**, see §6.4. + +**3. Establish the button convention separately.** Prose register does not predict it — +there are **five** patterns, one of them graded by length rather than categorical (§7.3). +Measure it from core's own short labels: resolve ~30 bare action keys (`Save`, `Delete`, +`Add`, `Cancel`, …) against core's catalogues and classify the results. Record the counts. + +**4. Write `detectors/$LOC.js`** from **closed word lists, never suffix patterns**, with +must-fire / must-not-fire controls that include that language's homograph traps (§8.1–8.3). +Every control should be a real value from this bundle or from core where possible. Aim for +40+ controls; the recent passes run 46–63. + +**5. Read the locale's own existing values, and GRAMMATICALLY AUDIT THEM.** All of them — an +unfinished bundle carries roughly half the key set already, and defect rates have run from +2.8% to 22%. This is the single most under-budgeted step: plan for it to take as long as a +translation batch, and see §6.9 for the method. You are also looking for the domain terms — +audit trail, view, chunk, embedding, webhook, flow vs workflow, payload, token, dashboard, +hash, soft delete, `Delete` vs `Remove`, `Type`, `Filters` — and for the locale's +**typographic and orthographic conventions**: ellipsis spacing, dash choice, whether `%` +takes a space, how progressive states are phrased, and **whether domain terms are +capitalised mid-sentence**. Measure that last one per term rather than eyeballing it +(§8.10); in `sr` it decides a third of all values and no gate can check it. + +**6. Worklist, then harvest.** + +```bash +npm run l10n:worklist -- $LOC > $SCRATCH/$LOC-todo.json +npm run l10n:harvest -- $LOC $SCRATCH/$LOC-todo.json +``` + +The worklist is `absent ∪ unjustified-identical`, **every round** — regenerate it between +batches rather than filtering by hand. Harvest writes +`scripts/l10n/harvest-$LOC.json` (gitignored) and prints what it dropped as another +language (§6.6). **Verify every hit at its call site** (§8.4). + +**7. Translate in batches of ~250 keys.** Per batch: + +```bash +npm run l10n:patchcheck -- $LOC $SCRATCH/$LOC-patch-N.json # register slip? +npm run l10n:apply -- $LOC $SCRATCH/$LOC-patch-N.json # dry run +npm run l10n:apply -- $LOC $SCRATCH/$LOC-patch-N.json --apply +cp l10n/$LOC.js $SCRATCH/$LOC-WIP.js # ALWAYS. See §6.10 +node scripts/l10n/batch.js absent $LOC > $SCRATCH/$LOC-todo-N.json # fresh worklist +``` + +Add each cognate's justification to `locales/$LOC.json` **as you go** — apply refuses the +batch without it, and it also refuses a recorded cognate that is in neither the patch nor +the bundle, so records and values must land together. + +**8. Sweep your own work** before verifying. Cheap, and it has caught something every time: + +- **English leftovers** — scan values for English function words (`the`, `and`, `with`, + `from`, `this`, `will`, `your`, …). Expect one false positive from `{from}`. +- **Foreign orthography** — scan for letters the target language does not have. For + Slovenian that is `ć đ ě ř ů ą ę ł ń ś ź ż`; it is how the Croatian `trag` was found. + For Slovak, `ě ř ů ą ę ć ś ź ż`. +- **Non-Latin locales** (`bg sr mk be`): a **script-coverage** check replaces the + English-leftover check, because for a Cyrillic target the signal is the script rather than + the vocabulary — an untranslated English value and a correct Bulgarian one are both just + words. + + ```bash + npm run l10n:script -- $LOC # never fails a build; it is a reading aid + ``` + + It prints two lists. **Values with no target-script character at all** should each be a + recorded cognate or a normalisation (`Url` → `URL`). **Latin runs inside translated + values** should all be placeholders, product names, acronyms or literal example values; + read them once and confirm. The script requires the locale as an argument and refuses one + whose expected alphabet is not recorded in it, since `sr` also ships in Latin in the wild. + + **This finds a defect class the Latin-script locales cannot have**, which is why it + replaces rather than supplements: a correct translation written in the *wrong alphabet* is + not empty, not identical to English, not wrong-arity, and reads as finished work to + anyone skimming. Run it **before** concluding the pre-existing half is sound. +- **Your own typos.** Grep for the near-miss spellings of the words you used most. The `sl` + pass shipped `namesčen` for `nameščen` in 8 values this way. + +**9. Verify.** + +```bash +npm run l10n:selfcheck -- $LOC # must be ALL CHECKS PASS +npm run l10n:runtime -- $LOC # must be ALL RUNTIME CHECKS PASS +npm run check:l10n # 0 missing / 0 unused +npm run test:l10n && npm run test:l10n:parity +``` + +**10. Negative-test the gate** on this locale. Parity is unconditional, so there is no list +to add to — but a gate nobody has seen fail is not known to work, so break the bundle on +purpose and check that it fails and names the locale. + +```bash +npm run l10n:gatetest -- $LOC # snapshot, break, assert, restore, re-assert +``` + +It asserts four things — green before, fails **and names this locale** with a key missing, +restores byte-identical, green again — and owns the whole cycle itself, so there is no +window in which you have to remember to put the file back. **Do not do this by hand with +`git checkout`**; see §6.10. + +**11. Update the docs — all four places.** Seven consecutive passes bumped `CLAUDE.md` and +left `docs/l10n-ui-translation.md` claiming twelve finished locales while the gate enforced +nineteen. + +- `CLAUDE.md` — the count snapshot, the enforced-locale list, and any new plural or + register fact. +- `docs/l10n-ui-translation.md` — the register list, the button table, the plural list, a + `### traps` section, and the "N locales are complete" line. **This is where a pass's + per-locale findings go.** +- `scripts/l10n/README.md` — the enforced-locale count. +- **this file** — remove the locale from §2.3, and add to §6/§7/§8 **only if the pass + taught a new rule**. Not the evidence for it, not the counts (§2), not a case study. If + what you have to say is "on `` this came out differently", it belongs in the + companion or the commit message. This file is capped at 800 lines. + +**12. Regression-check every recorded locale, then commit.** + +```bash +for f in scripts/l10n/detectors/*.js; do l=$(basename $f .js) + node scripts/l10n/selfcheck.js $l | tail -1 + node $f | grep controls + node scripts/l10n/runtime-check.mjs $l | tail -1 +done +``` + +Any change to `lib.js` or a shared gate can break a previously finished locale. Then one +commit for the language, with the measurements in the message. + +--- + +## 6. What to do when… + +### 6.1 `apply.js` refuses: "value===key with no recorded reason" + +Decide the split (§3.2). Genuine cognate → add it to `cognates` with a real justification. +Otherwise → translate it. Do not delete the key to make the message go away. + +### 6.2 `apply.js` refuses: "cognate recorded but absent from both the patch and the bundle" + +You added the record before the value. Either include that key in **this** patch, or remove +the record until the batch that carries it. This is also why **sequential re-application of +several patches from a clean bundle fails** — later batches' records are missing from +earlier patches. To replay a whole pass, **merge the patches and apply once** (§6.10). + +### 6.3 `apply.js` refuses: "would clobber a real value" + +Intentional? Name the keys explicitly and record why: + +```bash +npm run l10n:apply -- $LOC patch.json --apply -- --allow-replace='key one||key two' +``` + +It prints every `was:` → `now:` pair. Then: + +- **The value was pre-existing (present at `HEAD`)** → add an entry to `corrections` in + `locales/$LOC.json`. `selfcheck` compares against `HEAD` and will fail without it. +- **The value was written earlier in this same pass** → no `corrections` entry needed, + because the key is absent at `HEAD`. `apply` will still print a NOTE suggesting one; + that suggestion is wrong for this case. Mention the fix in the commit message instead. + +### 6.4 The core register scan says MIXED + +Core is genuinely inconclusive; that is a finding, not a failure. Procedure: + +1. Fall back to the **bundle's own** pre-existing keys and count those. +2. If that is also unclear, or the choice is a product decision rather than a linguistic + one, **ask the owner** — this is the one place in the pass where approval is right. + Record the answer, and *who* decided, in `registerEvidence`. +3. Note the direction in the docs. For `et` and `lv` core was one-sided and **overruled** + the file; for `ro` core was inconclusive and the **file won**. Same rule, opposite + outcomes. + +If the scan **throws** "no `` catalogues found", check whether the layout is wrong +before assuming it is: for some locales there is genuinely nothing to scan. It throws on +purpose either way — it used to scan zero files and print `verdict: MIXED` computed from +nothing. + +- **Layout differs** → set `L10N_SERVER_DIR`. +- **Core really ships nothing for this locale** → fall back to the bundle's own values, and + **say so explicitly in `registerEvidence`**; a verdict from the app's own file is weaker + evidence than core, and the record has to show which one it rests on. The detector's + `main` block then cannot call `scanCoreRegister` — copy `detectors/rm.js`, which + re-checks that core is still empty rather than asserting it from a comment. + + **Widen the fallback corpus to the sibling apps' FRONTEND bundles.** This tripled `mt`'s + evidence from 1015 values to 3422 and turned a thin 26-vs-0 into 128-vs-0. Two + constraints, both load-bearing: include only the **`.js`** bundles, because the backend + `.json` is a separate catalogue with a separate consumer (§1) and its markers would be + miscredited to the frontend; and exclude byte-identical mislabelled catalogues the same + way `harvest.js` does (§6.6). + +### 6.5 Deciding whether the 2sg imperative is detectable + +Most locales must **exclude** the bare 2sg imperative from the detector. Two independent +tests, and exclusion follows from **either**: + +1. Is the imperative the locale's own **label convention**? If yes, counting it flags every + button in the app. (`ca`, `et`, `hr`, `sl`, `sr`, `ga`, `mt`, `bs`: yes.) +2. Is the imperative a **homograph** of a form that is live in this bundle's prose — the 3sg + present, a past tense, a verbal noun, a participle, or an ordinary noun? If yes, counting + it flags ordinary third-person prose. + +**Run both; the answers are independent and neither predicts the other.** `sk` fails both, +so it counts imperatives — labels are infinitives and `ulož` ≠ `uloží`. Its immediate +neighbour `sl` passes both. `rm` shares `sk`'s infinitive labels (test 1 same) and fails +test 2 outright. So do not infer one test from the other, and **do not carry either answer +across from a neighbour** — this is about the data, not the language family. + +If the imperative is a homograph but you still need to catch it in *labels*, use a position +bound: string-initial plus a length cap (`ro.js` uses 40 characters). + +**There is a third outcome: PARTIALLY detectable, and it is free precision — look for it.** +The paradigm can fail for only *part* of the verb system, in which case enumerate the usable +class and record the rest in `UNDETECTABLE`. Three splits seen so far, by **conjugation +class** (`bg`: а-conjugation imperatives are unambiguous, и-conjugation ones collide with +both the 3sg present and the aorist; `is`: class-1 and strong verbs are distinct, class-2 +verbs are identical to the 3pl past), and by **lexical class** (`lb`: the modals syncretise +1sg/2sg/3sg and carry no address information, while regularly inflected 2sg forms of the +same verbs are usable). **So when a locale fails test 2, check whether it fails for every +class before excluding the whole paradigm** — the two informal slips already shipped in `bg` +were both in the detectable class, so a wholesale exclusion would have found neither. + +**A BIGRAM can rescue two individually-ambiguous tokens at once.** Where a locale has a +pronoun and a verb form that are each ambiguous but not jointly so, match the pair: `is` +counts bare `þér` as informal (it is overwhelmingly the 2sg dative) and the pair +`þér` + finite 2pl verb as formal, in both orders since a question inverts it. This recovers +recall a per-token closed list has to throw away. + +One caveat that comes with counting any imperative: it measures against **this bundle's** +label convention, not core's — core `bg` uses imperatives as labels in 29 places while the +bundle uses verbal nouns throughout. Say which one it is measuring in `locales/.json`. + +### 6.6 `harvest.js` prints "DROPPED … identical to another locale" + +A sibling app's catalogue for this locale is byte-identical to its catalogue for a different +language, so it cannot be this language's translation. Expected and correct for the +**openbuild** group: one Croatian catalogue ships under seven names — +`bs cs hr mk sk sl sr` — plus `da == sv` and `de == lb`. Nothing to do; the guard already +dropped it. Every hit from that file reads as a plausible Slavic translation of the right +key, which is exactly why a call-site check would not have caught it. + +If it drops something you believe is legitimate, check the **base language** comparison — +the first version of this guard compared locale *names* and reported core's +`et_EE.js == et_EE.json` as a mislabel, killing 33 of 40 sources for `et` and `lt`. + +### 6.7 `runtime-check` FAILS on which index each count selects + +The file's `plural=` expression and `@nextcloud/l10n`'s own `getPlural` disagree about +**which form index** a count selects. The library is what renders, always. There are +**three** kinds of disagreement and they take different remedies, so read which one the +check names rather than reaching for a remedy by reflex. + +**A PERMUTATION disagreement** — the two partition the counts into the same groups and only +label them differently. Reordering the arrays makes the locale fully correct: + +1. Order the **arrays** by the library, not the header. +2. Record `"pluralOrder": "library"` in `locales/$LOC.json`. + +Only `lv` is this: its header carries the legacy gettext order `[one, other, zero]` while the +library partitions `[zero, one, other]` — same three categories, rotated, so a file matching +its own header was wrong at **every** count while passing every other gate. + +**A BOUNDARY disagreement** — the two draw the lines in different *places*, so **no +permutation can agree with the header everywhere**. There is nothing to reorder; what you +choose is *which counts to be correct for*: + +1. Write each form to read correctly across the counts the library **actually routes to it**, + weighting by which counts a user plausibly hits. +2. Say in `pluralNote` **which counts stay wrong**, explicitly. +3. Record `"pluralBoundary": "library"` in `locales/$LOC.json`. Writing the rationale in + prose is not setting the field (§4.4). + +`is` and `mk` are both this, in **opposite directions** — which is the reason to classify +rather than pattern-match. On `is` the library reaches form 0 only at exactly 1, so 17 counts +in 0–200 render the plural where Icelandic wants the singular; form 1 stays the true plural +because contorting it would trade 17 wrong counts for ~180 unidiomatic ones. On `mk` only 11 +and 111 disagree and they go the other way, so the residue is two counts. + +**The shape to check for is a two-form header whose expression is modular rather than +`n != 1`**, since the library's coarse groups are mostly written `n === 1`. Plain `n != 1` +headers and three-form Slavic ones have all agreed exactly. + +**A NOTE that the library uses FEWER forms than declared** (`tr`, `rm`, `ga`) is the third +situation, and it is harmless **only when a single form is correct in that language anyway**. +The two cases look identical in the tool output, so **decide it by measuring core, not by +reasoning about the grammar**: `tr` and `ga` are harmless, while `rm` is not — the library +knows no Romansh and returns 0 at every count, but Romansh pluralises regularly with `+s`, so +a bare singular in form 0 renders `5 datoteca`. Nothing flags it: the arity is right, no value +is empty, and the file reads fine. + +Where the collapsed form is not correct on its own, **the fix is in form 0, not in the +arity** — a shape correct at every count, such as the `(s)` parenthetical, or a +number-neutral phrasing where a parenthetical cannot span three agreeing words. Write form 1 +as the true plural anyway, so the array becomes correct if the library ever gains an entry. +Do **not** add `pluralOrder`; there is no ordering disagreement here. This applies to +pre-existing arrays too — `rm`'s carried a singular noun **and** verb in form 0, wrong twice +at every count but 1, and had passed every gate for the life of the file. + +**A locale can also come out with no plural surprise at all.** Do not read that as "the +arrays are easy": `mt`'s four forms are Semitic rather than European (§7.1) and its one +flagged "suspect array" turned out **correct**. Run the check to find out which situation you +are in; do not infer it from the header's shape. + +### 6.8 `selfcheck` reports a stale cognate, or a NOTE + +- **"stale cognate record"** — the recorded value is no longer identical to its key, so the + record is a standing permission slip for whatever gets written there next. Remove it. + One subtlety: a **plural** key counts as in-use if **any single form** equals the source + form. Romanian's `["{count} email", "{count} emailuri", "{count} de emailuri"]` is a real + cognate for the singular only; `hasIdenticalForm` in `lib.js` is what reconciles the three + gates that used to disagree about it. +- **NOTE "plural arrays with every form identical"** — report, not failure. Confirm each is + genuine: Hungarian does not pluralise after a numeral, Swedish `objekt` is invariant, and + a key with no `{count}` legitimately reads the same for several counts. +- **A `SKIP` or `NOTE` is not a bug to tighten away.** "Identical to English" and "rendered + the English source" are the symptoms of the two worst defects this tooling catches *and* + of a perfectly legitimate cognate. The justification record is what separates them. + +### 6.9 The pre-existing values — AUDIT THEM ALL, then fix what is wrong + +**This is a required step of every pass**, not something you do when you happen to notice +something. None of what it finds is visible to any gate: a wrongly-inflected value is not +empty, not identical to English, not wrong-arity, and reads as finished work. §3.8 guards +against changes of taste, not against fixing grammar — do not cite it to skip the audit. + +**Run the three reports before reading anything.** Measured against `sk`'s corrections they +reach ~48 of 57 before you read a value: + +1. `npm run l10n:corediff -- ` — **first.** Its AGREE list is the set of values you + must never question; its DISAGREE list is where the real terminology decisions are. +2. `npm run l10n:termdrift -- ` — competing renderings per English term, over *every* + English word instead of a guessed list. This is the single highest-yield report: ~70 of + 113 on `cs`, 37 of 57 on `sk`, and it needs no knowledge of the language. **It reports + the MINORITY side, and the minority is not automatically the defect** — on both `sk` and + `sq` the minority reading was the right one. +3. `npm run l10n:spell -- --suggest` — wrong-language stems and typos. Needs the + `hunspell` binary plus `npm run l10n:fetchdicts`, once per machine; `fi ga lb mk mt rm` + have no dictionary, which is a known gap and not evidence those locales are clean. + **Read it for tooling failure before you read it for defects**: a cluster of implausible + short words sharing a stem with a real one means the tokeniser split something (§8.3). + +Then the **collision scan** — `grep -rn "tabs:" src/ -A4`, checking each sibling pair +against the bundle, and the paired empty states the same way. A byte-identical collision the +user can see on one screen is a defect however defensible each word is on its own, and it is +not findable by reading values one at a time (§8.5). Then the dangling-preposition sweep, +then **read every remaining value**. + +**Do not build mechanical morphology checks.** Yield has been 4 of 239 on `is` and ~0 of 113 +on `cs`; `sk` skipped them entirely and lost nothing. The one exception is an *orthographic* +rule with a deterministic trigger — §8.11. **Do not rebuild the cross-locale outlier +scanner** either: clustering all 36 locales per key and flagging the odd one out scored +recall 1 of 57 and precision 1 of 65, and was deleted. `node scripts/l10n-ai.js get ` +answers the same question on demand. + +**Read the bundle as a subagent fan-out, not sequentially.** Slice the listing into ~4 +chunks, spawn one subagent per chunk in a single message, and give each the same shared +context: the `corediff` AGREE list, the `termdrift` output, the collision list, and the +locale's register + button convention (§7.2/§7.3). Without that they re-derive candidates +core already killed and flag every infinitive button as a register slip. Subagents return +**candidates with call-site evidence, never verdicts** — adjudicate centrally, which is where +the errors are (11 of ~30 on `sk`). A second fan-out on `sk` found 5 defects the first +sequential read missed, so this raises recall and not just speed. **Cheap models may generate +candidates but never decide**: a Haiku-class reader gave 1 real finding and 2 confidently +wrong ones that would have written new errors into correct Slovak. + +**CHECK CORE AND THE CALL SITE BEFORE CALLING A VALUE WRONG.** This overturned four +candidates on `cs` and eleven on `sk` — more than any single class either pass corrected — +and on `ca` it killed two collisions that looked identical to the two real ones. A collision +core also has is not a defect. A majority inside the bundle is not authority on its own; a +lone outlier is sometimes the only value that matches core. And the call site decides the +sense: `Handler` is a **person** in the DSAR cases table, and an infinitive among noun +siblings is correct when the `

` sits over filter *controls*. + +**Read converse pairs against each other.** On `sk`, `Uses` and `Used by` were **swapped**. +Nothing catches this — not a gate, not the term count, which correctly reports both using +the right stem. Check Uses/Used by, Parent/Child, Source/Target, Merged from/into. Sibling +locales settle the direction fast. + +Record every correction in `corrections` (§6.3). At volume, per-key prose stops being +readable — use **short class codes** (`AGREEMENT`, `CASE`, `NUMBER`, `COMPOUND`, `HYPHEN`, +`TYPO`, `GARBLED-OR-FOREIGN`, `SENSE`, `TERM-*`, `CONSISTENCY`) and document the codes once +in a free-form field, as `locales/is.json` does. + +Three judgement calls: + +- **Register deviations are not optional cleanup.** Leaving them makes the bundle mix + registers inside a single dialog, which reads worse than either choice made consistently. +- **A mild inconsistency is not automatically a defect.** `hr` kept `lozinka` over core's + preferred `zaporka` because the file already shipped it and it is valid Croatian. +- **An escalated decision can come back "change nothing", and that must still be recorded** + as a *deliberate* divergence with its counts. An unrecorded "left alone" is + indistinguishable from "never looked". + +**A finished audit is not proof the locale is clean, and a `corrections` count of 0 means +"unverified".** Record the count, never a verdict. §9.2 has what to budget per locale. + +### 6.10 You destroyed the working bundle + +`l10n/.js` is **uncommitted for the entire pass**, so `git checkout -- l10n/.js` +does not undo your last step — it reverts to pre-pass `HEAD` and discards everything. This +happened on `sk` and threw away 999 applied values. + +Recovery: the per-batch patch JSONs in the scratchpad are the source of truth. **Merge them +into one and apply once** — sequential re-application fails for the reason in §6.2: + +```bash +node -e 'const fs=require("fs"),d="'$SCRATCH'";const out={}; +for(const i of [1,2,3,4]){const p=JSON.parse(fs.readFileSync(`${d}/sk-patch-${i}.json`)); +for(const [k,v] of Object.entries(p)){if(k in out) throw new Error("dup: "+k); out[k]=v}} +fs.writeFileSync(d+"/all.json",JSON.stringify(out,null,1)+"\n")' +npm run l10n:apply -- $LOC $SCRATCH/all.json --apply +``` + +Then confirm the totals match the sequential run. + +Prevention: `cp` after every batch, and restore with `cp`, never with git. The one place +this hazard used to be unavoidable — the gate negative test — is now designed out: +`l10n:gatetest` snapshots and restores the bundle itself. + +### 6.11 A harvested value looks right but is the wrong sense + +Read the call site. See §8.4 for the confirmed offenders — this is the single most +productive check in the whole pass. + +### 6.12 `patchcheck` reports informal (or formal) markers in your patch + +Fix the **values**. Do not loosen the detector to make the count go away. If the flagged +value is genuinely correct and the detector is wrong, the detector needs a new control and a +recorded reason — and that is a separate, deliberate change. + +### 6.13 A detector control fails + +Almost always: the word is not in the closed list. Add it. Do **not** replace the closed +list with a suffix pattern — §8.1 is a catalogue of why that fails. If the control itself +was wrong, delete the control and say so in a comment. + +### 6.14 The `format` leg is red + +`npx prettier --write` the specific files you touched. Never `l10n/`. If it is a `.md` file, +check whether the leg actually covers it (§4.3) before touching it — reformatting +`CLAUDE.md` wholesale creates a huge unrelated diff. + +### 6.15 The source added, renamed or removed an English string + +This is the most common way `test:l10n` or `test:l10n:parity` goes red **without anyone +touching l10n**, because a development merge that adds one `t()` call puts every finished +locale one key short. + +```bash +npm run test:l10n # names the keys en.js is missing +npm run test:l10n:write # extracts them into en.js +``` + +Then, for each new key, make the §3.2 decision **before** translating anything: + +- **Non-prose** (a placeholder, an example value, a bare product name) → **unwrap it in + `src/`** and do not add the key at all. If `test:l10n:write` already added it, remove it + from `en.js` too. This is the right answer more often than it looks. +- **Real prose** → it needs a value in **every finished locale**, or parity breaks. Use + `l10n-ai.js add --value en=… --value nl=… …`, or a small `apply.js` patch per + locale. Check `l10n-ai.js get ` first — a sibling key often already tells you + each locale's term. + +For a **rename**, `l10n-ai.js rename` handles all 37 bundles but **not the call site** — +grep `src/`. **Prefer a rename to an add whenever the English wording changed for a string +that already exists**: every finished locale's translation survives it and parity never +breaks. For a **removal**, `rm` the key everywhere in the same commit, or `check:l10n` +reports it unused forever. + +--- + +## 7. Per-locale reference data + +`docs/l10n-ui-translation.md` is the full version, with the evidence and the per-locale +trap sections. This is the part you need in front of you while writing arrays. + +### 7.1 Plurals + +Take `nplurals` from the locale file's **own** header and build against **that locale's +expression**. Equal form counts do not mean equal boundaries. + +| Locale | Forms | Boundaries | +| --- | --- | --- | +| `hr` `ru` `sr` `be` `bs` | 3 | modular: `1,21` / **2–4** / `0,5–20` | +| `lt` | 3 | modular, **wider form 1**: `1,21` / **2–9** / `0,10–20` | +| `pl` | 3 | modular, `n==1` exact for form 0 | +| `cs` `sk` | 3 | **absolute**: `1` / `2–4` / everything else incl. 0 | +| `lv` | 3 | `1,21` / nonzero / **dedicated zero form** — and the ORDER disagrees (§6.7) | +| `ro` | 3 | `1` only / **0 and 2–19** / 20+ | +| `sl` | **4** | modular on `n%100`: `1` / **2 = DUAL** / `3,4` / else incl. 0 | +| `is` | 2 | modular header, but the library reaches form 0 **only at n=1** — a BOUNDARY disagreement no reordering fixes (§6.7) | +| `mk` | 2 | same modular header as `is`, but the library **drops the `n%100!=11` guard**, so only 11 and 111 disagree, in the opposite direction (§6.7). **No counted form** — the `-а` form survives only in a closed set of measure nouns, so `bg`'s hazard below does *not* transfer | +| `ga` | 5 | header 5, library reaches **3**: `1` / `2` / `0` and all `n>=3`. Forms 3–4 dead, harmlessly (§6.7). What decides the arrays is the **counted-noun rule** below | +| `mt` | 4 | **Semitic, not European**: `1` / `0 and n%100 2–10` / `n%100 11–19` / `20+`. The noun is PLURAL only in form 1 — forms 0, 2 and 3 all take the SINGULAR, so three near-identical forms are correct, not a defect | +| `bg` | 2 | plain `n != 1` — but see the **count form** hazard below | +| `lb` `sq` | 2 | plain `n != 1`, header and library agree at every count | +| `rm` | 2 declared | the library knows no Romansh and returns **form 0 at every count**, so form 1 is unreachable — see the collapsed-form hazard below | + +The distinct hazards, every one of which has actually bitten: + +- **Wrong boundary.** A Croatian array pasted into Lithuanian is wrong for 5–9. +- **The library's boundaries not matching the header's**, with no reordering available to + reconcile them — §6.7. +- **Absolute vs modular.** `sk`/`cs` bound absolutely, so **22 selects form 2**; `hr` bounds + modularly, so 22 selects form 1. An array copied between two `nplurals=3` Slavic locales + is wrong at every compound number. +- **The same header AND the same boundaries can still need a different NOUN form.** `be`'s + expression is byte-identical to `ru`'s and partitions the counts identically, and Belarusian + still takes the **nominative plural** after 2–4 where Russian takes the genitive singular + (`2 запісы`, not `2 записа`) — with the adjective and verb going plural alongside it. So + matching headers is not even evidence for copying form 1, let alone the whole array. Core + decides it; core `be` is one-sided across all 30 of its arrays. +- **The dual.** Slovenian's form 1 governs noun case, adjective agreement **and verb + number** together: 2 needs `Objekta sta bila izbrisana` where the plural needs + `Objekti so bili izbrisani`. +- **A separate counting form, chosen by the SENTENCE and not by the form index.** Lexical + rather than structural, so `nplurals` gives no warning — `bg` has the simplest header in + the set and still needs it. Bulgarian masculine non-person nouns take a count form + (`-а`/`-я`) after a numeral, distinct from the ordinary plural, so **which noun form form + 1 needs depends on whether the string contains a numeral**: + + ```js + "_Delete {count} object_::_Delete {count} objects_": // numeral present + ["Изтриване на {count} обект", "Изтриване на {count} обекта"] + "_Object successfully deleted_::_Objects successfully deleted_": // no numeral + ["Обектът е изтрит успешно", "Обектите са изтрити успешно"] + ``` + + Masculine **person** nouns keep the plural; feminine/neuter have no count form at all. + Look for this in any locale with a paucal/counting form — it is invisible to every gate. + +- **The numeral forcing the SINGULAR — the mirror image.** Irish takes the singular noun + after a numeral, so for `ga` the split is again decided by whether the value contains a + numeral, in the opposite direction: **numeral present → the same counted singular in every + form; no numeral → a genuine singular/plural split.** Core separates cleanly on exactly + that axis (53 of 73 numeral-bearing arrays keep the singular; 28 of 28 without a numeral + pluralise), which is how the rule was settled rather than assumed. Initial mutation is + **not** applied after a digit, but a *fixed* numeral in a label does take it — the + difference being whether the number is known at authoring time. +- **The predicate agrees, not just the noun.** `mt`, `lb` and `sq` all have arrays where the + verb inflects with the count (`%n entrata għadha` / `%n entrati għadhom`; `%n Antrag huet` + / `%n Anträg hunn`). An array here cannot be built by swapping the noun alone. +- **The library collapses every count onto form 0, in a language that pluralises** (`rm`). + Same *symptom* as `tr`, opposite consequence — §6.7 for how to tell them apart, and where + the fix goes. + +`test:l10n:parity` catches wrong **length** only. Nothing catches a wrong boundary except +`l10n:runtime` on this locale's own counts, and **nothing at all** catches a form 0 that +is only correct at count 1. + +**The `n()` catalogue key is NEITHER source string.** It is the identifier +`"__::__"` — see `pluralIdentifier` in `lib.js`. Storing forms under the +bare singular renders correctly for `count === 1` and falls back to English for every other +count. That shape shipped in all 37 bundles until it was fixed, passing every gate. + +**At runtime the index comes from the library, not the header.** `register(app, bundle)` +**ignores** a plural function passed as a third argument. The header governs the arity gate; +the library governs which element renders. `runtime-check.mjs` calls `unregister()` and +`setLanguage(loc)` first for this reason. + +### 7.2 Register verdicts + +Informal: `nl de sv da nb pl fi hu et lv ga mt is`. +Formal: `fr cs ru uk tr el sr bg ca hr lt ro sk sl lb sq mk be bs`. + +The counts and how each was measured are in `locales/.json` and in the companion doc. +What belongs here is the taxonomy, because **the label is the same and the situation is +not** — five states have turned up behind these two words, and they differ in what a slip +looks like and therefore in what the detector must gate: + +| State | Example | What the gate is catching | +| --- | --- | --- | +| no T-V distinction exists | `ga` | 2pl address, which is only ever a defect here | +| has one, measured unused | `mt` | genuine deference — a live option the project declines | +| had one, abandoned it | `is` | archaic deference **and** the plain modern plural, which is the *likelier* slip | +| live and ordinary | `cs` `lb` `mk` | the plain measured choice, no structural story | +| core inconclusive, decided by the file or the owner | `ro` | whatever was decided, recorded in `registerEvidence` | + +Two measurement rules that generalise: + +- **Split the informal count by what the hit IS before reading a verdict.** On `bg`, 29 of + 43 informal hits were core using an imperative as a *button label*; only 11 were informal + prose, clustered in two old catalogues. Unsplit, 43 looks like a real minority position; + split, it is a label-style choice plus eleven legacy strings. This matters most where the + scan comes out close — exactly the shape that gets escalated to the owner. +- **Low counts can be structural rather than weak.** Most correct informal Latvian is + undetectable by design, so for `lv` **zero formal markers is the assertion that matters**, + not a high informal count. + +### 7.3 Button conventions — five patterns + +| Pattern | Locales | Example | +| --- | --- | --- | +| same register as prose | `tr` `ru` | formal imperative | +| bare 2sg imperative, whatever the prose | `ca` `et` `hr` `sl` `sr` `ga` `mt` `bs` | `Desa`, `Salvesta`, `Spremi`, `Shrani`, `Сачувај`, `Sábháil`, `Issejvja`, `Sačuvaj` | +| **infinitive — register-neutral** | `cs` `lt` `lv` `sk` `rm` `is` `lb` `be` | `Zobrazit`, `Įrašyti`, `Saglabāt`, `Uložiť`, `Memorisar`, `Vista`, `Späicheren`, `Захаваць` | +| **verbal noun — register-neutral** | `ro` `bg` | `Salvare`, `Adăugare endpoint`; `Запазване`, `Добавяне на крайна точка` | +| **2sg imperative for a label, 2pl once it is a sentence — GRADED BY LENGTH** | `sq` `mk` | `Ruaj` / `Fshi` / `Shto`, but `Menaxhoni regjistrat …`; `Зачувај` / `Избриши`, but `Управувајте со вашите апликации …` | + +Infinitive buttons must **not** be "corrected" to imperatives. + +**Check whether a locale's apparent convention is really two populations before recording +one answer.** The fifth pattern is the only non-categorical one and it has now been measured +twice, with the crossover landing in the same place both times, **~40 characters**. Above it +the long strings are not "labels" at all — they are ordinary prose that happens to open with +a verb, so the register (§7.2) governs them and the button convention governs only the short +end. A single ratio over all action-verb keys would have reported `sq` as 476:145 "mostly +2sg" and produced a 2sg rendering of a 40-character sentence that no sibling app writes. +(`ro.js` already used a 40-character cap as a *detector* trick; the same boundary turning out +to describe the convention itself is why the trick generalises.) + +**A lexical override can sit on top of the gradient, and it is lexically bounded rather than +"any prompt".** In both `sq` and `mk`, `Select`/`Choose`/`Enter` placeholder prompts take the +2pl at any length — but `Search` goes the other way and is not close (core `mk` 61:1 for the +2sg). The distinction that predicts it is neither length nor prompt-ness: a dropdown you pick +from and a field you type into address the user, a toolbar button is something you press. + +**But WHICH lexemes are inside the override differs per locale, so measure it per verb and +never carry the set over.** `bs` is a categorical pattern-2 locale with no gradient at all, +and it still has the override — splitting it *between* `Select` and `Choose`, which `mk` +groups together: `Select …` takes the 2sg at 14–45 characters (20 of 20) while `Choose …` and +`Enter …` take the 2pl at 15–128 (5 of 5 each). Three locales, three different partitions of +the same small verb set. A `Please …` frame forces the 2pl independently, and a prompt +embedded in prose follows the prose. + +`ro` is the only locale where the convention is a **project decision that knowingly diverges +from core**, and it was forced: `Create`/`Read`/`Update`/`Delete` are single keys rendered +both as `` column headers *and* as buttons, so a header reading "delete!" above a count +column would be wrong. `bg` reaches the same verbal noun for an ordinary §3.5 reason instead +— Bulgarian has no infinitive, core is genuinely mixed, and the file's 1053 pre-existing +values broke the tie. Keep the two straight: one contradicts core, the other follows the file. + +### 7.4 `{plural}` is BANNED — and four gates enforce it + +Nothing to decide here any more. `{plural}` cannot appear in a key, in a value, or in a +translation call, and if you write one the build stops. + +The defect it names: 13 call sites used to pass `plural: count !== 1 ? 's' : ''` for five +keys, so the catalogue key was `object{plural}` and the **runtime** glued an English `s` +onto whatever the locale had written. A language whose plural is not a suffixed `-s` cannot +render that, and a three- or four-form language cannot render it at all — the entire form +set has to fit in one string. Every locale invented its own workaround (`bestand(en)`, +`súbor(y)`, `аб'ект(ы)`, `objekat/i`), and none of them is how the language is written. + +The five keys are now real `n()` calls with a form array per locale, so a locale gets as +many forms as its grammar needs: + +```js +n('openregister', 'object', 'objects', count) // bare label +n('openregister', '{count} schema', '{count} schemas', c, { count: c }) // number in the string +``` + +Where the ban is enforced, and what each arm catches that the others cannot: + +| Gate | Runs | Catches | +| --- | --- | --- | +| `tests/l10n/check-l10n.js` | CI, `test:l10n` | `{plural}` in an extracted key, **and** `? 's' : ''` inside a call's argument span. Independent of the missing-key check, so adding the key to `en.js` does not buy a pass. `--write` refuses to extract it. | +| `tests/l10n/check-l10n-parity.js` | CI, `test:l10n:parity` | `{plural}` in any key or value of any `.js` bundle — a hand-edit or stale Transifex pull that never touched `src/` | +| `scripts/l10n/apply.js` | the only writer | a patch carrying it, refused before anything lands | +| `scripts/l10n/selfcheck.js` | local | the same as parity, without waiting for CI | + +`npm run l10n:gatetest -- ` proves all four refuse, by injection. There is deliberately +no live example left in the tree to read, so that script is the only evidence. + +**The ternary arm is bounded to the inside of a translation call**, so +`` `${years} year${years !== 1 ? 's' : ''}` `` in ordinary template-literal prose does *not* +fail it. That is an unwrapped string — a separate problem, counted by `npm run check:l10n`. + +**Scope limit worth knowing:** the backend `l10n/*.json` set still carries the five dead +keys. No PHP references them and nothing in `scripts/l10n/` writes that set, so the parity +gate reports them once as a NOTE instead of failing on a file it cannot offer a fix for. + +**It never applied to the sibling `(s)` keys** (`register(s)`, `schema(s)`, +`configuration(s)`, …). That `(s)` is ordinary translatable text, not an interpolated +variable — covered separately below, and still live. + +#### The one thing the conversion did NOT make automatic + +A form array is only as good as its forms, and two hazards survive the fix: + +- **The counted form is decided by what the user reads, not by what the string holds.** + `bg`'s count form (§7.1) is triggered by a numeral, and these labels render beside one + even though the string itself has no `{count}` — so they take `обекта`, not `обекти`. + `ga` is the mirror: Irish takes the singular after a numeral, so all five of its forms are + the same counted singular. +- **A form that is also a key in the same bundle renders that other key's value.** + `translatePlural` hands the form it picked back to `translate()`, which resolves it + against the bundle again. `rm`'s lowercase `schema(s)` collided with the bundle's own + `schema(s)` key. `test:l10n:parity` now fails on this; before it did not, and nothing else + would have shown it. + +#### The sibling `(s)` keys are a DIFFERENT thing, and carry no source defect + +Fourteen keys spell a literal `(s)` in the English source — `register(s)`, +`configuration(s)`, `schema(s) selected`, `{days} day(s) left`, `{count} widget(s)` and the +rest. Unlike the banned `{plural}`, that `(s)` is **ordinary translatable text rather than +an interpolated variable**, so a locale may render it however its morphology wants, +including dropping the parenthetical. Nothing needs fixing in `src/`, and the ban does not +reach them. Where a locale has something to say about the shape it chose, that goes in +`siblingParentheticalNote` — `ca`, `is` and `bs` carry one, and `is`'s records a known +non-word (`skema(r)`) still sitting in two pre-existing values. + +Three strategies are in use and all three are correct: + +| Strategy | Locales | +| --- | --- | +| its own parenthetical | `mk` `nb` (14 of 14), `da` (13), `sq` (12), `et` `ro` `uk` (11) | +| **no parenthetical at all** | `bs` `hu` `it` (14 of 14) | +| keeps `(s)`, because `-s` **is** the native plural marker | `es` `pt` `fr` `ca` `rm`, and Dutch `configuratie(s)`, Latvian `konfigurācija(s)` | + +**So "the value contains `(s)`" is not an audit signal** — it flags the whole Romance group +for writing correct Romance. The signal that works is **inconsistency within one locale**, +which needs no knowledge of the target morphology: a locale using its own parenthetical in +most of the fourteen and `(s)` in a few. That finds two real defects, both in Tier 1 +locales and so belonging to the re-audit rather than to a locale pass: + +- `de` — `Schema(s)` and `Schema(s) ausgewählt`, beside its own `Konfiguration(en)`, + `Objekt(e)`, `Tag(e)`. German pluralises this `Schemata`/`Schemen`, never `Schemas`. +- `nl` — `schema(s) geselecteerd`, beside `object(en)`, `dag(en)`. Dutch is `schema's` or + `schemata`. + +`Dashboard(s)` and `Widget(s)` in `de`/`nl`/`lb`/`da` are **not** defects by contrast: those +languages keep `-s` on English loanwords. `lv` mixes `(s)`, `(i)`, `(ām)` and `(us)` across +eight keys and needs a case-by-case read, since Latvian declines and each may be right. + +--- + +## 8. Traps catalogue + +### 8.1 Never use suffix patterns for register detection + +**Use closed word lists. Always.** Every suffix that has looked like a register marker has +turned out to be something else as well, and the per-language table is in the companion doc. +What generalises: + +- **A suffix rule can invert the verdict outright, not merely add noise.** The 2sg `-š` of + `hr`/`sk`/`sl`/`mk` is also `vaš` = your-**formal**; `cs`'s 2sg imperatives are each a + prefix of their own 2pl (`vyber` ⊂ `vyberte`); `sq`'s bare `do` is also the future + particle, 101 occurrences of ordinary third-person prose. In each case the rule scores the + commonest *opposite-polarity* shape in the corpus as its own polarity. +- **In any language whose 2pl is the 2sg plus a suffix** the trailing `(?!\p{L})` guard is + not hygiene, it is the only thing separating the two polarities. **Write the guard before + the word list**, not after a control fails. This looked like a West Slavic property and is + not one: Belarusian builds its 2pl imperative the same way (`выберы` → `выберыце`), so + check the morphology rather than the branch of the family tree. +- **Check whether a marker is a substring of the app's own commonest nouns.** `cs`'s + `nastav` sits inside `nastavení`, and `tvá` inside `vytvářet` — 48 occurrences, every one + inside that verb. +- **The commonest homograph class is inflectional**: a verb ending that is also a plural, + a definite article, or a case ending. `-те` is the Bulgarian and Macedonian **definite + plural article**; `-ið` is the Icelandic neuter definite article; `-ni` is the Albanian + definite singular of every masculine `-n` stem. These are among the most frequent + morphemes in their languages, so the rule fires on nearly every noun phrase in the app. +- **Exclusion can be partial** — by conjugation class or by lexical class (§6.5). Check + whether the paradigm fails for *every* class before excluding it wholesale. +- **Look for the locale's politeness FORMULA, not just its pronouns.** A "please" that + inflects for the addressee is a free marker: `mt`'s `jekk jogħġbok` carries a 2sg object + suffix and at 35 uses was the second commonest marker in the bundle, with `jogħġobkom` the + unambiguous deferential counterpart. Two of the three locales checked so far came out + empty (`ga`'s `le do thoil` inflects nothing detectable; Icelandic `vinsamlegast` is an + adverb), so **check rather than assume in either direction** — it is cheap and the payoff + when it lands is large. + +### 8.2 Pronoun homographs, per language + +The per-language table is in the companion doc. The rules: + +- **Do not port an exclusion across a family without measuring it.** Bare informal `ti`/`ty` + collides with a demonstrative in `cs`/`hr`/`sl` and must be left unmatched — but the bare + pronoun is **fully usable** in `bg`, `rm`, `ga`, `mt`, `is`, `lb` and `mk`. Six against + three, so the collision is the exception rather than the rule. The sharpest case is one + word: `bg`'s `те` is the 3pl pronoun *they* and must be excluded, while `mk`'s `те` is + 2sg address and is safe, because Macedonian's *they* is `тие`. **Measure the recall you + would give up before mourning it** — sometimes the excluded token occurs zero times. +- **`si` needs its reason, not just its verdict.** In `hr`/`sk`/`sl`/`bg` it is left + unmatched because it is *ambiguous* (2sg of *to be*, and a reflexive clitic commonest in + formal prose). In `cs` it is left unmatched because it is *empty* — Czech's 2sg is `jsi`, + so `si` is only ever reflexive and carries no address information at all. Same handling, + opposite reason; `jsi` itself is unambiguous and **is** matched. +- **Case can be the only thing separating the two polarities.** Then `fold()` must not + lowercase: `lb`'s `dir` is the informal dative and `Dir` the polite nominative, both + attested, so `detectors/lb.js` normalises whitespace and nothing else. `da`/`nb` `De` and + `pl` `Państwo` require a mid-sentence capital for the same reason. The residue — a value + *opening* with the informal form takes a sentence-initial capital and becomes + indistinguishable — goes in `UNDETECTABLE` rather than being papered over. +- **An ACRONYM can be a homograph of a marker.** `mk`'s `ВИ` is *AI* (вештачка + интелигенција) and the bundle uses `ВИ-агент`, while `ви` is the formal dative clitic — so + a folding detector scores the app's AI vocabulary as deference. `detectors/mk.js` + **consumes the all-caps form in `fold()` before lowercasing**, which is the `lb` problem + from the other end: `lb` must preserve case throughout, `mk` needs it for one token and + can spend it up front, keeping the word lists readable. +- **An impersonal or 1pl form carries no address at all.** `bg` `трябва`, `mk` `треба` and + `молиме` are not markers; in `Ви треба` and `Ве молиме` the register is carried by the + clitic, and in `треба да се случува` there is no addressee. + +### 8.3 JavaScript and Unicode traps + +- **`\b` is ASCII-only.** It treats `á`, `č`, `š` as boundaries, so `Alege\b` matches inside + `Alegeți`. Use `(?` | +| `Bucket` | an S3 bucket, a basket, a bouquet | a **histogram bin** | `QualityIndex.vue` fallback table | +| `View` | the noun (`Ogled`, `Visualització`) | an **action button** | `OrganisationsIndex.vue`, `SourcesIndex.vue` row actions | +| `Search` | core's verb (`Poišči`, `Найти`) | a **field/tab label** | `ObjectsList.vue`, `SearchSideBar.vue` | +| `Subject` | a mail subject, a school subject | the **GDPR data subject** | `AvgIndex.vue` column | +| `People` | humans in the abstract | the **`PERSON` entity type**, so *persons* | `EntitiesTab.vue` | +| `Label` vs `Labels` | the same word | a **facet range caption** vs **file tags** — often different words | `EditSchemaProperty.vue` vs `UploadFiles.vue` | +| `Test` | a cognate noun | a **button**, so the verb | `WebhooksIndex.vue` | +| `Revoke` | undo (`Cofnij`), reject (`Avslå`) | **revoking an API token** | `TokensSection.vue` | +| `Interval` | fine | a **date-facet granularity** — collides with `Bucket` if you translate `Bucket` as "interval" | `EditSchemaProperty.vue` | +| `Handler` | an event/callback handler | **a person** — the DSAR case handler, beside `Type`/`Status`/`Deadline` | `AvgIndex.vue` | +| `Apply` | fine | core `ga` has *submit a job application*; the key is a button | `PermissionMatrix.vue` | +| `Quota` | fine | core's value may be storage-specific and far too long | | +| `Slug` | a cognate | core `lt` and `sl` both translate it | | +| `Display Name` | fine | core `ca` has `Nom d'usuari` = *username* | | +| `Documentation` / `Avatar` | fine | core `et` has a gloss unfit for a label | | +| `Refresh` | fine | core `tr` has `Yenlle`, a typo. **Do not take typos** | | +| `Mappings` | fine | openconnector `hr` has a non-standard transliteration | | + +**Sibling apps are not automatically right**, and a whole catalogue can be the wrong +language (§6.6). + +**ASK WHAT LETTERS THE TARGET DOES NOT HAVE.** Where the script omits a letter its likeliest +contaminant uses, that gap is a free and exact wrong-language detector — and unlike §6.6's +byte-identity guard it works on the **committed bundle**, which is where the guard cannot +help. Belarusian has no `и`, `щ` or `ъ`: one grep found two committed Russian values in the +pre-existing half *and* established that openbuild's whole `be.json` is a separate Russian +translation, which the guard misses precisely because it is not byte-identical to their `ru`. +`uk` (no `ы ъ э`), `sr` and `mk` (no `й ы щ ъ`) all have such a gap. Run it before reading +values one at a time; where it exists it is the cheapest check in the pass. + +### 8.5 Collisions the target language creates + +Check that a coinage does not collide with a term the bundle already uses. Confirmed: + +- `lt` **entity** → `esybė`, not `subjektas`, because `duomenų subjektas` is the GDPR *data + subject*; **redaction** → `užtemdymas`, not `redagavimas`, which is this app's *Edit*; + **Reports** → `Ataskaitos`, distinct from `Pranešimai` (*Notifications*). +- `ro`/`sk`/`sl` **Bucket** → `Segment`/`Pásmo`/`Razred`, never "interval", because + `Interval` is its own key. +- `sl` **Revoke** → `Odvzemi`, not core's `Prekliči`, which this bundle already uses for + **Cancel** on the same screen. +- `bg` **Connections** → `Свързвания`, because `Връзки` is already **Relations**; and + **Subject** spelled out as `Субект на данните`, because bare `субект` was already shipped + for *entity* — the `lt` fix of coining a different word for *entity* was not available. + +Three rules: + +- **A collision on the app's OWN primary noun is the worst case, and the recipe is fixed.** + Core decides which sense cannot move; the bundle usually already contains the answer for + the other sense (§3.5, and it beats coining a word); and because it propagates through + scores of keys it is a terminology decision, so **ask the owner** (§6.4 step 2) and record + the answer and whose it was in `lexiconNote`. +- **Some collisions are unavoidable and should be recorded rather than worked around.** `bg` + renders both **Cancel** and **Denial** `Отказ`, correct in each case and core's own word + for Cancel in ten catalogues; they never share a screen. Forcing an artificial distinction + would make one of the two wrong. But **a collision the user can see on one screen is a + defect however defensible each word is** — grep the `tabs:` arrays and the paired empty + states (§6.9). +- Watch for locale-specific renderings of acronyms the bundle has already fixed: **AI** is + `MI` in Latvian and `UI` in Slovenian, but product names keep the English (`Fireworks AI`, + `OpenAI`). **GDPR** is `BDAR` in Lithuanian, `VDAR` in Latvian, `RGPD` in Romanian and + Catalan. + +### 8.6 Dutch source terms + +`Bewaartermijn`, `Rechtsgrond`, `Verantwoording`, `verwerkingsactiviteit`, `Inzage`, +`Vergetelheid`, `Portabiliteit`, `AVG / Verwerkingsregister`, `verantwoordingsdocument` all +get rendered into the target language's own GDPR terminology. Prefer the wording of the +**official local translation of the regulation** over a literal rendering — that is where +`ispitanik` (hr) and `duomenų subjektas` / `tvarkymo veikla` (lt) came from. +`Autoriteit Persoonsgegevens` stays a proper name. + +### 8.7 Permission-matrix headers are nouns, not buttons + +`PermissionMatrix.vue` defines `actions: ['read','create','update','delete','manage']` and +renders each through `t(app, action)` as a **``**. They are permission names, so they +need **verbal nouns**, not imperatives. Both the `hr` and `lt` passes wrote imperatives +first and had to correct them. Same shape for the paired badge `set` / `missing` in +`AvgIndex.vue`. + +### 8.8 Dynamic keys + +Keys reached through a variable are invisible to static extraction: `t(app, action)` +(PermissionMatrix), `t(app, step.status)` (ApprovalStepList), `t(app, preset.label)` +(DashboardIndex), and every `menu[].label` from `src/manifest.json`. They live in +`DYNAMIC_KEYS` / `collectDynamicKeys` in `lib.js` — **keep that registry current or +`clean:l10n` will delete live keys from all 37 bundles.** `collectDynamicKeys` is +deliberately narrow; it once harvested `observability.metrics[].name`, making Prometheus +identifiers look like translation keys. + +The lowercase `read`/`create`/`update`/`delete`/`manage` are **distinct keys** from the +capitalised `Read`/`Create`/… — the lowercase set are the matrix columns. + +### 8.9 Process traps + +- **The worklist is `absent ∪ identical`, every round.** Regenerating with `!(k in loc)` + finds absent keys only and silently skips remaining placeholders. This happened mid-`ca`: + 26 were missed and only the status count caught it. +- **Do not copy a script out and edit it.** A copied `harvest.js` still had `['hr','hr_HR']` + hardcoded, and the `lt` harvest returned 69 **Croatian** values matched against + Lithuanian's key set — with a plausible hit count, so nothing looked wrong until the values + were read. Every committed script takes the locale as an argument and errors without one. +- **A verification script written for one locale encodes that locale's assumptions.** + Generalising `selfcheck`/`runtime-check` from `hr`/`lt` to all 21 exposed three wrong + assertions, all from assuming every locale handled `{plural}` the same way. Run a new + assertion across **every** finished locale before trusting it. The `n()` conversion hit + the same shape from the other side: six locales needed a cognate record for a form that + renders the English source, and only a sweep over all 36 found them. +- **Doubled-whitespace checks must be horizontal-only** (`[ \t]{2,}`). `\s\s` also matches + the `\n\n` paragraph breaks the English source carries. +- **`clean:l10n` needs review before every run.** It removes keys from all 37 bundles, and + some candidates are live UI prose nobody has wrapped in `t()` yet. The npm alias is + deliberately the dry run. Cross-check against `find:unwrapped`, then remove by hand. +- **`find:unwrapped` is deliberately high-recall** (~1500 candidates). Audit by hand; do not + "fix" it by tightening the heuristic until real strings are missed. +- **Check a drafted value's meaning against the file's established term, not just its + plausibility.** The `lt` draft used `negrįžtamai` ("irreversibly") for *soft-deleted* — the + exact opposite — where the file already had `minkštai pašalinti`. + +### 8.10 Measure the locale's house conventions, not just its register + +Register, buttons and plurals all have a step in §5. **Orthographic and typographic house +conventions do not, and they touch more values than register does.** Measure them from the +pre-existing half of the bundle before the first batch, and record the counts in +`locales/.json` — the same standard of evidence. Mid-sentence capitalisation of domain +terms is the big one: in `sr` it decides a third of all values, and no gate can see a +wrongly-cased value because it is otherwise a perfectly good translation. + +**How to measure: `npm run l10n:casing -- `.** Do not hand-roll it again. The three ways +the hand measurement misled — a missing `i` flag hiding the very occurrences being counted, a +`(?<=.)` guard that excludes the value's first word but not a later sentence's, and brackets / +leading emoji / all-caps each licensing a capital — are all encoded, along with the +conditioning on the English key that separates prose from Title-Cased headings. It prints the +bundle against the sibling-frontend and core baselines, and every term with its own up:down +split. `--mine` restricts it to the values this working tree changed, which is the split-at-HEAD +check for your own drift. + +A one-sided split is a convention to follow; a 1-of-34 outlier is a slip worth normalising +while you are there. **Aggregate by word class before deciding**, because the report keys terms +by surface form: `bs` looked like four separate 5:0-to-20:0 terms and was one lemma at 29:0. +**And a term that is one-sided in neither direction has no convention to follow** — `bs` +capitalised `Izvor` in 5 values and lowercased it in 4, against five other nouns at zero +counter-examples. Resolve that one DOWN, to the ordinary-noun default: the capitalised set is +an explicit exception list and a term the bundle never settled does not join it. + +**Five outcomes have turned up, and they need opposite handling** — so run the *conditioned* +measurement even when the unconditioned one already looks one-sided, because that is the +only thing that tells the second outcome from the third: + +| Outcome | Locales | Tell | +| --- | --- | --- | +| a **list** of capitalised terms | `sr` `rm` `mt` | one-sided per term, and the lists disagree between locales — `rm` capitalises `Schema` but lowercases `object` 0:63; `mt` capitalises `Reġistru` but lowercases `skema`, the app's two paired core concepts | +| **mirror the source** | `ga` | every term reads MIXED per term, which looks like carelessness. Condition on whether the **English key** is Title Case and it resolves: 76:1 capitalised under title-cased keys, 0:193 under prose keys | +| **flat lowercase** | `is` `mk` | one-sided lowercase per term, and conditioning on the key's casing changes *nothing* | +| **forced by orthography** | `lb` | Luxembourgish capitalises every noun, so there is nothing to follow. Measure anyway — it costs one command and it is what proves it | +| flat uppercase | — | unobserved | + +Three further rules, each of which caught something no gate can: + +- **Condition on the English key's own casing, or the measurement will invent work.** On + `sq` the naive scan reported domain terms capitalised 25–35% against a family rate near + zero — a tidy ~110-value defect class that **does not exist**: restricted to prose keys + (English key ≥6 words, not Title Case) it is 0 against 177, every hit a Title-Cased + heading correctly mirroring its source. Note the direction of the error, because "my + locale capitalises where the family does not" is exactly the shape a pass will act on. + **The cheap tell: a defect class that is large, uniform and concentrated in short values + is measuring the source, not the translation.** +- **But it can come back real** — `mk` still ran 41:146 under prose keys against a family + ~2:750. Three things separate that from `sq`'s phantom: it does not evaporate under the + restriction; the **family disagrees** rather than the bundle mirroring its source; and the + bundle is **internally inconsistent**, so there is no convention to follow. §8.11 decides + the uniform terms: a uniform lemma is one decision copied, not a rule. +- **Measure the PRE-EXISTING half separately from the half you just wrote, per term.** This + catches your own drift, which whole-bundle counts hide because the pre-existing majority + outvotes the new values. On `mt`, HEAD capitalised `Fajl` 43:0 while the new values + lowercased it 24:4 — a convention broken in 24 places, invisible to every gate. + +**Apply the same split to DECLENSION, not only to casing.** A borrowed noun may be treated +as indeclinable by the bundle while the language would ordinarily inflect it — `is` had +`skema` bare 25 times at HEAD against one declined form, and the new values introduced +`skemað`: grammatically defensible and inconsistent with the file, which is what §3.5 +settles against. Two practical notes: tally **per surface form** rather than per lemma so +the split is visible at all, and use `\p{L}` not `\w` — `\w` is ASCII-only, so `skema\w*` +silently truncates `skemað` to `skema` and hides exactly the drift you are looking for. + +**Review the word list before applying any casing fix**, and generate the fix from the +reviewed list rather than from an open-ended pattern. Three ways the measurement misleads, +all met on `mk` in one session, splitting 132 raw hits into 118 real defects and 14 licensed +capitals: + +1. **Case-insensitivity** — a stem alternation written without the `i` flag matches only the + lowercase form, so every capitalised occurrence, *the entire thing being measured*, is + invisible and every term reports a clean 0-up. This gap hid the finding entirely. +2. **`(?<=.)` does not exclude a LATER sentence's first word**, only the value's. Three + first-cut hits were a second sentence in a multi-sentence description, and each would have + been "corrected" into an error. Add a lookbehind for a sentence terminator plus space. +3. **An opening parenthesis, a leading emoji and deliberate all-caps each license a capital** + the same way a sentence start does — `(Опционално)` mirroring `(Optional)`, emoji-initial + headings, `НЕМА ДЕЈСТВО` mirroring source caps. + +The same applies to the conventions collected in `docs/l10n-ui-translation.md`: ellipsis +spacing (`nb` and `sl` put a space before, `ru` and `bg` do not), dash choice, whether `%` +takes a space, quote glyphs (`da` opens with `”`, `ru` uses guillemets), and weaker +domain-term capitalisation in `da` `sv` `pl`. + +### 8.11 OBLIGATORY SANDHI — the one mechanical check that pays + +**§6.9 says not to build mechanical morphology checks. This is the exception**, and the +difference is that this is not agreement or case governance but an **orthographic rule with +a deterministic trigger**, so precision is high enough to act on. On `lb` the Eifeler Regel +— word-final `-n` deletes before any consonant but `n d t z h` — accounted for 60 of 77 +corrections, and it is obligatory, fires several times per sentence, and no gate can see it. + +Four things made it tractable, and all four generalise to any language with obligatory +sandhi (French elision and liaison, Irish initial mutation, Welsh, Italian `lo`/`il`): + +1. **Measure the bundle's own practice — but aggregate by WORD CLASS, not only per lemma.** + A per-lemma check is necessary and **not sufficient**, and believing otherwise cost `lb` a + whole round. A lemma occurring in only one environment carries no information, so a pile + of them reads as a convention: 26 values were excused on a "16:0, no counter-example" + count that was really one copy-pasted phrase repeated thirteen times. By word class the + family did both, 118 to 108. **Run both cuts** — per lemma to find the internally + inconsistent ones, per word class to find the ones that are uniformly wrong. **A uniform + lemma is evidence of one decision, possibly copied, not of a rule.** Be strict about what + counts as "no counter-example": search for the *construction*, not the word, and say which + environments you checked. +2. **Encode the exemption classes, or it is all noise.** On `lb`, stem-final `-n` is not + inflectional, so `-ioun`/`-ion` nouns keep it always (25 of the first 95 raw hits), as do + non-integrated loans (`Token`, `JSON`) and monosyllabic `-nn` stems. A later run needed + `-ern` too, after the auto-fixer turned `extern` into the non-word `exter`. +3. **Check BOTH directions.** The rule was broken both ways — 75 failures to delete and 8 + wrong deletions before a vowel. A check written for one direction reports a clean bill of + health on the other. +4. **Split at HEAD and re-run it on your own half.** The newly written half carried 44 + further violations, more than half the count in the pre-existing half. + +Where the bundle and the strict rule genuinely disagree and **no** environment in the corpus +breaks the tie, leave it and record it — on `lb` that was 2 values, not the 26 first claimed. + +**ASK THE QUESTION EVERY TIME, BUT DO NOT EXPECT A SANDHI RULE TO BE WAITING.** On `mk` the +answer was no, and the two reasons generalise. Macedonian obligatorily doubles a definite +direct object with an accusative clitic, which is obligatory, fires constantly, invisible to +every gate — and looks exactly like the Eifeler Regel while not being the same shape, +because **the trigger is semantic (definiteness) rather than orthographic**, and because +**the clitic's position depends on clause type** (after the verb in an imperative, before it +elsewhere). Measured yield: **0 of 5**, in line with §6.9's general rule rather than this +section's exception. What paid on `mk` instead was capitalisation (§8.10). So the durable +form of this section is a *question*: ask what rule this language has that the three reports +cannot check, and be willing for the answer to be a different rule each time — or none. + +--- + +## 9. Remaining work + +### 9.1 Parity is unconditional — keep it that way + +`test:l10n:parity` holds **every** required locale to full key-for-key parity, with no +per-locale exemption list and no env override. A missing key fails the build for any locale, +the same way an empty value and a wrong plural arity do. + +**Do not add an exemption mechanism back.** A per-locale "in progress" set, or an env override of +the gated set, is exactly the knob someone reaches for to turn a red build green, and it +defeats the one invariant this gate exists to protect. If a new English +string lands without translations, the failure is the missing translation: §6.15 has the +procedure, which is to translate it or to unwrap it from `t()` and delete the key from all 37 +bundles including `en.js`. + +`gate-negative-test.js` is what keeps the gate honest — it breaks one bundle on purpose and +asserts the gate notices and names it. Run it after any change to the gate. + +Cognate enforcement is the one thing that is still opt-in, keyed on +`scripts/l10n/locales/.json` existing, because 15 locales predate that rule and carry +~375 unreviewed identical values. Reviewing them is §9.2, not a reason to loosen parity. + +### 9.2 Review the pre-rule locales — `cs` done, 15 to go + +The remaining set is `da de el es fi fr hu it nb nl pl pt ru sv uk`. Per locale: +`npm run l10n:status -- ` lists the unjustified identical values; +`npm run test:l10n:parity -- --strict-identical` audits them. For each, decide genuine +cognate (record the reason) or filler (translate it). Then write `locales/.json` and it +becomes enforced from that moment — no code change needed, the gate keys on the file +existing. Expect a high legitimate rate in some (`nl` renders `Bewaartermijn` unchanged — +Dutch words in a Dutch bundle) and the opposite in others; this is the audit that turned up +46 placeholders in `tr`. + +Two defects are already located and waiting in this set, both in the literal-`(s)` keys +(§7.4): `de` writes `Schema(s)` beside its own `Konfiguration(en)`/`Objekt(e)`, and `nl` +writes `schema(s) geselecteerd` beside `object(en)`/`dag(en)`. `lv` needs the same keys read +case-by-case. Check the fourteen for **intra-locale** inconsistency in every Tier 1 pass — +it is a three-line check and needs no knowledge of the target's morphology. + +The register verdicts for these 16 were already recorded, so step 2 is done — but +**re-measure rather than trusting the record.** A detector is required for `selfcheck` +anyway, so the measurement is nearly free once you are there, and on `cs` it turned two +pre-tooling assumptions into 828-vs-0 and 27-vs-0. + +**What to budget for.** The re-audit handoff predicted these would be as bad as `is` or +worse. They are not — the defect rate tracks **how healthy the locale is upstream, not how +long it went un-audited**: + +| | `is` | `cs` | `sk` | `ca` | `lb` | `sq` | `mk` | +| --- | --- | --- | --- | --- | --- | --- | --- | +| values audited | 1052 | 2052 | 2052 | 2052 | 1011 | 1018 | 1053 | +| defects | 235 (**22%**) | 113 (**5.5%**) | 57 (**2.8%**) | 128 (**6.2%**) | 77 (**7.6%**) | 160 (**15.7%**) | 114 (**10.8%**) | +| garbled / foreign stems | 14 | **0** | 1 | 2 | 2 | 2 | 1 | +| agreement failures | 11 | **0** | **0** | 1 | 2 | 14 | 4 | +| wrong case | 19 | 6 | **0** | — | **0** | **0** | — | +| wrong plural arrays | — | **0** | **0** | **0** | **0** | **0** | **0** | +| dominant class | compounds, wrong senses | terminology drift | **one term (37 of 57)** | on-screen collisions plus a long tail | **one orthographic rule (60 of 77)** | one stem (39) plus the button convention (36) | **one orthographic rule (105 of 114)** | + +Four things that table is trying to say: + +- **Budget for terminology counting, not grammar repair**, on any actively maintained + locale. `cs` and `sk` are both healthy West Slavic bundles and for the same reason. +- **A locale can be healthy in grammar and healthy in vocabulary and still be a tenth + wrong, if it is inconsistent about one mechanical rule.** `lb`, `sq` and `mk` are all this + shape. **Measure the rule, then count violations** — do not read values hoping to notice. + So where all three reports come back thin, do not conclude the locale is healthy: ask what + rule the language has that the reports are structurally unable to check (§8.11). +- **The defects usually concentrate, but not always.** `sk` was 37-of-57 in a single term, so + the pass was one sweep. `ca`'s biggest class was 35 keys with eleven further classes + carrying 2–11 each, which is slower per defect and means **you cannot stop when the term + count goes quiet.** +- **A `corrections` count of 0 means "unverified", not "clean" — and not "a quarter of the + file is waiting" either.** `sk` had a measured register, a detector, a reviewed cognate set + and an empty `corrections`, and the audit still found 57 real defects including two + semantic reversals invisible to every gate. Read a 0 as "nobody looked". + +--- + +## 10. Source-side defects still open + +These are **source** fixes, not translation work. Each one costs 37 bundle entries or +renders wrongly in every locale. + +- **`test:l10n` may be RED without anyone touching l10n.** A `development` merge that + de-Dutchified the AVG source strings and added flow strings left 17 keys used in `src/` + but missing from `en.js`. Most were English replacements for still-present Dutch-keyed + strings, so **the fix is a rename, not an add** (§6.15) — that carries all 37 bundles at + once, so every finished locale's translation survives and parity never breaks. Do the + renames first, then `test:l10n:write` for the genuinely new keys, then translate those + across the finished set. Its own commit, per §3.10. Re-run `npm run test:l10n` to see + whether this is still open. +- ~~**`{plural}` hardcodes English morphology**~~ — **DONE.** The 13 call sites are real + `n()` calls, all 36 locales carry a proper form array, and `{plural}` is banned by four + gates — §7.4. Two things it left behind: the backend `l10n/*.json` set still holds the + five dead keys (no PHP reads them; the parity gate reports it as a NOTE), and `nl` + acquired a cognate-only `locales/nl.json`, which moved it into the cognate-enforced set + without it having had a pass. +- **Dual-role keys.** `Create`/`Read`/`Update`/`Delete` render as both `` headers and + buttons, which is what forced `ro` onto verbal nouns (§7.3). Splitting them would let the + pure-button keys take imperatives. +- **Duplicate keys for one concept**: `Url` vs `URL`, `Data Quality` vs `Data quality`, + `{days} day(s) left` vs `{days} day(s) remaining`, `Test Connection` vs `Test connection`, + `Add Endpoint` vs `Add endpoint`, `Loading...` vs `Loading…`, `Exclusive Maximum` vs + `Exclusive maximum`. The locales work around these by translating both identically; that + is a workaround, not the fix. +- **Unwrapped literals** remain — `npm run check:l10n` counts them and `npm run + find:unwrapped` lists candidates. Concentrated in `MassValidateModal`, the workflow + components, `ImportRegister` and `ValidateSchema`. Many already have translations in all + 37 bundles that never render. A handful of further `t()` calls are unanalyzable (dynamic + args) and are reported separately. +- **`pages[].title` is never translated.** `CnPageRenderer` forwards it as a raw prop; only + `menu[].label` goes through CnAppNav's `translate`. So `Application`, `Webhook logs`, + `Entity`, `My account`, `Report` render English in every locale. Fix belongs in + nextcloud-vue. +- **`RegisterSchemaCard.vue:713`** wraps a runtime-built template string in `t()`. + +--- + +## 11. openconnector reciprocal work + +`scripts/l10n/lib.js` here is the **origin**; openconnector vendors a copy, because the two +apps ship separate npm packages. The only intended divergence is `DYNAMIC_KEYS`, which is +app-specific data. + +As of 2026-08-14 openconnector is behind on four counts: + +- the file is still at the old **`scripts/lib/l10n.js`** path, which no longer exists here; +- it still stores plurals under the bare singular (§7.1); +- its `CLAUDE.md` still states the incorrect "the singular is the catalogue key" rule; +- its `check-l10n-parity.js` predates the arity, identical-value, finished-set and + cognate-justification gates, and its whitelist lacks `spellAllow` (§4.4) — port the + whitelist with the rest, or a vendored copy will keep silently dropping the field. + +The whole of `scripts/l10n/` is worth porting, not just the library — openconnector has the +same 37-bundle problem and none of the tooling. + +**A detector's family scan finds defects in the sibling apps' bundles, and they are worth +reporting there.** The `sq` detector found the only two informal values in a 3980-value +corpus, both in openconnector, against a measured 218:1 formal core. Re-run +`node scripts/l10n/detectors/.js` from this repo after any fix. diff --git a/jest.config.js b/jest.config.js index 0b2eede0fe..5133e506a5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -68,6 +68,14 @@ module.exports = { '/\\.playwright-mcp/', // Playwright suite — has its own runner via `npx playwright test`. '/tests/e2e/', + // The demo-environment Playwright suite. It sits OUTSIDE tests/e2e/ on + // purpose: playwright.config.ts sets testDir: './tests/e2e', so a spec + // placed there is collected by CI, which has no booted demo to point + // at. Moving it out solves that and creates this: jest's ignore list + // named tests/e2e/ specifically, so the new directory was collected + // here instead and jest tried to run a Playwright spec. Two collectors + // with opposite exclusions — a file has to be named in both. + '/tests/demo-e2e/', ], modulePathIgnorePatterns: [ '/custom_apps/', diff --git a/l10n/be.js b/l10n/be.js index 101ea5022d..466694cd4b 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -2556,7 +2556,7 @@ OC.L10N.register( "Zookeeper port number (optional, defaults to 2181)": "Номер порта Zookeeper (необязательно, по умолчанию 2181)", "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete ALL objects for schema \"{schema}\":\n\n\\u2022 Active objects: {active}\n\\u2022 Soft-deleted objects: {deleted}\n\\u2022 Total: {total}\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F ПРЕДУПРЕЖДЕНИЕ О БЕЗВОЗВРАТНОМ УДАЛЕНИИ \\u26A0\\uFE0F\n\nВы собираетесь БЕЗВОЗВРАТНО выдаліць ВСЕ аб'екты схемы \"{schema}\":\n\n\\u2022 Активных аб'ектаў: {active}\n\\u2022 Программно удалённых аб'ектаў: {deleted}\n\\u2022 Итого: {total}\n\nЭти аб'екты будут полностью удалены из базы данных и НЕ МОГУТ быть восстановлены.\n\nВы абсолютно уверены?", "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete {count} soft-deleted objects for schema \"{schema}\".\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F ПРЕДУПРЕЖДЕНИЕ О БЕЗВОЗВРАТНОМ УДАЛЕНИИ \\u26A0\\uFE0F\n\nВы собираетесь БЕЗВОЗВРАТНО выдаліць {count} программно удалённых аб'ектаў схемы \"{schema}\".\n\nЭти аб'екты будут полностью удалены из базы данных и НЕ МОГУТ быть восстановлены.\n\nВы абсолютно уверены?", - "_%n entry has no hash yet_::_%n entries have no hash yet_": ["%n запіс яшчэ не мае хэша","%n запісы яшчэ не маюць хэша","%n запісаў яшчэ не маюць хэша"], + "_%n entry has no hash yet_::_%n entries have no hash yet_": ["%n запіс яшчэ не мае хэша","%n запісы яшчэ не маюць хэша","%n запісаў яшчэ не маюць хэша","%n запіса яшчэ не мае хэша"], "across {count} registers": "across {count} registers", "and accessible to all users in selected groups (or all users if no groups selected).": "и доступно всем пользователям в выбранных группах (или всем пользователям, если группы не выбраны).", "and add the files there.": "и добавьте файлы туда.", @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Дэскрыптары рэестраў", "ships v{shipped}": "пастаўляе v{shipped}", "State": "Стан", - "v{installed} → ships v{shipped}": "v{installed} → пастаўляе v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → пастаўляе v{shipped}", + "Where the automation lives": "Дзе жыве аўтаматызацыя", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Патокі — гэта тое, што адбываецца без націскаў: аб'ект, які пазначаецца пры захаванні, праграма, якую апавяшчаюць пры змене запісу. Тут вы іх чытаеце і рэдагуеце. Зараз нічога будаваць не трэба.", + "Open Flows in the menu": "Адкрыйце Патокі ў меню" }, - "nplurals=2; plural=(n != 1);" + "nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);" ) diff --git a/l10n/be.json b/l10n/be.json index cd634403d7..de4c287e45 100644 --- a/l10n/be.json +++ b/l10n/be.json @@ -2558,7 +2558,8 @@ "_%n entry has no hash yet_::_%n entries have no hash yet_": [ "%n запіс яшчэ не мае хэша", "%n запісы яшчэ не маюць хэша", - "%n запісаў яшчэ не маюць хэша" + "%n запісаў яшчэ не маюць хэша", + "%n запіса яшчэ не мае хэша" ], "across {count} registers": "across {count} registers", "and accessible to all users in selected groups (or all users if no groups selected).": "и доступно всем пользователям в выбранных группах (или всем пользователям, если группы не выбраны).", @@ -2726,38 +2727,48 @@ "Register descriptors": "Дэскрыптары рэестраў", "ships v{shipped}": "пастаўляе v{shipped}", "State": "Стан", - "v{installed} → ships v{shipped}": "v{installed} → пастаўляе v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → пастаўляе v{shipped}", + "Where the automation lives": "Дзе жыве аўтаматызацыя", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Патокі — гэта тое, што адбываецца без націскаў: аб'ект, які пазначаецца пры захаванні, праграма, якую апавяшчаюць пры змене запісу. Тут вы іх чытаеце і рэдагуеце. Зараз нічога будаваць не трэба.", + "Open Flows in the menu": "Адкрыйце Патокі ў меню" }, + "pluralForm": "nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);", "plurals": { "{count} email": [ - "{count} письмо", - "{count} письма", - "{count} писем" + "{count} ліст", + "{count} лісты", + "{count} лістоў", + "{count} ліста" ], "Purge {count} object from database": [ - "Очистить {count} аб'ект из базы данных", - "Очистить {count} аб'екта из базы данных", - "Очистить {count} аб'ектаў из базы данных" + "Ачысціць {count} аб'ект з базы даных", + "Ачысціць {count} аб'екты з базы даных", + "Ачысціць {count} аб'ектаў з базы даных", + "Ачысціць {count} аб'екта з базы даных" ], "Restore {count} object": [ - "Восстановить {count} аб'ект", - "Восстановить {count} аб'екта", - "Восстановить {count} аб'ектаў" + "Аднавіць {count} аб'ект", + "Аднавіць {count} аб'екты", + "Аднавіць {count} аб'ектаў", + "Аднавіць {count} аб'екта" ], "Successfully restored {count} object": [ - "паспяхова восстановлен {count} аб'ект", - "паспяхова восстановлено {count} аб'екта", - "паспяхова восстановлено {count} аб'ектаў" + "Паспяхова адноўлены {count} аб'ект", + "Паспяхова адноўленыя {count} аб'екты", + "Паспяхова адноўлена {count} аб'ектаў", + "Паспяхова адноўлена {count} аб'екта" ], "Delete {count} object": [ - "выдаліць {count} аб'ект", - "выдаліць {count} аб'екта", - "выдаліць {count} аб'ектаў" + "Выдаліць {count} аб'ект", + "Выдаліць {count} аб'екты", + "Выдаліць {count} аб'ектаў", + "Выдаліць {count} аб'екта" ], "Object successfully deleted": [ - "аб'ект паспяхова выдалены", - "аб'екта паспяхова удалены", - "аб'ектаў паспяхова удалено" + "Аб'ект паспяхова выдалены", + "Аб'екты паспяхова выдаленыя", + "Аб'екты паспяхова выдаленыя", + "Аб'екты паспяхова выдаленыя" ] } } diff --git a/l10n/bg.js b/l10n/bg.js index 5b504e14d1..253e738992 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Дескриптори на регистри", "ships v{shipped}": "доставя v{shipped}", "State": "Състояние", - "v{installed} → ships v{shipped}": "v{installed} → доставя v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → доставя v{shipped}", + "Where the automation lives": "Къде живее автоматизацията", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Потоците са това, което се случва без някой да кликне: обект, подпечатан при запис, приложение, уведомено при промяна на запис. Тук ги четете и редактирате. Сега няма какво да се изгражда.", + "Open Flows in the menu": "Отворете Потоци в менюто" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bg.json b/l10n/bg.json index 2188f8ae81..07e7cdb011 100644 --- a/l10n/bg.json +++ b/l10n/bg.json @@ -2725,8 +2725,12 @@ "Register descriptors": "Дескриптори на регистри", "ships v{shipped}": "доставя v{shipped}", "State": "Състояние", - "v{installed} → ships v{shipped}": "v{installed} → доставя v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → доставя v{shipped}", + "Where the automation lives": "Къде живее автоматизацията", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Потоците са това, което се случва без някой да кликне: обект, подпечатан при запис, приложение, уведомено при промяна на запис. Тук ги четете и редактирате. Сега няма какво да се изгражда.", + "Open Flows in the menu": "Отворете Потоци в менюто" }, + "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { "{count} email": [ "{count} имейл", diff --git a/l10n/bs.js b/l10n/bs.js index 86ad833f49..efa5d26fdc 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Deskriptori registara", "ships v{shipped}": "isporučuje v{shipped}", "State": "Stanje", - "v{installed} → ships v{shipped}": "v{installed} → isporučuje v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → isporučuje v{shipped}", + "Where the automation lives": "Gdje živi automatizacija", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Tokovi su ono što se dešava bez klika: objekat označen pri spremanju, aplikacija obaviještena kad se zapis promijeni. Ovdje ih čitate i uređujete. Sada nema šta graditi.", + "Open Flows in the menu": "Otvorite Tokove u meniju" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;" ) diff --git a/l10n/bs.json b/l10n/bs.json index f11d9fae11..c58578b679 100644 --- a/l10n/bs.json +++ b/l10n/bs.json @@ -2726,31 +2726,41 @@ "Register descriptors": "Deskriptori registara", "ships v{shipped}": "isporučuje v{shipped}", "State": "Stanje", - "v{installed} → ships v{shipped}": "v{installed} → isporučuje v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → isporučuje v{shipped}", + "Where the automation lives": "Gdje živi automatizacija", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Tokovi su ono što se dešava bez klika: objekat označen pri spremanju, aplikacija obaviještena kad se zapis promijeni. Ovdje ih čitate i uređujete. Sada nema šta graditi.", + "Open Flows in the menu": "Otvorite Tokove u meniju" }, + "pluralForm": "nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;", "plurals": { "{count} email": [ "{count} e-pošta", - "{count} e-pošte" + "{count} e-pošte", + "{count} e-pošta" ], "Purge {count} object from database": [ "Trajno ukloni {count} objekt iz baze podataka", + "Trajno ukloni {count} objekta iz baze podataka", "Trajno ukloni {count} objekata iz baze podataka" ], "Restore {count} object": [ "Vrati {count} objekt", + "Vrati {count} objekta", "Vrati {count} objekata" ], "Successfully restored {count} object": [ "Uspješno vraćen {count} objekt", + "Uspješno vraćena {count} objekta", "Uspješno vraćeno {count} objekata" ], "Delete {count} object": [ "Izbriši {count} objekt", + "Izbriši {count} objekta", "Izbriši {count} objekata" ], "Object successfully deleted": [ "Objekt je uspješno izbrisan", + "Objekti su uspješno izbrisani", "Objekti su uspješno izbrisani" ] } diff --git a/l10n/ca.js b/l10n/ca.js index 5ad9273830..105dd38237 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Descriptors de registre", "ships v{shipped}": "lliura v{shipped}", "State": "Estat", - "v{installed} → ships v{shipped}": "v{installed} → lliura v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → lliura v{shipped}", + "Where the automation lives": "On viu l'automatització", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Els fluxos són el que passa sense que ningú faci clic: un objecte segellat en desar, una aplicació avisada quan canvia un registre. Aquí els llegiu i els editeu. Ara no cal construir res.", + "Open Flows in the menu": "Obriu Fluxos al menú" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ca.json b/l10n/ca.json index ee4dfa7362..89d9b721e8 100644 --- a/l10n/ca.json +++ b/l10n/ca.json @@ -2725,8 +2725,12 @@ "Register descriptors": "Descriptors de registre", "ships v{shipped}": "lliura v{shipped}", "State": "Estat", - "v{installed} → ships v{shipped}": "v{installed} → lliura v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → lliura v{shipped}", + "Where the automation lives": "On viu l'automatització", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Els fluxos són el que passa sense que ningú faci clic: un objecte segellat en desar, una aplicació avisada quan canvia un registre. Aquí els llegiu i els editeu. Ara no cal construir res.", + "Open Flows in the menu": "Obriu Fluxos al menú" }, + "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { "{count} email": [ "{count} correu electrònic", diff --git a/l10n/cs.js b/l10n/cs.js index 44a8e0cf5b..0bf5bbc970 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -2556,7 +2556,7 @@ OC.L10N.register( "Zookeeper port number (optional, defaults to 2181)": "Číslo portu Zookeeper (volitelné, výchozí je 2181)", "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete ALL objects for schema \"{schema}\":\n\n\\u2022 Active objects: {active}\n\\u2022 Soft-deleted objects: {deleted}\n\\u2022 Total: {total}\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F VAROVÁNÍ O TRVALÉM SMAZÁNÍ \\u26A0\\uFE0F\n\nChystáte se TRVALE smazat VŠECHNY objekty pro schéma \"{schema}\":\n\n\\u2022 Aktivní objekty: {active}\n\\u2022 Dočasně smazané objekty: {deleted}\n\\u2022 Celkem: {total}\n\nTyto objekty budou zcela odstraněny z databáze a NELZE je obnovit.\n\nJste si naprosto jisti?", "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete {count} soft-deleted objects for schema \"{schema}\".\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F VAROVÁNÍ O TRVALÉM SMAZÁNÍ \\u26A0\\uFE0F\n\nChystáte se TRVALE smazat {count} dočasně smazaných objektů pro schéma \"{schema}\".\n\nTyto objekty budou zcela odstraněny z databáze a NELZE je obnovit.\n\nJste si naprosto jisti?", - "_%n entry has no hash yet_::_%n entries have no hash yet_": ["%n záznam zatím nemá otisk","%n záznamy zatím nemají otisk","%n záznamů zatím nemá otisk"], + "_%n entry has no hash yet_::_%n entries have no hash yet_": ["%n záznam zatím nemá otisk","%n záznamy zatím nemají otisk","%n záznamu zatím nemá otisk","%n záznamů zatím nemá otisk"], "across {count} registers": "across {count} registers", "and accessible to all users in selected groups (or all users if no groups selected).": "a přístupné všem uživatelům ve vybraných skupinách (nebo všem uživatelům, pokud nejsou vybrány žádné skupiny).", "and add the files there.": "a přidejte soubory tam.", @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Deskriptory registrů", "ships v{shipped}": "dodává v{shipped}", "State": "Stav", - "v{installed} → ships v{shipped}": "v{installed} → dodává v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → dodává v{shipped}", + "Where the automation lives": "Kde žije automatizace", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Toky jsou to, co se děje bez kliknutí: objekt označený při uložení, aplikace informovaná při změně záznamu. Zde je čtete a upravujete. Nyní není třeba nic stavět.", + "Open Flows in the menu": "Otevřete Toky v nabídce" }, - "nplurals=2; plural=(n != 1);" + "nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n <= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;" ) diff --git a/l10n/cs.json b/l10n/cs.json index 289437f526..7ce5845a64 100644 --- a/l10n/cs.json +++ b/l10n/cs.json @@ -2558,6 +2558,7 @@ "_%n entry has no hash yet_::_%n entries have no hash yet_": [ "%n záznam zatím nemá otisk", "%n záznamy zatím nemají otisk", + "%n záznamu zatím nemá otisk", "%n záznamů zatím nemá otisk" ], "across {count} registers": "across {count} registers", @@ -2726,31 +2727,47 @@ "Register descriptors": "Deskriptory registrů", "ships v{shipped}": "dodává v{shipped}", "State": "Stav", - "v{installed} → ships v{shipped}": "v{installed} → dodává v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → dodává v{shipped}", + "Where the automation lives": "Kde žije automatizace", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Toky jsou to, co se děje bez kliknutí: objekt označený při uložení, aplikace informovaná při změně záznamu. Zde je čtete a upravujete. Nyní není třeba nic stavět.", + "Open Flows in the menu": "Otevřete Toky v nabídce" }, + "pluralForm": "nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n <= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;", "plurals": { "{count} email": [ "{count} e-mail", + "{count} e-maily", + "{count} e-mailu", "{count} e-mailů" ], "Purge {count} object from database": [ "Vyčistit {count} objekt z databáze", + "Vyčistit {count} objekty z databáze", + "Vyčistit {count} objektu z databáze", "Vyčistit {count} objektů z databáze" ], "Restore {count} object": [ "Obnovit {count} objekt", + "Obnovit {count} objekty", + "Obnovit {count} objektu", "Obnovit {count} objektů" ], "Successfully restored {count} object": [ "Úspěšně obnoven {count} objekt", + "Úspěšně obnoveny {count} objekty", + "Úspěšně obnoveno {count} objektu", "Úspěšně obnoveno {count} objektů" ], "Delete {count} object": [ "Smazat {count} objekt", + "Smazat {count} objekty", + "Smazat {count} objektu", "Smazat {count} objektů" ], "Object successfully deleted": [ "Objekt byl úspěšně smazán", + "Objekty byly úspěšně smazány", + "Objekty byly úspěšně smazány", "Objekty byly úspěšně smazány" ] } diff --git a/l10n/da.js b/l10n/da.js index 82950a9158..95798bc262 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Register-deskriptorer", "ships v{shipped}": "leverer v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → leverer v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → leverer v{shipped}", + "Where the automation lives": "Hvor automatiseringen bor", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows er det, der sker uden at nogen klikker: et objekt der stemples ved gem, en app der får besked når en post ændres. Her læser og redigerer du dem. Der er intet at bygge nu.", + "Open Flows in the menu": "Åbn Flows i menuen" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/da.json b/l10n/da.json index 433654fbfc..8b2833b9e8 100644 --- a/l10n/da.json +++ b/l10n/da.json @@ -2725,8 +2725,12 @@ "Register descriptors": "Register-deskriptorer", "ships v{shipped}": "leverer v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → leverer v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → leverer v{shipped}", + "Where the automation lives": "Hvor automatiseringen bor", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows er det, der sker uden at nogen klikker: et objekt der stemples ved gem, en app der får besked når en post ændres. Her læser og redigerer du dem. Der er intet at bygge nu.", + "Open Flows in the menu": "Åbn Flows i menuen" }, + "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { "{count} email": [ "{count} e-mail", diff --git a/l10n/de.js b/l10n/de.js index 0e646f1490..b1b51f1ad8 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Register-Deskriptoren", "ships v{shipped}": "liefert v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → liefert v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → liefert v{shipped}", + "Where the automation lives": "Wo die Automatisierung lebt", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows sind das, was ohne Klick passiert: ein Objekt, das beim Speichern gestempelt wird, eine nachgelagerte App, die bei einer Änderung benachrichtigt wird. Hier lesen und bearbeiten Sie sie. Jetzt ist nichts zu bauen.", + "Open Flows in the menu": "Flows im Menü öffnen" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/de.json b/l10n/de.json index 419b49424d..e32d53c4cf 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -2725,8 +2725,12 @@ "Register descriptors": "Register-Deskriptoren", "ships v{shipped}": "liefert v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → liefert v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → liefert v{shipped}", + "Where the automation lives": "Wo die Automatisierung lebt", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows sind das, was ohne Klick passiert: ein Objekt, das beim Speichern gestempelt wird, eine nachgelagerte App, die bei einer Änderung benachrichtigt wird. Hier lesen und bearbeiten Sie sie. Jetzt ist nichts zu bauen.", + "Open Flows in the menu": "Flows im Menü öffnen" }, + "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { "{count} email": [ "{count} E-Mail", diff --git a/l10n/el.js b/l10n/el.js index 02e9fc8a30..c0cf1623aa 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Περιγραφείς μητρώων", "ships v{shipped}": "παρέχει v{shipped}", "State": "Κατάσταση", - "v{installed} → ships v{shipped}": "v{installed} → παρέχει v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → παρέχει v{shipped}", + "Where the automation lives": "Πού ζει ο αυτοματισμός", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Οι ροές είναι ό,τι συμβαίνει χωρίς να κάνει κανείς κλικ: ένα αντικείμενο που σφραγίζεται κατά την αποθήκευση, μια εφαρμογή που ειδοποιείται όταν αλλάζει μια εγγραφή. Εδώ τις διαβάζετε και τις επεξεργάζεστε. Δεν χρειάζεται να φτιάξετε τίποτα τώρα.", + "Open Flows in the menu": "Ανοίξτε τις Ροές στο μενού" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/el.json b/l10n/el.json index 8401d62cef..7da56e99d6 100644 --- a/l10n/el.json +++ b/l10n/el.json @@ -2725,8 +2725,12 @@ "Register descriptors": "Περιγραφείς μητρώων", "ships v{shipped}": "παρέχει v{shipped}", "State": "Κατάσταση", - "v{installed} → ships v{shipped}": "v{installed} → παρέχει v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → παρέχει v{shipped}", + "Where the automation lives": "Πού ζει ο αυτοματισμός", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Οι ροές είναι ό,τι συμβαίνει χωρίς να κάνει κανείς κλικ: ένα αντικείμενο που σφραγίζεται κατά την αποθήκευση, μια εφαρμογή που ειδοποιείται όταν αλλάζει μια εγγραφή. Εδώ τις διαβάζετε και τις επεξεργάζεστε. Δεν χρειάζεται να φτιάξετε τίποτα τώρα.", + "Open Flows in the menu": "Ανοίξτε τις Ροές στο μενού" }, + "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { "{count} email": [ "{count} email", diff --git a/l10n/en.js b/l10n/en.js index 51d34b4113..2c8ac399c7 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Register descriptors", "ships v{shipped}": "ships v{shipped}", "State": "State", - "v{installed} → ships v{shipped}": "v{installed} → ships v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → ships v{shipped}", + "Where the automation lives": "Where the automation lives", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.", + "Open Flows in the menu": "Open Flows in the menu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 62c4766138..1ae0cb19ee 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -2725,8 +2725,12 @@ "Register descriptors": "Register descriptors", "ships v{shipped}": "ships v{shipped}", "State": "State", - "v{installed} → ships v{shipped}": "v{installed} → ships v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → ships v{shipped}", + "Where the automation lives": "Where the automation lives", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.", + "Open Flows in the menu": "Open Flows in the menu" }, + "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { "{count} email": [ "{count} email", diff --git a/l10n/es.js b/l10n/es.js index 023da9c390..c1e633bcbb 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -2556,7 +2556,7 @@ OC.L10N.register( "Zookeeper port number (optional, defaults to 2181)": "Número de puerto de Zookeeper (opcional, predeterminado 2181)", "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete ALL objects for schema \"{schema}\":\n\n\\u2022 Active objects: {active}\n\\u2022 Soft-deleted objects: {deleted}\n\\u2022 Total: {total}\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F ADVERTENCIA DE ELIMINACIÓN PERMANENTE \\u26A0\\uFE0F\n\nEstá a punto de eliminar PERMANENTEMENTE TODOS los objetos del esquema \"{schema}\":\n\n\\u2022 Objetos activos: {active}\n\\u2022 Objetos eliminados temporalmente: {deleted}\n\\u2022 Total: {total}\n\nEstos objetos se eliminarán por completo de la base de datos y NO se podrán recuperar.\n\n¿Está completamente seguro?", "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete {count} soft-deleted objects for schema \"{schema}\".\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F ADVERTENCIA DE ELIMINACIÓN PERMANENTE \\u26A0\\uFE0F\n\nEstá a punto de eliminar PERMANENTEMENTE {count} objetos eliminados temporalmente del esquema \"{schema}\".\n\nEstos objetos se eliminarán por completo de la base de datos y NO se podrán recuperar.\n\n¿Está completamente seguro?", - "_%n entry has no hash yet_::_%n entries have no hash yet_": ["%n entrada aún no tiene hash","%n entradas aún no tienen hash"], + "_%n entry has no hash yet_::_%n entries have no hash yet_": ["%n entrada aún no tiene hash","%n entradas aún no tienen hash","%n entradas aún no tienen hash"], "across {count} registers": "across {count} registers", "and accessible to all users in selected groups (or all users if no groups selected).": "y accesible para todos los usuarios de los grupos seleccionados (o todos los usuarios si no se selecciona ningún grupo).", "and add the files there.": "y añada los archivos allí.", @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Descriptores de registro", "ships v{shipped}": "entrega v{shipped}", "State": "Estado", - "v{installed} → ships v{shipped}": "v{installed} → entrega v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → entrega v{shipped}", + "Where the automation lives": "Dónde vive la automatización", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Los flujos son lo que ocurre sin que nadie haga clic: un objeto que se sella al guardar, una aplicación avisada cuando cambia un registro. Aquí los lees y los editas. No hay nada que construir ahora.", + "Open Flows in the menu": "Abrir Flujos en el menú" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;" ) diff --git a/l10n/es.json b/l10n/es.json index 913fd89106..989fdfbeb1 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -2557,6 +2557,7 @@ "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete {count} soft-deleted objects for schema \"{schema}\".\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F ADVERTENCIA DE ELIMINACIÓN PERMANENTE \\u26A0\\uFE0F\n\nEstá a punto de eliminar PERMANENTEMENTE {count} objetos eliminados temporalmente del esquema \"{schema}\".\n\nEstos objetos se eliminarán por completo de la base de datos y NO se podrán recuperar.\n\n¿Está completamente seguro?", "_%n entry has no hash yet_::_%n entries have no hash yet_": [ "%n entrada aún no tiene hash", + "%n entradas aún no tienen hash", "%n entradas aún no tienen hash" ], "across {count} registers": "across {count} registers", @@ -2725,31 +2726,41 @@ "Register descriptors": "Descriptores de registro", "ships v{shipped}": "entrega v{shipped}", "State": "Estado", - "v{installed} → ships v{shipped}": "v{installed} → entrega v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → entrega v{shipped}", + "Where the automation lives": "Dónde vive la automatización", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Los flujos son lo que ocurre sin que nadie haga clic: un objeto que se sella al guardar, una aplicación avisada cuando cambia un registro. Aquí los lees y los editas. No hay nada que construir ahora.", + "Open Flows in the menu": "Abrir Flujos en el menú" }, + "pluralForm": "nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;", "plurals": { "{count} email": [ "{count} correo electrónico", + "{count} correos electrónicos", "{count} correos electrónicos" ], "Purge {count} object from database": [ "Purgar {count} objeto de la base de datos", + "Purgar {count} objetos de la base de datos", "Purgar {count} objetos de la base de datos" ], "Restore {count} object": [ "Restaurar {count} objeto", + "Restaurar {count} objetos", "Restaurar {count} objetos" ], "Successfully restored {count} object": [ "Se restauró {count} objeto correctamente", + "Se restauraron {count} objetos correctamente", "Se restauraron {count} objetos correctamente" ], "Delete {count} object": [ "Eliminar {count} objeto", + "Eliminar {count} objetos", "Eliminar {count} objetos" ], "Object successfully deleted": [ "Objeto eliminado correctamente", + "Objetos eliminados correctamente", "Objetos eliminados correctamente" ] } diff --git a/l10n/et.js b/l10n/et.js index eca19e74dd..6615dfcff6 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Registrikirjeldajad", "ships v{shipped}": "tarnib v{shipped}", "State": "Olek", - "v{installed} → ships v{shipped}": "v{installed} → tarnib v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → tarnib v{shipped}", + "Where the automation lives": "Kus automatiseerimine elab", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Vood on see, mis juhtub ilma klõpsamata: salvestamisel tembeldatud objekt, rakendus, keda teavitatakse kirje muutumisel. Siin loete ja muudate neid. Praegu pole midagi ehitada.", + "Open Flows in the menu": "Avage Vood menüüs" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/et.json b/l10n/et.json index 2ce108ddb3..4ff6b5c2d8 100644 --- a/l10n/et.json +++ b/l10n/et.json @@ -2725,8 +2725,12 @@ "Register descriptors": "Registrikirjeldajad", "ships v{shipped}": "tarnib v{shipped}", "State": "Olek", - "v{installed} → ships v{shipped}": "v{installed} → tarnib v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → tarnib v{shipped}", + "Where the automation lives": "Kus automatiseerimine elab", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Vood on see, mis juhtub ilma klõpsamata: salvestamisel tembeldatud objekt, rakendus, keda teavitatakse kirje muutumisel. Siin loete ja muudate neid. Praegu pole midagi ehitada.", + "Open Flows in the menu": "Avage Vood menüüs" }, + "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { "{count} email": [ "{count} e-kiri", diff --git a/l10n/fi.js b/l10n/fi.js index 1b59565f75..ec37866c2f 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Rekisterikuvaukset", "ships v{shipped}": "toimittaa v{shipped}", "State": "Tila", - "v{installed} → ships v{shipped}": "v{installed} → toimittaa v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → toimittaa v{shipped}", + "Where the automation lives": "Missä automaatio asuu", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Vuot ovat sitä, mitä tapahtuu ilman että kukaan klikkaa: tallennettaessa leimattava objekti, sovellus jolle kerrotaan kun tietue muuttuu. Täällä luet ja muokkaat niitä. Nyt ei tarvitse rakentaa mitään.", + "Open Flows in the menu": "Avaa Vuot valikosta" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fi.json b/l10n/fi.json index 0109b932ca..fd918cc3c3 100644 --- a/l10n/fi.json +++ b/l10n/fi.json @@ -2725,8 +2725,12 @@ "Register descriptors": "Rekisterikuvaukset", "ships v{shipped}": "toimittaa v{shipped}", "State": "Tila", - "v{installed} → ships v{shipped}": "v{installed} → toimittaa v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → toimittaa v{shipped}", + "Where the automation lives": "Missä automaatio asuu", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Vuot ovat sitä, mitä tapahtuu ilman että kukaan klikkaa: tallennettaessa leimattava objekti, sovellus jolle kerrotaan kun tietue muuttuu. Täällä luet ja muokkaat niitä. Nyt ei tarvitse rakentaa mitään.", + "Open Flows in the menu": "Avaa Vuot valikosta" }, + "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { "{count} email": [ "{count} sähköposti", diff --git a/l10n/fr.js b/l10n/fr.js index ac1815b378..be272b9c45 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -2556,7 +2556,7 @@ OC.L10N.register( "Zookeeper port number (optional, defaults to 2181)": "Numéro de port Zookeeper (facultatif, par défaut 2181)", "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete ALL objects for schema \"{schema}\":\n\n\\u2022 Active objects: {active}\n\\u2022 Soft-deleted objects: {deleted}\n\\u2022 Total: {total}\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F AVERTISSEMENT DE SUPPRESSION DÉFINITIVE \\u26A0\\uFE0F\n\nVous êtes sur le point de supprimer DÉFINITIVEMENT TOUS les objets du schéma \"{schema}\" :\n\n\\u2022 Objets actifs : {active}\n\\u2022 Objets supprimés temporairement : {deleted}\n\\u2022 Total : {total}\n\nCes objets seront complètement retirés de la base de données et NE POURRONT PAS être récupérés.\n\nÊtes-vous absolument sûr ?", "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete {count} soft-deleted objects for schema \"{schema}\".\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F AVERTISSEMENT DE SUPPRESSION DÉFINITIVE \\u26A0\\uFE0F\n\nVous êtes sur le point de supprimer DÉFINITIVEMENT {count} objets supprimés temporairement du schéma \"{schema}\".\n\nCes objets seront complètement retirés de la base de données et NE POURRONT PAS être récupérés.\n\nÊtes-vous absolument sûr ?", - "_%n entry has no hash yet_::_%n entries have no hash yet_": ["%n entrée n'a pas encore de hachage","%n entrées n'ont pas encore de hachage"], + "_%n entry has no hash yet_::_%n entries have no hash yet_": ["%n entrée n'a pas encore de hachage","%n entrées n'ont pas encore de hachage","%n entrées n'ont pas encore de hachage"], "across {count} registers": "across {count} registers", "and accessible to all users in selected groups (or all users if no groups selected).": "et accessible à tous les utilisateurs des groupes sélectionnés (ou à tous les utilisateurs si aucun groupe n'est sélectionné).", "and add the files there.": "et ajoutez-y les fichiers.", @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Descripteurs de registre", "ships v{shipped}": "fournit v{shipped}", "State": "État", - "v{installed} → ships v{shipped}": "v{installed} → fournit v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → fournit v{shipped}", + "Where the automation lives": "Où vit l'automatisation", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Les flux sont ce qui se produit sans que personne ne clique : un objet horodaté à l'enregistrement, une application avertie quand un enregistrement change. C'est ici que vous les consultez et les modifiez. Rien à construire maintenant.", + "Open Flows in the menu": "Ouvrir Flux dans le menu" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;" ) diff --git a/l10n/fr.json b/l10n/fr.json index 1c5605fb84..21c34133dc 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -2557,6 +2557,7 @@ "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete {count} soft-deleted objects for schema \"{schema}\".\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F AVERTISSEMENT DE SUPPRESSION DÉFINITIVE \\u26A0\\uFE0F\n\nVous êtes sur le point de supprimer DÉFINITIVEMENT {count} objets supprimés temporairement du schéma \"{schema}\".\n\nCes objets seront complètement retirés de la base de données et NE POURRONT PAS être récupérés.\n\nÊtes-vous absolument sûr ?", "_%n entry has no hash yet_::_%n entries have no hash yet_": [ "%n entrée n'a pas encore de hachage", + "%n entrées n'ont pas encore de hachage", "%n entrées n'ont pas encore de hachage" ], "across {count} registers": "across {count} registers", @@ -2725,31 +2726,41 @@ "Register descriptors": "Descripteurs de registre", "ships v{shipped}": "fournit v{shipped}", "State": "État", - "v{installed} → ships v{shipped}": "v{installed} → fournit v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → fournit v{shipped}", + "Where the automation lives": "Où vit l'automatisation", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Les flux sont ce qui se produit sans que personne ne clique : un objet horodaté à l'enregistrement, une application avertie quand un enregistrement change. C'est ici que vous les consultez et les modifiez. Rien à construire maintenant.", + "Open Flows in the menu": "Ouvrir Flux dans le menu" }, + "pluralForm": "nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;", "plurals": { "{count} email": [ "{count} e-mail", + "{count} e-mails", "{count} e-mails" ], "Purge {count} object from database": [ "Purger {count} objet de la base de données", + "Purger {count} objets de la base de données", "Purger {count} objets de la base de données" ], "Restore {count} object": [ "Restaurer {count} objet", + "Restaurer {count} objets", "Restaurer {count} objets" ], "Successfully restored {count} object": [ "{count} objet restauré avec succès", + "{count} objets restaurés avec succès", "{count} objets restaurés avec succès" ], "Delete {count} object": [ "Supprimer {count} objet", + "Supprimer {count} objets", "Supprimer {count} objets" ], "Object successfully deleted": [ "Objet supprimé avec succès", + "Objets supprimés avec succès", "Objets supprimés avec succès" ] } diff --git a/l10n/ga.js b/l10n/ga.js index 285a5308fd..362e23a1de 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Tuairisceoirí clár", "ships v{shipped}": "soláthraíonn sé v{shipped}", "State": "Staid", - "v{installed} → ships v{shipped}": "v{installed} → soláthraíonn sé v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → soláthraíonn sé v{shipped}", + "Where the automation lives": "Áit a mhaireann an uathoibriú", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Is éard atá i sruthanna ná an rud a tharlaíonn gan aon chliceáil: réad a stampáiltear ar shábháil, aip a chuirtear ar an eolas nuair a athraíonn taifead. Is anseo a léann tú agus a chuireann tú in eagar iad. Níl aon rud le tógáil anois.", + "Open Flows in the menu": "Oscail Sruthanna sa roghchlár" }, - "nplurals=2; plural=(n != 1);" + "nplurals=5; plural=(n==1 ? 0 : n==2 ? 1 : n<7 ? 2 : n<11 ? 3 : 4);" ) diff --git a/l10n/ga.json b/l10n/ga.json index f428a5bcc9..033735a12f 100644 --- a/l10n/ga.json +++ b/l10n/ga.json @@ -2728,31 +2728,53 @@ "Register descriptors": "Tuairisceoirí clár", "ships v{shipped}": "soláthraíonn sé v{shipped}", "State": "Staid", - "v{installed} → ships v{shipped}": "v{installed} → soláthraíonn sé v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → soláthraíonn sé v{shipped}", + "Where the automation lives": "Áit a mhaireann an uathoibriú", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Is éard atá i sruthanna ná an rud a tharlaíonn gan aon chliceáil: réad a stampáiltear ar shábháil, aip a chuirtear ar an eolas nuair a athraíonn taifead. Is anseo a léann tú agus a chuireann tú in eagar iad. Níl aon rud le tógáil anois.", + "Open Flows in the menu": "Oscail Sruthanna sa roghchlár" }, + "pluralForm": "nplurals=5; plural=(n==1 ? 0 : n==2 ? 1 : n<7 ? 2 : n<11 ? 3 : 4);", "plurals": { "{count} email": [ + "{count} ríomhphost", + "{count} ríomhphost", + "{count} ríomhphoist", "{count} ríomhphost", "{count} ríomhphost" ], "Purge {count} object from database": [ + "Glan {count} réad ón mbunachar sonraí", + "Glan {count} réad ón mbunachar sonraí", + "Glan {count} réada ón mbunachar sonraí", "Glan {count} réad ón mbunachar sonraí", "Glan {count} réad ón mbunachar sonraí" ], "Restore {count} object": [ + "Athchóirigh {count} réad", + "Athchóirigh {count} réad", + "Athchóirigh {count} réada", "Athchóirigh {count} réad", "Athchóirigh {count} réad" ], "Successfully restored {count} object": [ + "Athchóiríodh {count} réad go rathúil", + "Athchóiríodh {count} réad go rathúil", + "Athchóiríodh {count} réada go rathúil", "Athchóiríodh {count} réad go rathúil", "Athchóiríodh {count} réad go rathúil" ], "Delete {count} object": [ + "Scrios {count} réad", + "Scrios {count} réad", + "Scrios {count} réada", "Scrios {count} réad", "Scrios {count} réad" ], "Object successfully deleted": [ "Scriosadh an réad go rathúil", + "Scriosadh na réada go rathúil", + "Scriosadh na réada go rathúil", + "Scriosadh na réada go rathúil", "Scriosadh na réada go rathúil" ] } diff --git a/l10n/hr.js b/l10n/hr.js index 2edd9d4b36..55b51fdc13 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Deskriptori registara", "ships v{shipped}": "isporučuje v{shipped}", "State": "Stanje", - "v{installed} → ships v{shipped}": "v{installed} → isporučuje v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → isporučuje v{shipped}", + "Where the automation lives": "Gdje živi automatizacija", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Tokovi su ono što se događa bez klika: objekt označen pri spremanju, aplikacija obaviještena kad se zapis promijeni. Ovdje ih čitate i uređujete. Sada nema što graditi.", + "Open Flows in the menu": "Otvorite Tokove u izborniku" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;" ) diff --git a/l10n/hr.json b/l10n/hr.json index ccc993586a..60025a38d0 100644 --- a/l10n/hr.json +++ b/l10n/hr.json @@ -2726,31 +2726,41 @@ "Register descriptors": "Deskriptori registara", "ships v{shipped}": "isporučuje v{shipped}", "State": "Stanje", - "v{installed} → ships v{shipped}": "v{installed} → isporučuje v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → isporučuje v{shipped}", + "Where the automation lives": "Gdje živi automatizacija", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Tokovi su ono što se događa bez klika: objekt označen pri spremanju, aplikacija obaviještena kad se zapis promijeni. Ovdje ih čitate i uređujete. Sada nema što graditi.", + "Open Flows in the menu": "Otvorite Tokove u izborniku" }, + "pluralForm": "nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;", "plurals": { "{count} email": [ "{count} e-pošta", - "{count} e-pošte" + "{count} e-pošte", + "{count} e-pošta" ], "Purge {count} object from database": [ "Trajno ukloni {count} objekt iz baze podataka", + "Trajno ukloni {count} objekta iz baze podataka", "Trajno ukloni {count} objekata iz baze podataka" ], "Restore {count} object": [ "Vrati {count} objekt", + "Vrati {count} objekta", "Vrati {count} objekata" ], "Successfully restored {count} object": [ "Uspješno vraćen {count} objekt", + "Uspješno vraćena {count} objekta", "Uspješno vraćeno {count} objekata" ], "Delete {count} object": [ "Izbriši {count} objekt", + "Izbriši {count} objekta", "Izbriši {count} objekata" ], "Object successfully deleted": [ "Objekt je uspješno izbrisan", + "Objekti su uspješno izbrisani", "Objekti su uspješno izbrisani" ] } diff --git a/l10n/hu.js b/l10n/hu.js index 724b4da19e..b3d495b1f3 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Regiszterleírók", "ships v{shipped}": "szállítja: v{shipped}", "State": "Állapot", - "v{installed} → ships v{shipped}": "v{installed} → szállítja: v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → szállítja: v{shipped}", + "Where the automation lives": "Ahol az automatizálás lakik", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "A folyamatok azok, amik kattintás nélkül történnek: mentéskor bélyegzett objektum, értesített alkalmazás rekordváltozáskor. Itt olvashatja és szerkesztheti őket. Most nincs mit építeni.", + "Open Flows in the menu": "Nyissa meg a Folyamatokat a menüben" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hu.json b/l10n/hu.json index f63ed369f6..af33545a69 100644 --- a/l10n/hu.json +++ b/l10n/hu.json @@ -2725,8 +2725,12 @@ "Register descriptors": "Regiszterleírók", "ships v{shipped}": "szállítja: v{shipped}", "State": "Állapot", - "v{installed} → ships v{shipped}": "v{installed} → szállítja: v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → szállítja: v{shipped}", + "Where the automation lives": "Ahol az automatizálás lakik", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "A folyamatok azok, amik kattintás nélkül történnek: mentéskor bélyegzett objektum, értesített alkalmazás rekordváltozáskor. Itt olvashatja és szerkesztheti őket. Most nincs mit építeni.", + "Open Flows in the menu": "Nyissa meg a Folyamatokat a menüben" }, + "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { "{count} email": [ "{count} e-mail", diff --git a/l10n/is.js b/l10n/is.js index b40de7f229..b2bf11c62f 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Skrárlýsingar", "ships v{shipped}": "skilar v{shipped}", "State": "Staða", - "v{installed} → ships v{shipped}": "v{installed} → skilar v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → skilar v{shipped}", + "Where the automation lives": "Þar sem sjálfvirknin býr", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flæði er það sem gerist án þess að nokkur smelli: hlutur sem er stimplaður við vistun, forrit sem fær boð þegar færsla breytist. Hér lestu og breytir þeim. Ekkert að byggja núna.", + "Open Flows in the menu": "Opnaðu Flæði í valmyndinni" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/is.json b/l10n/is.json index c69f121df8..848d5e55e8 100644 --- a/l10n/is.json +++ b/l10n/is.json @@ -2725,8 +2725,12 @@ "Register descriptors": "Skrárlýsingar", "ships v{shipped}": "skilar v{shipped}", "State": "Staða", - "v{installed} → ships v{shipped}": "v{installed} → skilar v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → skilar v{shipped}", + "Where the automation lives": "Þar sem sjálfvirknin býr", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flæði er það sem gerist án þess að nokkur smelli: hlutur sem er stimplaður við vistun, forrit sem fær boð þegar færsla breytist. Hér lestu og breytir þeim. Ekkert að byggja núna.", + "Open Flows in the menu": "Opnaðu Flæði í valmyndinni" }, + "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { "{count} email": [ "{count} tölvupóstur", diff --git a/l10n/it.js b/l10n/it.js index 5afbf81639..1f75cd0a17 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -2556,7 +2556,7 @@ OC.L10N.register( "Zookeeper port number (optional, defaults to 2181)": "Numero di porta Zookeeper (facoltativo, predefinito 2181)", "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete ALL objects for schema \"{schema}\":\n\n\\u2022 Active objects: {active}\n\\u2022 Soft-deleted objects: {deleted}\n\\u2022 Total: {total}\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F AVVISO DI ELIMINAZIONE PERMANENTE \\u26A0\\uFE0F\n\nSta per eliminare PERMANENTEMENTE TUTTI gli oggetti per lo schema \"{schema}\":\n\n\\u2022 Oggetti attivi: {active}\n\\u2022 Oggetti eliminati provvisoriamente: {deleted}\n\\u2022 Totale: {total}\n\nQuesti oggetti saranno rimossi completamente dal database e NON potranno essere recuperati.\n\nÈ assolutamente sicuro?", "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete {count} soft-deleted objects for schema \"{schema}\".\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F AVVISO DI ELIMINAZIONE PERMANENTE \\u26A0\\uFE0F\n\nSta per eliminare PERMANENTEMENTE {count} oggetti eliminati provvisoriamente per lo schema \"{schema}\".\n\nQuesti oggetti saranno rimossi completamente dal database e NON potranno essere recuperati.\n\nÈ assolutamente sicuro?", - "_%n entry has no hash yet_::_%n entries have no hash yet_": ["%n voce non ha ancora un hash","%n voci non hanno ancora un hash"], + "_%n entry has no hash yet_::_%n entries have no hash yet_": ["%n voce non ha ancora un hash","%n voci non hanno ancora un hash","%n voci non hanno ancora un hash"], "across {count} registers": "across {count} registers", "and accessible to all users in selected groups (or all users if no groups selected).": "e accessibile a tutti gli utenti nei gruppi selezionati (o a tutti gli utenti se non è selezionato alcun gruppo).", "and add the files there.": "e aggiunga i file lì.", @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Descrittori di registro", "ships v{shipped}": "fornisce v{shipped}", "State": "Stato", - "v{installed} → ships v{shipped}": "v{installed} → fornisce v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → fornisce v{shipped}", + "Where the automation lives": "Dove vive l'automazione", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "I flussi sono ciò che accade senza che nessuno clicchi: un oggetto marcato al salvataggio, un'app avvisata quando un record cambia. Qui li leggi e li modifichi. Nulla da costruire ora.", + "Open Flows in the menu": "Apri Flussi nel menu" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;" ) diff --git a/l10n/it.json b/l10n/it.json index 66033dbf6f..b841f5102f 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -2557,6 +2557,7 @@ "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete {count} soft-deleted objects for schema \"{schema}\".\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F AVVISO DI ELIMINAZIONE PERMANENTE \\u26A0\\uFE0F\n\nSta per eliminare PERMANENTEMENTE {count} oggetti eliminati provvisoriamente per lo schema \"{schema}\".\n\nQuesti oggetti saranno rimossi completamente dal database e NON potranno essere recuperati.\n\nÈ assolutamente sicuro?", "_%n entry has no hash yet_::_%n entries have no hash yet_": [ "%n voce non ha ancora un hash", + "%n voci non hanno ancora un hash", "%n voci non hanno ancora un hash" ], "across {count} registers": "across {count} registers", @@ -2725,31 +2726,41 @@ "Register descriptors": "Descrittori di registro", "ships v{shipped}": "fornisce v{shipped}", "State": "Stato", - "v{installed} → ships v{shipped}": "v{installed} → fornisce v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → fornisce v{shipped}", + "Where the automation lives": "Dove vive l'automazione", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "I flussi sono ciò che accade senza che nessuno clicchi: un oggetto marcato al salvataggio, un'app avvisata quando un record cambia. Qui li leggi e li modifichi. Nulla da costruire ora.", + "Open Flows in the menu": "Apri Flussi nel menu" }, + "pluralForm": "nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;", "plurals": { "{count} email": [ + "{count} email", "{count} email", "{count} email" ], "Purge {count} object from database": [ "Elimina definitivamente {count} oggetto dal database", + "Elimina definitivamente {count} oggetti dal database", "Elimina definitivamente {count} oggetti dal database" ], "Restore {count} object": [ "Ripristina {count} oggetto", + "Ripristina {count} oggetti", "Ripristina {count} oggetti" ], "Successfully restored {count} object": [ "Ripristinato correttamente {count} oggetto", + "Ripristinati correttamente {count} oggetti", "Ripristinati correttamente {count} oggetti" ], "Delete {count} object": [ "Elimina {count} oggetto", + "Elimina {count} oggetti", "Elimina {count} oggetti" ], "Object successfully deleted": [ "Oggetto eliminato correttamente", + "Oggetti eliminati correttamente", "Oggetti eliminati correttamente" ] } diff --git a/l10n/lb.js b/l10n/lb.js index f73a5e25c8..32a4c3ae65 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Register-Deskriptoren", "ships v{shipped}": "liwwert v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → liwwert v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → liwwert v{shipped}", + "Where the automation lives": "Wou d'Automatisatioun wunnt", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows sinn dat, wat ouni Klick geschitt: en Objet dee beim Späichere gestempelt gëtt, eng App déi Bescheed kritt wann e Record ännert. Hei liest an ännert Dir se. Elo ass näischt ze bauen.", + "Open Flows in the menu": "Flows am Menü opmaachen" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lb.json b/l10n/lb.json index 1a6bd20172..264c1e3e52 100644 --- a/l10n/lb.json +++ b/l10n/lb.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Register-Deskriptoren", "ships v{shipped}": "liwwert v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → liwwert v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → liwwert v{shipped}", + "Where the automation lives": "Wou d'Automatisatioun wunnt", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows sinn dat, wat ouni Klick geschitt: en Objet dee beim Späichere gestempelt gëtt, eng App déi Bescheed kritt wann e Record ännert. Hei liest an ännert Dir se. Elo ass näischt ze bauen.", + "Open Flows in the menu": "Flows am Menü opmaachen" }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/lt.js b/l10n/lt.js index 9bc8fa95c6..21efeae1e7 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Registrų deskriptoriai", "ships v{shipped}": "pateikia v{shipped}", "State": "Būsena", - "v{installed} → ships v{shipped}": "v{installed} → pateikia v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → pateikia v{shipped}", + "Where the automation lives": "Kur gyvena automatizavimas", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Srautai yra tai, kas vyksta niekam nespaudžiant: objektas, pažymimas išsaugant, programa, informuojama pasikeitus įrašui. Čia juos skaitote ir redaguojate. Dabar nieko kurti nereikia.", + "Open Flows in the menu": "Atidarykite Srautus meniu" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%100<10 || n%100>=20) ? 1 : 2);" ) diff --git a/l10n/lt.json b/l10n/lt.json index fb28b63fda..1b3891ebf2 100644 --- a/l10n/lt.json +++ b/l10n/lt.json @@ -2726,31 +2726,41 @@ "Register descriptors": "Registrų deskriptoriai", "ships v{shipped}": "pateikia v{shipped}", "State": "Būsena", - "v{installed} → ships v{shipped}": "v{installed} → pateikia v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → pateikia v{shipped}", + "Where the automation lives": "Kur gyvena automatizavimas", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Srautai yra tai, kas vyksta niekam nespaudžiant: objektas, pažymimas išsaugant, programa, informuojama pasikeitus įrašui. Čia juos skaitote ir redaguojate. Dabar nieko kurti nereikia.", + "Open Flows in the menu": "Atidarykite Srautus meniu" }, + "pluralForm": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%100<10 || n%100>=20) ? 1 : 2);", "plurals": { "{count} email": [ "{count} el. laiškas", + "{count} el. laiškai", "{count} el. laiškų" ], "Purge {count} object from database": [ "Išvalyti {count} objektą iš duomenų bazės", + "Išvalyti {count} objektus iš duomenų bazės", "Išvalyti {count} objektų iš duomenų bazės" ], "Restore {count} object": [ "Atkurti {count} objektą", + "Atkurti {count} objektus", "Atkurti {count} objektų" ], "Successfully restored {count} object": [ "Sėkmingai atkurtas {count} objektas", + "Sėkmingai atkurti {count} objektai", "Sėkmingai atkurta {count} objektų" ], "Delete {count} object": [ "Ištrinti {count} objektą", + "Ištrinti {count} objektus", "Ištrinti {count} objektų" ], "Object successfully deleted": [ "Objektas sėkmingai ištrintas", + "Objektai sėkmingai ištrinti", "Objektai sėkmingai ištrinti" ] } diff --git a/l10n/lv.js b/l10n/lv.js index bbf7946dee..5478a41787 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Reģistru deskriptori", "ships v{shipped}": "piegādā v{shipped}", "State": "Stāvoklis", - "v{installed} → ships v{shipped}": "v{installed} → piegādā v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → piegādā v{shipped}", + "Where the automation lives": "Kur dzīvo automatizācija", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Plūsmas ir tas, kas notiek bez klikšķa: objekts, kas tiek zīmogots saglabājot, lietotne, kurai paziņo, kad ieraksts mainās. Šeit tās lasāt un rediģējat. Tagad nav jāveido nekas.", + "Open Flows in the menu": "Atveriet Plūsmas izvēlnē" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2);" ) diff --git a/l10n/lv.json b/l10n/lv.json index a674ca83b2..aeae0aae7b 100644 --- a/l10n/lv.json +++ b/l10n/lv.json @@ -2726,31 +2726,41 @@ "Register descriptors": "Reģistru deskriptori", "ships v{shipped}": "piegādā v{shipped}", "State": "Stāvoklis", - "v{installed} → ships v{shipped}": "v{installed} → piegādā v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → piegādā v{shipped}", + "Where the automation lives": "Kur dzīvo automatizācija", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Plūsmas ir tas, kas notiek bez klikšķa: objekts, kas tiek zīmogots saglabājot, lietotne, kurai paziņo, kad ieraksts mainās. Šeit tās lasāt un rediģējat. Tagad nav jāveido nekas.", + "Open Flows in the menu": "Atveriet Plūsmas izvēlnē" }, + "pluralForm": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2);", "plurals": { "{count} email": [ "{count} e-pasts", - "{count} e-pasti" + "{count} e-pasti", + "{count} e-pastu" ], "Purge {count} object from database": [ "Iztīrīt {count} objektu no datubāzes", - "Iztīrīt {count} objektus no datubāzes" + "Iztīrīt {count} objektus no datubāzes", + "Iztīrīt {count} objektu no datubāzes" ], "Restore {count} object": [ "Atjaunot {count} objektu", - "Atjaunot {count} objektus" + "Atjaunot {count} objektus", + "Atjaunot {count} objektu" ], "Successfully restored {count} object": [ "Veiksmīgi atjaunots {count} objekts", + "Veiksmīgi atjaunoti {count} objekti", "Veiksmīgi atjaunoti {count} objekti" ], "Delete {count} object": [ "Dzēst {count} objektu", - "Dzēst {count} objektus" + "Dzēst {count} objektus", + "Dzēst {count} objektu" ], "Object successfully deleted": [ "Objekts veiksmīgi dzēsts", + "Objekti veiksmīgi dzēsti", "Objekti veiksmīgi dzēsti" ] } diff --git a/l10n/mk.js b/l10n/mk.js index 1ad7661465..9bd8f7d1ef 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Дескриптори на регистри", "ships v{shipped}": "испорачува v{shipped}", "State": "Состојба", - "v{installed} → ships v{shipped}": "v{installed} → испорачува v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → испорачува v{shipped}", + "Where the automation lives": "Каде живее автоматизацијата", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Текови се тоа што се случува без некој да кликне: објект означен при зачувување, апликација известена кога запис ќе се промени. Тука ги читате и уредувате. Сега нема што да се гради.", + "Open Flows in the menu": "Отворете Текови во менито" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mk.json b/l10n/mk.json index 651c153a81..109947d43c 100644 --- a/l10n/mk.json +++ b/l10n/mk.json @@ -2725,8 +2725,12 @@ "Register descriptors": "Дескриптори на регистри", "ships v{shipped}": "испорачува v{shipped}", "State": "Состојба", - "v{installed} → ships v{shipped}": "v{installed} → испорачува v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → испорачува v{shipped}", + "Where the automation lives": "Каде живее автоматизацијата", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Текови се тоа што се случува без некој да кликне: објект означен при зачувување, апликација известена кога запис ќе се промени. Тука ги читате и уредувате. Сега нема што да се гради.", + "Open Flows in the menu": "Отворете Текови во менито" }, + "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { "{count} email": [ "{count} е-пошта", diff --git a/l10n/mt.js b/l10n/mt.js index 5d8e9d1ae2..d883a1096f 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Deskritturi tar-reġistri", "ships v{shipped}": "iwassal v{shipped}", "State": "Stat", - "v{installed} → ships v{shipped}": "v{installed} → iwassal v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → iwassal v{shipped}", + "Where the automation lives": "Fejn tgħix l-awtomazzjoni", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Il-flussi huma dak li jiġri mingħajr ma xi ħadd jikklikkja: oġġett ittimbrat mal-iffrankar, applikazzjoni mgħarrfa meta jinbidel rekord. Hawn taqrahom u teditjahom. M'hemm xejn x'tibni issa.", + "Open Flows in the menu": "Iftaħ Flussi fil-menu" }, - "nplurals=2; plural=(n != 1);" + "nplurals=4; plural=(n==1 ? 0 : n==0 || ( n%100>1 && n%100<11) ? 1 : (n%100>10 && n%100<20 ) ? 2 : 3);" ) diff --git a/l10n/mt.json b/l10n/mt.json index f2994daec5..f1781bb2e5 100644 --- a/l10n/mt.json +++ b/l10n/mt.json @@ -2727,31 +2727,47 @@ "Register descriptors": "Deskritturi tar-reġistri", "ships v{shipped}": "iwassal v{shipped}", "State": "Stat", - "v{installed} → ships v{shipped}": "v{installed} → iwassal v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → iwassal v{shipped}", + "Where the automation lives": "Fejn tgħix l-awtomazzjoni", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Il-flussi huma dak li jiġri mingħajr ma xi ħadd jikklikkja: oġġett ittimbrat mal-iffrankar, applikazzjoni mgħarrfa meta jinbidel rekord. Hawn taqrahom u teditjahom. M'hemm xejn x'tibni issa.", + "Open Flows in the menu": "Iftaħ Flussi fil-menu" }, + "pluralForm": "nplurals=4; plural=(n==1 ? 0 : n==0 || ( n%100>1 && n%100<11) ? 1 : (n%100>10 && n%100<20 ) ? 2 : 3);", "plurals": { "{count} email": [ "{count} email", - "{count} emails" + "{count} emails", + "{count} email", + "{count} email" ], "Purge {count} object from database": [ "Neħħi {count} oġġett mid-database", - "Neħħi {count} oġġetti mid-database" + "Neħħi {count} oġġetti mid-database", + "Neħħi {count} oġġett mid-database", + "Neħħi {count} oġġett mid-database" ], "Restore {count} object": [ "Irrestawra {count} oġġett", - "Irrestawra {count} oġġetti" + "Irrestawra {count} oġġetti", + "Irrestawra {count} oġġett", + "Irrestawra {count} oġġett" ], "Successfully restored {count} object": [ "{count} oġġett ġie rrestawrat b'suċċess", - "{count} oġġetti ġew irrestawrati b'suċċess" + "{count} oġġetti ġew irrestawrati b'suċċess", + "{count} oġġett ġie rrestawrat b'suċċess", + "{count} oġġett ġie rrestawrat b'suċċess" ], "Delete {count} object": [ "Ħassar {count} oġġett", - "Ħassar {count} oġġetti" + "Ħassar {count} oġġetti", + "Ħassar {count} oġġett", + "Ħassar {count} oġġett" ], "Object successfully deleted": [ "L-oġġett tħassar b'suċċess", + "L-oġġetti tħassru b'suċċess", + "L-oġġetti tħassru b'suċċess", "L-oġġetti tħassru b'suċċess" ] } diff --git a/l10n/nb.js b/l10n/nb.js index da08211319..b297307db0 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Registerdeskriptorer", "ships v{shipped}": "leverer v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → leverer v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → leverer v{shipped}", + "Where the automation lives": "Der automatiseringen bor", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flyter er det som skjer uten at noen klikker: et objekt som stemples ved lagring, en app som varsles når en post endres. Her leser og redigerer du dem. Ingenting å bygge nå.", + "Open Flows in the menu": "Åpne Flyter i menyen" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nb.json b/l10n/nb.json index d1bc95d31d..8aa91556be 100644 --- a/l10n/nb.json +++ b/l10n/nb.json @@ -2725,8 +2725,12 @@ "Register descriptors": "Registerdeskriptorer", "ships v{shipped}": "leverer v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → leverer v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → leverer v{shipped}", + "Where the automation lives": "Der automatiseringen bor", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flyter er det som skjer uten at noen klikker: et objekt som stemples ved lagring, en app som varsles når en post endres. Her leser og redigerer du dem. Ingenting å bygge nå.", + "Open Flows in the menu": "Åpne Flyter i menyen" }, + "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { "{count} email": [ "{count} e-post", diff --git a/l10n/nl.js b/l10n/nl.js index 95ed4d79d6..ab33875a00 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -3,6 +3,11 @@ OC.L10N.register( { "3": "3", "30": "30", + "Welcome": "Welkom", + "A short setup to get this app ready. Nothing here is required; you can close it and come back later.": "Een korte installatie om deze app klaar te zetten. Niets hiervan is verplicht; je kunt dit sluiten en later terugkomen.", + "Demo data (optional)": "Demovoorbeelddata (optioneel)", + "Load a small example dataset so the lists, detail pages and dashboards show a working product straight away. The data is obviously sample data, it is safe to run more than once, and it can be removed afterwards. Skip this on a production install.": "Laad een kleine voorbeeldset zodat de lijsten, detailpagina's en dashboards meteen een werkend product laten zien. De data is duidelijk voorbeelddata, veilig om meerdere keren uit te voeren en achteraf te verwijderen. Sla dit over op een productie-installatie.", + "All set": "Klaar", "%n entries have no hash yet": "%n regels hebben nog geen hash", "%n entry has no hash yet": "%n regel heeft nog geen hash", "%s asks to act on your behalf": "%s vraagt om namens u te handelen", @@ -2775,7 +2780,10 @@ OC.L10N.register( "Register descriptors": "Register-descriptors", "ships v{shipped}": "levert v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → levert v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → levert v{shipped}", + "Where the automation lives": "Waar de automatisering zit", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows zijn wat er gebeurt zonder dat iemand klikt: een object dat bij opslaan wordt gestempeld, een afnemende app die bericht krijgt wanneer een record verandert. Hier leest en bewerkt u ze. U hoeft nu niets te bouwen.", + "Open Flows in the menu": "Open Flows in het menu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index ffe87e8376..90c4ac0bb2 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1,5 +1,10 @@ { "translations": { + "Welcome": "Welkom", + "A short setup to get this app ready. Nothing here is required; you can close it and come back later.": "Een korte installatie om deze app klaar te zetten. Niets hiervan is verplicht; je kunt dit sluiten en later terugkomen.", + "Demo data (optional)": "Demovoorbeelddata (optioneel)", + "Load a small example dataset so the lists, detail pages and dashboards show a working product straight away. The data is obviously sample data, it is safe to run more than once, and it can be removed afterwards. Skip this on a production install.": "Laad een kleine voorbeeldset zodat de lijsten, detailpagina's en dashboards meteen een werkend product laten zien. De data is duidelijk voorbeelddata, veilig om meerdere keren uit te voeren en achteraf te verwijderen. Sla dit over op een productie-installatie.", + "All set": "Klaar", "%n entries have no hash yet": "%n regels hebben nog geen hash", "%n entry has no hash yet": "%n regel heeft nog geen hash", "%s asks to act on your behalf": "%s vraagt om namens u te handelen", @@ -2777,8 +2782,12 @@ "Register descriptors": "Register-descriptors", "ships v{shipped}": "levert v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → levert v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → levert v{shipped}", + "Where the automation lives": "Waar de automatisering zit", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows zijn wat er gebeurt zonder dat iemand klikt: een object dat bij opslaan wordt gestempeld, een afnemende app die bericht krijgt wanneer een record verandert. Hier leest en bewerkt u ze. U hoeft nu niets te bouwen.", + "Open Flows in the menu": "Open Flows in het menu" }, + "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { "{count} email": [ "{count} email", diff --git a/l10n/pl.js b/l10n/pl.js index 1ba69bdf4f..8773741898 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -2556,7 +2556,7 @@ OC.L10N.register( "Zookeeper port number (optional, defaults to 2181)": "Numer portu ZooKeeper (opcjonalny, domyślnie 2181)", "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete ALL objects for schema \"{schema}\":\n\n\\u2022 Active objects: {active}\n\\u2022 Soft-deleted objects: {deleted}\n\\u2022 Total: {total}\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F OSTRZEŻENIE O TRWAŁYM USUNIĘCIU \\u26A0\\uFE0F\n\nZamierzają Państwo TRWALE usunąć WSZYSTKIE Obiekty dla Schematu \"{schema}\":\n\n\\u2022 Aktywne Obiekty: {active}\n\\u2022 Miękko usunięte Obiekty: {deleted}\n\\u2022 Razem: {total}\n\nTe Obiekty zostaną całkowicie usunięte z bazy danych i NIE BĘDZIE można ich odzyskać.\n\nCzy są Państwo absolutnie pewni?", "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete {count} soft-deleted objects for schema \"{schema}\".\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F OSTRZEŻENIE O TRWAŁYM USUNIĘCIU \\u26A0\\uFE0F\n\nZamierzają Państwo TRWALE usunąć {count} miękko usuniętych Obiektów dla Schematu \"{schema}\".\n\nTe Obiekty zostaną całkowicie usunięte z bazy danych i NIE BĘDZIE można ich odzyskać.\n\nCzy są Państwo absolutnie pewni?", - "_%n entry has no hash yet_::_%n entries have no hash yet_": ["%n wpis nie ma jeszcze skrótu","%n wpisy nie mają jeszcze skrótu","%n wpisów nie ma jeszcze skrótu"], + "_%n entry has no hash yet_::_%n entries have no hash yet_": ["%n wpis nie ma jeszcze skrótu","%n wpisy nie mają jeszcze skrótu","%n wpisów nie ma jeszcze skrótu","%n wpisu nie ma jeszcze skrótu"], "across {count} registers": "across {count} registers", "and accessible to all users in selected groups (or all users if no groups selected).": "i dostępne dla wszystkich użytkowników w wybranych grupach (lub wszystkich użytkowników, jeśli nie wybrano grup).", "and add the files there.": "i dodać tam Pliki.", @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Deskryptory rejestrów", "ships v{shipped}": "dostarcza v{shipped}", "State": "Stan", - "v{installed} → ships v{shipped}": "v{installed} → dostarcza v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → dostarcza v{shipped}", + "Where the automation lives": "Gdzie mieszka automatyzacja", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Przepływy to co dzieje się bez kliknięcia: obiekt stemplowany przy zapisie, aplikacja powiadamiana gdy rekord się zmienia. Tutaj je czytasz i edytujesz. Teraz nic nie trzeba budować.", + "Open Flows in the menu": "Otwórz Przepływy w menu" }, - "nplurals=2; plural=(n != 1);" + "nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);" ) diff --git a/l10n/pl.json b/l10n/pl.json index 71238e558e..21a7b9e7bd 100644 --- a/l10n/pl.json +++ b/l10n/pl.json @@ -2558,7 +2558,8 @@ "_%n entry has no hash yet_::_%n entries have no hash yet_": [ "%n wpis nie ma jeszcze skrótu", "%n wpisy nie mają jeszcze skrótu", - "%n wpisów nie ma jeszcze skrótu" + "%n wpisów nie ma jeszcze skrótu", + "%n wpisu nie ma jeszcze skrótu" ], "across {count} registers": "across {count} registers", "and accessible to all users in selected groups (or all users if no groups selected).": "i dostępne dla wszystkich użytkowników w wybranych grupach (lub wszystkich użytkowników, jeśli nie wybrano grup).", @@ -2726,31 +2727,47 @@ "Register descriptors": "Deskryptory rejestrów", "ships v{shipped}": "dostarcza v{shipped}", "State": "Stan", - "v{installed} → ships v{shipped}": "v{installed} → dostarcza v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → dostarcza v{shipped}", + "Where the automation lives": "Gdzie mieszka automatyzacja", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Przepływy to co dzieje się bez kliknięcia: obiekt stemplowany przy zapisie, aplikacja powiadamiana gdy rekord się zmienia. Tutaj je czytasz i edytujesz. Teraz nic nie trzeba budować.", + "Open Flows in the menu": "Otwórz Przepływy w menu" }, + "pluralForm": "nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);", "plurals": { "{count} email": [ "{count} e-mail", - "{count} e-maili" + "{count} e-maile", + "{count} e-maili", + "{count} e-maila" ], "Purge {count} object from database": [ "Wyczyść {count} obiekt z bazy danych", - "Wyczyść {count} obiektów z bazy danych" + "Wyczyść {count} obiekty z bazy danych", + "Wyczyść {count} obiektów z bazy danych", + "Wyczyść {count} obiektu z bazy danych" ], "Restore {count} object": [ "Przywróć {count} obiekt", - "Przywróć {count} obiektów" + "Przywróć {count} obiekty", + "Przywróć {count} obiektów", + "Przywróć {count} obiektu" ], "Successfully restored {count} object": [ "Pomyślnie przywrócono {count} obiekt", - "Pomyślnie przywrócono {count} obiektów" + "Pomyślnie przywrócono {count} obiekty", + "Pomyślnie przywrócono {count} obiektów", + "Pomyślnie przywrócono {count} obiektu" ], "Delete {count} object": [ "Usuń {count} obiekt", - "Usuń {count} obiektów" + "Usuń {count} obiekty", + "Usuń {count} obiektów", + "Usuń {count} obiektu" ], "Object successfully deleted": [ "Obiekt został pomyślnie usunięty", + "Obiekty zostały pomyślnie usunięte", + "Obiekty zostały pomyślnie usunięte", "Obiekty zostały pomyślnie usunięte" ] } diff --git a/l10n/pt.js b/l10n/pt.js index 5da2bd72ce..0ae4e004e1 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Descritores de registo", "ships v{shipped}": "fornece v{shipped}", "State": "Estado", - "v{installed} → ships v{shipped}": "v{installed} → fornece v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → fornece v{shipped}", + "Where the automation lives": "Onde vive a automação", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Os fluxos são o que acontece sem ninguém clicar: um objeto carimbado ao guardar, uma aplicação avisada quando um registo muda. É aqui que os lê e edita. Nada a construir agora.", + "Open Flows in the menu": "Abrir Fluxos no menu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pt.json b/l10n/pt.json index 0df1efd2a5..c46df5a543 100644 --- a/l10n/pt.json +++ b/l10n/pt.json @@ -2725,8 +2725,12 @@ "Register descriptors": "Descritores de registo", "ships v{shipped}": "fornece v{shipped}", "State": "Estado", - "v{installed} → ships v{shipped}": "v{installed} → fornece v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → fornece v{shipped}", + "Where the automation lives": "Onde vive a automação", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Os fluxos são o que acontece sem ninguém clicar: um objeto carimbado ao guardar, uma aplicação avisada quando um registo muda. É aqui que os lê e edita. Nada a construir agora.", + "Open Flows in the menu": "Abrir Fluxos no menu" }, + "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { "{count} email": [ "{count} e-mail", diff --git a/l10n/rm.js b/l10n/rm.js index 711314e13e..431e212a47 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Descripturs da register", "ships v{shipped}": "furnescha v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → furnescha v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → furnescha v{shipped}", + "Where the automation lives": "Nua che l'automatisaziun viva", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flussins èn quai che capita senza che insatgi cliccheschia: in object che vegn stampà cun memorisar, ina app che vegn infurmada cura ch'ina endataziun mida. Qua als legias e modifitgeschas. Uss n'è nagut da construir.", + "Open Flows in the menu": "Avrir Flussins en il menu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/rm.json b/l10n/rm.json index 7bf3ca2a25..22522079b8 100644 --- a/l10n/rm.json +++ b/l10n/rm.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Descripturs da register", "ships v{shipped}": "furnescha v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → furnescha v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → furnescha v{shipped}", + "Where the automation lives": "Nua che l'automatisaziun viva", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flussins èn quai che capita senza che insatgi cliccheschia: in object che vegn stampà cun memorisar, ina app che vegn infurmada cura ch'ina endataziun mida. Qua als legias e modifitgeschas. Uss n'è nagut da construir.", + "Open Flows in the menu": "Avrir Flussins en il menu" }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/ro.js b/l10n/ro.js index 446e6f90c9..add661ff3a 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Descriptori de registru", "ships v{shipped}": "livrează v{shipped}", "State": "Stare", - "v{installed} → ships v{shipped}": "v{installed} → livrează v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → livrează v{shipped}", + "Where the automation lives": "Unde trăiește automatizarea", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Fluxurile sunt ce se întâmplă fără ca cineva să dea clic: un obiect ștampilat la salvare, o aplicație anunțată când un înregistrare se schimbă. Aici le citiți și le editați. Nu e nimic de construit acum.", + "Open Flows in the menu": "Deschideți Fluxuri în meniu" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=(n==1?0:(((n%100>19)||((n%100==0)&&(n!=0)))?2:1));" ) diff --git a/l10n/ro.json b/l10n/ro.json index fd89123ebd..cca60e096f 100644 --- a/l10n/ro.json +++ b/l10n/ro.json @@ -2726,31 +2726,41 @@ "Register descriptors": "Descriptori de registru", "ships v{shipped}": "livrează v{shipped}", "State": "Stare", - "v{installed} → ships v{shipped}": "v{installed} → livrează v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → livrează v{shipped}", + "Where the automation lives": "Unde trăiește automatizarea", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Fluxurile sunt ce se întâmplă fără ca cineva să dea clic: un obiect ștampilat la salvare, o aplicație anunțată când un înregistrare se schimbă. Aici le citiți și le editați. Nu e nimic de construit acum.", + "Open Flows in the menu": "Deschideți Fluxuri în meniu" }, + "pluralForm": "nplurals=3; plural=(n==1?0:(((n%100>19)||((n%100==0)&&(n!=0)))?2:1));", "plurals": { "{count} email": [ "{count} e-mail", - "{count} e-mailuri" + "{count} e-mailuri", + "{count} de e-mailuri" ], "Purge {count} object from database": [ "Epurați {count} obiect din baza de date", - "Epurați {count} obiecte din baza de date" + "Epurați {count} obiecte din baza de date", + "Epurați {count} de obiecte din baza de date" ], "Restore {count} object": [ "Restaurați {count} obiect", - "Restaurați {count} obiecte" + "Restaurați {count} obiecte", + "Restaurați {count} de obiecte" ], "Successfully restored {count} object": [ "S-a restaurat cu succes {count} obiect", - "S-au restaurat cu succes {count} obiecte" + "S-au restaurat cu succes {count} obiecte", + "S-au restaurat cu succes {count} de obiecte" ], "Delete {count} object": [ "Ștergeți {count} obiect", - "Ștergeți {count} obiecte" + "Ștergeți {count} obiecte", + "Ștergeți {count} de obiecte" ], "Object successfully deleted": [ "Obiectul a fost șters cu succes", + "Obiectele au fost șterse cu succes", "Obiectele au fost șterse cu succes" ] } diff --git a/l10n/ru.js b/l10n/ru.js index db9c164af6..63e59786d7 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -2556,7 +2556,7 @@ OC.L10N.register( "Zookeeper port number (optional, defaults to 2181)": "Номер порта Zookeeper (необязательно, по умолчанию 2181)", "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete ALL objects for schema \"{schema}\":\n\n\\u2022 Active objects: {active}\n\\u2022 Soft-deleted objects: {deleted}\n\\u2022 Total: {total}\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F ПРЕДУПРЕЖДЕНИЕ О БЕЗВОЗВРАТНОМ УДАЛЕНИИ \\u26A0\\uFE0F\n\nВы собираетесь БЕЗВОЗВРАТНО удалить ВСЕ объекты схемы \"{schema}\":\n\n\\u2022 Активных объектов: {active}\n\\u2022 Программно удалённых объектов: {deleted}\n\\u2022 Итого: {total}\n\nЭти объекты будут полностью удалены из базы данных и НЕ МОГУТ быть восстановлены.\n\nВы абсолютно уверены?", "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete {count} soft-deleted objects for schema \"{schema}\".\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F ПРЕДУПРЕЖДЕНИЕ О БЕЗВОЗВРАТНОМ УДАЛЕНИИ \\u26A0\\uFE0F\n\nВы собираетесь БЕЗВОЗВРАТНО удалить {count} программно удалённых объектов схемы \"{schema}\".\n\nЭти объекты будут полностью удалены из базы данных и НЕ МОГУТ быть восстановлены.\n\nВы абсолютно уверены?", - "_%n entry has no hash yet_::_%n entries have no hash yet_": ["%n запись ещё не имеет хеша","%n записи ещё не имеют хеша","%n записей ещё не имеют хеша"], + "_%n entry has no hash yet_::_%n entries have no hash yet_": ["%n запись ещё не имеет хеша","%n записи ещё не имеют хеша","%n записей ещё не имеют хеша","%n записи ещё не имеют хеша"], "across {count} registers": "across {count} registers", "and accessible to all users in selected groups (or all users if no groups selected).": "и доступно всем пользователям в выбранных группах (или всем пользователям, если группы не выбраны).", "and add the files there.": "и добавьте файлы туда.", @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Дескрипторы реестров", "ships v{shipped}": "поставляет v{shipped}", "State": "Состояние", - "v{installed} → ships v{shipped}": "v{installed} → поставляет v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → поставляет v{shipped}", + "Where the automation lives": "Где живёт автоматизация", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Потоки — это то, что происходит без нажатий: объект, помечаемый при сохранении, приложение, уведомляемое при изменении записи. Здесь вы их читаете и редактируете. Сейчас ничего строить не нужно.", + "Open Flows in the menu": "Откройте Потоки в меню" }, - "nplurals=2; plural=(n != 1);" + "nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);" ) diff --git a/l10n/ru.json b/l10n/ru.json index fe4230231d..7da6ff50f0 100644 --- a/l10n/ru.json +++ b/l10n/ru.json @@ -2558,7 +2558,8 @@ "_%n entry has no hash yet_::_%n entries have no hash yet_": [ "%n запись ещё не имеет хеша", "%n записи ещё не имеют хеша", - "%n записей ещё не имеют хеша" + "%n записей ещё не имеют хеша", + "%n записи ещё не имеют хеша" ], "across {count} registers": "across {count} registers", "and accessible to all users in selected groups (or all users if no groups selected).": "и доступно всем пользователям в выбранных группах (или всем пользователям, если группы не выбраны).", @@ -2726,38 +2727,48 @@ "Register descriptors": "Дескрипторы реестров", "ships v{shipped}": "поставляет v{shipped}", "State": "Состояние", - "v{installed} → ships v{shipped}": "v{installed} → поставляет v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → поставляет v{shipped}", + "Where the automation lives": "Где живёт автоматизация", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Потоки — это то, что происходит без нажатий: объект, помечаемый при сохранении, приложение, уведомляемое при изменении записи. Здесь вы их читаете и редактируете. Сейчас ничего строить не нужно.", + "Open Flows in the menu": "Откройте Потоки в меню" }, + "pluralForm": "nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);", "plurals": { "{count} email": [ "{count} письмо", "{count} письма", - "{count} писем" + "{count} писем", + "{count} письма" ], "Purge {count} object from database": [ "Очистить {count} объект из базы данных", "Очистить {count} объекта из базы данных", - "Очистить {count} объектов из базы данных" + "Очистить {count} объектов из базы данных", + "Очистить {count} объекта из базы данных" ], "Restore {count} object": [ "Восстановить {count} объект", "Восстановить {count} объекта", - "Восстановить {count} объектов" + "Восстановить {count} объектов", + "Восстановить {count} объекта" ], "Successfully restored {count} object": [ "Успешно восстановлен {count} объект", "Успешно восстановлено {count} объекта", - "Успешно восстановлено {count} объектов" + "Успешно восстановлено {count} объектов", + "Успешно восстановлено {count} объекта" ], "Delete {count} object": [ "Удалить {count} объект", "Удалить {count} объекта", - "Удалить {count} объектов" + "Удалить {count} объектов", + "Удалить {count} объекта" ], "Object successfully deleted": [ "Объект успешно удалён", - "Объекта успешно удалены", - "Объектов успешно удалено" + "Объекты успешно удалены", + "Объекты успешно удалены", + "Объекты успешно удалены" ] } } diff --git a/l10n/sk.js b/l10n/sk.js index cee47e9037..2f917301a7 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -2556,7 +2556,7 @@ OC.L10N.register( "Zookeeper port number (optional, defaults to 2181)": "Číslo portu Zookeeper (voliteľné, predvolene 2181)", "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete ALL objects for schema \"{schema}\":\n\n\\u2022 Active objects: {active}\n\\u2022 Soft-deleted objects: {deleted}\n\\u2022 Total: {total}\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F UPOZORNENIE NA TRVALÉ ODSTRÁNENIE \\u26A0\\uFE0F\n\nChystáte sa NATRVALO odstrániť VŠETKY objekty pre schému \"{schema}\":\n\n\\u2022 Aktívne objekty: {active}\n\\u2022 Mäkko odstránené objekty: {deleted}\n\\u2022 Celkom: {total}\n\nTieto objekty budú úplne odstránené z databázy a NEBUDE ich možné obnoviť.\n\nNaozaj ste si úplne istí?", "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete {count} soft-deleted objects for schema \"{schema}\".\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F UPOZORNENIE NA TRVALÉ ODSTRÁNENIE \\u26A0\\uFE0F\n\nChystáte sa NATRVALO odstrániť {count} mäkko odstránených objektov pre schému \"{schema}\".\n\nTieto objekty budú úplne odstránené z databázy a NEBUDE ich možné obnoviť.\n\nNaozaj ste si úplne istí?", - "_%n entry has no hash yet_::_%n entries have no hash yet_": ["%n záznam zatiaľ nemá odtlačok","%n záznamy zatiaľ nemajú odtlačok","%n záznamov zatiaľ nemá odtlačok"], + "_%n entry has no hash yet_::_%n entries have no hash yet_": ["%n záznam zatiaľ nemá odtlačok","%n záznamy zatiaľ nemajú odtlačok","%n záznamu zatiaľ nemá odtlačok","%n záznamov zatiaľ nemá odtlačok"], "across {count} registers": "across {count} registers", "and accessible to all users in selected groups (or all users if no groups selected).": "a prístupné všetkým používateľom vo vybraných skupinách (alebo všetkým používateľom, ak nie sú vybrané žiadne skupiny).", "and add the files there.": "a pridajte tam súbory.", @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Deskriptory registrov", "ships v{shipped}": "dodáva v{shipped}", "State": "Stav", - "v{installed} → ships v{shipped}": "v{installed} → dodáva v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → dodáva v{shipped}", + "Where the automation lives": "Kde žije automatizácia", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Toky sú to, čo sa deje bez kliknutia: objekt označený pri uložení, aplikácia informovaná pri zmene záznamu. Tu ich čítate a upravujete. Teraz netreba nič stavať.", + "Open Flows in the menu": "Otvorte Toky v ponuke" }, - "nplurals=2; plural=(n != 1);" + "nplurals=4; plural=(n % 1 == 0 && n == 1 ? 0 : n % 1 == 0 && n >= 2 && n <= 4 ? 1 : n % 1 != 0 ? 2: 3);" ) diff --git a/l10n/sk.json b/l10n/sk.json index 3d17424ae7..32b1fa2f1c 100644 --- a/l10n/sk.json +++ b/l10n/sk.json @@ -2558,6 +2558,7 @@ "_%n entry has no hash yet_::_%n entries have no hash yet_": [ "%n záznam zatiaľ nemá odtlačok", "%n záznamy zatiaľ nemajú odtlačok", + "%n záznamu zatiaľ nemá odtlačok", "%n záznamov zatiaľ nemá odtlačok" ], "across {count} registers": "across {count} registers", @@ -2726,6 +2727,10 @@ "Register descriptors": "Deskriptory registrov", "ships v{shipped}": "dodáva v{shipped}", "State": "Stav", - "v{installed} → ships v{shipped}": "v{installed} → dodáva v{shipped}" - } + "v{installed} → ships v{shipped}": "v{installed} → dodáva v{shipped}", + "Where the automation lives": "Kde žije automatizácia", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Toky sú to, čo sa deje bez kliknutia: objekt označený pri uložení, aplikácia informovaná pri zmene záznamu. Tu ich čítate a upravujete. Teraz netreba nič stavať.", + "Open Flows in the menu": "Otvorte Toky v ponuke" + }, + "pluralForm": "nplurals=4; plural=(n % 1 == 0 && n == 1 ? 0 : n % 1 == 0 && n >= 2 && n <= 4 ? 1 : n % 1 != 0 ? 2: 3);" } diff --git a/l10n/sl.js b/l10n/sl.js index 301e97c50f..2ebf53cb79 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Deskriptorji registrov", "ships v{shipped}": "dobavlja v{shipped}", "State": "Stanje", - "v{installed} → ships v{shipped}": "v{installed} → dobavlja v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → dobavlja v{shipped}", + "Where the automation lives": "Kje živi avtomatizacija", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Tokovi so tisto, kar se zgodi brez klika: predmet, žigosan ob shranjevanju, aplikacija, obveščena ob spremembi zapisa. Tu jih berete in urejate. Zdaj ni treba ničesar graditi.", + "Open Flows in the menu": "Odprite Tokove v meniju" }, - "nplurals=2; plural=(n != 1);" + "nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3);" ) diff --git a/l10n/sl.json b/l10n/sl.json index 053c2d5323..d1982bd73f 100644 --- a/l10n/sl.json +++ b/l10n/sl.json @@ -2727,31 +2727,47 @@ "Register descriptors": "Deskriptorji registrov", "ships v{shipped}": "dobavlja v{shipped}", "State": "Stanje", - "v{installed} → ships v{shipped}": "v{installed} → dobavlja v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → dobavlja v{shipped}", + "Where the automation lives": "Kje živi avtomatizacija", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Tokovi so tisto, kar se zgodi brez klika: predmet, žigosan ob shranjevanju, aplikacija, obveščena ob spremembi zapisa. Tu jih berete in urejate. Zdaj ni treba ničesar graditi.", + "Open Flows in the menu": "Odprite Tokove v meniju" }, + "pluralForm": "nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3);", "plurals": { "{count} email": [ "{count} e-pošta", - "{count} e-pošti" + "{count} e-pošti", + "{count} e-pošte", + "{count} e-pošt" ], "Purge {count} object from database": [ "Trajno odstrani {count} objekt iz baze podatkov", + "Trajno odstrani {count} objekta iz baze podatkov", + "Trajno odstrani {count} objekte iz baze podatkov", "Trajno odstrani {count} objektov iz baze podatkov" ], "Restore {count} object": [ "Obnovi {count} objekt", + "Obnovi {count} objekta", + "Obnovi {count} objekte", "Obnovi {count} objektov" ], "Successfully restored {count} object": [ "Uspešno obnovljen {count} objekt", - "Uspešno obnovljeno {count} objektov" + "Uspešno obnovljena {count} objekta", + "Uspešno obnovljeni {count} objekti", + "Uspešno obnovljenih {count} objektov" ], "Delete {count} object": [ "Izbriši {count} objekt", + "Izbriši {count} objekta", + "Izbriši {count} objekte", "Izbriši {count} objektov" ], "Object successfully deleted": [ "Objekt je uspešno izbrisan", + "Objekta sta uspešno izbrisana", + "Objekti so uspešno izbrisani", "Objekti so uspešno izbrisani" ] } diff --git a/l10n/sq.js b/l10n/sq.js index c4ff8ce5cd..d308622ac5 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Përshkrues regjistrash", "ships v{shipped}": "jep v{shipped}", "State": "Gjendja", - "v{installed} → ships v{shipped}": "v{installed} → jep v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → jep v{shipped}", + "Where the automation lives": "Ku jeton automatizimi", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Rrjedhat janë ajo që ndodh pa klikuar askush: një objekt i vulosur në ruajtje, një aplikacion i njoftuar kur ndryshon një regjistrim. Këtu i lexoni dhe i redaktoni. Tani nuk ka gjë për të ndërtuar.", + "Open Flows in the menu": "Hapni Rrjedhat në meny" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sq.json b/l10n/sq.json index bef3041f17..cc3b3d7a97 100644 --- a/l10n/sq.json +++ b/l10n/sq.json @@ -2725,8 +2725,12 @@ "Register descriptors": "Përshkrues regjistrash", "ships v{shipped}": "jep v{shipped}", "State": "Gjendja", - "v{installed} → ships v{shipped}": "v{installed} → jep v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → jep v{shipped}", + "Where the automation lives": "Ku jeton automatizimi", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Rrjedhat janë ajo që ndodh pa klikuar askush: një objekt i vulosur në ruajtje, një aplikacion i njoftuar kur ndryshon një regjistrim. Këtu i lexoni dhe i redaktoni. Tani nuk ka gjë për të ndërtuar.", + "Open Flows in the menu": "Hapni Rrjedhat në meny" }, + "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { "{count} email": [ "{count} email", diff --git a/l10n/sr.js b/l10n/sr.js index 2a16b0cd81..87318cdc62 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Дескриптори регистара", "ships v{shipped}": "испоручује v{shipped}", "State": "Стање", - "v{installed} → ships v{shipped}": "v{installed} → испоручује v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → испоручује v{shipped}", + "Where the automation lives": "Где живи аутоматизација", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Токови су оно што се дешава без клика: објекат означен при чувању, апликација обавештена када се запис промени. Овде их читате и уређујете. Сада нема шта да се гради.", + "Open Flows in the menu": "Отворите Токове у менију" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" ) diff --git a/l10n/sr.json b/l10n/sr.json index 74cb057493..8f9f96e6d5 100644 --- a/l10n/sr.json +++ b/l10n/sr.json @@ -2726,31 +2726,41 @@ "Register descriptors": "Дескриптори регистара", "ships v{shipped}": "испоручује v{shipped}", "State": "Стање", - "v{installed} → ships v{shipped}": "v{installed} → испоручује v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → испоручује v{shipped}", + "Where the automation lives": "Где живи аутоматизација", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Токови су оно што се дешава без клика: објекат означен при чувању, апликација обавештена када се запис промени. Овде их читате и уређујете. Сада нема шта да се гради.", + "Open Flows in the menu": "Отворите Токове у менију" }, + "pluralForm": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", "plurals": { "{count} email": [ "{count} e-pošta", - "{count} e-pošte" + "{count} e-pošte", + "{count} e-pošta" ], "Purge {count} object from database": [ "Trajno ukloni {count} objekt iz baze podataka", + "Trajno ukloni {count} objekta iz baze podataka", "Trajno ukloni {count} objekata iz baze podataka" ], "Restore {count} object": [ "Vrati {count} objekt", + "Vrati {count} objekta", "Vrati {count} objekata" ], "Successfully restored {count} object": [ "Uspješno vraćen {count} objekt", + "Uspješno vraćena {count} objekta", "Uspješno vraćeno {count} objekata" ], "Delete {count} object": [ "Izbriši {count} objekt", + "Izbriši {count} objekta", "Izbriši {count} objekata" ], "Object successfully deleted": [ "Objekt je uspješno izbrisan", + "Objekti su uspješno izbrisani", "Objekti su uspješno izbrisani" ] } diff --git a/l10n/sv.js b/l10n/sv.js index ba87df1681..ff72868074 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Registerdeskriptorer", "ships v{shipped}": "levererar v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → levererar v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → levererar v{shipped}", + "Where the automation lives": "Där automatiseringen bor", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flöden är det som händer utan att någon klickar: ett objekt som stämplas vid sparande, en app som meddelas när en post ändras. Här läser och redigerar du dem. Inget att bygga nu.", + "Open Flows in the menu": "Öppna Flöden i menyn" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sv.json b/l10n/sv.json index c0d2247143..b5fec279ae 100644 --- a/l10n/sv.json +++ b/l10n/sv.json @@ -2725,8 +2725,12 @@ "Register descriptors": "Registerdeskriptorer", "ships v{shipped}": "levererar v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → levererar v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → levererar v{shipped}", + "Where the automation lives": "Där automatiseringen bor", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flöden är det som händer utan att någon klickar: ett objekt som stämplas vid sparande, en app som meddelas när en post ändras. Här läser och redigerar du dem. Inget att bygga nu.", + "Open Flows in the menu": "Öppna Flöden i menyn" }, + "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { "{count} email": [ "{count} e-post", diff --git a/l10n/tr.js b/l10n/tr.js index a49108eb7f..c36af23ef4 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Kayıt tanımlayıcıları", "ships v{shipped}": "v{shipped} sunuyor", "State": "Durum", - "v{installed} → ships v{shipped}": "v{installed} → v{shipped} sunuyor" + "v{installed} → ships v{shipped}": "v{installed} → v{shipped} sunuyor", + "Where the automation lives": "Otomasyonun yaşadığı yer", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Akışlar kimse tıklamadan olan şeylerdir: kaydederken damgalanan bir nesne, bir kayıt değiştiğinde haber verilen bir uygulama. Bunları burada okur ve düzenlersiniz. Şimdi inşa edilecek bir şey yok.", + "Open Flows in the menu": "Menüden Akışlar'ı açın" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/tr.json b/l10n/tr.json index 7437cea572..2977ec6106 100644 --- a/l10n/tr.json +++ b/l10n/tr.json @@ -2725,8 +2725,12 @@ "Register descriptors": "Kayıt tanımlayıcıları", "ships v{shipped}": "v{shipped} sunuyor", "State": "Durum", - "v{installed} → ships v{shipped}": "v{installed} → v{shipped} sunuyor" + "v{installed} → ships v{shipped}": "v{installed} → v{shipped} sunuyor", + "Where the automation lives": "Otomasyonun yaşadığı yer", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Akışlar kimse tıklamadan olan şeylerdir: kaydederken damgalanan bir nesne, bir kayıt değiştiğinde haber verilen bir uygulama. Bunları burada okur ve düzenlersiniz. Şimdi inşa edilecek bir şey yok.", + "Open Flows in the menu": "Menüden Akışlar'ı açın" }, + "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { "{count} email": [ "{count} e-posta", diff --git a/l10n/uk.js b/l10n/uk.js index 7e875fbb16..df3dc22ddc 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -2556,7 +2556,7 @@ OC.L10N.register( "Zookeeper port number (optional, defaults to 2181)": "Номер порта Zookeeper (необязательно, за замовчуванням 2181)", "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete ALL objects for schema \"{schema}\":\n\n\\u2022 Active objects: {active}\n\\u2022 Soft-deleted objects: {deleted}\n\\u2022 Total: {total}\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F ПРЕДУПРЕЖДЕНИЕ Про БЕЗВОЗВРАТНОМ УДАЛЕНИИ \\u26A0\\uFE0F\n\nВи собираетесь БЕЗПОВОРОТНО видалити ВСІ об'єкти схеми \"{schema}\":\n\n\\u2022 Активних об'єктів: {active}\n\\u2022 Програмно видалених об'єктів: {deleted}\n\\u2022 Итого: {total}\n\nЦі об'єкти будуть полностью видалено з бази даних і НЕ МОЖУТЬ бути відновлено.\n\nВи абсолютно впевнені?", "\\u26A0\\uFE0F PERMANENT DELETION WARNING \\u26A0\\uFE0F\n\nYou are about to PERMANENTLY delete {count} soft-deleted objects for schema \"{schema}\".\n\nThese objects will be completely removed from the database and CANNOT be recovered.\n\nAre you absolutely sure?": "\\u26A0\\uFE0F ПРЕДУПРЕЖДЕНИЕ Про БЕЗВОЗВРАТНОМ УДАЛЕНИИ \\u26A0\\uFE0F\n\nВи собираетесь БЕЗПОВОРОТНО видалити {count} програмно видалених об'єктів схеми \"{schema}\".\n\nЦі об'єкти будуть полностью видалено з бази даних і НЕ МОЖУТЬ бути відновлено.\n\nВи абсолютно впевнені?", - "_%n entry has no hash yet_::_%n entries have no hash yet_": ["%n запис ще не має хешу","%n записи ще не мають хешу","%n записів ще не мають хешу"], + "_%n entry has no hash yet_::_%n entries have no hash yet_": ["%n запис ще не має хешу","%n записи ще не мають хешу","%n записів ще не мають хешу","%n запису ще не має хешу"], "across {count} registers": "across {count} registers", "and accessible to all users in selected groups (or all users if no groups selected).": "і доступно всім пользователям у вибраних группах (або всім пользователям, якщо групи не вибрано).", "and add the files there.": "і додайте файли туда.", @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Дескриптори реєстрів", "ships v{shipped}": "постачає v{shipped}", "State": "Стан", - "v{installed} → ships v{shipped}": "v{installed} → постачає v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → постачає v{shipped}", + "Where the automation lives": "Де живе автоматизація", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Потоки — це те, що відбувається без кліків: об'єкт, що позначається під час збереження, застосунок, сповіщений про зміну запису. Тут ви їх читаєте та редагуєте. Зараз нічого будувати не потрібно.", + "Open Flows in the menu": "Відкрийте Потоки в меню" }, - "nplurals=2; plural=(n != 1);" + "nplurals=4; plural=(n % 1 == 0 && n % 10 == 1 && n % 100 != 11 ? 0 : n % 1 == 0 && n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) ? 1 : n % 1 == 0 && (n % 10 ==0 || (n % 10 >=5 && n % 10 <=9) || (n % 100 >=11 && n % 100 <=14 )) ? 2: 3);" ) diff --git a/l10n/uk.json b/l10n/uk.json index 64075a88f9..1ffa93ca3a 100644 --- a/l10n/uk.json +++ b/l10n/uk.json @@ -2558,7 +2558,8 @@ "_%n entry has no hash yet_::_%n entries have no hash yet_": [ "%n запис ще не має хешу", "%n записи ще не мають хешу", - "%n записів ще не мають хешу" + "%n записів ще не мають хешу", + "%n запису ще не має хешу" ], "across {count} registers": "across {count} registers", "and accessible to all users in selected groups (or all users if no groups selected).": "і доступно всім пользователям у вибраних группах (або всім пользователям, якщо групи не вибрано).", @@ -2726,38 +2727,48 @@ "Register descriptors": "Дескриптори реєстрів", "ships v{shipped}": "постачає v{shipped}", "State": "Стан", - "v{installed} → ships v{shipped}": "v{installed} → постачає v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → постачає v{shipped}", + "Where the automation lives": "Де живе автоматизація", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Потоки — це те, що відбувається без кліків: об'єкт, що позначається під час збереження, застосунок, сповіщений про зміну запису. Тут ви їх читаєте та редагуєте. Зараз нічого будувати не потрібно.", + "Open Flows in the menu": "Відкрийте Потоки в меню" }, + "pluralForm": "nplurals=4; plural=(n % 1 == 0 && n % 10 == 1 && n % 100 != 11 ? 0 : n % 1 == 0 && n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) ? 1 : n % 1 == 0 && (n % 10 ==0 || (n % 10 >=5 && n % 10 <=9) || (n % 100 >=11 && n % 100 <=14 )) ? 2: 3);", "plurals": { "{count} email": [ "{count} лист", "{count} листи", - "{count} листів" + "{count} листів", + "{count} листа" ], "Purge {count} object from database": [ "Очистити {count} об'єкт з бази даних", - "Очистити {count} об'єкта з бази даних", - "Очистити {count} об'єктів з бази даних" + "Очистити {count} об'єкти з бази даних", + "Очистити {count} об'єктів з бази даних", + "Очистити {count} об'єкта з бази даних" ], "Restore {count} object": [ "Відновити {count} об'єкт", - "Відновити {count} об'єкта", - "Відновити {count} об'єктів" + "Відновити {count} об'єкти", + "Відновити {count} об'єктів", + "Відновити {count} об'єкта" ], "Successfully restored {count} object": [ "Успішно відновлено {count} об'єкт", - "Успішно відновлено {count} об'єкта", - "Успішно відновлено {count} об'єктів" + "Успішно відновлено {count} об'єкти", + "Успішно відновлено {count} об'єктів", + "Успішно відновлено {count} об'єкта" ], "Delete {count} object": [ "Видалити {count} об'єкт", - "Видалити {count} об'єкта", - "Видалити {count} об'єктів" + "Видалити {count} об'єкти", + "Видалити {count} об'єктів", + "Видалити {count} об'єкта" ], "Object successfully deleted": [ "Об'єкт успішно видалено", - "Об'єкта успішно видалено", - "Об'єктів успішно видалено" + "Об'єкти успішно видалено", + "Об'єкти успішно видалено", + "Об'єкти успішно видалено" ] } } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index e3749f1a33..45973c2c9c 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -168,6 +168,7 @@ use OCA\OpenRegister\Service\File\FolderManagementHandler; use OCA\OpenRegister\Service\File\Pdf\Fallback\NullNcOfficeConverter; use OCA\OpenRegister\Service\FileService; +use OCA\OpenRegister\Service\Flow\FlowRunContext; use OCA\OpenRegister\Service\Flow\RegistryStepDispatcher; use OCA\OpenRegister\Service\FlowLinkService; use OCA\OpenRegister\Service\Gdpr\Evidence\EvidenceSourceRegistry; @@ -413,6 +414,18 @@ function () { } ); + // The ambient flow-attribution stack MUST be shared. Nextcloud's + // container builds an auto-wired class fresh at every injection point, + // so without this registration the engine would push frames onto one + // instance and the audit mapper would read an empty stack on another — + // attributing nothing at all, silently and with no error anywhere. + $context->registerService( + FlowRunContext::class, + function () { + return new FlowRunContext(); + } + ); + // Register the LanguageMiddleware for Accept-Language header parsing. $context->registerMiddleware(LanguageMiddleware::class); diff --git a/lib/Command/ImportSkosCsvCommand.php b/lib/Command/ImportSkosCsvCommand.php new file mode 100644 index 0000000000..3af9e024e6 --- /dev/null +++ b/lib/Command/ImportSkosCsvCommand.php @@ -0,0 +1,143 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/specs/skos-concept-registers/spec.md#requirement-idempotent-skos-import-keyed-on-uri-skos-002 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Command; + +use InvalidArgumentException; +use OCA\OpenRegister\Service\VocabularyImportService; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Throwable; + +/** + * `occ openregister:vocabulary:import-csv` — load a SKOS scheme from CSV. + * + * @spec openspec/specs/skos-concept-registers/spec.md#requirement-idempotent-skos-import-keyed-on-uri-skos-002 + */ +class ImportSkosCsvCommand extends Command { + /** + * Wire the import service. + * + * @param VocabularyImportService $vocabulary Performs the idempotent import. + * + * @spec openspec/specs/skos-concept-registers/spec.md#requirement-idempotent-skos-import-keyed-on-uri-skos-002 + */ + public function __construct( + private readonly VocabularyImportService $vocabulary, + ) { + parent::__construct(); + + }//end __construct() + + /** + * Define the command name, argument and options. + * + * @return void + * + * @spec openspec/specs/skos-concept-registers/spec.md#requirement-idempotent-skos-import-keyed-on-uri-skos-002 + */ + protected function configure(): void { + $this->setName(name: 'openregister:vocabulary:import-csv') + ->setDescription( + 'Import a SKOS concept scheme from a CSV value list. The import is idempotent and keyed on ' + . 'concept URI, so re-running it updates rather than duplicating.' + ) + ->addArgument('file', InputArgument::REQUIRED, 'Path to the CSV file, on this server') + ->addOption('scheme-uri', null, InputOption::VALUE_REQUIRED, 'The concept scheme URI (required)') + ->addOption('scheme-title', null, InputOption::VALUE_REQUIRED, 'A human title for the scheme') + ->addOption('register', null, InputOption::VALUE_REQUIRED, 'Register slug or id to import into') + ->addOption('schema', null, InputOption::VALUE_REQUIRED, 'Schema slug or id to import into'); + + }//end configure() + + /** + * Run the import and report what it changed. + * + * Reports the four counts the service returns rather than a bare "done": + * an idempotent import's whole point is that the SECOND run changes + * nothing, and that is only visible if the numbers are printed. + * + * @param InputInterface $input Console input. + * @param OutputInterface $output Console output. + * + * @return integer Symfony exit code. + * + * @spec openspec/specs/skos-concept-registers/spec.md#requirement-idempotent-skos-import-keyed-on-uri-skos-002 + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $file = (string)$input->getArgument('file'); + $schemeUri = (string)($input->getOption('scheme-uri') ?? ''); + + if ($schemeUri === '') { + $output->writeln('--scheme-uri is required: a SKOS import keyed on URI needs the scheme it belongs to.'); + return Command::INVALID; + } + + $meta = ['uri' => $schemeUri]; + foreach (['scheme-title' => 'title', 'register' => 'register', 'schema' => 'schema'] as $option => $key) { + $value = (string)($input->getOption($option) ?? ''); + if ($value !== '') { + $meta[$key] = $value; + } + } + + try { + $result = $this->vocabulary->importCsvValueList(csvPath: $file, schemeMeta: $meta); + } catch (InvalidArgumentException $e) { + // A bad path or an empty file is the OPERATOR's mistake, not a + // crash: report it as invalid input so a script can tell the two + // apart by exit code. + $output->writeln('' . $e->getMessage() . ''); + return Command::INVALID; + } catch (Throwable $e) { + $output->writeln('Import failed: ' . $e->getMessage() . ''); + return Command::FAILURE; + }//end try + + $output->writeln(sprintf( + 'Imported scheme %s — created %d, updated %d, unchanged %d, deprecated %d.', + (string)($result['scheme'] ?? $schemeUri), + (int)($result['created'] ?? 0), + (int)($result['updated'] ?? 0), + (int)($result['unchanged'] ?? 0), + (int)($result['deprecated'] ?? 0) + )); + + return Command::SUCCESS; + + }//end execute() +}//end class diff --git a/lib/Command/RematerialiseCalculationsCommand.php b/lib/Command/RematerialiseCalculationsCommand.php index d864bbaf44..7b581193e1 100644 --- a/lib/Command/RematerialiseCalculationsCommand.php +++ b/lib/Command/RematerialiseCalculationsCommand.php @@ -210,6 +210,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int continue; } + // A `sequence` reserves exactly once, on create. This command has no + // SequenceContext, so re-evaluating would resolve the node to null and + // `concat` would render it as "", replacing every assigned number with + // a truncated stub ("2026-0013" -> "2026-"). Never rewrite those. + // openregister#3075. + if ($this->evaluator->expressionUsesSequence($spec['expression'] ?? null) === true) { + continue; + } + try { $value = $this->evaluator->evaluate($payload, $spec['expression'] ?? null); if ($value instanceof DateTimeInterface) { diff --git a/lib/Controller/FilesController.php b/lib/Controller/FilesController.php index 90a4248e2c..ad08125b1f 100644 --- a/lib/Controller/FilesController.php +++ b/lib/Controller/FilesController.php @@ -2116,6 +2116,88 @@ public function preview(string $register, string $schema, string $id, int $fileI }//end try }//end preview() + /** + * Update a file's OpenRegister-side metadata: description, category, labels. + * + * 🔴 DESCRIPTION AND CATEGORY HAD NO SURFACE AT ALL. + * `FileMapper::setDescriptionForFile()` and `setCategoryForFile()` are + * reached only from `UpdateFileHandler::updateFileMetadata()`, and nothing + * called that — so two thirds of the metadata the `file-actions` spec + * describes could be stored by the data model and set by nobody. Labels had + * their own route and worked; that is why the gap was easy to miss. + * + * Each field is nullable and SKIPPED when null, which is what lets a caller + * change one without clearing the others. An empty string clears a field — + * that distinction is the handler's contract and is preserved here rather + * than flattened by a truthiness check. + * + * @param string $register Register slug. + * @param string $schema Schema slug. + * @param string $id Object ID. + * @param integer $fileId File ID. + * + * @return JSONResponse The updated metadata, 403, or 404. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @spec openspec/specs/file-actions/spec.md + */ + #[AnonRateLimit(limit: 30, period: 60)] + public function updateMetadata(string $register, string $schema, string $id, int $fileId): JSONResponse { + $this->setObjectContext(register: $register, schema: $schema); + + try { + // ADR-005 / gate-7: the same object-level RBAC updateLabels applies. + // Metadata is no less a mutation for being descriptive. + $this->ensureObjectAccess(register: $register, schema: $schema, id: $id); + + $this->objectService->setObject($id); + if ($this->objectService->getObject() === null) { + return new JSONResponse( + data: ['error' => $this->translate(text: 'Object not found')], + statusCode: 404 + ); + } + + $data = $this->request->getParams(); + + // Presence, not truthiness: the handler treats null as "leave alone" + // and '' as "clear", so `??` would make an empty description + // impossible to send — array_key_exists keeps the two distinct. + $description = null; + if (array_key_exists('description', $data) === true) { + $description = (string)$data['description']; + } + + $category = null; + if (array_key_exists('category', $data) === true) { + $category = (string)$data['category']; + } + + $labels = null; + if (array_key_exists('labels', $data) === true) { + $labels = (array)$data['labels']; + } + + $entity = $this->fileService->updateFileMetadata( + fileId: $fileId, + description: $description, + category: $category, + labels: $labels + ); + + return new JSONResponse(data: $entity->jsonSerialize()); + } catch (\OCA\OpenRegister\Exception\NotAuthorizedException $e) { + return new JSONResponse( + data: ['error' => $this->translate(text: 'You do not have access to this object')], + statusCode: 403 + ); + } catch (Exception $e) { + return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 400); + }//end try + }//end updateMetadata() + /** * Update file labels * diff --git a/lib/Controller/FlowController.php b/lib/Controller/FlowController.php index f759601844..b63836d845 100644 --- a/lib/Controller/FlowController.php +++ b/lib/Controller/FlowController.php @@ -42,9 +42,12 @@ use OCA\OpenRegister\Service\Flow\EventCatalogService; use OCA\OpenRegister\Service\Flow\FlowAccess; use OCA\OpenRegister\Service\Flow\FlowDeadEnd; +use OCA\OpenRegister\Service\Flow\FlowLifecycleRefused; use OCA\OpenRegister\Service\Flow\FlowNodePreflight; use OCA\OpenRegister\Service\Flow\FlowNodeRegistry; +use OCA\OpenRegister\Service\Flow\FlowRunVersionPin; use OCA\OpenRegister\Service\Flow\FlowService; +use OCA\OpenRegister\Service\Flow\FlowVersionService; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; @@ -103,6 +106,7 @@ class FlowController extends Controller { * @param FlowNodePreflight $preflight Resolves a document's step types. * @param FlowService $flows Reads, writes and runs flow definitions. * @param FlowAccess $access Who may do what with a flow. + * @param FlowVersionService $flowVersionService Reads, publishes, drafts and deprecates versions. */ public function __construct( string $appName, @@ -113,6 +117,7 @@ public function __construct( private readonly FlowNodePreflight $preflight, private readonly FlowService $flows, private readonly FlowAccess $access, + private readonly FlowVersionService $flowVersionService, ) { parent::__construct(appName: $appName, request: $request); }//end __construct() @@ -615,9 +620,41 @@ private function saveOrRefuse(array $data, ?string $uuid = null): Flow|JSONRespo return $this->flows->save(data: $data, uuid: $uuid); } catch (InvalidArgumentException $e) { return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (FlowLifecycleRefused $e) { + // 409, not 400. The request is well-formed; the flow's STATE is + // what refuses it, and that state can change — the author creates + // a draft and the identical request then succeeds. A 400 would + // tell the editor to fix the payload, which is the wrong advice. + return $this->refusal(refusal: $e); } }//end saveOrRefuse() + /** + * A lifecycle refusal as a 409 the editor can act on. + * + * 🔑 THE REASON IS A FIELD, NOT PROSE. "This version is published, create a + * draft" and "this flow has no published version, publish one" want + * opposite buttons from the author; parsing an English sentence to tell + * them apart is how a UI ends up offering the wrong one. + * + * @param FlowLifecycleRefused $refusal The refusal. + * + * @return JSONResponse The 409. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + private function refusal(FlowLifecycleRefused $refusal): JSONResponse { + return new JSONResponse( + [ + 'error' => $refusal->getMessage(), + 'reason' => $refusal->getReason(), + 'lifecycleStatus' => $refusal->getState(), + 'flowId' => $refusal->getFlowId(), + ], + Http::STATUS_CONFLICT + ); + }//end refusal() + /** * The stored flow, plus any warning the author should see about it. * @@ -751,12 +788,29 @@ public function run(string $id): JSONResponse { $sync = ($syncParam === true || $syncParam === 'true' || $syncParam === '1' || $syncParam === 1); try { + // THIS ENDPOINT IS THE EDITOR'S "RUN NOW", and that is the + // interactive draft test run — the one documented exception to "a + // draft cannot back a run" (`FlowPublishedGraph::overlayOnto`). + // + // Versioning (#3047) made every other trigger require a published + // version, correctly. But this path queued as MANUAL, so pressing + // Run in the editor on a flow that had never been published was + // refused — the exception the design carves out could not be + // reached from the only screen that needs it. Naming the trigger + // here is what connects the two. $flowRun = $this->flows->run( uuid: $id, subject: (array)$subject, context: (array)$context, - sync: $sync + sync: $sync, + trigger: FlowRunVersionPin::TRIGGER_TEST ); + } catch (FlowLifecycleRefused $e) { + // A REFUSAL, not a fault. It carries `reason` and `lifecycleStatus` + // precisely so the editor can offer the right button; letting it + // escape as a 500 threw that away and told the author only that + // something broke. + return $this->refusal(refusal: $e); } catch (DoesNotExistException $e) { return new JSONResponse(['error' => 'No such flow'], Http::STATUS_NOT_FOUND); } catch (FlowDeadEnd $e) { @@ -788,6 +842,191 @@ public function run(string $id): JSONResponse { return new JSONResponse($flowRun->jsonSerialize(), Http::STATUS_CREATED); }//end run() + /** + * List a flow's versions, newest first. + * + * @param string $id The flow uuid. + * + * @return JSONResponse The versions, or 404. + * + * @NoAdminRequired + * + * @no-admin-idor-exempt Guarded downstream: the flow is resolved through + * `FlowService::find()`, which is organisation-scoped, so versions of a + * flow the caller cannot see are refused as "no such flow". + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + #[NoAdminRequired] + public function versions(string $id): JSONResponse { + $denied = $this->denyUnless(action: 'flow.read'); + if ($denied !== null) { + return $denied; + } + + try { + $flow = $this->flows->find(uuid: $id); + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'No such flow'], Http::STATUS_NOT_FOUND); + } + + $rows = $this->flowVersionService->versionsOf(flowUuid: (string)$flow->getUuid()); + + return new JSONResponse([ + 'results' => array_map(static fn ($row): array => $row->jsonSerialize(), $rows), + 'total' => count($rows), + ]); + }//end versions() + + /** + * Read one version of a flow, including the graph it names. + * + * @param string $id The flow uuid. + * @param integer $version The version number. + * + * @return JSONResponse The version and its graph, or 404. + * + * @NoAdminRequired + * + * @no-admin-idor-exempt Guarded downstream: see versions(). + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + #[NoAdminRequired] + public function version(string $id, int $version): JSONResponse { + $denied = $this->denyUnless(action: 'flow.read'); + if ($denied !== null) { + return $denied; + } + + try { + $flow = $this->flows->find(uuid: $id); + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'No such flow'], Http::STATUS_NOT_FOUND); + } + + $row = $this->flowVersionService->versionOf(flowUuid: (string)$flow->getUuid(), number: $version); + if ($row === null) { + return new JSONResponse(['error' => 'No such version'], Http::STATUS_NOT_FOUND); + } + + $body = $row->jsonSerialize(); + $body['graph'] = $this->flowVersionService->graphOfVersion(version: $row); + + return new JSONResponse($body); + }//end version() + + /** + * Publish the flow's draft head. + * + * @param string $id The flow uuid. + * + * @return JSONResponse The published version, 404, or 409. + * + * @NoAdminRequired + * + * @no-admin-idor-exempt Guarded downstream: see versions(). + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + #[NoAdminRequired] + public function publish(string $id): JSONResponse { + $denied = $this->denyUnless(action: 'flow.update'); + if ($denied !== null) { + return $denied; + } + + try { + $flow = $this->flows->find(uuid: $id); + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'No such flow'], Http::STATUS_NOT_FOUND); + } + + try { + $version = $this->flowVersionService->publish( + flow: $flow, + // From FlowAccess, which already holds the session — a second + // IUserSession here would push this constructor past the + // parameter limit for one string. + publishedBy: $this->access->currentUser()?->getUID() + ); + } catch (FlowLifecycleRefused $e) { + return $this->refusal(refusal: $e); + } + + return new JSONResponse($version->jsonSerialize()); + }//end publish() + + /** + * Create a draft version from the published one. + * + * @param string $id The flow uuid. + * + * @return JSONResponse The new draft version, 404, or 409. + * + * @NoAdminRequired + * + * @no-admin-idor-exempt Guarded downstream: see versions(). + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + #[NoAdminRequired] + public function draft(string $id): JSONResponse { + $denied = $this->denyUnless(action: 'flow.update'); + if ($denied !== null) { + return $denied; + } + + try { + $flow = $this->flows->find(uuid: $id); + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'No such flow'], Http::STATUS_NOT_FOUND); + } + + try { + $version = $this->flowVersionService->createDraft(flow: $flow); + } catch (FlowLifecycleRefused $e) { + return $this->refusal(refusal: $e); + } + + return new JSONResponse($version->jsonSerialize(), Http::STATUS_CREATED); + }//end draft() + + /** + * Retire the published version, so the flow backs no new runs. + * + * @param string $id The flow uuid. + * + * @return JSONResponse The deprecated version, 404, or 409. + * + * @NoAdminRequired + * + * @no-admin-idor-exempt Guarded downstream: see versions(). + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + #[NoAdminRequired] + public function deprecate(string $id): JSONResponse { + $denied = $this->denyUnless(action: 'flow.update'); + if ($denied !== null) { + return $denied; + } + + try { + $flow = $this->flows->find(uuid: $id); + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'No such flow'], Http::STATUS_NOT_FOUND); + } + + try { + $version = $this->flowVersionService->deprecate(flow: $flow); + } catch (FlowLifecycleRefused $e) { + return $this->refusal(refusal: $e); + } + + return new JSONResponse($version->jsonSerialize()); + }//end deprecate() + /** * The flow fields carried on a create or update request. * diff --git a/lib/Controller/FlowRunController.php b/lib/Controller/FlowRunController.php index c822790c7a..e6902d8118 100644 --- a/lib/Controller/FlowRunController.php +++ b/lib/Controller/FlowRunController.php @@ -28,11 +28,14 @@ namespace OCA\OpenRegister\Controller; +use OCA\OpenRegister\Db\AuditFlowAttribution; use OCA\OpenRegister\Db\FlowRun; use OCA\OpenRegister\Db\FlowRunMapper; use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowDeadEnd; +use OCA\OpenRegister\Service\Flow\FlowLifecycleRefused; use OCA\OpenRegister\Service\Flow\FlowLocator; -use OCA\OpenRegister\Service\Flow\FlowResumeState; +use OCA\OpenRegister\Service\Flow\FlowRunAssignee; use OCA\OpenRegister\Service\Flow\FlowRunService; use OCA\OpenRegister\Service\Flow\FlowService; use OCA\OpenRegister\Service\OrganisationService; @@ -63,8 +66,8 @@ * deliberately unauthenticated because it runs flows as their owner with no * session. Moving them out would either re-open the bypass or add a second * indirection over four small private helpers. - * @SuppressWarnings(PHPMD.ExcessiveParameterList) Ten constructor parameters, the - * last three nullable-with-default precisely so adding them broke no existing + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Eleven constructor parameters, the + * last four nullable-with-default precisely so adding them broke no existing * construction site. They are collaborators of those same guards, not options. */ class FlowRunController extends Controller { @@ -87,6 +90,13 @@ class FlowRunController extends Controller { * native flow store. Nullable for the same * reason as $groupManager: absent yields no * owned ids, which scopes rather than widens. + * @param AuditFlowAttribution|null $auditTrails Reads the attribution stamped on + * audit rows, for the objects a run + * touched. Nullable and LAST so + * adding it shifts no positional + * caller; absent, the endpoint + * reports the surface unavailable + * rather than an empty run. */ public function __construct( string $appName, @@ -98,6 +108,10 @@ public function __construct( private readonly OrganisationService $organisationService, private readonly ?IGroupManager $groupManager = null, private readonly ?FlowService $flows = null, + // Appended LAST and nullable on purpose: a new constructor argument + // inserted anywhere else shifts every positional caller, and the + // resulting TypeError names the argument AFTER the one that moved. + private readonly ?AuditFlowAttribution $auditTrails = null, ) { parent::__construct(appName: $appName, request: $request); @@ -349,15 +363,132 @@ private function currentStep(FlowRun $run): ?string { #[NoAdminRequired] #[NoCSRFRequired] public function show(string $uuid): JSONResponse { - try { - $run = $this->mapper->findByUuid($uuid); - } catch (DoesNotExistException $e) { + // Scoped, as `index()` has been since `shared-credentials-and-flows` + // D7. It was not, and the omission was the whole point of that scoping: + // a run's serialisation carries its log, which records the subject data + // the flow touched, so an unscoped read by uuid handed any authenticated + // caller the contents of anyone's run. + $run = $this->visibleRun(uuid: $uuid); + if ($run === null) { return new JSONResponse(['error' => 'No such run'], Http::STATUS_NOT_FOUND); } return new JSONResponse($run->jsonSerialize()); }//end show() + /** + * The objects one run touched, grouped by the node that touched them. + * + * A run recorded what it DID (its steps) and, of what it did it TO, only + * the object that triggered it. This reads back the attribution stamped on + * every audit row the run caused, which is the other half. + * + * @param string $uuid The run uuid. + * + * @return JSONResponse The touched objects grouped by node, or 404. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function objects(string $uuid): JSONResponse { + // Resolved through the SAME visibility rule as reading the run itself. + // A new endpoint that answered for runs `show()` refuses would widen + // access without anything looking like an access change. + $run = $this->visibleRun(uuid: $uuid); + if ($run === null) { + return new JSONResponse(['error' => 'No such run'], Http::STATUS_NOT_FOUND); + } + + if ($this->auditTrails === null) { + return new JSONResponse(['error' => 'Audit trail unavailable'], Http::STATUS_SERVICE_UNAVAILABLE); + } + + $rows = $this->auditTrails->findByRun(runUuid: $uuid); + + $byNode = []; + foreach ($rows as $row) { + $node = (string)$row->getFlowNode(); + + if (isset($byNode[$node]) === false) { + $byNode[$node] = [ + 'node' => $node, + 'step' => $row->getFlowStep(), + 'objects' => [], + ]; + } + + $byNode[$node]['objects'][] = [ + 'objectUuid' => $row->getObjectUuid(), + 'register' => $row->getRegister(), + 'schema' => $row->getSchema(), + 'action' => $row->getAction(), + 'step' => $row->getFlowStep(), + 'created' => $row->getCreated()?->format('c'), + 'auditUuid' => $row->getUuid(), + ]; + } + + // Step order, so a reader follows the run in the order it happened + // rather than in whatever order the rows came back. + usort( + $byNode, + static fn (array $a, array $b): int => (((int)$a['step']) <=> ((int)$b['step'])) + ); + + return new JSONResponse( + [ + 'run' => $uuid, + 'flowId' => $run->getFlowId(), + // An empty list is the honest answer for a run that wrote + // nothing, and for a suspended run that has not written + // anything YET. Neither is an error, and neither is withheld + // until the run finishes. + // usort() re-indexed $byNode into a list, so no array_values() + // here: it would be a no-op that reads as a safeguard. + 'nodes' => $byNode, + ] + ); + }//end objects() + + /** + * Resolve a run by uuid, or null when this caller may not see it. + * + * One place, so `show()` and `objects()` cannot drift apart on who may read + * a run. Delegates the predicate itself to the mapper, which is where the + * list read gets it too. + * + * A non-admin with no session resolves to null rather than falling through: + * a null uid means "no scoping" at the mapper, which is an ADMINISTRATOR's + * semantics, so treating an absent caller as one would turn having no + * identity into the widest possible read. + * + * @param string $uuid The run uuid. + * + * @return FlowRun|null The run, or null when absent or not visible. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + private function visibleRun(string $uuid): ?FlowRun { + if ($this->isAdmin() === true) { + return $this->mapper->findByUuidVisibleTo(uuid: $uuid, requesterUid: null); + } + + $uid = $this->callerUid(); + if ($uid === null || $uid === '') { + return null; + } + + return $this->mapper->findByUuidVisibleTo( + uuid: $uuid, + requesterUid: $uid, + ownedFlowIds: $this->flowIdsOwnedByCaller() + ); + }//end visibleRun() + /** * Retry a finished run: queue a fresh one, leave the original untouched. * @@ -517,28 +648,29 @@ private function refuseUnlessRunnable(string $flowId): ?JSONResponse { * @return JSONResponse|null A 403 when the caller is not the assignee. */ private function refuseUnlessAssignee(FlowRun $run): ?JSONResponse { - $assignee = $this->recordedAssignee(run: $run); + // The rule itself lives in FlowRunAssignee, because HTTP is no longer + // the only way to answer a step: a leaf app whose object completes a + // task resumes the run in-process through FlowRunService::signal(), + // which never passes this controller. One implementation, so the two + // paths cannot drift into disagreeing about who may answer. + $assignee = $this->assignees()->recordedFor(run: $run); if ($assignee === '') { return null; } $uid = $this->callerUid(); + + if ($this->assignees()->mayAnswer(run: $run, uid: $uid) === true) { + return null; + } + if ($uid === null) { - // Fail CLOSED: an assigned decision is never anonymous. return new JSONResponse( ['error' => 'This step is assigned; sign in as the assignee to answer it.'], Http::STATUS_FORBIDDEN ); } - if ($uid === $assignee) { - return null; - } - - if ($this->groupManager !== null && $this->groupManager->isInGroup($uid, $assignee) === true) { - return null; - } - return new JSONResponse( ['error' => 'This step is assigned to someone else.'], Http::STATUS_FORBIDDEN @@ -546,40 +678,19 @@ private function refuseUnlessAssignee(FlowRun $run): ?JSONResponse { }//end refuseUnlessAssignee() /** - * The assignee recorded by whichever step is currently awaiting a signal. + * The assignee rule, made on demand when none was injected. * - * Reads the per-node resume slots the node wrote. A run can carry slots for - * several nodes across its life, so the one that matters is a slot that - * asked (`askedAt`) and has not been answered. + * Built locally rather than required as a constructor argument so adding it + * breaks no existing construction site; it holds no state, so a locally + * made one is indistinguishable from an injected one. * - * @param FlowRun $run The suspended run. + * @return FlowRunAssignee The rule. * - * @return string The assignee uid or group id, '' when unassigned. + * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-suspended-on-an-external-signal-must-be-reachable */ - private function recordedAssignee(FlowRun $run): string { - $context = ($run->getContext() ?? []); - $slots = ($context[FlowResumeState::CONTEXT_KEY] ?? []); - if (is_array($slots) === false) { - return ''; - } - - foreach ($slots as $slot) { - if (is_array($slot) === false) { - continue; - } - - if (isset($slot['askedAt']) === false) { - continue; - } - - $assignee = trim((string)($slot['assignee'] ?? '')); - if ($assignee !== '') { - return $assignee; - } - } - - return ''; - }//end recordedAssignee() + private function assignees(): FlowRunAssignee { + return new FlowRunAssignee(groupManager: $this->groupManager); + }//end assignees() /** * The current caller's uid, or null when anonymous. @@ -710,21 +821,43 @@ public function test(): JSONResponse { // definition, so there is no reason for it to be the one dispatch path // that discards its actor. Same defect class as or#2158 in // FlowMcpToolProvider::runFlow(). - $run = $this->runner->queue( - flowId: $flowId, - subject: [], - trigger: 'test', - context: ['pins' => $pins], - user: $this->userSession->getUser()?->getUID() - ); + // 🔴 A REFUSAL MUST NOT LEAVE HERE AS A 500. A dead end, or a flow with + // no published version, is the engine DECLINING to run something — an + // answer the author can act on. Unwrapped, both reached the editor as + // an HTML error page, which reads as "the server is broken" and sends + // the author to the wrong place entirely. + try { + $run = $this->runner->queue( + flowId: $flowId, + subject: [], + trigger: 'test', + context: ['pins' => $pins], + user: $this->userSession->getUser()?->getUID() + ); - $run = $this->runner->execute( - run: $run, - flow: $flow, - subject: new stdClass(), - seedItems: $seed, - startAt: $startAt - ); + $run = $this->runner->execute( + run: $run, + flow: $flow, + subject: new stdClass(), + seedItems: $seed, + startAt: $startAt + ); + } catch (FlowLifecycleRefused $e) { + return new JSONResponse( + [ + 'error' => $e->getMessage(), + 'reason' => $e->getReason(), + 'lifecycleStatus' => $e->getState(), + 'flowId' => $e->getFlowId(), + ], + Http::STATUS_CONFLICT + ); + } catch (FlowDeadEnd $e) { + return new JSONResponse( + ['error' => $e->getMessage(), 'reason' => 'dead-end', 'flowId' => $flowId], + Http::STATUS_CONFLICT + ); + }//end try return new JSONResponse($run->jsonSerialize()); }//end test() diff --git a/lib/Controller/SetupController.php b/lib/Controller/SetupController.php new file mode 100644 index 0000000000..65db0bd995 --- /dev/null +++ b/lib/Controller/SetupController.php @@ -0,0 +1,180 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Controller; + +use OCA\OpenRegister\AppInfo\Application; +use OCA\OpenRegister\Settings\OpenRegisterAdmin; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IAppConfig; +use OCP\IRequest; +use Psr\Log\LoggerInterface; +use OCA\OpenRegister\Service\DemoDataService; + +/** + * First-time setup wizard endpoints. + * + * @spec exclude First-time-setup action dispatch; ADR-042 contract, no per-app behavioural spec. + */ +class SetupController extends Controller { + /** + * Setup contract version; matches manifest.setup.version. + * + * @var integer + */ + private const SETUP_VERSION = 1; + + /** + * App-config key recording that the demo-data step was DEALT WITH. + * + * Not "objects exist": an operator who declines has finished the step, and + * re-offering the import on every visit would make "no thanks" impossible to + * express. Since @conduction/nextcloud-vue 2.21 that also matters visually — + * an OUTSTANDING OPTIONAL step opens the wizard over every page + * (nextcloud-vue#806), so a step that can never be marked done is a dialog + * that never closes. + * + * @var string + */ + private const DEMO_DECIDED_KEY = 'demo_data_decided'; + + /** + * Constructor. + * + * @param IRequest $request The request. + * @param IAppConfig $appConfig Records the demo-data decision. + * @param LoggerInterface $logger Records a failed import. + * @param DemoDataService $demoDataService Imports the shipped demo dataset. + * + * @return void + */ + public function __construct( + IRequest $request, + private readonly IAppConfig $appConfig, + private readonly LoggerInterface $logger, + private readonly DemoDataService $demoDataService, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + + }//end __construct() + + /** + * Report per-step setup status for the wizard. + * + * `completed` is deliberately TRUE: this app declares no REQUIRED step, so + * setup must never gate the app. The demo-data step is reported so the wizard + * can stop asking once it has an answer. + * + * @return JSONResponse The status document. + * + * @spec exclude Setup status document; ADR-042 contract, no per-app behavioural spec. + */ + #[AuthorizedAdminSetting(OpenRegisterAdmin::class)] + public function status(): JSONResponse { + $demoDecided = $this->appConfig->getValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, '') !== ''; + + return new JSONResponse( + data: [ + 'version' => self::SETUP_VERSION, + 'completed' => true, + 'steps' => [ + 'demo-data' => ['done' => $demoDecided], + ], + ] + ); + + }//end status() + + /** + * Run a privileged server-side setup action. + * + * Admin-only by Nextcloud's default for an un-attributed method. + * + * @param string $actionId One of `install-demo-data` | `skip-demo-data`. + * + * @return JSONResponse `{ success, message }`. + * + * @spec exclude Setup action dispatch; ADR-042 contract, no per-app behavioural spec. + */ + #[AuthorizedAdminSetting(OpenRegisterAdmin::class)] + public function runAction(string $actionId): JSONResponse { + if ($actionId === 'install-demo-data') { + return $this->installDemoData(); + } + + // DECLINING IS AN ANSWER — see DEMO_DECIDED_KEY. + if ($actionId === 'skip-demo-data') { + $this->appConfig->setValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, 'skipped'); + + return new JSONResponse(data: ['success' => true, 'message' => 'Demo data skipped.']); + } + + return new JSONResponse( + data: ['success' => false, 'message' => 'Unknown setup action: ' . $actionId], + statusCode: Http::STATUS_NOT_FOUND, + ); + + }//end runAction() + + /** + * Import the shipped demo dataset. + * + * Reports the FAILURE rather than a quiet success: an operator who asked for + * demo data and got none must be told, which is why DemoDataService::install() + * throws instead of returning an empty result. + * + * @return JSONResponse `{ success, message }`. + */ + private function installDemoData(): JSONResponse { + try { + $imported = $this->demoDataService->install(); + } catch (\Throwable $e) { + $this->logger->error( + 'Setup install-demo-data failed: ' . $e->getMessage(), + ['app' => Application::APP_ID, 'exception' => $e] + ); + + return new JSONResponse( + data: ['success' => false, 'message' => 'Could not import the demo data: ' . $e->getMessage()], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR, + ); + } + + $this->appConfig->setValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, 'installed'); + + return new JSONResponse( + data: [ + 'success' => true, + 'message' => 'Imported ' . $imported['objects'] . ' demo object(s).', + ] + ); + + }//end installDemoData() +}//end class diff --git a/lib/Controller/TransferController.php b/lib/Controller/TransferController.php index b6adcd2e49..bf32605f5f 100644 --- a/lib/Controller/TransferController.php +++ b/lib/Controller/TransferController.php @@ -31,6 +31,7 @@ namespace OCA\OpenRegister\Controller; +use InvalidArgumentException; use OCA\OpenRegister\BackgroundJob\TransferExecutionJob; use OCA\OpenRegister\Service\Edepot\TransferListService; use OCA\OpenRegister\Service\Edepot\TransferRecordService; @@ -40,6 +41,7 @@ use OCP\AppFramework\Http\JSONResponse; use OCP\BackgroundJob\IJobList; use OCP\IRequest; +use OCP\IUserSession; use Psr\Log\LoggerInterface; /** @@ -61,6 +63,8 @@ class TransferController extends Controller { * @param TransferRecordService $transferRecordService Durable transfer-record persistence. * @param IJobList $jobList Background-job scheduler (dispatch execution). * @param LoggerInterface $logger Logger. + * @param TransferListService $transferListService Approves and rejects transfer lists. + * @param IUserSession $userSession Names the archivist who decided. */ public function __construct( $appName, @@ -68,6 +72,8 @@ public function __construct( private readonly TransferRecordService $transferRecordService, private readonly IJobList $jobList, private readonly LoggerInterface $logger, + private readonly TransferListService $transferListService, + private readonly IUserSession $userSession, ) { parent::__construct(appName: $appName, request: $request); @@ -111,6 +117,108 @@ public function show(string $id): JSONResponse { return new JSONResponse(data: $list); }//end show() + /** + * Approve a transfer list, so it can be dispatched. + * + * 🔴 WITHOUT THIS THE WHOLE FLOW WAS UNREACHABLE. `create()` refuses to + * dispatch anything that is not `approved`, and nothing could set that + * status: `TransferListService::approveTransferList()` was implemented, + * specified, and had no caller anywhere in the fleet. A list could be built + * and then never moved. The service was even already imported here — the + * wiring was started and left unfinished. + * + * @param string $id The transfer list uuid. + * + * @return JSONResponse The approved list, 404, or 409 when it is not in review. + * + * @spec openspec/specs/edepot-transfer/spec.md#requirement-the-system-must-support-transfer-list-management + */ + #[NoCSRFRequired] + public function approve(string $id): JSONResponse { + return $this->decide(id: $id, approve: true, reason: ''); + }//end approve() + + /** + * Reject a transfer list, with the reason the archivist gave. + * + * Unreachable for the same reason approve() was. A review that can only + * ever end in approval is not a review. + * + * @param string $id The transfer list uuid. + * + * @return JSONResponse The rejected list, 404, 400 without a reason, or 409. + * + * @spec openspec/specs/edepot-transfer/spec.md#requirement-the-system-must-support-transfer-list-management + */ + #[NoCSRFRequired] + public function reject(string $id): JSONResponse { + $reason = trim((string)($this->request->getParam('reason', ''))); + if ($reason === '') { + return new JSONResponse( + data: ['error' => 'A rejection needs a reason.'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + return $this->decide(id: $id, approve: false, reason: $reason); + }//end reject() + + /** + * Record an archivist's decision on a transfer list. + * + * Both decisions share their whole shape — load, refuse if absent, refuse + * if the status forbids it, stamp who decided — so they share an + * implementation rather than two that drift. + * + * The `InvalidArgumentException` the service raises is a REFUSAL, not a + * fault: it means the list is not in review, which is a state the caller + * can see and act on. It answers 409, never 500. + * + * @param string $id The transfer list uuid. + * @param boolean $approve Whether this is an approval. + * @param string $reason The rejection reason; ignored for an approval. + * + * @return JSONResponse The updated list, or the refusal. + * + * @spec openspec/specs/edepot-transfer/spec.md#requirement-the-system-must-support-transfer-list-management + */ + private function decide(string $id, bool $approve, string $reason): JSONResponse { + $list = $this->transferRecordService->loadTransferList($id); + if ($list === null) { + return new JSONResponse( + data: ['error' => "Transfer list '{$id}' not found"], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + $archivist = (string)($this->userSession->getUser()?->getUID() ?? ''); + + try { + if ($approve === true) { + return new JSONResponse( + data: $this->transferListService->approveTransferList( + transferList: $list, + archivistId: $archivist + ) + ); + } + + return new JSONResponse( + data: $this->transferListService->rejectTransferList( + transferList: $list, + archivistId: $archivist, + reason: $reason + ) + ); + } catch (InvalidArgumentException $e) { + return new JSONResponse( + data: ['error' => $e->getMessage(), 'reason' => 'wrong-status'], + statusCode: Http::STATUS_CONFLICT + ); + }//end try + }//end decide() + + /** * Initiate a transfer from an approved transfer list. * diff --git a/lib/Db/AuditFlowAttribution.php b/lib/Db/AuditFlowAttribution.php new file mode 100644 index 0000000000..2999894b08 --- /dev/null +++ b/lib/Db/AuditFlowAttribution.php @@ -0,0 +1,130 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use OCA\OpenRegister\Service\Flow\FlowRunContext; +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; +use Psr\Container\ContainerInterface; +use Throwable; + +/** + * Applies the ambient flow attribution to an audit row, and reads it back. + * + * @template-extends QBMapper + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ +class AuditFlowAttribution extends QBMapper { + /** + * Constructor. + * + * @param IDBConnection $db The database. + * @param ContainerInterface $container Resolves the shared run context. + */ + public function __construct( + IDBConnection $db, + private readonly ContainerInterface $container, + ) { + parent::__construct(db: $db, tableName: 'openregister_audit_trails', entityClass: AuditTrail::class); + }//end __construct() + + /** + * Stamp the executing run, node and step onto a row. + * + * MUST be called before the row is written: these three fields are part of + * the canonical JSON the hash covers, exactly like `expires`, so setting + * them afterwards would put them outside the hash the row is later given. + * + * Fail-soft. If the context cannot be resolved the row is written + * unattributed — an audit row is evidence and must survive a bookkeeping + * problem, and an unattributed row is honest about what it does not know. + * + * @param AuditTrail $auditTrail The row being built. + * + * @return void + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function apply(AuditTrail $auditTrail): void { + try { + $context = $this->container->get(FlowRunContext::class); + } catch (Throwable $e) { + return; + } + + if (($context instanceof FlowRunContext) === false) { + return; + } + + $frame = $context->current(); + if ($frame === null) { + return; + } + + $auditTrail->setFlowRun($frame['run']); + $auditTrail->setFlowNode($frame['node']); + $auditTrail->setFlowStep($frame['step']); + }//end apply() + + /** + * Every audit row attributed to one flow run, oldest first. + * + * Ordered by id rather than by `flow_step` so a run that visited the same + * node twice — a loop — reads in the order the writes actually happened + * rather than collapsing both visits into one position. + * + * @param string $runUuid The flow run's uuid. + * @param integer $limit Maximum rows to return. + * + * @return AuditTrail[] The rows this run caused. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function findByRun(string $runUuid, int $limit = 1000): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('openregister_audit_trails') + ->where($qb->expr()->eq('flow_run', $qb->createNamedParameter($runUuid))) + ->orderBy('id', 'ASC') + ->setMaxResults($limit); + + return $this->findEntities(query: $qb); + }//end findByRun() +}//end class diff --git a/lib/Db/AuditTrail.php b/lib/Db/AuditTrail.php index b7ab9f1273..6dd1e49df3 100644 --- a/lib/Db/AuditTrail.php +++ b/lib/Db/AuditTrail.php @@ -80,6 +80,12 @@ * @method void setParamsDigest(?string $paramsDigest) * @method array|null getResultSummary() * @method void setResultSummary(?array $resultSummary) + * @method string|null getFlowRun() + * @method void setFlowRun(?string $flowRun) + * @method string|null getFlowNode() + * @method void setFlowNode(?string $flowNode) + * @method integer|null getFlowStep() + * @method void setFlowStep(?int $flowStep) * * @psalm-suppress PossiblyUnusedMethod * @psalm-suppress PropertyNotSetInConstructor $id is set by Nextcloud's Entity base class @@ -363,6 +369,41 @@ class AuditTrail extends Entity implements JsonSerializable { */ protected ?array $resultSummary = null; + /** + * The uuid of the flow run that was executing when this row was written. + * + * Set from the ambient run context rather than passed by the writing code, + * so a write made by an app that has never heard of flows is attributed + * exactly like a write made by the node itself. Null on every row written + * outside a run — and null is the ONLY way to say "no run", because a run + * uuid is never an empty string. + * + * Stored as a plain stamp with no foreign key: flow-run retention prunes + * runs, and an audit row is immutable, so a reference that could be + * invalidated by retention must not be one the reader depends on + * resolving. + * + * @var string|null Uuid of the attributing flow run. + */ + protected ?string $flowRun = null; + + /** + * The id, within the flow graph, of the node that was executing. + * + * @var string|null Node id of the attributing step. + */ + protected ?string $flowNode = null; + + /** + * The sequence number of the step that was executing. + * + * Steps are appended across a resume and never renumbered, so this orders + * a run's writes even when the run suspended and continued days later. + * + * @var integer|null Step sequence of the attributing step. + */ + protected ?int $flowStep = null; + /** * Constructor for the AuditTrail class * @@ -401,6 +442,9 @@ public function __construct() { $this->addType(fieldName: 'paramsDigest', type: 'string'); $this->addType(fieldName: 'resultSummary', type: 'json'); $this->addType(fieldName: 'purgedAt', type: 'datetime'); + $this->addType(fieldName: 'flowRun', type: 'string'); + $this->addType(fieldName: 'flowNode', type: 'string'); + $this->addType(fieldName: 'flowStep', type: 'integer'); }//end __construct() /** @@ -508,7 +552,10 @@ public function hydrate(array $object): static { * previousHash: null|string, * toolId: null|string, * paramsDigest: null|string, - * resultSummary: array|null + * resultSummary: array|null, + * flowRun: null|string, + * flowNode: null|string, + * flowStep: int|null * } */ public function jsonSerialize(): array { @@ -554,6 +601,18 @@ public function jsonSerialize(): array { 'toolId' => $this->toolId, 'paramsDigest' => $this->paramsDigest, 'resultSummary' => $this->resultSummary, + // ⚠️ These three are COVERED BY THE HASH CHAIN. They are part of the + // canonical JSON that AuditHashService seals, which is what makes + // re-pointing a row at a different run detectable rather than + // merely unlikely. That coverage is also why adding them was a + // genesis-seed migration (v1 → v2, ADR-003 Rule 4) rather than a + // column addition: any key added here changes the canonical form of + // every row ever written. Do not add a key to this array without + // reading that ADR — and note that `purgedAt` is deliberately + // ABSENT for exactly this reason. + 'flowRun' => $this->flowRun, + 'flowNode' => $this->flowNode, + 'flowStep' => $this->flowStep, ]; }//end jsonSerialize() diff --git a/lib/Db/AuditTrailMapper.php b/lib/Db/AuditTrailMapper.php index 7a6502b8fb..30ec351ebe 100644 --- a/lib/Db/AuditTrailMapper.php +++ b/lib/Db/AuditTrailMapper.php @@ -111,6 +111,9 @@ public function __construct( parent::__construct(db: $db, tableName: 'openregister_audit_trails', entityClass: AuditTrail::class); }//end __construct() + + + /** * Insert an audit-trail entry sealed into the SHA-256 hash chain. * @@ -150,6 +153,19 @@ private function insertHashChained(AuditTrail $auditTrail): AuditTrail { // minutes). verifyChain() skips unsealed rows and carries the last // sealed hash forward, so a tail of them is a smaller claim, never a // false alarm. + // + // Attribution is applied here as well as in buildAuditTrail(), which + // covers the entry points that do NOT build their row there — + // createAuditTrailEntry() (archival/retention) and + // createToolInvocationEntry() (MCP). Both can be reached from inside a + // flow. Re-applying to a row the builder already stamped is idempotent: + // it reads the same ambient frame and writes the same three values. + // + // It must happen before the row is INSERTED, not merely before it is + // sealed: the sweep seals whatever is in the row, so a field added + // after insert would be outside the hash it is later given. + (new AuditFlowAttribution($this->db, $this->container))->apply(auditTrail: $auditTrail); + return $this->insert(entity: $auditTrail); }//end insertHashChained() @@ -288,6 +304,14 @@ function ($key) { 'ip_address', 'version', 'created', + // Flow attribution. Absent from this allowlist a filter is + // not rejected — it is silently DROPPED by the `continue` + // below, so `?flow_run=` would return the whole + // unfiltered audit trail with a 200 and read as a run that + // had touched everything on the instance. + 'flow_run', + 'flow_node', + 'flow_step', ] ) === false ) { @@ -344,6 +368,11 @@ function ($key) { 'ip_address', 'version', 'created', + // Sortable for the same reason they are filterable: a run's + // writes are read in step order. + 'flow_run', + 'flow_node', + 'flow_step', ] ) === false ) { @@ -574,6 +603,13 @@ public function buildAuditTrail( $auditTrail->setImportJobId($importJobId); } + // Flow attribution — which run, node and step caused this write. + // Applied HERE, in the shared builder, and not in the two insert + // methods: `insertAuditTrails()` (the batched path) builds its rows + // through this same method, and stamping the inserts instead would have + // left every bulk write in a flow silently unattributed. + (new AuditFlowAttribution($this->db, $this->container))->apply(auditTrail: $auditTrail); + // Set the size to the byte size of the serialized object, with a minimum default of 14 bytes. $serializedSize = strlen(serialize($objectEntity->jsonSerialize())); $auditTrail->setSize(max($serializedSize, 14)); diff --git a/lib/Db/Flow.php b/lib/Db/Flow.php index 3cadabce5e..a33ae6495f 100644 --- a/lib/Db/Flow.php +++ b/lib/Db/Flow.php @@ -56,6 +56,10 @@ * @method void setApplicationSlug(?string $applicationSlug) * @method boolean|null getEnabled() * @method void setEnabled(?bool $enabled) + * @method integer|null getVersion() + * @method void setVersion(?int $version) + * @method string|null getLifecycleStatus() + * @method void setLifecycleStatus(?string $lifecycleStatus) * @method string|null getTrigger() * @method void setTrigger(?string $trigger) * @method string|null getTriggerRegister() @@ -197,6 +201,30 @@ class Flow extends Entity implements JsonSerializable { */ protected ?bool $enabled = false; + /** + * The version number of this flow's head. + * + * A convenience mirror of the highest row in `openregister_flow_versions`, + * kept so listing flows does not need a join. Never the source the next + * version number is derived from — {@see FlowVersionMapper::highestVersion} + * reads the version rows for that. + * + * @var integer|null + */ + protected ?int $version = 1; + + /** + * The lifecycle status of this flow's head version. + * + * 🔴 NOT $status. That field means the last RUN's outcome and changes every + * time the flow executes; this one means "may this be edited" and changes + * only when somebody publishes. Merging them would make a failed run look + * like an unpublishable flow. + * + * @var string|null + */ + protected ?string $lifecycleStatus = FlowVersion::STATUS_DRAFT; + /** * The event that starts the flow (a catalog trigger id, `manual`, or * `schedule`). @@ -417,6 +445,8 @@ public function __construct() { $this->addType(fieldName: 'app', type: 'string'); $this->addType(fieldName: 'applicationSlug', type: 'string'); $this->addType(fieldName: 'enabled', type: 'boolean'); + $this->addType(fieldName: 'version', type: 'integer'); + $this->addType(fieldName: 'lifecycleStatus', type: 'string'); $this->addType(fieldName: 'trigger', type: 'string'); $this->addType(fieldName: 'triggerRegister', type: 'string'); $this->addType(fieldName: 'triggerSchema', type: 'string'); @@ -607,6 +637,8 @@ public function jsonSerialize(): array { 'app' => $this->app, 'applicationSlug' => $this->applicationSlug, 'enabled' => (bool)$this->enabled, + 'version' => (int)($this->version ?? 1), + 'lifecycleStatus' => ($this->lifecycleStatus ?? FlowVersion::STATUS_DRAFT), 'trigger' => $this->trigger, 'triggerRegister' => $this->triggerRegister, 'triggerSchema' => $this->triggerSchema, diff --git a/lib/Db/FlowDefinition.php b/lib/Db/FlowDefinition.php new file mode 100644 index 0000000000..631ad247cc --- /dev/null +++ b/lib/Db/FlowDefinition.php @@ -0,0 +1,135 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use OCP\AppFramework\Db\Entity; + +/** + * A pinned flow definition. + * + * @method string|null getHash() + * @method void setHash(?string $hash) + * @method string|null getFlowUuid() + * @method void setFlowUuid(?string $flowUuid) + * @method string|null getDefinition() + * @method void setDefinition(?string $definition) + * @method DateTime|null getCreated() + * @method void setCreated(?DateTime $created) + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ +class FlowDefinition extends Entity implements \JsonSerializable { + /** + * The sha256 of the canonical definition. + * + * @var string|null + */ + protected ?string $hash = null; + + /** + * The flow this definition was captured from. + * + * Provenance only. A definition stays readable after its flow is deleted, + * which is exactly what an in-flight run needs. + * + * @var string|null + */ + protected ?string $flowUuid = null; + + /** + * The canonical definition, JSON-encoded. + * + * @var string|null + */ + protected ?string $definition = null; + + /** + * When this definition was first seen. + * + * @var DateTime|null + */ + protected ?DateTime $created = null; + + /** + * Constructor. + */ + public function __construct() { + $this->addType(fieldName: 'hash', type: 'string'); + $this->addType(fieldName: 'flowUuid', type: 'string'); + $this->addType(fieldName: 'definition', type: 'string'); + $this->addType(fieldName: 'created', type: 'datetime'); + + }//end __construct() + + /** + * Serialise for the API. + * + * @return array The definition as an array. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'hash' => $this->hash, + 'flowUuid' => $this->flowUuid, + 'definition' => $this->decoded(), + 'created' => $this->created?->format('c'), + ]; + + }//end jsonSerialize() + + /** + * The definition as an array. + * + * Returns an empty array rather than null on unreadable JSON. A caller + * that gets `[]` walks a flow with no nodes and stops immediately, which + * is the safe end of the two — returning null would put a type error in + * the advancer instead. + * + * @return array The decoded definition. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function decoded(): array { + if ($this->definition === null || trim($this->definition) === '') { + return []; + } + + $decoded = json_decode($this->definition, true); + if (is_array($decoded) === false) { + return []; + } + + return $decoded; + + }//end decoded() +}//end class diff --git a/lib/Db/FlowDefinitionMapper.php b/lib/Db/FlowDefinitionMapper.php new file mode 100644 index 0000000000..3170ee2d50 --- /dev/null +++ b/lib/Db/FlowDefinitionMapper.php @@ -0,0 +1,120 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use Throwable; + +/** + * Mapper for pinned flow definitions. + * + * @template-extends QBMapper + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ +class FlowDefinitionMapper extends QBMapper { + /** + * Constructor. + * + * @param IDBConnection $db The database connection. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'openregister_flow_defs', entityClass: FlowDefinition::class); + + }//end __construct() + + /** + * Find a definition by its hash. + * + * @param string $hash The canonical hash. + * + * @return FlowDefinition|null The definition, or null when unknown. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function findByHash(string $hash): ?FlowDefinition { + if (trim($hash) === '') { + return null; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('hash', $qb->createNamedParameter($hash))) + ->setMaxResults(1); + + try { + return $this->findEntity(query: $qb); + } catch (DoesNotExistException $e) { + return null; + } catch (Throwable $e) { + return null; + } + + }//end findByHash() + + /** + * Store a definition under its hash, or return the one already there. + * + * 🔑 THE INSERT CAN LOSE A RACE AND THAT IS FINE. Two workers pinning the + * same unedited flow at the same moment both miss on the read and both + * insert; the unique index rejects one. Because the hash IS the content, + * the row the loser then reads back is byte-identical to the one it tried + * to write — so re-reading is a complete recovery, not a compromise. + * + * @param string $hash The canonical hash. + * @param string $definition The canonical definition JSON. + * @param string|null $flowUuid The originating flow, for provenance. + * + * @return FlowDefinition|null The stored definition, or null when it could not be stored. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function store(string $hash, string $definition, ?string $flowUuid = null): ?FlowDefinition { + $existing = $this->findByHash(hash: $hash); + if ($existing !== null) { + return $existing; + } + + $entity = new FlowDefinition(); + $entity->setHash($hash); + $entity->setDefinition($definition); + $entity->setFlowUuid($flowUuid); + $entity->setCreated(new DateTime()); + + try { + return $this->insert(entity: $entity); + } catch (Throwable $e) { + // Lost the race, or the write failed. Re-read: on a race this + // returns the winner's identical row; on a real failure it + // returns null and the caller leaves the run unpinned rather + // than failing the run outright. + return $this->findByHash(hash: $hash); + } + + }//end store() +}//end class diff --git a/lib/Db/FlowRun.php b/lib/Db/FlowRun.php index b39e869d72..e2e6807279 100644 --- a/lib/Db/FlowRun.php +++ b/lib/Db/FlowRun.php @@ -46,6 +46,8 @@ * @method void setUuid(?string $uuid) * @method string|null getFlowId() * @method void setFlowId(?string $flowId) + * @method integer|null getFlowVersion() + * @method void setFlowVersion(?int $flowVersion) * @method string|null getStatus() * @method void setStatus(?string $status) * @method array|null getMarking() @@ -179,6 +181,23 @@ class FlowRun extends Entity implements JsonSerializable { */ protected ?string $flowId = null; + /** + * The flow version this run is pinned to. + * + * 🔴 THIS ALONE DECIDES WHICH GRAPH THE RUN WALKS. The advancer resolves + * flow + this number to a version row, and that row to an immutable + * definition. It is never re-read from the flow's head and never advanced + * to a newer version, because the run's marking, its taken decisions and + * its log all belong to the version it started on. + * + * Nullable at the type level only so the column can exist before the + * repair step fills it. A run that can still move and has no version is a + * defect, not a supported state — the advancer fails it by name. + * + * @var integer|null + */ + protected ?int $flowVersion = null; + /** * Current lifecycle status. * @@ -322,6 +341,7 @@ class FlowRun extends Entity implements JsonSerializable { public function __construct() { $this->addType(fieldName: 'uuid', type: 'string'); $this->addType(fieldName: 'flowId', type: 'string'); + $this->addType(fieldName: 'flowVersion', type: 'integer'); $this->addType(fieldName: 'status', type: 'string'); $this->addType(fieldName: 'marking', type: 'json'); $this->addType(fieldName: 'items', type: 'json'); @@ -380,6 +400,7 @@ public function jsonSerialize(): array { 'id' => $this->id, 'uuid' => $this->uuid, 'flowId' => $this->flowId, + 'flowVersion' => $this->flowVersion, 'status' => $this->status, 'marking' => ($this->marking ?? []), 'items' => ($this->items ?? []), diff --git a/lib/Db/FlowRunMapper.php b/lib/Db/FlowRunMapper.php index 0770903e68..84a7d1b6b0 100644 --- a/lib/Db/FlowRunMapper.php +++ b/lib/Db/FlowRunMapper.php @@ -133,25 +133,123 @@ public function findAllRuns( $qb->andWhere($qb->expr()->eq('status', $qb->createNamedParameter($status))); } - if ($requesterUid !== null) { - $visible = $qb->expr()->orX( - $qb->expr()->eq('triggered_by', $qb->createNamedParameter($requesterUid)) + $this->scopeToVisible(qb: $qb, requesterUid: $requesterUid, ownedFlowIds: $ownedFlowIds); + + return $this->findEntities(query: $qb); + }//end findAllRuns() + + /** + * Narrow a run query to what one caller may see. + * + * Extracted so the LIST and the SINGLE-RUN reads apply the same predicate + * rather than each carrying its own copy. Two copies of one access rule do + * not stay identical, and the copy that drifts is the one nobody is looking + * at — a divergence here does not throw, it just answers with somebody + * else's run, correctly formatted, HTTP 200. + * + * A null `$requesterUid` applies NO scoping. That is an administrator's + * semantics and must never be reached by falling through from a missing + * session; callers resolve "no identity" to a refusal before calling. + * + * @param IQueryBuilder $qb The query being built. + * @param string|null $requesterUid The caller, or null for no scoping. + * @param array $ownedFlowIds Flow ids the caller owns. + * + * @return void + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + private function scopeToVisible(IQueryBuilder $qb, ?string $requesterUid, array $ownedFlowIds): void { + if ($requesterUid === null) { + return; + } + + $visible = $qb->expr()->orX( + $qb->expr()->eq('triggered_by', $qb->createNamedParameter($requesterUid)) + ); + + if (empty($ownedFlowIds) === false) { + $visible->add( + $qb->expr()->in( + 'flow_id', + $qb->createNamedParameter($ownedFlowIds, IQueryBuilder::PARAM_STR_ARRAY) + ) ); + } - if (empty($ownedFlowIds) === false) { - $visible->add( - $qb->expr()->in( - 'flow_id', - $qb->createNamedParameter($ownedFlowIds, IQueryBuilder::PARAM_STR_ARRAY) - ) - ); - } + $qb->andWhere($visible); + }//end scopeToVisible() - $qb->andWhere($visible); + /** + * The suspended runs whose subject is this object. + * + * "Which run is waiting on this thing" is the question a leaf app asks when + * something outside the engine finishes — a decision concluded, a document + * signed — and needs to wake whatever was waiting for it. + * + * Deliberately narrowed to SUSPENDED. A completed or failed run is not + * waiting for anything, and signalling one would be a no-op at best and a + * second advance of a finished run at worst. + * + * This does NOT scope by caller: it is an engine-side lookup used to route + * an external outcome, not a user-facing read. Callers that expose anything + * derived from it must apply their own visibility rule. + * + * @param string $subjectUuid The subject object's uuid. + * @param integer $limit Maximum runs to return. + * + * @return FlowRun[] The suspended runs for that subject, oldest first. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function findSuspendedBySubject(string $subjectUuid, int $limit = 25): array { + if (trim($subjectUuid) === '') { + return []; } + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('subject_uuid', $qb->createNamedParameter($subjectUuid))) + ->andWhere($qb->expr()->eq('status', $qb->createNamedParameter(FlowRun::STATUS_SUSPENDED))) + ->orderBy('id', 'ASC') + ->setMaxResults($limit); + return $this->findEntities(query: $qb); - }//end findAllRuns() + }//end findSuspendedBySubject() + + /** + * Find one run by uuid, but only if this caller may see it. + * + * Returns null both when the run does not exist and when it exists but is + * not the caller's, so the caller cannot distinguish the two — the absence + * of a run and the absence of permission look identical from outside, which + * is what stops the endpoint being a probe for which runs exist. + * + * @param string $uuid The run uuid. + * @param string|null $requesterUid The caller, or null for an administrator. + * @param array $ownedFlowIds Flow ids the caller owns. + * + * @return FlowRun|null The run, or null when it does not exist or is not visible. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function findByUuidVisibleTo(string $uuid, ?string $requesterUid, array $ownedFlowIds = []): ?FlowRun { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('uuid', $qb->createNamedParameter($uuid))) + ->setMaxResults(1); + + $this->scopeToVisible(qb: $qb, requesterUid: $requesterUid, ownedFlowIds: $ownedFlowIds); + + $found = $this->findEntities(query: $qb); + if ($found === []) { + return null; + } + + return $found[0]; + }//end findByUuidVisibleTo() /** * Delete terminal runs older than a cutoff, optionally for one flow only. @@ -871,4 +969,60 @@ public function pruneBefore(DateTime $before): int { return (int)$qb->executeStatement(); }//end pruneBefore() + + /** + * How many runs that can still move are pinned to one version of a flow. + * + * Asked before a version may be removed. Counts only ACTIVE runs on + * purpose: a completed run's pin is history, and history keeping a version + * alive forever would make the version table unprunable. A run that can + * still move, though, needs its graph to still exist. + * + * @param string $flowUuid The flow. + * @param integer $version The version number. + * + * @return integer The number of non-terminal runs pinned to that version. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function countActivePinnedTo(string $flowUuid, int $version): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->createFunction('COUNT(*) AS `c`')) + ->from($this->getTableName()) + ->where($qb->expr()->eq('flow_id', $qb->createNamedParameter($flowUuid))) + ->andWhere($qb->expr()->eq('flow_version', $qb->createNamedParameter($version, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->in('status', $qb->createNamedParameter(FlowRun::ACTIVE, IQueryBuilder::PARAM_STR_ARRAY))); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + return (int)($row['c'] ?? 0); + }//end countActivePinnedTo() + + /** + * Pin every still-movable run of a flow that has no version yet. + * + * The upgrade path. Deliberately scoped to `flow_version IS NULL` so it is + * idempotent — a second pass matches nothing — and to ACTIVE runs, so a + * terminal run from before versioning keeps its null rather than being + * told, retrospectively, that it executed version 1. + * + * @param string $flowUuid The flow. + * @param integer $version The version to pin. + * + * @return integer The number of runs pinned. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function pinUnversionedActive(string $flowUuid, int $version): int { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('flow_version', $qb->createNamedParameter($version, IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('flow_id', $qb->createNamedParameter($flowUuid))) + ->andWhere($qb->expr()->isNull('flow_version')) + ->andWhere($qb->expr()->in('status', $qb->createNamedParameter(FlowRun::ACTIVE, IQueryBuilder::PARAM_STR_ARRAY))); + + return (int)$qb->executeStatement(); + }//end pinUnversionedActive() }//end class diff --git a/lib/Db/FlowVersion.php b/lib/Db/FlowVersion.php new file mode 100644 index 0000000000..fb5d3a08c7 --- /dev/null +++ b/lib/Db/FlowVersion.php @@ -0,0 +1,241 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use DateTime; +use OCP\AppFramework\Db\Entity; + +/** + * A single version of a flow definition. + * + * @method string|null getFlowUuid() + * @method void setFlowUuid(?string $flowUuid) + * @method integer|null getVersion() + * @method void setVersion(?int $version) + * @method string|null getStatus() + * @method void setStatus(?string $status) + * @method string|null getDefinitionHash() + * @method void setDefinitionHash(?string $definitionHash) + * @method string|null getOwner() + * @method void setOwner(?string $owner) + * @method string|null getOrganisation() + * @method void setOrganisation(?string $organisation) + * @method DateTime|null getPublishedAt() + * @method void setPublishedAt(?DateTime $publishedAt) + * @method string|null getPublishedBy() + * @method void setPublishedBy(?string $publishedBy) + * @method DateTime|null getDeprecatedAt() + * @method void setDeprecatedAt(?DateTime $deprecatedAt) + * @method DateTime|null getCreated() + * @method void setCreated(?DateTime $created) + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ +class FlowVersion extends Entity implements \JsonSerializable { + /** + * Editable. Must not back a triggered, scheduled or sub-flow run. + * + * @var string + */ + public const STATUS_DRAFT = 'draft'; + + /** + * Immutable, and the only status a new run may be queued against. + * + * @var string + */ + public const STATUS_PUBLISHED = 'published'; + + /** + * Immutable and retired. Backs no new run; runs already pinned to it finish. + * + * @var string + */ + public const STATUS_DEPRECATED = 'deprecated'; + + /** + * Every status a version may hold. + * + * 🔑 A CLOSED LIST, checked on write. An unrecognised status would be read + * as "not published" by every dispatch path and as "not draft" by the + * editor, so a typo would silently make a flow unrunnable AND uneditable. + * + * @var string[] + */ + public const STATUSES = [ + self::STATUS_DRAFT, + self::STATUS_PUBLISHED, + self::STATUS_DEPRECATED, + ]; + + /** + * The flow this is a version of. + * + * @var string|null + */ + protected ?string $flowUuid = null; + + /** + * The version number, starting at 1 and rising by one per draft. + * + * @var integer|null + */ + protected ?int $version = null; + + /** + * The lifecycle status; one of self::STATUSES. + * + * @var string|null + */ + protected ?string $status = null; + + /** + * The sha256 of the graph this version names. + * + * @var string|null + */ + protected ?string $definitionHash = null; + + /** + * Who owned the flow when this version was cut. + * + * @var string|null + */ + protected ?string $owner = null; + + /** + * Which tenant owned the flow when this version was cut. + * + * @var string|null + */ + protected ?string $organisation = null; + + /** + * When this version was published. + * + * @var DateTime|null + */ + protected ?DateTime $publishedAt = null; + + /** + * Who published it. + * + * @var string|null + */ + protected ?string $publishedBy = null; + + /** + * When it was deprecated. + * + * @var DateTime|null + */ + protected ?DateTime $deprecatedAt = null; + + /** + * When the row was created. + * + * @var DateTime|null + */ + protected ?DateTime $created = null; + + /** + * Constructor. + */ + public function __construct() { + $this->addType(fieldName: 'flowUuid', type: 'string'); + $this->addType(fieldName: 'version', type: 'integer'); + $this->addType(fieldName: 'status', type: 'string'); + $this->addType(fieldName: 'definitionHash', type: 'string'); + $this->addType(fieldName: 'owner', type: 'string'); + $this->addType(fieldName: 'organisation', type: 'string'); + $this->addType(fieldName: 'publishedAt', type: 'datetime'); + $this->addType(fieldName: 'publishedBy', type: 'string'); + $this->addType(fieldName: 'deprecatedAt', type: 'datetime'); + $this->addType(fieldName: 'created', type: 'datetime'); + + }//end __construct() + + /** + * Whether this version may back a newly queued run. + * + * Asked rather than compared, because "may this back a run" is the + * question every dispatch path actually has, and spelling it as + * `getStatus() === 'published'` at six call sites is six chances to write + * a different comparison. + * + * @return boolean True when this version is published. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function isPublished(): bool { + return $this->status === self::STATUS_PUBLISHED; + + }//end isPublished() + + /** + * Whether this version may still be edited. + * + * @return boolean True when this version is a draft. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function isDraft(): bool { + return $this->status === self::STATUS_DRAFT; + + }//end isDraft() + + /** + * Serialise for the API. + * + * @return array The version as an array. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'flowUuid' => $this->flowUuid, + 'version' => $this->version, + 'status' => $this->status, + 'definitionHash' => $this->definitionHash, + 'owner' => $this->owner, + 'organisation' => $this->organisation, + 'publishedAt' => $this->publishedAt?->format('c'), + 'publishedBy' => $this->publishedBy, + 'deprecatedAt' => $this->deprecatedAt?->format('c'), + 'created' => $this->created?->format('c'), + ]; + + }//end jsonSerialize() +}//end class diff --git a/lib/Db/FlowVersionMapper.php b/lib/Db/FlowVersionMapper.php new file mode 100644 index 0000000000..6677b0ee43 --- /dev/null +++ b/lib/Db/FlowVersionMapper.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://OpenRegister.app + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +/** + * Mapper for flow versions. + * + * @template-extends QBMapper + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ +class FlowVersionMapper extends QBMapper { + /** + * Constructor. + * + * @param IDBConnection $db The database connection. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'openregister_flow_versions', entityClass: FlowVersion::class); + + }//end __construct() + + /** + * Find one exact version of one flow. + * + * @param string $flowUuid The flow. + * @param integer $version The version number. + * + * @return FlowVersion|null The version, or null when there is no such row. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function find(string $flowUuid, int $version): ?FlowVersion { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('flow_uuid', $qb->createNamedParameter($flowUuid))) + ->andWhere($qb->expr()->eq('version', $qb->createNamedParameter($version, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))) + ->setMaxResults(1); + + try { + return $this->findEntity(query: $qb); + } catch (DoesNotExistException $e) { + return null; + } + + }//end find() + + /** + * Find the single published version of a flow. + * + * 🔑 ORDERED BY VERSION DESCENDING AND CAPPED AT ONE, even though the + * service guarantees there is at most one published row. The guarantee is + * enforced in a transaction; this read runs on the object-write path and + * must give a DETERMINISTIC answer even if that invariant were ever + * broken by a bad migration or a manual edit. An unordered `LIMIT 1` over + * two published rows would hand different workers different graphs and the + * symptom would be intermittent. + * + * @param string $flowUuid The flow. + * + * @return FlowVersion|null The published version, or null when none is published. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function findPublished(string $flowUuid): ?FlowVersion { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('flow_uuid', $qb->createNamedParameter($flowUuid))) + ->andWhere($qb->expr()->eq('status', $qb->createNamedParameter(FlowVersion::STATUS_PUBLISHED))) + ->orderBy('version', 'DESC') + ->setMaxResults(1); + + try { + return $this->findEntity(query: $qb); + } catch (DoesNotExistException $e) { + return null; + } + + }//end findPublished() + + /** + * List every version of a flow, newest first. + * + * @param string $flowUuid The flow. + * + * @return FlowVersion[] The versions. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function findAllForFlow(string $flowUuid): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('flow_uuid', $qb->createNamedParameter($flowUuid))) + ->orderBy('version', 'DESC'); + + return $this->findEntities(query: $qb); + + }//end findAllForFlow() + + /** + * The highest version number a flow has, or 0 when it has none. + * + * Read from the version rows rather than from the flow's own `version` + * column, deliberately: the flow column is a convenience mirror for the + * UI, and deriving the next number from a mirror is how two drafts end up + * claiming the same number. The unique index would catch that, but only + * after the transaction had done its other work. + * + * @param string $flowUuid The flow. + * + * @return integer The highest version number, or 0 for a flow with no versions. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function highestVersion(string $flowUuid): int { + $qb = $this->db->getQueryBuilder(); + + $qb->select('version') + ->from($this->getTableName()) + ->where($qb->expr()->eq('flow_uuid', $qb->createNamedParameter($flowUuid))) + ->orderBy('version', 'DESC') + ->setMaxResults(1); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + if ($row === false || isset($row['version']) === false) { + return 0; + } + + return (int)$row['version']; + + }//end highestVersion() + + /** + * Delete every version row of one flow. + * + * Called when the flow itself is deleted, AFTER its runs are gone. A + * version row exists so an in-flight run can resolve the graph it was + * pinned to; once no run of this flow remains, nothing can reach these rows + * through any read path the app has — every version read is by flow. + * + * @param string $flowUuid The flow being deleted. + * + * @return integer The number of version rows removed. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function deleteByFlow(string $flowUuid): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('flow_uuid', $qb->createNamedParameter($flowUuid))); + + return (int)$qb->executeStatement(); + + }//end deleteByFlow() + + /** + * Delete version rows whose flow no longer exists. + * + * 🔑 A ONE-OFF SWEEP FOR INSTANCES UPGRADED MID-FLIGHT. `FlowService::delete()` + * now cascades to this table, so no NEW orphan can appear — but any + * instance that deleted a flow between the version table landing and that + * cascade landing kept the rows. Measured on the dev instance: 38, growing + * with every flow anyone removed. + * + * Safe on the same terms as the cascade: `delete()` removes a flow's RUNS + * as well, so a version row whose flow is gone has no run left that could + * be pinned to it. + * + * @return integer The number of orphaned rows removed. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function deleteOrphaned(): int { + $qb = $this->db->getQueryBuilder(); + $sub = $this->db->getQueryBuilder(); + + $sub->select('uuid')->from('openregister_flows'); + + $qb->delete($this->getTableName()) + ->where($qb->expr()->notIn('flow_uuid', $qb->createFunction('(' . $sub->getSQL() . ')'))); + + return (int)$qb->executeStatement(); + + }//end deleteOrphaned() +}//end class diff --git a/lib/Db/Organisation.php b/lib/Db/Organisation.php index 043e8caf89..402320dc88 100644 --- a/lib/Db/Organisation.php +++ b/lib/Db/Organisation.php @@ -75,6 +75,28 @@ * @method void setSuspendedAt(?DateTime $suspendedAt) * @method DateTime|null getDeprovisionedAt() * @method void setDeprovisionedAt(?DateTime $deprovisionedAt) + * @method string|null getType() + * @method void setType(?string $type) + * @method string|null getSummary() + * @method void setSummary(?string $summary) + * @method string|null getOin() + * @method void setOin(?string $oin) + * @method string|null getTooi() + * @method void setTooi(?string $tooi) + * @method string|null getRsin() + * @method void setRsin(?string $rsin) + * @method string|null getKvk() + * @method void setKvk(?string $kvk) + * @method string|null getPki() + * @method void setPki(?string $pki) + * @method string|null getImage() + * @method void setImage(?string $image) + * @method string|null getRegistrationStatus() + * @method void setRegistrationStatus(?string $registrationStatus) + * @method string|null getMergedInto() + * @method void setMergedInto(?string $mergedInto) + * @method DateTime|null getMergedAt() + * @method void setMergedAt(?DateTime $mergedAt) * @method string|null getParent() * @method static setParent(?string $parent) * @method array|null getMail() @@ -265,6 +287,107 @@ class Organisation extends Entity implements JsonSerializable { */ protected ?string $parent = null; + /** + * Discriminator for what KIND of organisation this row describes. + * + * Every row is still a full organisation and still a valid tenant; this + * only records which facet the operator cares about, so a UI can present a + * vendor differently from a municipality. It is deliberately NOT an + * authorization input — see ADR-002 Rule 1: the UUID is the only tenant key. + * + * One of: organisation, government, vendor, collaboration, department. + * + * @var string|null Organisation type discriminator + */ + protected ?string $type = 'organisation'; + + /** + * Short summary for overview pages (OpenCatalogi `summary`). + * + * @var string|null + */ + protected ?string $summary = null; + + /** + * Overheidsidentificatienummer (OIN). + * + * @var string|null + */ + protected ?string $oin = null; + + /** + * TOOI identifier for this organisation. + * + * @var string|null + */ + protected ?string $tooi = null; + + /** + * RSIN of the non-natural person (9 digits, 11-proef). + * + * @var string|null + */ + protected ?string $rsin = null; + + /** + * Chamber-of-Commerce (KvK) number. + * + * @var string|null + */ + protected ?string $kvk = null; + + /** + * PKIoverheid certificate reference. + * + * @var string|null + */ + protected ?string $pki = null; + + /** + * Logo/avatar as a URL or base64 data URI. + * + * @var string|null + */ + protected ?string $image = null; + + /** + * Registration lifecycle of this organisation as a catalogued party. + * + * Distinct from `status`, which is the TENANT lifecycle (provisioning, + * active, suspended...). A vendor can be `registered` here while its tenant + * has never been provisioned, and vice versa. + * + * One of: concept, submitted, registered, rejected, merged. + * + * @var string|null + */ + protected ?string $registrationStatus = null; + + /** + * UUID of the surviving organisation when this one was merged away. + * + * This is an AUTHORIZATION-BEARING field, not a display hint. A merged + * organisation must stop resolving as a tenant: if it kept resolving, every + * query scoped to it would read the survivor's data under the old boundary. + * {@see \OCA\OpenRegister\Db\OrganisationMapper::resolveMergeTarget()} + * walks this to the survivor. + * + * NOT YET WIRED: the live tenant-resolution path does not call the resolver + * yet, so a merged organisation still resolves as itself. Doing so changes + * which rows every scoped query returns and is held for its own change -- + * see openspec/changes/consolidate-organisation-on-or/tasks.md task 3.3. + * + * @var string|null + */ + protected ?string $mergedInto = null; + + /** + * When this organisation was merged away. + * + * @var DateTime|null + */ + protected ?DateTime $mergedAt = null; + /** * Array of child organisation UUIDs (computed, not stored in database) * @@ -358,9 +481,9 @@ public function __construct() { $this->addType(fieldName: 'created', type: 'datetime'); $this->addType(fieldName: 'updated', type: 'datetime'); $this->addType(fieldName: 'active', type: 'boolean'); - $this->addType(fieldName: 'storage_quota', type: 'integer'); - $this->addType(fieldName: 'bandwidth_quota', type: 'integer'); - $this->addType(fieldName: 'request_quota', type: 'integer'); + $this->addType(fieldName: 'storageQuota', type: 'integer'); + $this->addType(fieldName: 'bandwidthQuota', type: 'integer'); + $this->addType(fieldName: 'requestQuota', type: 'integer'); $this->addType(fieldName: 'authorization', type: 'json'); $this->addType(fieldName: 'parent', type: 'string'); $this->addType(fieldName: 'mail', type: 'json'); @@ -372,9 +495,23 @@ public function __construct() { $this->addType(fieldName: 'deck', type: 'json'); $this->addType(fieldName: 'status', type: 'string'); $this->addType(fieldName: 'environment', type: 'string'); - $this->addType(fieldName: 'provisioned_at', type: 'datetime'); - $this->addType(fieldName: 'suspended_at', type: 'datetime'); - $this->addType(fieldName: 'deprovisioned_at', type: 'datetime'); + $this->addType(fieldName: 'provisionedAt', type: 'datetime'); + $this->addType(fieldName: 'suspendedAt', type: 'datetime'); + $this->addType(fieldName: 'deprovisionedAt', type: 'datetime'); + // Identity facet (ADR-022 §3): the statutory identifiers a leaf app + // used to keep in its own publisher/vendor record. + $this->addType(fieldName: 'type', type: 'string'); + $this->addType(fieldName: 'summary', type: 'string'); + $this->addType(fieldName: 'oin', type: 'string'); + $this->addType(fieldName: 'tooi', type: 'string'); + $this->addType(fieldName: 'rsin', type: 'string'); + $this->addType(fieldName: 'kvk', type: 'string'); + $this->addType(fieldName: 'pki', type: 'string'); + $this->addType(fieldName: 'image', type: 'string'); + // Relationship facet. + $this->addType(fieldName: 'registrationStatus', type: 'string'); + $this->addType(fieldName: 'mergedInto', type: 'string'); + $this->addType(fieldName: 'mergedAt', type: 'datetime'); }//end __construct() /** @@ -521,6 +658,20 @@ public function setActive(mixed $active): static { return $this; }//end setActive() + /** + * Whether this organisation has been merged into another one + * + * A merged organisation is NOT a usable tenant: its data now belongs to the + * survivor, so anything still scoped to this UUID would be reading across + * the boundary. Callers resolving a tenant MUST follow + * {@see getMergedInto()} rather than using this row. + * + * @return bool True when this organisation was merged away + */ + public function isMerged(): bool { + return $this->mergedInto !== null && $this->mergedInto !== ''; + }//end isMerged() + /** * Get default authorization structure for organisations * @@ -757,6 +908,11 @@ public function jsonSerialize(): array { $deprovisionedAt = $this->deprovisionedAt->format('c'); } + $mergedAt = null; + if ($this->mergedAt instanceof DateTime) { + $mergedAt = $this->mergedAt->format('c'); + } + return [ 'id' => $this->id, 'uuid' => $this->uuid, @@ -791,6 +947,17 @@ public function jsonSerialize(): array { 'authorization' => $this->authorization ?? $this->getDefaultAuthorization(), 'status' => $this->status ?? 'active', 'environment' => $this->environment ?? 'production', + 'type' => $this->type ?? 'organisation', + 'summary' => $this->summary, + 'oin' => $this->oin, + 'tooi' => $this->tooi, + 'rsin' => $this->rsin, + 'kvk' => $this->kvk, + 'pki' => $this->pki, + 'image' => $this->image, + 'registrationStatus' => $this->registrationStatus, + 'mergedInto' => $this->mergedInto, + 'mergedAt' => $mergedAt, 'provisionedAt' => $provisionedAt, 'suspendedAt' => $suspendedAt, 'deprovisionedAt' => $deprovisionedAt, diff --git a/lib/Db/OrganisationMapper.php b/lib/Db/OrganisationMapper.php index 02765e7057..263c8512d9 100644 --- a/lib/Db/OrganisationMapper.php +++ b/lib/Db/OrganisationMapper.php @@ -66,6 +66,13 @@ */ class OrganisationMapper extends QBMapper { + /** + * Maximum merge hops followed before the chain is treated as a data defect. + * + * @var int + */ + private const MAX_MERGE_HOPS = 16; + /** * Request-scoped memo of the active-organisation UUID per user id. * @@ -1142,4 +1149,78 @@ public function getOrganisationHierarchy(string $organisationUuid): array { return $hierarchy; }//end getOrganisationHierarchy() + + /** + * Follow a merge chain to the organisation that is authoritative today + * + * An organisation that was merged away no longer owns its data — the + * survivor does. Resolving a tenant therefore has to follow `merged_into` + * before the UUID is used as a scope, or every query issued against the old + * UUID reads the survivor's rows under a boundary that no longer applies. + * + * The walk is bounded and cycle-guarded: a merge chain is data, and data can + * be wrong. On a cycle or an unresolvable link this returns the LAST UUID it + * could actually load, never null and never a UUID it did not verify — a + * resolver that fails open would hand the caller an unscoped identifier. + * + * @param string $organisationUuid The (possibly merged-away) organisation UUID + * + * @return string The UUID of the surviving organisation + */ + public function resolveMergeTarget(string $organisationUuid): string { + $seen = [$organisationUuid => true]; + $current = $organisationUuid; + + // A chain longer than this is a data defect, not a deep hierarchy. + for ($hop = 0; $hop < self::MAX_MERGE_HOPS; $hop++) { + try { + $organisation = $this->findByUuid(uuid: $current); + } catch (DoesNotExistException | MultipleObjectsReturnedException $e) { + // Unresolvable link: stay on the last UUID we did verify. + return $current; + } + + $next = $organisation->getMergedInto(); + if ($next === null || $next === '' || $next === $current) { + return $current; + } + + // Cycle: A merged into B merged into A. Stop on the current node + // rather than looping; the caller gets a real, loadable tenant. + if (isset($seen[$next]) === true) { + $this->logger->warning( + 'Organisation merge chain contains a cycle; stopping resolution', + ['organisation' => $organisationUuid, 'at' => $current] + ); + return $current; + } + + $seen[$next] = true; + $current = $next; + } + + $this->logger->warning( + 'Organisation merge chain exceeded the hop limit; stopping resolution', + ['organisation' => $organisationUuid, 'at' => $current] + ); + + return $current; + }//end resolveMergeTarget() + + /** + * Find an organisation, following any merge to the surviving one + * + * The read counterpart of {@see resolveMergeTarget()}. Use this wherever a + * UUID arrives from outside (a request, a stored reference, a federation + * peer) and is about to be used as a tenant scope. + * + * @param string $uuid The organisation UUID + * + * @return Organisation The surviving organisation + * + * @throws DoesNotExistException If no organisation resolves + */ + public function findByUuidFollowingMerge(string $uuid): Organisation { + return $this->findByUuid(uuid: $this->resolveMergeTarget(organisationUuid: $uuid)); + }//end findByUuidFollowingMerge() }//end class diff --git a/lib/Listener/CalculationOnSaveListener.php b/lib/Listener/CalculationOnSaveListener.php index bf928fa7db..a65f1df036 100644 --- a/lib/Listener/CalculationOnSaveListener.php +++ b/lib/Listener/CalculationOnSaveListener.php @@ -165,6 +165,18 @@ private function process(ObjectEntity $object, bool $isUpdate): void { continue; } + // A `sequence` reserves its number exactly once, on create, through the + // SequenceContext built above. On update the context is deliberately + // null — but the evaluator then resolves the `sequence` node to null and + // `concat` renders that as "", so re-materialising would OVERWRITE the + // number assigned at create ("2026-0013" -> "2026-"). Skip the field and + // leave the stored value untouched. openregister#3075. + if ($sequenceContext === null + && $this->evaluator->expressionUsesSequence($spec['expression'] ?? null) === true + ) { + continue; + } + try { $value = $this->evaluator->evaluate($data, $spec['expression'] ?? null, $sequenceContext); } catch (EvaluationException $e) { diff --git a/lib/Listener/SchemaFlowImportListener.php b/lib/Listener/SchemaFlowImportListener.php index c6716ff129..023460452a 100644 --- a/lib/Listener/SchemaFlowImportListener.php +++ b/lib/Listener/SchemaFlowImportListener.php @@ -43,6 +43,7 @@ use InvalidArgumentException; use OCA\OpenRegister\Db\Flow; use OCA\OpenRegister\Db\FlowMapper; +use Psr\Container\ContainerInterface; use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Event\SchemaCreatedEvent; use OCA\OpenRegister\Event\SchemaUpdatedEvent; @@ -71,10 +72,16 @@ class SchemaFlowImportListener implements IEventListener { * * @param FlowMapper $flows The flow store. * @param LoggerInterface $logger Records what was imported, and what could not be. + * @param ContainerInterface|null $container Resolves the organisation a declared flow + * belongs to. Nullable and LAST so adding it + * shifts no positional caller; absent, the + * flow still imports but stays unlisted, + * which is logged rather than silent. */ public function __construct( private readonly FlowMapper $flows, private readonly LoggerInterface $logger, + private readonly ?ContainerInterface $container = null, ) { }//end __construct() @@ -192,6 +199,17 @@ private function upsert(array $declaration, string $schemaSlug): void { // Inert until adopted. See the class docblock. $flow->setEnabled(false); $flow->setOwner(null); + + // 🔴 AND IT MUST BELONG TO A TENANT, or it is imported into + // invisibility. Every flow READ is organisation-scoped + // (`FlowService::findAll()`), so a flow stored with no organisation + // is returned by nothing: it does not appear in the flows list, so + // it cannot be opened, so it can never be adopted — while sitting + // perfectly intact in the table. Measured 2026-08-28: the first + // shipped `x-openregister-flows` declaration imported with + // organisation NULL and was invisible to the API that lists it, + // next to two seeded flows that carried one and showed up fine. + $flow->setOrganisation($this->activeOrganisation()); } $flow->setDescription(($declaration['description'] ?? null)); @@ -205,7 +223,8 @@ private function upsert(array $declaration, string $schemaSlug): void { $flow->setUpdated(new DateTime()); if ($existing === null) { - $this->flows->insert($flow); + $stored = $this->flows->insert($flow); + $this->publishVersionOne(flow: $stored); $this->logger->info( message: '[SchemaFlowImport] Imported declared flow "' . $name . '" for schema "' . $schemaSlug . '" (disabled until adopted).' @@ -217,6 +236,95 @@ private function upsert(array $declaration, string $schemaSlug): void { }//end upsert() + /** + * Publish version 1 of a freshly imported flow. + * + * 🔴 A SHIPPED FLOW MUST NOT ARRIVE AS AN UNRUNNABLE DRAFT. Since + * versioning, `FlowRunService::queue()` refuses any flow with no published + * version. An app that declares a flow in `x-openregister-flows` is + * shipping a finished process, not a work in progress — leaving it a draft + * would mean adopting it (enabling it) still ran nothing, with no error to + * explain why. + * + * Published, but still DISABLED and unowned: publishing answers "which + * graph would run", adoption answers "may it run at all". They are separate + * questions and this changes only the first. + * + * Never raises. A failure here leaves the flow importable and unpublished, + * which an operator can fix by publishing it; throwing would abort a schema + * import over a flow. + * + * @param Flow $flow The freshly inserted flow. + * + * @return void + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + private function publishVersionOne(Flow $flow): void { + if ($this->container === null) { + return; + } + + try { + $this->container->get('OCA\OpenRegister\Service\Flow\FlowVersionService') + ->publish(flow: $flow, publishedBy: null); + } catch (Throwable $e) { + $this->logger->warning( + message: '[SchemaFlowImport] Imported flow "' . $flow->getUuid() + . '" could not be published: ' . $e->getMessage() + . '. It exists as a draft and will not back a run until it is published.', + context: ['file' => __FILE__, 'line' => __LINE__, 'flow' => $flow->getUuid()] + ); + } + + }//end publishVersionOne() + + + /** + * The organisation a newly imported flow belongs to. + * + * Resolved through the container rather than injected so this listener + * stays constructible where OrganisationService is not registered — a + * declared flow should still import on such an instance, even though a + * null organisation leaves it unlisted. + * + * @return string|null The organisation uuid, or null when unresolvable. + * + * @spec openspec/changes/flow-engine-unification/specs/flow-storage/spec.md + */ + private function activeOrganisation(): ?string { + if ($this->container === null) { + return null; + } + + try { + // 🔴 `getOrganisationForNewEntity()`, NOT `getActiveOrganisation()`. + // A schema import runs during install and during + // `occ maintenance:repair` — with NO user session, so there is no + // ACTIVE organisation to find and the flow imported ownerless and + // invisible all over again. This is the same call every ordinary + // object save makes, and it falls back to the DEFAULT organisation + // exactly for callers with no session. Measured 2026-08-28: the fix + // that resolved the active organisation passed on a dev stack that + // happened to have one, and failed in CI, which does not. + $uuid = $this->container + ->get('OCA\OpenRegister\Service\OrganisationService') + ->getOrganisationForNewEntity(); + } catch (Throwable $e) { + $this->logger->warning( + message: '[SchemaFlowImport] Could not resolve an organisation for a declared flow; ' + . 'it will import but stay unlisted: ' . $e->getMessage() + ); + return null; + } + + if ($uuid === null || (string)$uuid === '') { + return null; + } + + return (string)$uuid; + }//end activeOrganisation() + /** * Mint a v4 uuid. * diff --git a/lib/Migration/Version1Date20260828100000.php b/lib/Migration/Version1Date20260828100000.php new file mode 100644 index 0000000000..215bf69db0 --- /dev/null +++ b/lib/Migration/Version1Date20260828100000.php @@ -0,0 +1,328 @@ + `ERROR: column "groups" ... does not exist`. + * + * 2. `storage_quota` / `bandwidth_quota` / `request_quota` HAVE NO COLUMN + * ANYWHERE. The entity declares all three and `__construct()` calls + * `addType()` on them, but the only migration creating those columns + * (`Version1Date20251101120000`) targets `openregister_applications`. They + * were copy-pasted from `Db/Application`. + * + * Both are reachable, not theoretical. `QBMapper::update()` builds its SET list + * from `getUpdatedFields()`, so a marked field with no column is an SQL error at + * write time, not a no-op: + * - `TenantLifecycleService::provision()` calls `setGroups([...])` — so + * provisioning a tenant could never have succeeded. + * - `PUT /api/organisations/{uuid}` whitelists `storageQuota`, + * `bandwidthQuota`, `requestQuota` and `groups` in + * `OrganisationController::applySimpleFieldUpdates()` / + * `applyArrayFieldUpdates()` — each a guaranteed 500. + * + * Adding the columns is a prerequisite for the consolidation regardless: the + * consolidated organisation carries groups and quotas as part of its tenant + * facet. + * + * ## Additive only + * + * Every column is nullable (or defaulted) and no existing row is rewritten. + * + * This migration adds the columns ONLY. It does NOT backfill the leaf apps' + * records: that needs a ruling on what happens when the same legal entity + * exists in both OpenCatalogi and Stackiq under different UUIDs, and + * identifiers already written into stored data are FROZEN, so any backfill + * must preserve the existing uuid/slug rather than mint new ones. It will land + * as its own repair step so it can be inspected and re-run independently -- + * see openspec/changes/consolidate-organisation-on-or/tasks.md task 5.1. + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Migration + * @package OCA\OpenRegister\Migration + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://www.OpenRegister.app + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Gives the tenant organisation its identity and relationship facets. + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md + */ +class Version1Date20260828100000 extends SimpleMigrationStep { + + /** + * The table extended here. + * + * @var string + */ + private const TABLE = 'openregister_organisations'; + + /** + * The columns this migration adds, in the order they are applied. + * + * Kept as data rather than as a run of `if (hasColumn)` blocks: the list is + * pure description, and expressing it as control flow made changeSchema() a + * 20-branch method whose every branch was identical in shape. Split by facet + * because that is how the consolidation is reasoned about - see the class + * docblock. + * + * @return array}> The column specifications. + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md + */ + private function columnSpecifications(): array { + return array_merge( + $this->tenancyColumnSpecifications(), + $this->identityColumnSpecifications(), + $this->relationshipColumnSpecifications() + ); + }//end columnSpecifications() + + /** + * Tenancy facet columns - repairs, not new features. + * + * `groups` and the three quotas are declared by the entity but were never + * created by any migration; see the class docblock for how each one escaped. + * + * @return array}> The column specifications. + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md + */ + private function tenancyColumnSpecifications(): array { + return [ + [ + 'name' => 'groups', + 'type' => Types::JSON, + 'options' => [ + 'notnull' => false, + 'comment' => 'Nextcloud group IDs attached to this organisation (RBAC principals, not tenancy - ADR-002 Rule 3)', + ], + ], + [ + 'name' => 'storage_quota', + 'type' => Types::BIGINT, + 'options' => ['notnull' => false, 'comment' => 'Storage quota in bytes (NULL = unlimited)'], + ], + [ + 'name' => 'bandwidth_quota', + 'type' => Types::BIGINT, + 'options' => ['notnull' => false, 'comment' => 'Bandwidth quota in bytes per month (NULL = unlimited)'], + ], + [ + 'name' => 'request_quota', + 'type' => Types::INTEGER, + 'options' => ['notnull' => false, 'comment' => 'API request quota per day (NULL = unlimited)'], + ], + ]; + }//end tenancyColumnSpecifications() + + /** + * Identity facet columns - what OpenCatalogi's publisher record carried. + * + * These answer the same question `uuid` answers ("which legal entity is + * this"), which is why they live on the organisation row rather than in a + * second store keyed differently. + * + * @return array}> The column specifications. + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md + */ + private function identityColumnSpecifications(): array { + return [ + [ + 'name' => 'type', + 'type' => Types::STRING, + 'options' => [ + 'notnull' => false, + 'length' => 64, + 'default' => 'organisation', + 'comment' => 'Discriminator: organisation|government|vendor|collaboration|department', + ], + ], + [ + 'name' => 'summary', + 'type' => Types::TEXT, + 'options' => ['notnull' => false, 'comment' => 'Short summary for overview pages (OpenCatalogi `summary`)'], + ], + [ + 'name' => 'oin', + 'type' => Types::STRING, + 'options' => ['notnull' => false, 'length' => 64, 'comment' => 'Overheidsidentificatienummer'], + ], + [ + 'name' => 'tooi', + 'type' => Types::STRING, + 'options' => ['notnull' => false, 'length' => 64, 'comment' => 'TOOI identifier for the organisation'], + ], + [ + 'name' => 'rsin', + 'type' => Types::STRING, + 'options' => ['notnull' => false, 'length' => 16, 'comment' => 'RSIN (9 digits, 11-proef) of the non-natural person'], + ], + [ + 'name' => 'kvk', + 'type' => Types::STRING, + 'options' => ['notnull' => false, 'length' => 16, 'comment' => 'Chamber-of-Commerce (KvK) number'], + ], + [ + 'name' => 'pki', + 'type' => Types::TEXT, + 'options' => ['notnull' => false, 'comment' => 'PKIoverheid certificate reference'], + ], + [ + 'name' => 'image', + 'type' => Types::TEXT, + 'options' => ['notnull' => false, 'comment' => 'Logo/avatar as a URL or base64 data URI'], + ], + ]; + }//end identityColumnSpecifications() + + /** + * Relationship facet columns - what Stackiq's vendor record carried. + * + * `merged_into` is core rather than app-specific because a merge changes + * WHICH UUID IS AUTHORITATIVE. An organisation that has been merged away must + * stop resolving as a tenant, or every query scoped to it is a cross-tenant + * read of the survivor's data. + * + * @return array}> The column specifications. + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md + */ + private function relationshipColumnSpecifications(): array { + return [ + [ + 'name' => 'registration_status', + 'type' => Types::STRING, + 'options' => [ + 'notnull' => false, + 'length' => 32, + 'comment' => 'Registration lifecycle: concept|submitted|registered|rejected|merged', + ], + ], + [ + 'name' => 'merged_into', + 'type' => Types::STRING, + 'options' => [ + 'notnull' => false, + 'length' => 255, + 'comment' => 'UUID of the surviving organisation when this one was merged away', + ], + ], + [ + 'name' => 'merged_at', + 'type' => Types::DATETIME, + 'options' => ['notnull' => false, 'comment' => 'When this organisation was merged away'], + ], + ]; + }//end relationshipColumnSpecifications() + + /** + * The indexes the consolidation introduces. + * + * `oin` is how a publication resolves its publisher; `merged_into` is walked + * on every tenant resolution to follow a merge chain to the survivor. + * + * @return array> Index name mapped to its columns. + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md + */ + private function indexSpecifications(): array { + return [ + 'openregister_org_oin_idx' => ['oin'], + 'openregister_org_merged_idx' => ['merged_into'], + ]; + }//end indexSpecifications() + + /** + * Add the identity, relationship and repaired tenancy columns. + * + * @param IOutput $output The migration output. + * @param Closure $schemaClosure Returns the schema wrapper. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The altered schema, or null when nothing changed. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) $options is part of the base signature. + * + * @spec openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /* @var ISchemaWrapper $schema The schema wrapper. */ + $schema = $schemaClosure(); + + // Unlike Version1Date20250102000000, this class sorts AFTER the one that + // creates the table, so the guard below is the ordinary "already ran" + // check rather than a permanent skip. + if ($schema->hasTable(self::TABLE) === false) { + $output->warning(message: 'openregister_organisations is absent; skipping organisation consolidation columns'); + return null; + } + + $table = $schema->getTable(self::TABLE); + $added = []; + + foreach ($this->columnSpecifications() as $column) { + if ($table->hasColumn($column['name']) === true) { + continue; + } + + $table->addColumn($column['name'], $column['type'], $column['options']); + $added[] = $column['name']; + } + + foreach ($this->indexSpecifications() as $indexName => $indexColumns) { + if ($table->hasIndex($indexName) === true) { + continue; + } + + $table->addIndex($indexColumns, $indexName); + $added[] = 'index:' . implode(',', $indexColumns); + } + + if ($added === []) { + return null; + } + + $output->info(message: 'Organisation consolidation added: ' . implode(', ', $added)); + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Migration/Version1Date20260828120000.php b/lib/Migration/Version1Date20260828120000.php new file mode 100644 index 0000000000..dea520bb89 --- /dev/null +++ b/lib/Migration/Version1Date20260828120000.php @@ -0,0 +1,122 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use OCP\DB\Types; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Add flow_run / flow_node / flow_step to openregister_audit_trails. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ +class Version1Date20260828120000 extends SimpleMigrationStep { + /** + * Add the attribution columns and the run index. + * + * @param IOutput $output Output for the migration process. + * @param Closure $schemaClosure The schema closure. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The modified schema, or null when nothing changed. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /* + * @var ISchemaWrapper $schema + */ + + $schema = $schemaClosure(); + + if ($schema->hasTable('openregister_audit_trails') === false) { + return null; + } + + $table = $schema->getTable('openregister_audit_trails'); + $changed = false; + + // Every column is added independently and guarded on its own presence. + // A single "if the first column is missing, add all three" guard is the + // shape that leaves a half-migrated table permanently half-migrated + // after one interrupted upgrade. + if ($table->hasColumn('flow_run') === false) { + $table->addColumn('flow_run', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $changed = true; + } + + if ($table->hasColumn('flow_node') === false) { + $table->addColumn('flow_node', Types::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + $changed = true; + } + + if ($table->hasColumn('flow_step') === false) { + $table->addColumn('flow_step', Types::INTEGER, [ + 'notnull' => false, + ]); + $changed = true; + } + + // The run direction ("what did this run touch") is the one that needs + // help; the object direction is already served by the existing + // object_uuid index. No composite until a query asks for one — a + // speculative index on this table is a write cost on every audited + // mutation forever (ADR-009). + if ($table->hasIndex('idx_audit_flow_run') === false) { + $table->addIndex(['flow_run'], 'idx_audit_flow_run'); + $changed = true; + } + + if ($changed === false) { + return null; + } + + $output->info('Added flow_run / flow_node / flow_step and idx_audit_flow_run to openregister_audit_trails'); + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Migration/Version1Date20260829090000.php b/lib/Migration/Version1Date20260829090000.php new file mode 100644 index 0000000000..de2bdfa8be --- /dev/null +++ b/lib/Migration/Version1Date20260829090000.php @@ -0,0 +1,309 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use OCP\DB\Types; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Create the definition and version stores, and the columns that point at them. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ +class Version1Date20260829090000 extends SimpleMigrationStep { + /** + * Create the definition store and point runs at it. + * + * @param IOutput $output Output for the migration process. + * @param Closure $schemaClosure The schema closure. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The modified schema, or null when nothing changed. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /* + * @var ISchemaWrapper $schema + */ + + $schema = $schemaClosure(); + + // 🔑 EACH CALL FIRST, THEN THE OR. Written as `$changed || $this->x()` + // the short-circuit would SKIP the later steps as soon as one reported + // a change — creating the definition store and silently never adding + // the columns that point at it. + $definitions = $this->definitionStore(schema: $schema); + $versions = $this->versionStore(schema: $schema); + $columns = $this->pinColumns(schema: $schema); + + $changed = ($definitions === true || $versions === true || $columns === true); + + if ($changed === false) { + return null; + } + + return $schema; + }//end changeSchema() + + /** + * Create the content store: one row per distinct graph, keyed by its hash. + * + * @param ISchemaWrapper $schema The schema being changed. + * + * @return boolean Whether anything changed. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + private function definitionStore(ISchemaWrapper $schema): bool { + $changed = false; + + if ($schema->hasTable('openregister_flow_defs') === false) { + $table = $schema->createTable('openregister_flow_defs'); + + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + ]); + + // 64 hex characters: sha256 over the canonical definition. The + // hash IS the identity — two flows that happen to hold the same + // graph share one row, which is correct and costs nothing. + $table->addColumn('hash', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + + $table->addColumn('flow_uuid', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + + // TEXT, not JSON: this column is only ever read back whole and + // decoded in PHP. Nothing queries inside it, so a JSON type would + // buy indexing nobody uses and cost portability across the three + // databases the fleet supports. + $table->addColumn('definition', Types::TEXT, [ + 'notnull' => true, + ]); + + $table->addColumn('created', Types::DATETIME, [ + 'notnull' => false, + ]); + + $table->setPrimaryKey(['id']); + + // UNIQUE is what makes the dedupe real rather than hopeful: two + // workers pinning the same unedited flow concurrently race, and + // the constraint is what turns that race into a caught duplicate + // instead of two rows. + $table->addUniqueIndex(['hash'], 'idx_flow_defs_hash'); + $table->addIndex(['flow_uuid'], 'idx_flow_defs_flow'); + + $changed = true; + }//end if + return $changed; + + }//end definitionStore() + + /** + * Create the version rows that NAME those graphs. + * + * @param ISchemaWrapper $schema The schema being changed. + * + * @return boolean Whether anything changed. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + private function versionStore(ISchemaWrapper $schema): bool { + $changed = false; + + if ($schema->hasTable('openregister_flow_versions') === false) { + $versions = $schema->createTable('openregister_flow_versions'); + + $versions->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + ]); + + $versions->addColumn('flow_uuid', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + + $versions->addColumn('version', Types::INTEGER, [ + 'notnull' => true, + 'default' => 1, + ]); + + $versions->addColumn('status', Types::STRING, [ + 'notnull' => true, + 'length' => 16, + 'default' => 'draft', + ]); + + // 🔑 THE GRAPH IS NOT COPIED HERE. A version row POINTS AT a row in + // `openregister_flow_defs`, which is keyed by the canonical hash of + // the graph. Publishing an unchanged graph therefore stores one + // short row, not a second 4.3 KB copy of the same 18 nodes; and two + // versions that happen to hold identical graphs share the content. + // + // It also makes immutability structural rather than promised: the + // hash addresses the content, so a version cannot be edited in + // place without becoming a different hash — which is a different + // row, which the unique index would reject. + $versions->addColumn('definition_hash', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + + // Copied onto the version rather than read through the flow. The + // flow's owner can change after a version is published, and a run + // pinned to that version must keep answering the question "who did + // this belong to when it was published". + $versions->addColumn('owner', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + + $versions->addColumn('organisation', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + + $versions->addColumn('published_at', Types::DATETIME, [ + 'notnull' => false, + ]); + + $versions->addColumn('published_by', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + + $versions->addColumn('deprecated_at', Types::DATETIME, [ + 'notnull' => false, + ]); + + $versions->addColumn('created', Types::DATETIME, [ + 'notnull' => false, + ]); + + $versions->setPrimaryKey(['id']); + + // UNIQUE(flow, version) is the constraint that makes "version 3 of + // this flow" a name rather than a hope: two concurrent publishes + // cannot both claim N+1. + $versions->addUniqueIndex(['flow_uuid', 'version'], 'or_flowver_uniq'); + + // The hot read is "the published version of this flow", on every + // dispatch. Indexed on (flow, status) so it is one index seek and + // does not scan a flow's whole history. + $versions->addIndex(['flow_uuid', 'status'], 'or_flowver_status'); + + $changed = true; + }//end if + return $changed; + + }//end versionStore() + + /** + * Add the columns that point at a version: the flow head, and the run pin. + * + * @param ISchemaWrapper $schema The schema being changed. + * + * @return boolean Whether anything changed. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + private function pinColumns(ISchemaWrapper $schema): bool { + $changed = false; + + if ($schema->hasTable('openregister_flows') === true) { + $flows = $schema->getTable('openregister_flows'); + + if ($flows->hasColumn('version') === false) { + $flows->addColumn('version', Types::INTEGER, [ + 'notnull' => true, + 'default' => 1, + ]); + $changed = true; + } + + // 🔴 DELIBERATELY NOT `status`. `openregister_flows` already has a + // `status` column, and it means the last RUN's outcome. Reusing it + // for the lifecycle would make "this flow is failed" and "this flow + // is a draft" the same field. They answer different questions and + // change at different times. + if ($flows->hasColumn('lifecycle_status') === false) { + $flows->addColumn('lifecycle_status', Types::STRING, [ + 'notnull' => true, + 'length' => 16, + 'default' => 'draft', + ]); + $changed = true; + } + }//end if + + if ($schema->hasTable('openregister_flow_runs') === true) { + $runs = $schema->getTable('openregister_flow_runs'); + + // Nullable, and it must stay nullable at the SCHEMA level: the + // column has to exist before the repair step that fills it can run. + // The repair step then pins every non-terminal run, so no run that + // can still move is left without one — but a terminated run from + // before the upgrade keeps its null, because inventing a version + // for history would be a lie about what it executed. + if ($runs->hasColumn('flow_version') === false) { + $runs->addColumn('flow_version', Types::INTEGER, [ + 'notnull' => false, + ]); + $changed = true; + } + } + return $changed; + + }//end pinColumns() +}//end class diff --git a/lib/Migration/Version1Date20260829160000.php b/lib/Migration/Version1Date20260829160000.php new file mode 100644 index 0000000000..5cbbdd1fde --- /dev/null +++ b/lib/Migration/Version1Date20260829160000.php @@ -0,0 +1,135 @@ +migrate('latest', $previousVersion === '')`, and that second argument is `$schemaOnly` — + * true whenever the app has no previously recorded version. `MigrationService::migrateSchemaOnly()` + * invokes `changeSchema()` on every step and nothing else, then marks every step as EXECUTED. So on + * a fresh install the English columns were added, the Dutch ones were never dropped, and the + * migration was recorded as done and will never retry. + * + * `naam`, `doelbinding` and `rechtsgrond` are `notnull => true` (Version1Date20260430160000), while + * every current write names only the English columns. Every insert therefore failed permanently: + * + * ERROR: null value in column "naam" of relation "oc_openregister_verwerkingsactiviteiten" + * violates not-null constraint + * + * Measured on dossiq CI run 33255960731 — 238 failures in one run, taking its case-edit E2E with + * them — and reproduced on a local install, whose table carries all 33 columns. + * + * The drop is done HERE, in `changeSchema()`, because that is the one hook both migration paths + * run. Dropping a column through the schema object is honoured here (the diff is applied by + * `migrateToSchema()`), unlike in `postSchemaChange()`, whose schema is never applied — the same + * asymmetry Version1Date20260818230000 already documents for its own drop. + * + * Data-safe in every path. On an upgrade, 20260818230000's `postSchemaChange()` has already copied + * the Dutch values across and dropped the columns, so this finds nothing to do. On a fresh install + * there is nothing to copy: `$schemaOnly` is only ever true when the app had no previous version, + * so no row can predate this. + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @category Migration + * @package OCA\OpenRegister\Migration + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * @spec openspec/specs/avg-verwerkingsregister/spec.md#requirement-the-system-must-maintain-a-verwerkingsactiviteiten-register-as-an-openregister-schema + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Drops the 13 Dutch columns Version1Date20260818230000 could not drop on a fresh install. + * + * @spec openspec/specs/avg-verwerkingsregister/spec.md#requirement-the-system-must-maintain-a-verwerkingsactiviteiten-register-as-an-openregister-schema + */ +class Version1Date20260829160000 extends SimpleMigrationStep { + + /** + * The Dutch columns replaced by Version1Date20260818230000's English ones. + * + * Kept as its own list rather than referencing that migration's COLUMN_MAP: a migration is a + * record of what the schema did on a given date, and must not change meaning because a later + * edit changed a constant somewhere else. + * + * @var string[] + */ + private const OLD_DUTCH_COLUMNS = [ + 'naam', + 'beschrijving', + 'doelbinding', + 'rechtsgrond', + 'categorieen_betrokkenen', + 'categorieen_persoonsgegevens', + 'bewaartermijn', + 'ontvangers', + 'doorgifte_buiten_eu', + 'technische_maatregelen', + 'organisatorische_maatregelen', + 'verwerkingsverantwoordelijke', + 'contactgegevens_fg', + ]; + + /** + * Drop any Dutch column still present, so the table matches what the entity writes. + * + * @param IOutput $output Migration output sink. + * @param Closure $schemaClosure Closure returning the ISchemaWrapper. + * @param array $options Migration options (unused). + * + * @return ISchemaWrapper|null The changed schema, or null when there was nothing to change. + * + * @spec openspec/specs/avg-verwerkingsregister/spec.md#requirement-the-system-must-maintain-a-verwerkingsactiviteiten-register-as-an-openregister-schema + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + + if ($schema->hasTable('openregister_verwerkingsactiviteiten') === false) { + return null; + } + + $table = $schema->getTable('openregister_verwerkingsactiviteiten'); + $dropped = 0; + + foreach (self::OLD_DUTCH_COLUMNS as $columnName) { + if ($table->hasColumn($columnName) === false) { + continue; + } + + $table->dropColumn($columnName); + $dropped++; + } + + if ($dropped === 0) { + // The upgrade path already dropped them in 20260818230000's postSchemaChange. + return null; + } + + $output->info( + sprintf( + 'Dropped %d Dutch column(s) from openregister_verwerkingsactiviteiten that ' + . 'postSchemaChange could not reach on a schema-only install.', + $dropped + ) + ); + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Repair/BackfillFlowTriggerIndex.php b/lib/Repair/BackfillFlowTriggerIndex.php index 0298fb4478..da4ec53d90 100644 --- a/lib/Repair/BackfillFlowTriggerIndex.php +++ b/lib/Repair/BackfillFlowTriggerIndex.php @@ -104,7 +104,22 @@ public function run(IOutput $output): void { try { $mapper = $this->container->get(FlowMapper::class); $index = $this->container->get(FlowTriggerIndex::class); - $flows = $mapper->findAllFlows(); + // 🔴 PAGED. `findAllFlows()` defaults to a limit of 100, so this + // derived only the first page's trigger rows and reported success — + // every flow past 100 was left unsubscribed, which on the trigger + // path is silent: the flow simply never fires. + $flows = []; + $page = 500; + $offset = 0; + while (true) { + $batch = $mapper->findAllFlows(limit: $page, offset: $offset); + $flows = array_merge($flows, $batch); + if (count($batch) < $page) { + break; + } + + $offset += $page; + } } catch (Throwable $e) { $output->info('Flow trigger index backfill skipped: ' . $e->getMessage()); return; diff --git a/lib/Repair/BackfillFlowVersions.php b/lib/Repair/BackfillFlowVersions.php new file mode 100644 index 0000000000..b1f0904475 --- /dev/null +++ b/lib/Repair/BackfillFlowVersions.php @@ -0,0 +1,266 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Repair; + +use DateTime; +use OCA\OpenRegister\Db\Flow; +use OCA\OpenRegister\Db\FlowMapper; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\FlowVersion; +use OCA\OpenRegister\Db\FlowVersionMapper; +use OCA\OpenRegister\Service\Flow\FlowDefinitionPin; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Throwable; + +/** + * Gives every pre-versioning flow a published version 1, and pins its runs. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ +class BackfillFlowVersions implements IRepairStep { + /** + * Constructor. + * + * Resolved lazily from the container, like the other flow repair steps: + * this runs during install and upgrade, when the flow tables may not exist + * yet, and a constructor-injected mapper would make that a fatal rather + * than a skip. + * + * @param ContainerInterface $container The app container. + * @param LoggerInterface $logger Diagnostics. + */ + public function __construct( + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * The step's name. + * + * @return string The name. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function getName(): string { + return 'Publish version 1 of every existing flow and pin in-flight runs to it'; + }//end getName() + + /** + * Run the back-fill. + * + * Never throws. An upgrade that aborted here would leave the instance with + * the new refusals in place and no versions to satisfy them — strictly + * worse than reporting the failure and letting an operator re-run repair. + * + * @param IOutput $output Migration output. + * + * @return void + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function run(IOutput $output): void { + try { + $flows = $this->everyFlow(); + } catch (Throwable $e) { + $output->info('Flow version backfill skipped: ' . $e->getMessage()); + return; + } + + if ($flows === []) { + $output->info('Flow versions: no flows to publish.'); + return; + } + + $published = 0; + $pinned = 0; + $skipped = 0; + + foreach ($flows as $flow) { + try { + $result = $this->backfillOne(flow: $flow); + } catch (Throwable $e) { + // One flow's failure must not cost the other flows their + // versions — an aborted loop here is the difference between + // one unrunnable flow and an unrunnable instance. + $this->logger->error( + message: '[BackfillFlowVersions] Could not publish flow "' . $flow->getUuid() + . '": ' . $e->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__, 'flow' => $flow->getUuid()] + ); + $output->warning('Flow "' . $flow->getUuid() . '" could not be versioned: ' . $e->getMessage()); + continue; + } + + if ($result['published'] === false) { + $skipped++; + } + + if ($result['published'] === true) { + $published++; + } + + $pinned += $result['pinned']; + }//end foreach + + $output->info( + sprintf( + 'Flow versions: published version 1 for %d flow(s), %d already versioned, pinned %d in-flight run(s).', + $published, + $skipped, + $pinned + ) + ); + + // Sweep version rows whose flow is gone. `FlowService::delete()` now + // cascades to them, so no NEW orphan can appear — but an instance that + // deleted a flow between the version table landing and that cascade + // landing kept the rows, unreachable through any read path because + // every version read is keyed by flow. + try { + $orphans = $this->container->get(FlowVersionMapper::class)->deleteOrphaned(); + if ($orphans > 0) { + $output->info(sprintf('Flow versions: removed %d orphaned version row(s).', $orphans)); + } + } catch (Throwable $e) { + $output->warning('Orphaned flow versions could not be swept: ' . $e->getMessage()); + } + + }//end run() + + /** + * Every flow, paged. + * + * 🔴 `findAllFlows()` DEFAULTS TO 100. Calling it bare versioned only the + * first page and reported success — measured on a dev instance: 219 flows, + * 100 versioned, 119 left with no published version and therefore + * unrunnable. That is exactly the outage this repair step exists to + * prevent, produced by the repair step itself. + * + * Paged rather than called with a huge limit: a limit large enough to be + * "obviously safe" today is a limit that silently truncates once an + * instance grows past it, and the failure would look identical. + * + * @return array Every flow on the instance. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + private function everyFlow(): array { + $mapper = $this->container->get(FlowMapper::class); + $page = 500; + $offset = 0; + $all = []; + + while (true) { + $batch = $mapper->findAllFlows(limit: $page, offset: $offset); + $all = array_merge($all, $batch); + + if (count($batch) < $page) { + return $all; + } + + $offset += $page; + } + + }//end everyFlow() + + + /** + * Version one flow and pin its in-flight runs. + * + * @param Flow $flow The flow. + * + * @return array{published: boolean, pinned: integer} What was done. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + private function backfillOne(Flow $flow): array { + $flowId = (string)$flow->getUuid(); + $versions = $this->container->get(FlowVersionMapper::class); + + // Already versioned. Not "already run once" — the check is for any + // version row at all, so a flow that was drafted but never published + // is left as the draft its author made it. + if ($versions->highestVersion(flowUuid: $flowId) > 0) { + return ['published' => false, 'pinned' => 0]; + } + + $graph = [ + 'nodes' => ($flow->getNodes() ?? []), + 'edges' => ($flow->getEdges() ?? []), + 'limits' => ($flow->getLimits() ?? []), + 'executionMode' => (string)($flow->getExecutionMode() ?? Flow::MODE_ASYNC), + ]; + + $hash = $this->container->get(FlowDefinitionPin::class)->pin(flow: $graph, flowId: $flowId); + if ($hash === null) { + throw new RuntimeException('the definition could not be stored'); + } + + $version = new FlowVersion(); + $version->setFlowUuid($flowId); + $version->setVersion(1); + // PUBLISHED, not draft. Every one of these flows was live before the + // upgrade; delivering them as drafts would silently switch off every + // automation on the instance, which is the outage this step exists to + // prevent. + $version->setStatus(FlowVersion::STATUS_PUBLISHED); + $version->setDefinitionHash($hash); + $version->setOwner($flow->getOwner()); + $version->setOrganisation($flow->getOrganisation()); + $version->setPublishedAt(new DateTime()); + $version->setCreated(new DateTime()); + $versions->insert($version); + + $flow->setVersion(1); + $flow->setLifecycleStatus(FlowVersion::STATUS_PUBLISHED); + $this->container->get(FlowMapper::class)->update($flow); + + $pinned = $this->container->get(FlowRunMapper::class) + ->pinUnversionedActive(flowUuid: $flowId, version: 1); + + return ['published' => true, 'pinned' => $pinned]; + + }//end backfillOne() +}//end class diff --git a/lib/Repair/RechainAuditTrailForFlowAttribution.php b/lib/Repair/RechainAuditTrailForFlowAttribution.php new file mode 100644 index 0000000000..fcb35c1310 --- /dev/null +++ b/lib/Repair/RechainAuditTrailForFlowAttribution.php @@ -0,0 +1,377 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Repair; + +use DateTime; +use OCA\OpenRegister\Db\AuditTrail; +use OCA\OpenRegister\Service\AuditCanonicalV1; +use OCA\OpenRegister\Service\AuditHashService; +use OCP\IAppConfig; +use OCP\IDBConnection; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Pre-verifies the v1 chain and re-seals the audit trail under the v2 seed. + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ +class RechainAuditTrailForFlowAttribution implements IRepairStep { + /** + * App-config key recording that the v1 → v2 re-chain has been performed. + * + * Presence is what makes the step idempotent: a re-chain is expensive and, + * far more importantly, its pre-verification is only meaningful ONCE. After + * the first pass every row is sealed under v2, so a second pre-verify would + * compare v2 rows against the v1 form and report a false break — recording + * a scary, meaningless verdict over the real one. + * + * @var string + */ + public const DONE_KEY = 'audit_chain_seed_v2_rechained_at'; + + /** + * App-config key holding the pre-migration verdict, as JSON. + * + * @var string + */ + public const VERDICT_KEY = 'audit_chain_seed_v2_preverify'; + + /** + * How many rows to verify per query window. + * + * @var integer + */ + private const WINDOW = 500; + + /** + * Constructor. + * + * @param IDBConnection $db Reads the audit rows to verify. + * @param IAppConfig $config Stores the verdict and the done-marker. + * @param AuditHashService $hashes Performs the re-seal. + * @param LoggerInterface $logger Records the verdict in the log as well. + */ + public function __construct( + private readonly IDBConnection $db, + private readonly IAppConfig $config, + private readonly AuditHashService $hashes, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * The step's name, as shown by `occ maintenance:repair`. + * + * @return string The human-readable name. + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + public function getName(): string { + return 'Verify and re-seal the OpenRegister audit chain for flow attribution (seed v1 → v2)'; + }//end getName() + + /** + * Verify under v1, record the verdict, then re-chain under v2. + * + * @param IOutput $output Progress output. + * + * @return void + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + public function run(IOutput $output): void { + if ($this->config->getValueString('openregister', self::DONE_KEY, '') !== '') { + $output->info('Audit chain already re-sealed under seed v2; nothing to do.'); + return; + } + + $verdict = $this->verifyUnderV1(); + + // Refuse before touching a single row if the account of what we are + // about to overwrite cannot be kept. A re-chain is not reversible, so + // "it ran but we do not know what it ran over" is strictly worse than + // "it has not run yet". + if ($this->recordVerdict(verdict: $verdict, output: $output) === false) { + $output->warning( + 'REFUSING to re-chain: the pre-verification verdict could not be stored. ' + . 'A re-chain that leaves no record of the chain state it replaced is not recoverable. ' + . 'Fix app-config writes and re-run `occ maintenance:repair`.' + ); + return; + } + + $this->reportVerdict(verdict: $verdict, output: $output); + + $result = $this->hashes->rechainAll(); + + $this->config->setValueString('openregister', self::DONE_KEY, (new DateTime())->format('c')); + + $output->info( + sprintf( + 'Re-sealed %d audit row(s) under seed v2; %d retention tombstone(s) carried forward.', + (int)($result['rechained'] ?? 0), + (int)($result['tombstonesPreserved'] ?? 0) + ) + ); + }//end run() + + /** + * Say what the pre-check found, in the register an operator will actually read. + * + * A broken chain is a WARNING and not a refusal: an operator whose chain is + * already broken still needs their instance to upgrade, and refusing would + * strand them. What must not happen is the re-seal going by unremarked, + * because afterwards the break is no longer detectable. + * + * @param array $verdict The pre-verification result. + * @param IOutput $output Progress output. + * + * @return void + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + private function reportVerdict(array $verdict, IOutput $output): void { + if ($verdict['valid'] === true) { + $output->info( + sprintf( + 'Audit chain verified intact under seed v1 (%d row(s), %d tombstone(s)). Re-sealing under v2.', + $verdict['entriesVerified'], + $verdict['purgedTombstones'] + ) + ); + + return; + } + + $output->warning( + sprintf( + 'The audit chain did NOT verify under seed v1 before re-sealing ' + . '(first break at row id %s, %d row(s) verified). ' + . 'This is recorded under app-config `%s`. The re-seal below will make the ' + . 'chain verify again — it does NOT repair the cause, and after it runs the ' + . 'break is no longer detectable. Investigate using the stored verdict.', + var_export($verdict['brokenAt'], true), + $verdict['entriesVerified'], + self::VERDICT_KEY + ) + ); + }//end reportVerdict() + + /** + * Walk the chain using the FROZEN v1 canonical form. + * + * Mirrors the live verifier's tombstone rule: a purged row's content no + * longer re-hashes to its stored value by design, but that stored value is + * still the link the next row committed to, so the chain is carried across + * it rather than reported as a break. + * + * Unsealed rows (null hash) are skipped and carry the previous hash + * forward, exactly as the live verifier does — a tail of unsealed rows is a + * smaller claim, never a false alarm. + * + * @return array{valid: bool, entriesVerified: int, brokenAt: int|null, skippedNullHashes: int, purgedTombstones: int} + * + * @SuppressWarnings(PHPMD.StaticAccess) AuditCanonicalV1 is deliberately static and + * deliberately FROZEN. Injecting it would present it as a collaborator that could be + * swapped or updated, which is the one thing it must never be: it describes the audit + * entity as it WAS, and a substituted implementation would silently invalidate the + * only check that can still tell an intact v1 chain from a tampered one. + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + private function verifyUnderV1(): array { + $previousHash = AuditCanonicalV1::genesisHash(); + $entriesVerified = 0; + $skippedNullHashes = 0; + $purgedTombstones = 0; + $brokenAt = null; + $afterId = 0; + + while ($brokenAt === null) { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('openregister_audit_trails') + ->where($qb->expr()->gt('id', $qb->createNamedParameter($afterId, IQueryBuilder::PARAM_INT))) + ->orderBy('id', 'ASC') + ->setMaxResults(self::WINDOW); + + $result = $qb->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + + if ($rows === []) { + break; + } + + foreach ($rows as $row) { + $afterId = (int)$row['id']; + + $entity = $this->hydrate(row: $row); + $storedHash = $entity->getHash(); + + if ($storedHash === null || $storedHash === '') { + $skippedNullHashes++; + continue; + } + + if ($entity->isPurged() === true) { + // A declared tombstone: carry its stored hash forward as + // the chain link without re-deriving its blanked content. + $purgedTombstones++; + $previousHash = $storedHash; + continue; + } + + $expected = AuditCanonicalV1::computeHash(entry: $entity, previousHash: $previousHash); + + if (hash_equals($expected, $storedHash) === false) { + $brokenAt = (int)$row['id']; + break; + } + + $entriesVerified++; + $previousHash = $storedHash; + }//end foreach + }//end while + + return [ + 'valid' => ($brokenAt === null), + 'entriesVerified' => $entriesVerified, + 'brokenAt' => $brokenAt, + 'skippedNullHashes' => $skippedNullHashes, + 'purgedTombstones' => $purgedTombstones, + ]; + }//end verifyUnderV1() + + /** + * Hydrate a raw row exactly the way the live verifier does. + * + * 🔑 This deliberately mirrors `AuditHashService::mapRowToEntity()` + + * `AuditTrail::hydrate()` rather than using `Entity::fromRow()`. The two are + * very nearly equivalent, and "very nearly" is worth nothing here: this + * method feeds the one comparison whose job is to tell an intact chain from + * a tampered one. Any difference in how a date is parsed or a JSON column is + * decoded would change the canonical JSON, mismatch every row, and report a + * healthy chain as broken — the precise false verdict this whole step is + * built to avoid. `hydrate()` also ignores unknown properties, where + * `fromRow()` would throw on a column the entity does not declare. + * + * @param array $row The raw database row. + * + * @return AuditTrail The hydrated entity. + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + private function hydrate(array $row): AuditTrail { + $mapped = []; + foreach ($row as $key => $value) { + // Snake_case to camelCase, character for character as the verifier does. + $camelKey = lcfirst(str_replace('_', '', ucwords((string)$key, '_'))); + $mapped[$camelKey] = $value; + } + + $entity = new AuditTrail(); + $entity->hydrate(object: $mapped); + + return $entity; + }//end hydrate() + + /** + * Persist the verdict, and say whether it actually landed. + * + * The boolean is load-bearing: {@see run()} refuses to re-chain on false. + * It is verified by READING BACK what was written rather than by the write + * not throwing — a config backend that silently drops a write would + * otherwise report success and leave no record at all. + * + * @param array $verdict The pre-verification result. + * @param IOutput $output Progress output. + * + * @return boolean True when the verdict is durably stored. + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + private function recordVerdict(array $verdict, IOutput $output): bool { + $payload = json_encode( + array_merge($verdict, [ + 'seedFrom' => AuditCanonicalV1::GENESIS_SEED, + 'seedTo' => 'openregister-genesis-v2', + 'verifiedAt' => (new DateTime())->format('c'), + ]) + ); + + if ($payload === false) { + return false; + } + + $this->logger->warning( + message: '[RechainAuditTrailForFlowAttribution] Pre-re-chain audit chain verdict: ' . $payload, + context: ['file' => __FILE__, 'line' => __LINE__, 'app' => 'openregister'] + ); + + try { + $this->config->setValueString('openregister', self::VERDICT_KEY, $payload); + } catch (Throwable $e) { + $output->warning('Could not store the pre-verification verdict: ' . $e->getMessage()); + return false; + } + + // Read it back. "The setter did not throw" is not evidence the value is + // there, and this is the one fact the whole step exists to preserve. + return ($this->config->getValueString('openregister', self::VERDICT_KEY, '') === $payload); + }//end recordVerdict() +}//end class diff --git a/lib/Service/AuditCanonicalV1.php b/lib/Service/AuditCanonicalV1.php new file mode 100644 index 0000000000..e3a9fcaaf6 --- /dev/null +++ b/lib/Service/AuditCanonicalV1.php @@ -0,0 +1,162 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service; + +use OCA\OpenRegister\Db\AuditTrail; + +/** + * Canonicalises an audit row the way the v1 chain did. + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ +final class AuditCanonicalV1 { + /** + * The genesis seed the v1 chain was built from. + * + * @var string + */ + public const GENESIS_SEED = 'openregister-genesis-v1'; + + /** + * Every key the v1 canonical JSON contained, `hash` and `previousHash` + * excluded as they always were. + * + * 🔴 FROZEN. Adding an entry here changes what a v1 row is claimed to have + * been sealed over, which makes intact rows read as tampered. + * + * @var string[] + */ + private const FIELDS = [ + 'id', + 'uuid', + 'schema', + 'register', + 'object', + 'objectUuid', + 'registerUuid', + 'schemaUuid', + 'action', + 'changed', + 'user', + 'userName', + 'session', + 'request', + 'ipAddress', + 'version', + 'created', + 'organisationId', + 'organisationIdType', + 'processingActivityId', + 'processingActivityUrl', + 'processingId', + 'confidentiality', + 'retentionPeriod', + 'size', + 'expires', + 'toolId', + 'paramsDigest', + 'resultSummary', + ]; + + /** + * The v1 genesis hash. + * + * @return string SHA-256 of the v1 seed. + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + public static function genesisHash(): string { + return hash('sha256', self::GENESIS_SEED); + }//end genesisHash() + + /** + * The canonical JSON a v1 row was sealed over. + * + * Same rules the v1 canonicaliser used: the allowlisted fields only, sorted + * keys, compact form. + * + * @param AuditTrail $entry The row to canonicalise. + * + * @return string The v1 canonical JSON. + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + public static function canonicalJson(AuditTrail $entry): string { + $serialized = $entry->jsonSerialize(); + + $data = []; + foreach (self::FIELDS as $field) { + // Present-but-null and absent are different canonical forms, and + // every v1 field was always PRESENT — jsonSerialize() emitted the + // whole array unconditionally. So a missing key is filled with null + // rather than skipped. + $data[$field] = ($serialized[$field] ?? null); + } + + ksort($data); + + return json_encode($data, (JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + }//end canonicalJson() + + /** + * The hash a v1 row should carry, given its predecessor. + * + * @param AuditTrail $entry The row. + * @param string $previousHash The hash of the row before it. + * + * @return string The expected v1 hash. + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + public static function computeHash(AuditTrail $entry, string $previousHash): string { + return hash('sha256', ($previousHash . self::canonicalJson(entry: $entry))); + }//end computeHash() +}//end class diff --git a/lib/Service/AuditHashService.php b/lib/Service/AuditHashService.php index 5966678644..e7209fc82f 100644 --- a/lib/Service/AuditHashService.php +++ b/lib/Service/AuditHashService.php @@ -73,9 +73,23 @@ class AuditHashService { /** * The genesis seed used for the first entry in the hash chain. * + * ⚠️ VERSIONED, and versioned TOGETHER WITH the canonical form. The seed and + * the set of fields {@see getCanonicalJson()} covers are jointly the chain's + * identity: change either and every previously stored hash stops being + * re-derivable. ADR-003 Rule 4 therefore treats a change to either as a + * migration event — verify under the outgoing form, record the verdict, + * then re-seal — never an in-place edit. + * + * v1 → v2 (2026-08-28): the flow attribution fields joined the canonical + * form so that re-pointing an audit row at a different flow run breaks + * verification instead of going unnoticed. The v1 form is preserved in + * {@see AuditCanonicalV1} solely so the migration could verify what it was + * about to overwrite; see + * {@see \OCA\OpenRegister\Migration\RechainAuditTrailForFlowAttribution}. + * * @var string */ - private const GENESIS_SEED = 'openregister-genesis-v1'; + private const GENESIS_SEED = 'openregister-genesis-v2'; /** * Well-known advisory lock key serializing ALL seal passes. diff --git a/lib/Service/Calculation/CalculationEvaluator.php b/lib/Service/Calculation/CalculationEvaluator.php index 30e10c408e..296fbaf5da 100644 --- a/lib/Service/Calculation/CalculationEvaluator.php +++ b/lib/Service/Calculation/CalculationEvaluator.php @@ -939,6 +939,45 @@ private function sequence(array $object, mixed $args): ?string { return str_pad((string)$reserved, $pad, '0', STR_PAD_LEFT); }//end sequence() + /** + * Whether an expression tree contains a `sequence` node. + * + * A `sequence` reserves its running number exactly once, on create, through + * the per-save SequenceContext. Off that path {@see self::sequence()} + * deliberately returns null — which is correct for the evaluator but + * catastrophic for a caller that PERSISTS the result: `concat` renders the + * null as an empty string, so a declared identifier such as + * `concat(year(startDate), "-", sequence(...))` recomputes to "2026-" and + * overwrites the number assigned at create. + * + * Every caller that materialises a calculation must therefore skip a + * sequence-bearing expression while no context is active, leaving the + * stored value untouched. See openregister#3075. + * + * @param mixed $expression Expression AST (any node). + * + * @return bool True when a `sequence` operator appears anywhere in the tree. + * + * @spec openspec/specs/computed-fields/spec.md#requirement-save-time-evaluation + */ + public function expressionUsesSequence(mixed $expression): bool { + if (is_array($expression) === false) { + return false; + } + + foreach ($expression as $key => $value) { + if ($key === 'sequence') { + return true; + } + + if ($this->expressionUsesSequence(expression: $value) === true) { + return true; + } + } + + return false; + }//end expressionUsesSequence() + /** * Coerce a value to DateTimeImmutable when possible. * diff --git a/lib/Service/Calculation/TemporalCalculationSweepService.php b/lib/Service/Calculation/TemporalCalculationSweepService.php index 9a4ece6d3e..44dcb5518a 100644 --- a/lib/Service/Calculation/TemporalCalculationSweepService.php +++ b/lib/Service/Calculation/TemporalCalculationSweepService.php @@ -263,6 +263,14 @@ private function recomputeChanges(ObjectEntity $object, Schema $schema, array $c $changed = false; foreach ($calcs as $name => $spec) { + // A `sequence` only resolves on the create path, where a + // SequenceContext is active; the sweep never has one. Recomputing here + // would render the node as "" and report a spurious change on every + // pass (and, before openregister#3075, persist the truncated value). + if ($this->evaluator->expressionUsesSequence($spec['expression'] ?? null) === true) { + continue; + } + try { $value = $this->evaluator->evaluate($payload, $spec['expression'] ?? null); } catch (EvaluationException $e) { diff --git a/lib/Service/Configuration/ImportHandler.php b/lib/Service/Configuration/ImportHandler.php index dee30ce553..4b0f9bc964 100644 --- a/lib/Service/Configuration/ImportHandler.php +++ b/lib/Service/Configuration/ImportHandler.php @@ -885,6 +885,39 @@ public function importRegister( return $existingRegister; } + // NEVER DROP A SCHEMA LINK THIS IMPORT COULD NOT RE-ESTABLISH. + // + // The caller builds `$data['schemas']` purely from `schemasMap`, + // which holds only the schemas THIS run processed — the schema + // pass does `if ($schema === null) { continue; }` before + // populating it, so a schema that already existed and was skipped + // or rejected never enters the map. The id list arriving here is + // then missing it, and this update REPLACES the register's list, + // unlinking a schema the register still owns. + // + // Nothing fails when that happens. The schema keeps its table and + // its rows; the register simply stops listing it. Every + // register-scoped read then returns an empty collection for data + // that is present — indistinguishable from a working-but-empty + // install, which is exactly how #2935 presented: 60 of dossiq's + // schemas unlinked, 11 of them holding rows, reported as "the + // config import silently writes zero objects". + // + // Union rather than replace: a link this run can prove stays, and + // a link it merely cannot see is left alone. The failure direction + // becomes a STALE link, which `occ + // openregister:registers:relink-schemas` already reports and + // repairs; the opposite direction loses reachable data with no + // error anywhere. + if (isset($data['schemas']) === true && is_array($data['schemas']) === true) { + $existingSchemaIds = $existingRegister->getSchemas(); + if (is_array($existingSchemaIds) === true && $existingSchemaIds !== []) { + $data['schemas'] = array_values( + array_unique(array_merge($existingSchemaIds, $data['schemas'])) + ); + } + } + // Update existing register. $existingRegister = $this->registerMapper->updateFromArray(id: $existingRegister->getId(), object: $data); if ($owner !== null) { diff --git a/lib/Service/DemoDataService.php b/lib/Service/DemoDataService.php new file mode 100644 index 0000000000..a4a622ef43 --- /dev/null +++ b/lib/Service/DemoDataService.php @@ -0,0 +1,185 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service; + +use OCA\OpenRegister\AppInfo\Application; +use OCP\App\IAppManager; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Imports the shipped demo dataset on request. + * + * @spec exclude Demo-data import; ADR-111 rule 1, no per-app behavioural spec. + */ +class DemoDataService { + /** + * App-relative path to the generated mock descriptor. + * + * @var string + */ + private const DESCRIPTOR = '/lib/Settings/openregister_mock_register.json'; + + /** + * Configuration identity for the demo import. + * + * 🔴 ITS OWN NAMESPACE, not the app id. Sharing the app's identity would make + * the demo import and the real configuration import share one version gate, so + * installing demo data could mask a pending configuration update — or be + * masked by one. + * + * @var string + */ + private const CONFIG_APP_ID = Application::APP_ID . '.demo'; + + /** + * Constructor. + * + * @param IAppManager $appManager Resolves this app's path and version. + * @param ContainerInterface $container Resolves OpenRegister's importer. + * @param LoggerInterface $logger Records what was imported. + * + * @return void + */ + public function __construct( + private readonly IAppManager $appManager, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Whether this app ships a demo dataset at all. + * + * @return boolean True when the descriptor is present on disk. + * + * @spec exclude Demo-data availability probe; ADR-111 rule 1 has no per-app behavioural spec. + */ + public function isAvailable(): bool { + return is_file($this->descriptorPath()) === true; + }//end isAvailable() + + /** + * Import the demo dataset. + * + * 🔴 THROWS RATHER THAN RETURNING A QUIET FAILURE. The caller reports the + * outcome to an operator who just asked for this, so "nothing happened" must + * not be presentable as success. + * + * @return array{objects: integer, registers: integer, schemas: integer} What was imported. + * + * @throws RuntimeException When the descriptor is missing, unreadable, or OpenRegister is absent. + * + * @spec exclude Demo-data import; ADR-111 rule 1 has no per-app behavioural spec. + */ + public function install(): array { + $path = $this->descriptorPath(); + if (is_file($path) === false) { + throw new RuntimeException('No demo dataset ships with this app (' . self::DESCRIPTOR . ' not found).'); + } + + $raw = file_get_contents($path); + if ($raw === false) { + throw new RuntimeException('The demo dataset could not be read: ' . $path); + } + + $data = json_decode($raw, true); + if (is_array($data) === false) { + throw new RuntimeException('The demo dataset is not valid JSON: ' . $path); + } + + // Counted from the FILE, not the importer's reply, so the number reported + // is the number ASKED FOR. An object whose schema does not resolve is + // SKIPPED rather than errored, so a discrepancy here is a real condition + // an operator should be able to see. + $objects = 0; + $components = ($data['components'] ?? []); + if (is_array($components) === true && is_array(($components['objects'] ?? null)) === true) { + $objects = count($components['objects']); + } + + $result = $this->configurationService()->importFromApp( + appId: self::CONFIG_APP_ID, + data: $data, + version: $this->appManager->getAppVersion(Application::APP_ID), + force: true + ); + + $imported = [ + 'objects' => $objects, + 'registers' => count((array)($result['registers'] ?? [])), + 'schemas' => count((array)($result['schemas'] ?? [])), + ]; + + $this->logger->info( + '[DemoDataService] imported demo data: ' + . $imported['objects'] . ' object(s), ' + . $imported['registers'] . ' register(s), ' + . $imported['schemas'] . ' schema(s).', + ['app' => Application::APP_ID] + ); + + return $imported; + }//end install() + + /** + * Absolute path to the shipped descriptor. + * + * @return string The path. + */ + private function descriptorPath(): string { + return $this->appManager->getAppPath(Application::APP_ID) . self::DESCRIPTOR; + }//end descriptorPath() + + /** + * OpenRegister's configuration importer. + * + * 🔴 A CROSS-APP CLASS IS A RUNTIME LOOKUP. OpenRegister may not be installed, + * and asking the container for a class from a missing app raises something the + * caller cannot act on. Check first and say which app is missing. + * + * 🔴 THE RETURN TYPE IS `object`, NOT THE CLASS, AND THAT IS THE POINT. Naming + * a class from an OPTIONAL app in a native return type makes PHP resolve it + * whenever this method returns, so on an instance without OpenRegister the + * failure is a TypeError about a class nobody mentioned instead of the + * RuntimeException above that names the missing app. + * + * @return object The importer — an OCA\OpenRegister\Service\ConfigurationService. + * + * @psalm-return \OCA\OpenRegister\Service\ConfigurationService + * + * @throws RuntimeException When OpenRegister is not installed. + */ + private function configurationService(): object { + if (in_array('openregister', $this->appManager->getInstalledApps(), true) === false) { + throw new RuntimeException('Demo data needs OpenRegister, which is not installed.'); + } + + return $this->container->get('OCA\OpenRegister\Service\ConfigurationService'); + }//end configurationService() +}//end class diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index 5b8fd86e09..68db363378 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -1333,6 +1333,38 @@ public function updateFile(string|int $filePath, mixed $content = null, array $t ); }//end updateFile() + /** + * Update a file's OpenRegister-side metadata. + * + * Fronts `UpdateFileHandler::updateFileMetadata()` the same way this + * service already fronts `updateFile()`, so the controller keeps talking to + * one collaborator instead of reaching through this one to the handler. + * + * @param integer $fileId The Nextcloud filecache fileid. + * @param string|null $description New description; '' clears, null skips. + * @param string|null $category New category; '' clears, null skips. + * @param string[]|null $labels New labels; [] clears, null skips. + * + * @return \OCA\OpenRegister\Db\File The persisted metadata row. + * + * @spec openspec/specs/file-actions/spec.md + */ + public function updateFileMetadata( + int $fileId, + ?string $description = null, + ?string $category = null, + ?array $labels = null, + ): \OCA\OpenRegister\Db\File { + return $this->updateFileHandler->updateFileMetadata( + fileId: $fileId, + description: $description, + category: $category, + labels: $labels + ); + + }//end updateFileMetadata() + + /** * Deletes a file from NextCloud. * diff --git a/lib/Service/Flow/FlowDefinitionPin.php b/lib/Service/Flow/FlowDefinitionPin.php new file mode 100644 index 0000000000..9582bbaf9c --- /dev/null +++ b/lib/Service/Flow/FlowDefinitionPin.php @@ -0,0 +1,230 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use OCA\OpenRegister\Db\FlowDefinitionMapper; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Captures and restores the definition a run is pinned to. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ +class FlowDefinitionPin { + /** + * The graph keys that are pinned. + * + * An explicit allowlist, not "everything except". A later key added to the + * resolved document — a counter, a timestamp, a cache marker — would + * otherwise join the hash and make every run pin a distinct definition, + * silently turning the dedupe off. Adding a key here is a deliberate act. + * + * @var string[] + */ + private const PINNED_KEYS = ['nodes', 'edges', 'limits', 'executionMode']; + + /** + * Constructor. + * + * @param FlowDefinitionMapper $mapper Stores and reads pinned definitions. + * @param LoggerInterface $logger Records pins that could not be taken. + */ + public function __construct( + private readonly FlowDefinitionMapper $mapper, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * The canonical hash of a resolved flow document. + * + * 🔑 CANONICAL MEANS KEY ORDER CANNOT CHANGE THE ANSWER. `json_encode` of a + * PHP array preserves insertion order, so the same graph loaded through two + * code paths that built it in different orders would hash differently and + * store a duplicate row for identical content. Sorting recursively by key + * is what makes the hash a property of the CONTENT rather than of the + * traversal that produced it. + * + * @param array $flow The resolved flow document. + * + * @return array{hash: string, json: string}|null The canonical form, or null when it cannot be encoded. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function canonicalise(array $flow): ?array { + $subset = []; + foreach (self::PINNED_KEYS as $key) { + $subset[$key] = ($flow[$key] ?? null); + } + + $this->sortRecursive(value: $subset); + + $json = json_encode($subset, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if (is_string($json) === false) { + return null; + } + + return ['hash' => hash('sha256', $json), 'json' => $json]; + + }//end canonicalise() + + /** + * Pin a definition and return the hash a run should carry. + * + * Returns null rather than throwing when the definition cannot be stored. + * A run that cannot be pinned still runs — unpinned, exactly as every run + * did before this class existed. Refusing to queue would turn a storage + * hiccup into a halted workflow, which is a worse failure than the one + * being fixed. + * + * @param array $flow The resolved flow document. + * @param string $flowId The flow uuid, for provenance. + * + * @return string|null The hash to pin, or null when it could not be pinned. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function pin(array $flow, string $flowId): ?string { + $canonical = $this->canonicalise(flow: $flow); + if ($canonical === null) { + $this->logger->warning( + '[FlowDefinitionPin] Could not encode flow "' . $flowId . '"; the run will be unpinned.' + ); + return null; + } + + try { + $stored = $this->mapper->store( + hash: $canonical['hash'], + definition: $canonical['json'], + flowUuid: $flowId + ); + } catch (Throwable $e) { + $this->logger->warning( + '[FlowDefinitionPin] Could not store the definition for flow "' . $flowId + . '"; the run will be unpinned: ' . $e->getMessage() + ); + return null; + } + + if ($stored === null) { + return null; + } + + return $canonical['hash']; + + }//end pin() + + /** + * The graph a hash names, or null when it is not there. + * + * 🔴 NO FALLBACK, EVER. An earlier draft of this class fell back to the + * flow's LIVE definition when the pinned row was missing. That is exactly + * the defect versioning exists to remove: it silently promotes an in-flight + * run onto a graph it did not start on, and the run's marking, its taken + * decisions and its log all belong to the version it started on. Returning + * null makes the caller decide, and the only correct decision is to fail + * the run naming the version — never to substitute another one. + * + * @param string|null $hash The definition hash. + * + * @return array|null The graph, or null when unresolvable. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function graphFor(?string $hash): ?array { + if ($hash === null || trim($hash) === '') { + return null; + } + + try { + $pinned = $this->mapper->findByHash($hash); + } catch (Throwable $e) { + $pinned = null; + } + + if ($pinned === null) { + $this->logger->warning( + '[FlowDefinitionPin] Definition "' . $hash . '" is missing.' + ); + return null; + } + + $decoded = $pinned->decoded(); + if ($decoded === []) { + $this->logger->warning( + '[FlowDefinitionPin] Definition "' . $hash . '" is unreadable.' + ); + return null; + } + + return $decoded; + + }//end graphFor() + + /** + * Sort an array recursively by key, in place. + * + * @param array $value The array to sort. + * + * @return void + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + private function sortRecursive(array &$value): void { + foreach ($value as &$item) { + if (is_array($item) === true) { + $this->sortRecursive(value: $item); + } + } + + unset($item); + + // LISTS KEEP THEIR ORDER. `nodes` and `edges` are lists, and their + // order can carry meaning; ksort on a list would renumber nothing but + // would still be wrong the day a caller relies on position. Only + // associative arrays are sorted. + if (array_is_list($value) === false) { + ksort($value); + } + + }//end sortRecursive() +}//end class diff --git a/lib/Service/Flow/FlowEngine.php b/lib/Service/Flow/FlowEngine.php index d824bd1cad..ab8ae72735 100644 --- a/lib/Service/Flow/FlowEngine.php +++ b/lib/Service/Flow/FlowEngine.php @@ -119,6 +119,10 @@ class FlowEngine { * exactly as an empty registry does. * @param FlowTokenRouter|null $router Decides which exit a token takes. * @param FlowItemPlacement|null $placement Decides which items sit on which place. + * @param FlowRunContext|null $runContext The ambient attribution stack. Nullable + * so the engine stays unit-testable without + * a container; absent, writes are simply + * unattributed rather than mis-attributed. */ public function __construct( private readonly FlowDefinitionBuilder $builder, @@ -126,10 +130,38 @@ public function __construct( private readonly ?FlowOversightRegistry $oversight = null, private readonly ?FlowTokenRouter $router = null, private readonly ?FlowItemPlacement $placement = null, + private readonly ?FlowRunContext $runContext = null, ) { }//end __construct() + /** + * Open an attribution frame for the hop about to run. + * + * Split out of run() so the walk reads as the walk. The arithmetic is the + * part worth keeping together: the step number is the run's sequence BASE + * plus this hop's index in the segment log, and `recordSteps()` numbers the + * same entries from the same base in the same order — so an attributed audit + * row and its FlowRunStep row carry the same number. Deriving it from a + * dispatch counter instead desynchronises the moment a step is PINNED, since + * a pin logs an entry without ever reaching the dispatcher. + * + * @param array $context The run context, carrying the run uuid and base. + * @param string $name The node about to run. + * @param integer $index This hop's index within the segment's log. + * + * @return void + * + * @spec openspec/changes/flow-object-attribution/specs/flow-engine/spec.md + */ + private function enterHop(array $context, string $name, int $index): void { + $this->runContext?->push( + runUuid: ($context[FlowRunContext::CONTEXT_RUN] ?? null), + nodeId: $name, + sequence: ((int)($context[FlowRunContext::CONTEXT_BASE] ?? 0) + $index) + ); + }//end enterHop() + /** * The token router, made on demand when none was injected. * @@ -262,6 +294,16 @@ private function assertOversightAllows(array $context, string $name, string $typ * * @return array The run result: `{status, log: [], context: [], items: []}`. * + * @SuppressWarnings(PHPMD.NPathComplexity) 226 against a threshold of 200, and the + * 26 came from adding a `finally` to the hop. That `finally` is the attribution + * safety property, not a convenience: every other exit from a hop is a `return` + * inside a catch — a stop, a suspension, a terminally-failed step — so a pop on the + * success path would leave the frame standing and attribute LATER writes, in a LATER + * run advanced by the same worker, to a run that had already finished. That failure + * is silent and produces well-formed rows. Restructuring the walk to win the metric + * would trade a real correctness guarantee for a number; the branches themselves are + * each one clearly-labelled outcome of a hop. + * * @spec openspec/changes/or-flow-engine/specs/flow-engine/spec.md */ public function run( @@ -416,6 +458,24 @@ public function run( continue; }//end if + // ATTRIBUTION, around the hop. Everything written from here until the + // matching pop is filed under this run and node — including writes + // made by code that has no idea a flow is running, which is the + // whole point (a node cannot report what it did not know it did). + // + // The step number is `base + count($log)`, and that is not an + // approximation: `recordSteps()` numbers this segment's entries from + // the same base in the same order, so an attributed audit row and + // its `FlowRunStep` row carry the SAME sequence. Deriving it from a + // dispatch counter instead would desynchronise the moment a step is + // PINNED — a pin logs an entry without ever reaching the dispatcher. + // + // Pushed here rather than around the pinned branch above because a + // pinned step is not executed at all: it produces no writes, so it + // needs no frame, and skipping it costs nothing since the index is + // read from the log rather than counted per push. + $this->enterHop(context: $context, name: $name, index: count($log)); + try { // OVERSIGHT, before the hop. A veto is raised as a FlowStop so it // travels the same path as an author's Stop step: the run ENDS. @@ -514,6 +574,16 @@ public function run( if ($outcome !== null) { return $outcome; } + } finally { + // 🔴 UNCONDITIONAL, and structurally so. Every other exit from + // this hop is a `return` inside a catch — a stop, a suspension, + // a failed step whose policy is terminal. A pop placed on the + // success path would leave the frame standing on all three, and + // the next write in the process — a LATER run advanced by the + // same worker — would be filed under a run that had already + // finished. Nothing about that row looks wrong, which is why it + // has to be impossible rather than merely remembered. + $this->runContext?->pop(); }//end try // Which single exit this firing takes. A token is unique and diff --git a/lib/Service/Flow/FlowLifecycleGuard.php b/lib/Service/Flow/FlowLifecycleGuard.php new file mode 100644 index 0000000000..e3a81573a9 --- /dev/null +++ b/lib/Service/Flow/FlowLifecycleGuard.php @@ -0,0 +1,243 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\FlowVersion; +use Psr\Log\LoggerInterface; + +/** + * Decides whether a lifecycle transition is allowed, and refuses out loud. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ +class FlowLifecycleGuard { + /** + * Constructor. + * + * @param FlowRunMapper $runs Counts runs pinned to a version. + * @param FlowNodePreflight $preflight Inspects a graph for dead ends. + * @param LoggerInterface $logger Records every refusal and its reason. + */ + public function __construct( + private readonly FlowRunMapper $runs, + private readonly FlowNodePreflight $preflight, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Refuse a definition change against anything but a draft. + * + * @param string $flowId The flow being written. + * @param string|null $state The flow head's lifecycle status. + * + * @return void + * + * @throws FlowLifecycleRefused When the head is not a draft. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function refuseEditUnlessDraft(string $flowId, ?string $state): void { + if ($state === null || $state === FlowVersion::STATUS_DRAFT) { + return; + } + + $this->refuse( + reason: FlowLifecycleRefused::REASON_IMMUTABLE, + flowId: $flowId, + state: $state + ); + + }//end refuseEditUnlessDraft() + + /** + * Refuse to publish anything but a draft. + * + * @param string $flowId The flow. + * @param string|null $state The lifecycle status of the version being published. + * + * @return void + * + * @throws FlowLifecycleRefused When the version is not a draft. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function refusePublishUnlessDraft(string $flowId, ?string $state): void { + if ($state === FlowVersion::STATUS_DRAFT) { + return; + } + + $this->refuse( + reason: FlowLifecycleRefused::REASON_NOT_A_DRAFT, + flowId: $flowId, + state: $state + ); + + }//end refusePublishUnlessDraft() + + /** + * Refuse to deprecate anything but a published version. + * + * @param string $flowId The flow. + * @param string|null $state The lifecycle status of the version being deprecated. + * + * @return void + * + * @throws FlowLifecycleRefused When the version is not published. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function refuseDeprecateUnlessPublished(string $flowId, ?string $state): void { + if ($state === FlowVersion::STATUS_PUBLISHED) { + return; + } + + $this->refuse( + reason: FlowLifecycleRefused::REASON_NOT_PUBLISHED, + flowId: $flowId, + state: $state + ); + + }//end refuseDeprecateUnlessPublished() + + /** + * Refuse to publish a graph with a node a token cannot leave. + * + * 🔑 THE PREFLIGHT RUNS ON THE GRAPH BEING PUBLISHED, not on the flow's + * head. Judging the head would let a broken draft block the publication of + * a sound version, and — worse in the other direction — let a repaired + * draft mask a dead end in the graph actually going live. + * + * @param string $flowId The flow. + * @param array $graph The nodes and edges being published. + * + * @return void + * + * @throws FlowLifecycleRefused When the graph has a dead end. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function refusePublishOnDeadEnd(string $flowId, array $graph): void { + $findings = $this->preflight->inspect( + flow: [ + 'nodes' => (array)($graph['nodes'] ?? []), + 'edges' => (array)($graph['edges'] ?? []), + ] + ); + + $deadEnds = []; + foreach (($findings['warnings'] ?? []) as $warning) { + if (($warning['reason'] ?? '') === FlowNodePreflight::REASON_DEAD_END) { + $deadEnds[] = (string)($warning['step'] ?? ''); + } + } + + if ($deadEnds === []) { + return; + } + + $this->refuse( + reason: FlowLifecycleRefused::REASON_DEAD_END, + flowId: $flowId, + state: FlowVersion::STATUS_DRAFT, + detail: 'Offending steps: ' . implode(separator: ', ', array: $deadEnds) . '.' + ); + + }//end refusePublishOnDeadEnd() + + /** + * Refuse to remove a version a still-movable run is pinned to. + * + * @param string $flowId The flow. + * @param integer $version The version being removed. + * + * @return void + * + * @throws FlowLifecycleRefused When a non-terminal run is pinned to it. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function refuseRemoveWhilePinned(string $flowId, int $version): void { + $pinned = $this->runs->countActivePinnedTo(flowUuid: $flowId, version: $version); + if ($pinned === 0) { + return; + } + + $this->refuse( + reason: FlowLifecycleRefused::REASON_VERSION_IN_USE, + flowId: $flowId, + state: null, + detail: $pinned . ' run(s) are still pinned to version ' . $version . '.' + ); + + }//end refuseRemoveWhilePinned() + + /** + * Log the refusal, then throw it. + * + * Both, always. The throw is what stops the transition; the log is what + * lets an operator answer "why did publishing do nothing" without a + * debugger, which is the complaint every silent guard produces. + * + * @param string $reason One of the FlowLifecycleRefused REASON_* constants. + * @param string $flowId The flow. + * @param string|null $state The state that caused it. + * @param string|null $detail Extra human detail. + * + * @return void + * + * @throws FlowLifecycleRefused Always. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + private function refuse(string $reason, string $flowId, ?string $state, ?string $detail = null): void { + $refusal = new FlowLifecycleRefused( + reason: $reason, + flowId: $flowId, + state: $state, + detail: $detail + ); + + $this->logger->warning( + message: '[FlowLifecycleGuard] Refused: ' . $refusal->getMessage(), + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'flow' => $flowId, + 'reason' => $reason, + 'state' => $state, + ] + ); + + throw $refusal; + + }//end refuse() +}//end class diff --git a/lib/Service/Flow/FlowLifecycleRefused.php b/lib/Service/Flow/FlowLifecycleRefused.php new file mode 100644 index 0000000000..d8ffed9741 --- /dev/null +++ b/lib/Service/Flow/FlowLifecycleRefused.php @@ -0,0 +1,164 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use RuntimeException; + +/** + * A refusal carrying the reason a lifecycle rule rejected the request. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ +class FlowLifecycleRefused extends RuntimeException { + /** + * The definition of a published or deprecated version may not be changed. + * + * @var string + */ + public const REASON_IMMUTABLE = 'version-immutable'; + + /** + * Only a draft may be published. + * + * @var string + */ + public const REASON_NOT_A_DRAFT = 'not-a-draft'; + + /** + * Only a published version may be deprecated. + * + * @var string + */ + public const REASON_NOT_PUBLISHED = 'not-published'; + + /** + * The flow has no published version, so nothing may be queued against it. + * + * @var string + */ + public const REASON_NO_PUBLISHED_VERSION = 'no-published-version'; + + /** + * The graph being published has a node its token cannot leave. + * + * @var string + */ + public const REASON_DEAD_END = 'dead-end'; + + /** + * A version with a run still pinned to it may not be removed. + * + * @var string + */ + public const REASON_VERSION_IN_USE = 'version-in-use'; + + /** + * Constructor. + * + * @param string $reason One of the REASON_* constants. + * @param string $flowId The flow the refusal is about. + * @param string|null $state The lifecycle state that caused it, when there is one. + * @param string|null $detail Extra human detail appended to the message. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function __construct( + private readonly string $reason, + private readonly string $flowId, + private readonly ?string $state = null, + ?string $detail = null, + ) { + $messages = [ + self::REASON_IMMUTABLE => 'This version of the flow is published and cannot be edited. ' + . 'Create a draft version to make changes; the published version keeps serving until ' + . 'you publish the draft.', + self::REASON_NOT_A_DRAFT => 'Only a draft version can be published.', + self::REASON_NOT_PUBLISHED => 'Only a published version can be deprecated.', + self::REASON_NO_PUBLISHED_VERSION => 'This flow has no published version, so it cannot back ' + . 'a run. Publish a version first.', + self::REASON_DEAD_END => 'This version cannot be published while it has a node a token ' + . 'cannot leave.', + self::REASON_VERSION_IN_USE => 'This version cannot be removed while a run is still pinned ' + . 'to it.', + ]; + + $where = ' (flow ' . $flowId; + if ($state !== null) { + $where .= ', state ' . $state; + } + + $where .= ')'; + + $message = ($messages[$reason] ?? 'This flow lifecycle transition was refused.') . $where; + + if ($detail !== null && trim($detail) !== '') { + $message .= ' ' . $detail; + } + + parent::__construct(message: $message); + + }//end __construct() + + /** + * The machine-readable reason. + * + * @return string One of the REASON_* constants. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function getReason(): string { + return $this->reason; + + }//end getReason() + + /** + * The flow the refusal is about. + * + * @return string The flow uuid. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function getFlowId(): string { + return $this->flowId; + + }//end getFlowId() + + /** + * The lifecycle state that caused the refusal. + * + * @return string|null The state, or null when the refusal is not about one. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function getState(): ?string { + return $this->state; + + }//end getState() +}//end class diff --git a/lib/Service/Flow/FlowNodeResumeState.php b/lib/Service/Flow/FlowNodeResumeState.php index 7dd247cbf0..ef1bdd0e32 100644 --- a/lib/Service/Flow/FlowNodeResumeState.php +++ b/lib/Service/Flow/FlowNodeResumeState.php @@ -64,6 +64,25 @@ public function __construct( }//end __construct() + /** + * Which node this slot belongs to. + * + * A node is handed its own slot but was never told its own NAME, which is + * fine while the only thing it does with the slot is read and write it. + * It stops being fine as soon as a node has to hand its identity to + * something OUTSIDE the run — a task record that must later resume this + * exact node, for instance. A run accumulates one awaiting slot per node, + * so "resume this run" is not an answer: the resumer has to name the node, + * and it can only do that if the node could name itself. + * + * @return string This node's id within the flow graph. + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-node-must-be-able-to-resume-from-where-it-stopped + */ + public function nodeId(): string { + return $this->nodeId; + }//end nodeId() + /** * Whether this node has progress stored from an earlier pass. * diff --git a/lib/Service/Flow/FlowPublishedGraph.php b/lib/Service/Flow/FlowPublishedGraph.php new file mode 100644 index 0000000000..f5e8a9ea11 --- /dev/null +++ b/lib/Service/Flow/FlowPublishedGraph.php @@ -0,0 +1,145 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowVersionMapper; +use Psr\Container\ContainerInterface; + +/** + * Resolves a flow's published graph and keeps its trigger rows in step with it. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ +class FlowPublishedGraph { + /** + * Constructor. + * + * @param ContainerInterface $container Resolves the version mapper and the pin. + */ + public function __construct( + private readonly ContainerInterface $container, + ) { + + }//end __construct() + + /** + * The published graph of a flow, or null when it has no published version. + * + * @param string $flowId The flow. + * + * @return array|null The published graph, or null. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function graphOf(string $flowId): ?array { + $published = $this->container->get(FlowVersionMapper::class)->findPublished(flowUuid: $flowId); + if ($published === null) { + return null; + } + + return $this->container->get(FlowDefinitionPin::class)->graphFor($published->getDefinitionHash()); + + }//end graphOf() + + /** + * The graph of the version a RUN is pinned to. + * + * 🔴 THE SINGLE RESOLVER. Four call sites hand a flow document to + * `FlowRunService::execute()`, and three of them had resolved it LIVE — so + * a pinned run reached the engine and then walked the current graph anyway. + * Every one of them now goes through this, because a rule with four + * implementations is a rule with four chances to drift. + * + * @param FlowRun $run The run. + * + * @return array|null The pinned graph, or null when the run + * carries no version or it is unresolvable. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function ofRun(FlowRun $run): ?array { + $version = $run->getFlowVersion(); + if ($version === null) { + return null; + } + + $row = $this->container->get(FlowVersionMapper::class) + ->find(flowUuid: (string)$run->getFlowId(), version: (int)$version); + + if ($row === null) { + return null; + } + + return $this->container->get(FlowDefinitionPin::class)->graphFor($row->getDefinitionHash()); + + }//end ofRun() + + /** + * A live document with the run's pinned graph laid over it. + * + * 🔑 THE GRAPH, AND ONLY THE GRAPH. `owner` and `organisation` stay as the + * LIVE document has them, because authorization must re-resolve on every + * pass: a revoked grant has to stop the next hop of a run queued while it + * was still valid. Pinning those too would make revocation cosmetic for + * exactly the long-running work nobody is watching. + * + * @param FlowRun $run The run. + * @param array $live The live flow document. + * + * @return array|null The document to walk, or null when the + * run is pinned to a version that is gone. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function overlayOnto(FlowRun $run, array $live): ?array { + // An unpinned run is the interactive draft test run — the one + // documented exception to "a draft cannot back a run". It carries its + // own snapshot and walks the document it was given. + if ($run->getFlowVersion() === null) { + return $live; + } + + $pinned = $this->ofRun(run: $run); + if ($pinned === null) { + return null; + } + + foreach (['nodes', 'edges', 'limits', 'executionMode'] as $key) { + if (array_key_exists($key, $pinned) === true) { + $live[$key] = $pinned[$key]; + } + } + + return $live; + + }//end overlayOnto() + +}//end class diff --git a/lib/Service/Flow/FlowRunAdvancer.php b/lib/Service/Flow/FlowRunAdvancer.php index ec8889061a..d44871e1d5 100644 --- a/lib/Service/Flow/FlowRunAdvancer.php +++ b/lib/Service/Flow/FlowRunAdvancer.php @@ -100,6 +100,16 @@ public function advance(FlowRun $run, bool $rethrow = false): FlowRun { return $run; } + // 🔴 THE PIN. Up to here `$flow` is the LIVE definition — which is + // correct for authorization (a revoked owner must stop the next + // hop) and wrong for the graph. A run parked on a human step for a + // week must finish the flow it started, not the one its author has + // since edited. So the graph, and only the graph, comes from the + // version this run was pinned to at queue time. + // The pin itself is enforced inside FlowRunService::execute(), which + // every path into the engine goes through. Checking it here as well + // would be a second copy of the rule — and the two would drift. + // A run may legitimately have no subject (a manual or webhook run // seeded from its payload). Only resolve one when the run names it. $subject = null; @@ -154,4 +164,5 @@ public function advance(FlowRun $run, bool $rethrow = false): FlowRun { }//end try }//end advance() + }//end class diff --git a/lib/Service/Flow/FlowRunAssignee.php b/lib/Service/Flow/FlowRunAssignee.php new file mode 100644 index 0000000000..88d77143d8 --- /dev/null +++ b/lib/Service/Flow/FlowRunAssignee.php @@ -0,0 +1,145 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-suspended-on-an-external-signal-must-be-reachable + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use OCA\OpenRegister\Db\FlowRun; +use OCP\IGroupManager; + +/** + * Reads a suspended run's recorded assignee and decides who may answer it. + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-suspended-on-an-external-signal-must-be-reachable + */ +class FlowRunAssignee { + /** + * Constructor. + * + * @param IGroupManager|null $groupManager Resolves group membership. Nullable so the + * service stays constructible without a + * container; absent, a group assignment + * REFUSES rather than admits — the + * fail-closed direction. + */ + public function __construct( + private readonly ?IGroupManager $groupManager = null, + ) { + }//end __construct() + + /** + * The assignee recorded by whichever step is currently awaiting an answer. + * + * Reads the per-node resume slots the node wrote. A run carries slots for + * several nodes across its life, so the one that matters is a slot that + * ASKED (`askedAt`) and has not been answered. + * + * @param FlowRun $run The suspended run. + * + * @return string The assignee uid or group id; '' when the step is unassigned. + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-suspended-on-an-external-signal-must-be-reachable + */ + public function recordedFor(FlowRun $run): string { + $context = ($run->getContext() ?? []); + $slots = ($context[FlowResumeState::CONTEXT_KEY] ?? []); + if (is_array($slots) === false) { + return ''; + } + + foreach ($slots as $slot) { + if (is_array($slot) === false) { + continue; + } + + if (isset($slot['askedAt']) === false) { + continue; + } + + $assignee = trim((string)($slot['assignee'] ?? '')); + if ($assignee !== '') { + return $assignee; + } + } + + return ''; + }//end recordedFor() + + /** + * Whether this user may answer the step the run is waiting on. + * + * @param FlowRun $run The suspended run. + * @param string|null $uid The acting user, or null when there is no session. + * + * @return boolean True when the answer may be accepted. + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-suspended-on-an-external-signal-must-be-reachable + */ + public function mayAnswer(FlowRun $run, ?string $uid): bool { + $assignee = $this->recordedFor(run: $run); + + // Unassigned is deliberately open — see the class docblock. This is the + // one branch that must NOT be tightened without changing the spec. + if ($assignee === '') { + return true; + } + + // Fail CLOSED: an assigned decision is never anonymous. + if ($uid === null || trim($uid) === '') { + return false; + } + + if ($uid === $assignee) { + return true; + } + + // The group branch. Absent a group manager this refuses rather than + // admits, which is the safe direction — and is why its absence must be + // visible in tests rather than inferred from a passing suite. + if ($this->groupManager !== null && $this->groupManager->isInGroup($uid, $assignee) === true) { + return true; + } + + return false; + }//end mayAnswer() +}//end class diff --git a/lib/Service/Flow/FlowRunContext.php b/lib/Service/Flow/FlowRunContext.php new file mode 100644 index 0000000000..cc34fa3540 --- /dev/null +++ b/lib/Service/Flow/FlowRunContext.php @@ -0,0 +1,177 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +/** + * Holds the ambient attribution frame for the step currently being dispatched. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ +class FlowRunContext { + /** + * The context key carrying the executing run's uuid into the engine. + * + * @var string + */ + public const CONTEXT_RUN = 'x-openregister-attribution-run'; + + /** + * The context key carrying the run's step-sequence base into the engine. + * + * Steps are numbered continuously across a resume, so a segment that starts + * after a suspension must begin where the previous one stopped rather than + * at zero. The base is read once before the walk and the engine adds each + * hop's index to it, which is what makes an attributed audit row's step + * number the SAME number the matching `FlowRunStep` row is given. + * + * @var string + */ + public const CONTEXT_BASE = 'x-openregister-attribution-base'; + + /** + * The stack of attribution frames, innermost last. + * + * A frame is `['run' => string, 'node' => string, 'step' => int]`, or NULL + * for a hop that is not attributable (see {@see push()}). + * + * @var array + */ + private array $frames = []; + + /** + * Enter a step: everything written from here until the matching pop is + * attributed to this run, node and step. + * + * A null or empty `$runUuid` pushes an explicit NON-attributing frame + * rather than pushing nothing. Two reasons, both about failure modes: + * + * - It keeps push and pop unconditionally paired, so the caller cannot + * unbalance the stack by taking a different branch on the way out than + * it took on the way in. + * - It stops an unattributable inner hop from silently inheriting the + * ENCLOSING run's attribution, which would file a sub-flow's writes + * under its parent — wrong, and wrong in a way that reads as correct. + * + * @param string|null $runUuid The uuid of the executing run, or null when the caller has no run (the flow tester, node unit tests). + * @param string $nodeId The id of the node being dispatched. + * @param integer $sequence The step's sequence number within the run. + * + * @return void + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function push(?string $runUuid, string $nodeId, int $sequence): void { + if ($runUuid === null || trim($runUuid) === '') { + $this->frames[] = null; + return; + } + + $this->frames[] = [ + 'run' => $runUuid, + 'node' => $nodeId, + 'step' => $sequence, + ]; + }//end push() + + /** + * Leave the innermost step, restoring the enclosing one if there is one. + * + * Popping an empty stack is deliberately NOT an error. This is called from + * a `finally`, and a `finally` that can itself throw would replace the + * exception the step actually failed with — turning a diagnosable node + * failure into a confusing context-bookkeeping error. + * + * @return void + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function pop(): void { + array_pop($this->frames); + }//end pop() + + /** + * The frame to attribute a write to, or null when nothing is executing. + * + * @return array{run: string, node: string, step: int}|null The innermost frame, or null outside any run. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function current(): ?array { + if ($this->frames === []) { + return null; + } + + // The INNERMOST frame, even when it is null. A null innermost frame + // means "the hop currently executing is not attributable", and must not + // fall through to an enclosing run's frame. + return $this->frames[(count($this->frames) - 1)]; + }//end current() + + /** + * How deep the stack is. Test and diagnostic use only. + * + * Exposed so a test can assert the stack is EMPTY after a step rather than + * inferring it from an attribution being absent — an assertion that would + * also pass if attribution were broken for some unrelated reason. + * + * @return integer The number of frames currently held. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function depth(): int { + return count($this->frames); + }//end depth() +}//end class diff --git a/lib/Service/Flow/FlowRunRow.php b/lib/Service/Flow/FlowRunRow.php new file mode 100644 index 0000000000..91efa143a6 --- /dev/null +++ b/lib/Service/Flow/FlowRunRow.php @@ -0,0 +1,115 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use DateTime; +use OCA\OpenRegister\Db\FlowRun; + +/** + * The unsaved run a dispatch produces. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ +class FlowRunRow { + /** + * Build the queued row for a dispatch that has already cleared every guard. + * + * Split out of queue() only because that method reached the length limit; + * it has no callers of its own and must stay private. Every guard — + * dead-end, attribution, delegation — runs BEFORE this, so this method + * deliberately performs no checks and assumes an attributed dispatch. + * + * @param string $flowId The flow being dispatched. + * @param array $subject The subject object, when the trigger names one. + * @param string $trigger What caused the dispatch. + * @param array $context The starting context. + * @param array $attribution The resolved user/organisation/declaredBy. + * @param integer|null $version The published version this run is pinned to, + * or null for an interactive test run of a draft. + * + * @return FlowRun The unsaved run. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function build( + string $flowId, + array $subject, + string $trigger, + array $context, + array $attribution, + ?int $version, + ): FlowRun { + $run = new FlowRun(); + $run->setUuid($this->newUuid()); + $run->setFlowId($flowId); + $run->setStatus(FlowRun::STATUS_QUEUED); + $run->setTrigger($trigger); + $run->setContext($context); + $run->setLog([]); + $run->setSubjectUuid(($subject['uuid'] ?? null)); + $run->setSubjectRegister(($subject['register'] ?? null)); + $run->setSubjectSchema(($subject['schema'] ?? null)); + // Both, deliberately. `triggeredBy` is PROVENANCE and keeps recording who + // caused the run; `runAs` is AUTHORIZATION and is what every access + // decision reads from here on. They are equal at this point for a + // caller-driven dispatch and differ for a scheduled one, where the cause + // is a schedule and the acting identity is the user its trigger names. + $run->setTriggeredBy($attribution['user']); + $run->setRunAs($attribution['user']); + $run->setOrganisation($attribution['organisation']); + $run->setCreated(new DateTime()); + $run->setUpdated(new DateTime()); + + // 🔴 PIN HERE, at queue time, and nowhere later. This is the only + // moment the definition the caller MEANT to run is unambiguous: from + // here the run may sit suspended on a human step for days while its + // author drafts and publishes new versions. Authorization is NOT + // pinned — see FlowDefinitionPin — because a revoked grant must stop + // mattering immediately, while the graph must not move at all. + $run->setFlowVersion($version); + + return $run; + + }//end build() + + /** + * A v4 UUID for a new run. + * + * @return string The uuid. + * + * @spec openspec/changes/or-flow-runs/specs/flow-runs/spec.md + */ + private function newUuid(): string { + $data = random_bytes(16); + $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); + $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + }//end newUuid() +}//end class diff --git a/lib/Service/Flow/FlowRunService.php b/lib/Service/Flow/FlowRunService.php index d75ebe001f..78e6ac01f8 100644 --- a/lib/Service/Flow/FlowRunService.php +++ b/lib/Service/Flow/FlowRunService.php @@ -125,6 +125,22 @@ public function __construct( }//end __construct() + /** + * The run's step history — its numbering, and its recording. + * + * Made on demand rather than injected: this constructor is called explicitly + * by three test suites, and inserting a parameter would silently shift every + * later slot for them. It holds no state beyond the collaborators this + * service already has. + * + * @return FlowStepHistory The history recorder. + * + * @spec openspec/changes/flow-engine-unification/specs/flow-execution-history/spec.md + */ + private function stepHistory(): FlowStepHistory { + return new FlowStepHistory(steps: $this->steps, logger: $this->logger); + }//end stepHistory() + /** * Make an unattributed refusal visible on the flow, and stop a dead schedule. * @@ -267,12 +283,18 @@ private function nodeContextFor(FlowRun $run, bool $resuming, FlowRunGuard $guar $context[FlowResumeState::CONTEXT_KEY] = FlowResumeState::fromArray(($context[FlowResumeState::CONTEXT_KEY] ?? null)); $context[FlowRunGuard::CONTEXT_KEY] = $guard; + // ATTRIBUTION. Read BEFORE the walk: the audit rows are written during + // it, so the base has to be predicted. See {@see FlowStepHistory}. + $context[FlowRunContext::CONTEXT_RUN] = (string)$run->getUuid(); + $context[FlowRunContext::CONTEXT_BASE] = $this->stepHistory()->baseFor(runUuid: (string)$run->getUuid()); + $this->flowState->attach(run: $run, context: $context); return $context; }//end nodeContextFor() + /** * The liveness-and-deadline handle for one run of one flow. * @@ -378,7 +400,13 @@ public function queue( // one place a refusal covers all of them. Guarding `FlowService::run()` // instead would leave cron-fired flows unguarded, and those are most of // them. - $this->refuseDeadEnd(flowId: $flowId); + // 🔴 WHICH GRAPH WILL THIS RUN, AND IS IT SOUND — both answered here, + // before anything else is decided. A run that cannot name the graph it + // will execute must not exist, and the soundness check must judge THAT + // graph rather than the flow's editable head. Both refusals cover all + // six dispatch paths because all six funnel through this method. + $version = (new FlowRunVersionPin($this->container, $this->logger)) + ->requirePublishedAndSound(flowId: $flowId, trigger: $trigger); // A run is only runnable if it names WHOSE RIGHTS it executes with, and // only listable if it names WHICH tenant it belongs to. @@ -437,26 +465,14 @@ public function queue( runAs: $attribution['user'] ); - $run = new FlowRun(); - $run->setUuid($this->newUuid()); - $run->setFlowId($flowId); - $run->setStatus(FlowRun::STATUS_QUEUED); - $run->setTrigger($trigger); - $run->setContext($context); - $run->setLog([]); - $run->setSubjectUuid(($subject['uuid'] ?? null)); - $run->setSubjectRegister(($subject['register'] ?? null)); - $run->setSubjectSchema(($subject['schema'] ?? null)); - // Both, deliberately. `triggeredBy` is PROVENANCE and keeps recording who - // caused the run; `runAs` is AUTHORIZATION and is what every access - // decision reads from here on. They are equal at this point for a - // caller-driven dispatch and differ for a scheduled one, where the cause - // is a schedule and the acting identity is the user its trigger names. - $run->setTriggeredBy($attribution['user']); - $run->setRunAs($attribution['user']); - $run->setOrganisation($attribution['organisation']); - $run->setCreated(new DateTime()); - $run->setUpdated(new DateTime()); + $run = (new FlowRunRow())->build( + flowId: $flowId, + subject: $subject, + trigger: $trigger, + context: $context, + attribution: $attribution, + version: $version?->getVersion() + ); return $this->parkIfAwaiting(run: $this->mapper->insert($run), park: $park); }//end queue() @@ -500,87 +516,6 @@ private function parkIfAwaiting(FlowRun $run, ?array $park): FlowRun { ); }//end parkIfAwaiting() - /** - * Refuse to queue a flow that has a node its token cannot leave. - * - * Writes the verdict onto the FLOW before throwing, so a refused flow is - * distinguishable from one nobody has triggered without reading run - * history — there is no run to read, which is the point. A run that IS - * accepted clears the verdict back to `ok`, so a fixed flow stops - * reporting an error it no longer has. - * - * Resolved lazily through the container for the same reason - * {@see FlowRunAttribution} resolves its own collaborators that way: this - * service is constructed on paths that do not need either collaborator, and - * several tests build it by hand. - * - * @param string $flowId The flow uuid. - * - * @return void - * - * @throws FlowDeadEnd When a non-terminal node has no outgoing edge. - * - * @spec openspec/specs/flow-engine/spec.md - */ - private function refuseDeadEnd(string $flowId): void { - try { - $mapper = $this->container->get('OCA\OpenRegister\Db\FlowMapper'); - $preflight = $this->container->get('OCA\OpenRegister\Service\Flow\FlowNodePreflight'); - $flow = $mapper->findByUuid($flowId); - } catch (Throwable $e) { - // A flow we cannot load is not a flow we can judge — `findByUuid()` - // throws rather than returning null. Queueing proceeds and the - // existing not-found handling downstream reports it; inventing a - // dead-end refusal here would blame the document for a lookup - // failure. - return; - } - - $findings = $preflight->inspect( - flow: [ - 'nodes' => ($flow->getNodes() ?? []), - 'edges' => ($flow->getEdges() ?? []), - ] - ); - - $deadEnds = []; - foreach ($findings['warnings'] as $warning) { - if (($warning['reason'] ?? '') === FlowNodePreflight::REASON_DEAD_END) { - $deadEnds[] = (string)($warning['step'] ?? ''); - } - } - - if ($deadEnds === []) { - // Accepted. Clear a stale refusal so a repaired flow stops - // reporting the error it no longer has. - if ($flow->getStatus() === Flow::STATUS_ERROR) { - $flow->setStatus(Flow::STATUS_OK); - $flow->setStatusMessage(null); - $mapper->update($flow); - } - - return; - } - - $refusal = new FlowDeadEnd(nodeIds: $deadEnds); - - $flow->setStatus(Flow::STATUS_ERROR); - $flow->setStatusMessage($refusal->getMessage()); - $mapper->update($flow); - - $this->logger->warning( - message: '[FlowRunService] Refused to queue a flow with a dead end', - context: [ - 'file' => __FILE__, - 'line' => __LINE__, - 'flow' => $flowId, - 'nodes' => $deadEnds, - ] - ); - - throw $refusal; - }//end refuseDeadEnd() - /** * Whether a flow already has a run that has not finished. * @@ -713,6 +648,20 @@ public function execute(FlowRun $run, array $flow, object $subject, ?array $seed return $run; } + // 🔴 THE RUN'S PIN OUTRANKS THE CALLER'S DOCUMENT. Four call sites hand + // a flow document in, and three of them resolved it LIVE — a pinned run + // reached the engine and then walked the current graph anyway, which is + // the exact defect versioning exists to remove. Enforcing it HERE covers + // all four, so the next caller inherits the rule instead of becoming the + // fifth exception. + $pinned = (new FlowPublishedGraph($this->container))->overlayOnto(run: $run, live: $flow); + + if ($pinned === null) { + return $this->failUnresolvableVersion(run: $run); + } + + $flow = $pinned; + $resuming = ($run->getStatus() === FlowRun::STATUS_SUSPENDED); $run->setStatus(FlowRun::STATUS_RUNNING); $run->setUpdated(new DateTime()); @@ -783,103 +732,49 @@ public function execute(FlowRun $run, array $flow, object $subject, ?array $seed }//end execute() /** - * Write a completed walk back onto the run. - * - * @param FlowRun $run The run. - * @param array $result What the engine returned. - * - * @return FlowRun The updated run. - * - * @spec openspec/changes/or-flow-runs/specs/flow-runs/spec.md - */ - - /** - * Write one step row per node execution in this segment. - * - * The aggregate `log` column answers "what happened in this run" and - * nothing else — "which node type fails", "every failed step for this - * flow", "what did node X output" all require loading and walking every - * run's blob. One row per hop makes those queryable, and gives retention - * something it can prune per flow. + * Fail a run whose pinned version cannot be resolved. * - * Sequence CONTINUES from the highest already recorded rather than - * restarting at zero, so a run that suspends on a wait node and resumes - * later reads as one ordered history instead of two interleaved ones. + * 🔴 FAIL, NEVER SUBSTITUTE. The version is gone — the flow was deleted, + * its app removed, or the row is absent. Promoting the run onto the newest + * published version would silently change what it does halfway through: + * its marking, its taken decisions and its log all belong to the version it + * started on. The message names the VERSION, which is what distinguishes it + * from "no app provides this flow" — the two want different fixes, and an + * operator has to be able to tell them apart. * - * Failing to record history must never fail the run itself: the run is the - * work, the rows are the account of it. + * @param FlowRun $run The run to fail. * - * @param FlowRun $run The run these steps belong to. - * @param array $entries The engine log entries for this segment. + * @return FlowRun The failed run. * - * @return void - * - * @spec openspec/changes/flow-engine-unification/specs/flow-execution-history/spec.md + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md */ - private function recordSteps(FlowRun $run, array $entries): void { - if ($this->steps === null || empty($entries) === true) { - return; - } - - $runUuid = (string)$run->getUuid(); + private function failUnresolvableVersion(FlowRun $run): FlowRun { + $run->setStatus(FlowRun::STATUS_FAILED); + $run->setError(sprintf( + 'Version %s of flow "%s" could not be resolved, so this run cannot continue. ' + . 'It has NOT been moved to another version.', + (string)($run->getFlowVersion() ?? 'unknown'), + (string)$run->getFlowId() + )); - try { - $sequence = ($this->steps->highestSequence(runUuid: $runUuid) + 1); - } catch (Throwable $e) { - $this->logger->warning( - message: '[FlowRunService] Could not read the step sequence for run ' . $runUuid . ': ' . $e->getMessage(), - context: ['file' => __FILE__, 'line' => __LINE__] - ); - return; - } + $this->mapper->update($run); - foreach ($entries as $entry) { - if (is_array($entry) === false) { - continue; - } + return $run; - $step = new FlowRunStep(); - $step->setRunUuid($runUuid); - $step->setFlowId((string)$run->getFlowId()); - $step->setNodeId((string)($entry['transition'] ?? '')); - $step->setNodeType(($entry['type'] ?? null)); - $step->setSequence($sequence); - $step->setStatus((string)($entry['status'] ?? 'unknown')); - $step->setDurationMs(($entry['durationMs'] ?? null)); - $step->setCreated(new DateTime()); - $step->setFinished(new DateTime()); - - // `error` and `reason` are distinct outcomes that both belong in - // the error column: a thrown step and a deliberately stopped one - // are each something a person needs to read back. - $step->setError(($entry['error'] ?? ($entry['reason'] ?? null))); - - // What the node produced, minus the items themselves — a step row - // is an index into the run, not a second copy of its data. - $step->setOutput( - array_filter( - [ - 'itemsIn' => ($entry['itemsIn'] ?? null), - 'itemsOut' => ($entry['itemsOut'] ?? null), - 'checkId' => ($entry['checkId'] ?? null), - ], - static fn ($v): bool => $v !== null - ) - ); + }//end failUnresolvableVersion() - try { - $this->steps->insert($step); - } catch (Throwable $e) { - $this->logger->warning( - message: '[FlowRunService] Could not record a step row for run ' . $runUuid . ': ' . $e->getMessage(), - context: ['file' => __FILE__, 'line' => __LINE__] - ); - } - $sequence++; - }//end foreach + /** + * Write a completed walk back onto the run. + * + * @param FlowRun $run The run. + * @param array $result What the engine returned. + * + * @return FlowRun The updated run. + * + * @spec openspec/changes/or-flow-runs/specs/flow-runs/spec.md + */ - }//end recordSteps() /** * Write back what a walk produced. @@ -901,7 +796,7 @@ private function persistResult(FlowRun $run, array $result): FlowRun { // Promote THIS segment's entries to step rows. Only the new entries — // `$result['log']`, not the merged `$log` — or every resume would // re-record the whole history it had already written. - $this->recordSteps(run: $run, entries: (array)($result['log'] ?? [])); + $this->stepHistory()->record(run: $run, entries: (array)($result['log'] ?? [])); // The token travels as an object so steps can write to it; the column // holds JSON. Serialising here — on the suspended path as much as the @@ -1036,18 +931,4 @@ private function recordLastRun(FlowRun $run, string $status): void { }//end recordLastRun() - /** - * A v4 UUID for a new run. - * - * @return string The uuid. - * - * @spec openspec/changes/or-flow-runs/specs/flow-runs/spec.md - */ - private function newUuid(): string { - $data = random_bytes(16); - $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); - $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); - - return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); - }//end newUuid() }//end class diff --git a/lib/Service/Flow/FlowRunVersionPin.php b/lib/Service/Flow/FlowRunVersionPin.php new file mode 100644 index 0000000000..1166d49b8f --- /dev/null +++ b/lib/Service/Flow/FlowRunVersionPin.php @@ -0,0 +1,307 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use OCA\OpenRegister\Db\Flow; +use OCA\OpenRegister\Db\FlowVersion; +use OCA\OpenRegister\Db\FlowVersionMapper; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Resolves the published version a new run must be pinned to. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ +class FlowRunVersionPin { + /** + * The trigger an interactive test run carries. + * + * 🔑 THE ONE DISPATCH THAT MAY WALK A DRAFT. Every other path is a live + * process acting on real data and must run a published version. A test run + * is an author pressing "try this" on the graph in front of them — refusing + * it would mean a flow could not be tried until it was published, which is + * exactly backwards: publishing is the thing you do AFTER testing. + * + * @var string + */ + public const TRIGGER_TEST = 'test'; + + /** + * Constructor. + * + * @param ContainerInterface $container Resolves the version mapper. + * @param LoggerInterface $logger Diagnostics. + */ + public function __construct( + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * The published version of a flow, or null when it has none. + * + * @param string $flowId The flow being dispatched. + * + * @return FlowVersion|null The published version, or null. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function publishedVersionOf(string $flowId): ?FlowVersion { + try { + $mapper = $this->container->get(FlowVersionMapper::class); + } catch (Throwable $e) { + // 🔴 NOT a fail-open. The caller refuses the dispatch when this is + // null, exactly as it does for a flow with no published version. + // An unresolvable mapper means we cannot establish which graph a + // run would execute, and "run it against whatever is current" is + // the behaviour versioning exists to remove. + $this->logger->error( + message: '[FlowRunVersionPin] Could not resolve the version mapper for flow "' + . $flowId . '"; the dispatch is refused rather than run unpinned.', + context: ['file' => __FILE__, 'line' => __LINE__, 'flow' => $flowId] + ); + + return null; + } + + if ($mapper instanceof FlowVersionMapper === false) { + return null; + } + + try { + return $mapper->findPublished(flowUuid: $flowId); + } catch (Throwable $e) { + $this->logger->error( + message: '[FlowRunVersionPin] Could not read the published version of flow "' + . $flowId . '": ' . $e->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__, 'flow' => $flowId] + ); + + return null; + } + + }//end publishedVersionOf() + + /** + * The graph a version names. + * + * @param FlowVersion|null $version The version. + * + * @return array|null The graph, or null when unresolvable. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function graphOf(?FlowVersion $version): ?array { + if ($version === null) { + return null; + } + + try { + return $this->container->get(FlowDefinitionPin::class) + ->graphFor($version->getDefinitionHash()); + } catch (Throwable $e) { + $this->logger->error( + message: '[FlowRunVersionPin] Could not read the definition of version ' + . $version->getVersion() . ' of flow "' . $version->getFlowUuid() . '": ' . $e->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + + return null; + } + + }//end graphOf() + + /** + * The published version of a flow, refusing when there is none or it is unsound. + * + * Both questions in one call because a dispatch has no use for either + * answer alone: knowing the version without knowing the graph is sound + * lets a run start and stop halfway; checking a graph without knowing + * which version it belongs to checks the wrong document. + * + * @param string $flowId The flow being dispatched. + * @param string $trigger What caused the dispatch, for the log. + * + * @return FlowVersion|null The published version, guaranteed sound; null for + * an interactive test run of an unpublished flow. + * + * @throws FlowLifecycleRefused When a non-test dispatch has no published version. + * @throws FlowDeadEnd When that version has a node a token cannot leave. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function requirePublishedAndSound(string $flowId, string $trigger): ?FlowVersion { + $version = $this->publishedVersionOf(flowId: $flowId); + + if ($version === null && $trigger === self::TRIGGER_TEST) { + // Unpinned, deliberately, and this is the ONLY way a run is. The + // caller hands the draft document straight to the engine and + // `FlowPublishedGraph::overlayOnto()` passes an unpinned run's + // document through untouched — so a test run is pinned in the sense + // that matters: it walks the exact graph it was started with, and + // nothing can substitute another one for it mid-run. + return null; + } + + if ($version === null) { + $this->logger->warning( + message: '[FlowRunVersionPin] Refused to queue a run of an unpublished flow', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'flow' => $flowId, + 'trigger' => $trigger, + ] + ); + + throw new FlowLifecycleRefused( + reason: FlowLifecycleRefused::REASON_NO_PUBLISHED_VERSION, + flowId: $flowId, + state: null + ); + } + + $this->refuseDeadEnd(flowId: $flowId, graph: $this->graphOf(version: $version)); + + return $version; + + }//end requirePublishedAndSound() + + /** + * Refuse a graph that has a node its token cannot leave. + * + * 🔑 JUDGES THE GRAPH IT IS GIVEN — the version being pinned — never the + * flow's editable head. Judging the head would let a half-authored draft + * refuse runs of a sound published version, and let a repaired draft mask + * a dead end in the version actually going live. + * + * The flow's own status is still written, because that is the surface an + * author looks at, and a refusal nobody can see is a refusal that reads as + * "nothing happened". + * + * @param string $flowId The flow uuid. + * @param array|null $graph The graph of the version being pinned, or null. + * + * @return void + * + * @throws FlowDeadEnd When a non-terminal node has no outgoing edge. + * + * @spec openspec/specs/flow-engine/spec.md + */ + private function refuseDeadEnd(string $flowId, ?array $graph): void { + if ($graph === null) { + // Not a dead end, and it must not be reported as one: the advancer + // fails the run naming the version, which is the accurate error. + return; + } + + try { + $mapper = $this->container->get('OCA\OpenRegister\Db\FlowMapper'); + $preflight = $this->container->get('OCA\OpenRegister\Service\Flow\FlowNodePreflight'); + $flow = $mapper->findByUuid($flowId); + } catch (Throwable $e) { + // A flow we cannot load is not a flow we can judge — `findByUuid()` + // throws rather than returning null. Queueing proceeds and the + // existing not-found handling downstream reports it; inventing a + // dead-end refusal here would blame the document for a lookup + // failure. + return; + } + + $deadEnds = $this->deadEndsIn(preflight: $preflight, graph: $graph); + + if ($deadEnds === []) { + // Accepted. Clear a stale refusal so a repaired flow stops + // reporting the error it no longer has. + if ($flow->getStatus() === Flow::STATUS_ERROR) { + $flow->setStatus(Flow::STATUS_OK); + $flow->setStatusMessage(null); + $mapper->update($flow); + } + + return; + } + + $refusal = new FlowDeadEnd(nodeIds: $deadEnds); + + $flow->setStatus(Flow::STATUS_ERROR); + $flow->setStatusMessage($refusal->getMessage()); + $mapper->update($flow); + + $this->logger->warning( + message: '[FlowRunVersionPin] Refused to queue a flow with a dead end', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'flow' => $flowId, + 'nodes' => $deadEnds, + ] + ); + + throw $refusal; + + }//end refuseDeadEnd() + + /** + * The ids of the steps a token cannot leave. + * + * @param FlowNodePreflight $preflight The inspector. + * @param array $graph The graph being judged. + * + * @return array The offending step ids. + * + * @spec openspec/specs/flow-engine/spec.md + */ + private function deadEndsIn(FlowNodePreflight $preflight, array $graph): array { + $findings = $preflight->inspect( + flow: [ + 'nodes' => (array)($graph['nodes'] ?? []), + 'edges' => (array)($graph['edges'] ?? []), + ] + ); + + $deadEnds = []; + foreach (($findings['warnings'] ?? []) as $warning) { + if (($warning['reason'] ?? '') === FlowNodePreflight::REASON_DEAD_END) { + $deadEnds[] = (string)($warning['step'] ?? ''); + } + } + + return $deadEnds; + + }//end deadEndsIn() +}//end class diff --git a/lib/Service/Flow/FlowService.php b/lib/Service/Flow/FlowService.php index 329177e652..1185477f49 100644 --- a/lib/Service/Flow/FlowService.php +++ b/lib/Service/Flow/FlowService.php @@ -41,6 +41,7 @@ use OCA\OpenRegister\Db\FlowRunMapper; use OCA\OpenRegister\Db\FlowRunStepMapper; use OCA\OpenRegister\Db\FlowStateMapper; +use OCA\OpenRegister\Db\FlowVersionMapper; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IUserSession; use Psr\Container\ContainerInterface; @@ -239,27 +240,77 @@ public function find(string $uuid): Flow { * @throws DoesNotExistException When updating a flow that is not the caller's, * or creating one with no owner / organisation. * @throws \InvalidArgumentException When a trigger node rejects its own config. + * @throws FlowLifecycleRefused When the definition changes and the head is not a draft. * * @spec openspec/changes/flow-engine-unification/specs/flow-storage/spec.md */ public function save(array $data, ?string $uuid = null): Flow { $flow = $this->flowToSave(data: $data, uuid: $uuid); + // 🔴 THE GRAPH BEFORE THE WRITE, captured while the entity still holds + // what is stored. `applyEditableFields()` mutates it in place, so + // reading afterwards would compare the incoming graph with itself and + // the refusal would never fire. + $graphBefore = $this->graphSignature(flow: $flow); + $this->applyEditableFields(flow: $flow, data: $data); (new FlowTriggerValidator($this->container, $this->logger))->validate(flow: $flow); + + // A published version is immutable. Only a DEFINITION change is + // refused, deliberately: renaming a flow, editing its description or + // switching it off are not changes to the process, and refusing them + // would make a published flow unmanageable rather than merely + // uneditable. + if ($this->graphSignature(flow: $flow) !== $graphBefore) { + (new FlowLifecycleGuard( + $this->runs, + $this->container->get(FlowNodePreflight::class), + $this->logger + ))->refuseEditUnlessDraft( + flowId: (string)$flow->getUuid(), + state: $flow->getLifecycleStatus() + ); + } + $flow->setUpdated(new DateTime()); $stored = $this->persistFlow(flow: $flow, uuid: $uuid); - // Re-derive the trigger index from the nodes that were just saved. - // AFTER the write, so the index can never name a subscription the - // stored document does not declare — and never raising, so a failure - // to index cannot cost an author their work. + // 🔑 THE TRIGGER SET FOLLOWS THE PUBLISHED VERSION, NOT THE HEAD. This + // used to re-derive from whatever was just saved, which under + // versioning would subscribe a DRAFT's trigger nodes and unsubscribe + // the published version's — the opposite of both rules. Deriving from + // the published version is also what keeps an `enabled` toggle working + // while a draft is open. $this->triggerIndex->reindex(flow: $stored); return $stored; }//end save() + /** + * A value that changes exactly when the flow's definition changes. + * + * Compared, not stored. Only the four keys that make up the graph are + * included — the same four `FlowDefinitionPin` pins — so that metadata + * edits do not read as definition edits. + * + * @param Flow $flow The flow. + * + * @return string The signature. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + private function graphSignature(Flow $flow): string { + return (string)json_encode([ + 'nodes' => ($flow->getNodes() ?? []), + 'edges' => ($flow->getEdges() ?? []), + 'limits' => ($flow->getLimits() ?? []), + 'executionMode' => (string)($flow->getExecutionMode() ?? Flow::MODE_ASYNC), + ]); + }//end graphSignature() + + + /** * Insert a new flow, or update the existing one. * @@ -482,6 +533,22 @@ public function delete(string $uuid): void { } $this->state->deleteByFlow(flowId: $uuid); + + // 🔴 AND ITS VERSION ROWS, for exactly the same reason — I added + // the table and did not extend this cascade, so every deleted flow + // left its versions behind. Measured on the dev instance: 38 + // orphans, and no way to reach them, since every read is by flow. + // + // Safe precisely BECAUSE the runs went first: a version row exists + // so an in-flight run can resolve the graph it was pinned to, and + // this method has just deleted every run of this flow. Nothing can + // still be pinned to them. + // + // `openregister_flow_defs` is deliberately NOT touched. It is + // content-addressed and SHARED — two flows holding the same graph + // share one row — so deleting by flow would pull a definition out + // from under an unrelated flow's version. + $this->container->get(FlowVersionMapper::class)->deleteByFlow(flowUuid: $uuid); } catch (Throwable $e) { // The flow itself is already gone, so this must not turn a // successful delete into an error the caller has to retry — a retry @@ -507,11 +574,19 @@ public function delete(string $uuid): void { * @param array $subject `{uuid, register, schema}` of the object. * @param array $context Run-level metadata. * @param boolean $sync Execute inline and return the finished run. + * @param string $trigger The dispatch trigger recorded on the run. + * + * `$trigger` is what the version pin reads to decide whether this run may + * walk a DRAFT. `FlowRunVersionPin::TRIGGER_TEST` is the interactive + * draft test run — the one documented exception to "a draft cannot back a + * run" (see `FlowPublishedGraph::overlayOnto`). Every other trigger + * requires a published version, which is the point of versioning. * * @return FlowRun The queued run, or the finished run when $sync is true. * * @throws DoesNotExistException When no such flow exists, or it is not the caller's. * @throws FlowDeadEnd When a node's token has nowhere to go, so the run is refused. + * @throws FlowLifecycleRefused When the trigger requires a published version and there is none. * * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $sync chooses WHO advances the * run, not what running means: the same run row is queued either way, and @@ -520,13 +595,19 @@ public function delete(string $uuid): void { * * @spec openspec/changes/flow-engine-unification/specs/flow-storage/spec.md */ - public function run(string $uuid, array $subject = [], array $context = [], bool $sync = false): FlowRun { + public function run( + string $uuid, + array $subject = [], + array $context = [], + bool $sync = false, + string $trigger = Flow::TRIGGER_MANUAL + ): FlowRun { $flow = $this->find(uuid: $uuid); $run = $this->runner->queue( flowId: (string)$flow->getUuid(), subject: $subject, - trigger: Flow::TRIGGER_MANUAL, + trigger: $trigger, context: $context, user: $this->actingUser() ); diff --git a/lib/Service/Flow/FlowStepHistory.php b/lib/Service/Flow/FlowStepHistory.php new file mode 100644 index 0000000000..66e40e4d18 --- /dev/null +++ b/lib/Service/Flow/FlowStepHistory.php @@ -0,0 +1,187 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use DateTime; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunStep; +use OCA\OpenRegister\Db\FlowRunStepMapper; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Computes the step-sequence base for a walk about to start. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ +class FlowStepHistory { + /** + * Constructor. + * + * @param FlowRunStepMapper|null $steps Reads the highest sequence already recorded. + * Null where history is not being recorded at + * all, in which case numbering starts at zero. + * @param LoggerInterface|null $logger Records a failed read. + */ + public function __construct( + private readonly ?FlowRunStepMapper $steps = null, + private readonly ?LoggerInterface $logger = null, + ) { + }//end __construct() + + /** + * The sequence the first step of this segment will carry. + * + * Continues from the highest already recorded, so a run that suspended and + * resumed days later reads as ONE ordered history rather than two + * interleaved ones starting at zero. + * + * A failure to read is not a reason to fail the run: the work is the run, + * and the numbering is the account of it. Attribution degrades to numbering + * from zero — wrong only in its offset, and still correctly ordering the + * run's writes among themselves. + * + * @param string $runUuid The run about to be walked. + * + * @return integer The base sequence. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function baseFor(string $runUuid): int { + if ($this->steps === null || trim($runUuid) === '') { + return 0; + } + + try { + return ($this->steps->highestSequence(runUuid: $runUuid) + 1); + } catch (Throwable $e) { + $this->logger?->warning( + message: '[FlowStepHistory] Could not read the step sequence base for attribution: ' + . $e->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + + return 0; + } + }//end baseFor() + + /** + * Write one step row per node execution in this segment. + * + * The aggregate `log` column answers "what happened in this run" and + * nothing else — "which node type fails", "every failed step for this + * flow", "what did node X output" all require loading and walking every + * run's blob. One row per hop makes those queryable, and gives retention + * something it can prune per flow. + * + * Sequence CONTINUES from the highest already recorded rather than + * restarting at zero, so a run that suspends on a wait node and resumes + * later reads as one ordered history instead of two interleaved ones. + * + * Failing to record history must never fail the run itself: the run is the + * work, the rows are the account of it. + * + * @param FlowRun $run The run these steps belong to. + * @param array $entries The engine log entries for this segment. + * + * @return void + * + * @spec openspec/changes/flow-engine-unification/specs/flow-execution-history/spec.md + */ + public function record(FlowRun $run, array $entries): void { + if ($this->steps === null || empty($entries) === true) { + return; + } + + $runUuid = (string)$run->getUuid(); + + // The SAME computation the attribution used before the walk. One method, + // so the number an audit row was stamped with and the number its step row + // is given cannot drift apart. + $sequence = $this->baseFor(runUuid: $runUuid); + + foreach ($entries as $entry) { + if (is_array($entry) === false) { + continue; + } + + $step = new FlowRunStep(); + $step->setRunUuid($runUuid); + $step->setFlowId((string)$run->getFlowId()); + $step->setNodeId((string)($entry['transition'] ?? '')); + $step->setNodeType(($entry['type'] ?? null)); + $step->setSequence($sequence); + $step->setStatus((string)($entry['status'] ?? 'unknown')); + $step->setDurationMs(($entry['durationMs'] ?? null)); + $step->setCreated(new DateTime()); + $step->setFinished(new DateTime()); + + // `error` and `reason` are distinct outcomes that both belong in + // the error column: a thrown step and a deliberately stopped one + // are each something a person needs to read back. + $step->setError(($entry['error'] ?? ($entry['reason'] ?? null))); + + // What the node produced, minus the items themselves — a step row + // is an index into the run, not a second copy of its data. + $step->setOutput( + array_filter( + [ + 'itemsIn' => ($entry['itemsIn'] ?? null), + 'itemsOut' => ($entry['itemsOut'] ?? null), + 'checkId' => ($entry['checkId'] ?? null), + ], + static fn ($v): bool => $v !== null + ) + ); + + try { + $this->steps->insert($step); + } catch (Throwable $e) { + $this->logger->warning( + message: '[FlowStepHistory] Could not record a step row for run ' . $runUuid . ': ' . $e->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + } + + $sequence++; + }//end foreach + + }//end record() +}//end class diff --git a/lib/Service/Flow/FlowTriggerIndex.php b/lib/Service/Flow/FlowTriggerIndex.php index 66933161fe..3f46a26016 100644 --- a/lib/Service/Flow/FlowTriggerIndex.php +++ b/lib/Service/Flow/FlowTriggerIndex.php @@ -59,11 +59,13 @@ class FlowTriggerIndex { * @param FlowTriggerMapper $mapper The indexed trigger set. * @param FlowTriggerDerivation $derivation Nodes to triggers. * @param LoggerInterface $logger Diagnostics. + * @param FlowPublishedGraph|null $published Resolves the published graph. */ public function __construct( private readonly FlowTriggerMapper $mapper, private readonly FlowTriggerDerivation $derivation, private readonly LoggerInterface $logger, + private readonly ?FlowPublishedGraph $published = null, ) { }//end __construct() @@ -89,7 +91,14 @@ public function reindex(Flow $flow): int { } try { - $triggers = $this->derivation->objectTriggersOf(flow: $flow); + // 🔴 THE ROWS COME FROM THE PUBLISHED VERSION, NEVER THE HEAD. While + // a draft is open the head holds nodes that must NOT be subscribed, + // and omits the published ones that must stay subscribed — deriving + // from it is both rules backwards. Putting the resolution here + // rather than at each caller is what stops the two from drifting: + // the flow save path, the publish transaction and the upgrade + // back-fill all reach the index through this one method. + $triggers = $this->derivation->objectTriggersOf(flow: $this->publishedFace(flow: $flow)); // A flow that derives NO object triggers must not be left with // stale rows from a previous save — deleting the flow's last @@ -111,6 +120,48 @@ public function reindex(Flow $flow): int { }//end reindex() + /** + * The flow as its PUBLISHED version sees it. + * + * Returns a detached carrier holding the published graph, or a carrier + * with NO nodes when the flow has no published version — which makes + * `objectTriggersOf()` derive nothing and `replaceFor()` clear the flow's + * rows. That is the correct reading of "a draft must not back a run": an + * unpublished flow is subscribed to nothing at all. + * + * `enabled` always comes from the LIVE flow, because switching a flow off + * must take effect at once and has nothing to do with which version is + * published. + * + * @param Flow $flow The stored flow. + * + * @return Flow The carrier to derive triggers from. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + private function publishedFace(Flow $flow): Flow { + $carrier = new Flow(); + $carrier->setUuid((string)$flow->getUuid()); + $carrier->setEnabled($flow->getEnabled()); + $carrier->setNodes([]); + $carrier->setEdges([]); + + if ($this->published === null) { + return $carrier; + } + + $graph = $this->published->graphOf(flowId: (string)$flow->getUuid()); + if ($graph === null) { + return $carrier; + } + + $carrier->setNodes((array)($graph['nodes'] ?? [])); + $carrier->setEdges((array)($graph['edges'] ?? [])); + + return $carrier; + + }//end publishedFace() + /** * Drop a deleted flow's rows. * diff --git a/lib/Service/Flow/FlowVersionService.php b/lib/Service/Flow/FlowVersionService.php new file mode 100644 index 0000000000..c65398c4cf --- /dev/null +++ b/lib/Service/Flow/FlowVersionService.php @@ -0,0 +1,375 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use DateTime; +use OCA\OpenRegister\Db\Flow; +use OCA\OpenRegister\Db\FlowMapper; +use OCA\OpenRegister\Db\FlowVersion; +use OCA\OpenRegister\Db\FlowVersionMapper; +use OCP\IDBConnection; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Publishes, drafts and deprecates flow versions. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ +class FlowVersionService { + /** + * Constructor. + * + * @param FlowVersionMapper $versions The version rows. + * @param FlowMapper $flows The flows whose head mirrors the versions. + * @param FlowDefinitionPin $pin Canonicalises and stores a graph by hash. + * @param FlowLifecycleGuard $guard The preconditions on every transition. + * @param FlowTriggerIndex $triggers The derived trigger rows. + * @param IDBConnection $db Wraps each transition in one transaction. + * @param LoggerInterface $logger Diagnostics. + */ + public function __construct( + private readonly FlowVersionMapper $versions, + private readonly FlowMapper $flows, + private readonly FlowDefinitionPin $pin, + private readonly FlowLifecycleGuard $guard, + private readonly FlowTriggerIndex $triggers, + private readonly IDBConnection $db, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Every version of a flow, newest first. + * + * @param string $flowUuid The flow. + * + * @return FlowVersion[] The versions. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function versionsOf(string $flowUuid): array { + return $this->versions->findAllForFlow(flowUuid: $flowUuid); + + }//end versionsOf() + + /** + * One version of a flow. + * + * @param string $flowUuid The flow. + * @param integer $number The version number. + * + * @return FlowVersion|null The version, or null. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function versionOf(string $flowUuid, int $number): ?FlowVersion { + return $this->versions->find(flowUuid: $flowUuid, version: $number); + + }//end versionOf() + + /** + * The graph a version names. + * + * @param FlowVersion $version The version. + * + * @return array|null The graph, or null when unresolvable. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function graphOfVersion(FlowVersion $version): ?array { + return $this->pin->graphFor($version->getDefinitionHash()); + + }//end graphOfVersion() + + /** + * The graph of a flow's head, as a definition document. + * + * @param Flow $flow The flow. + * + * @return array The graph. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function graphOf(Flow $flow): array { + return [ + 'nodes' => ($flow->getNodes() ?? []), + 'edges' => ($flow->getEdges() ?? []), + 'limits' => ($flow->getLimits() ?? []), + 'executionMode' => (string)($flow->getExecutionMode() ?? Flow::MODE_ASYNC), + ]; + + }//end graphOf() + + /** + * Publish the flow's head, deprecating whatever was published before it. + * + * @param Flow $flow The flow whose head is being published. + * @param string|null $publishedBy The acting user. + * + * @return FlowVersion The now-published version. + * + * @throws FlowLifecycleRefused When the head is not a draft, or has a dead end. + * @throws Throwable When the transaction could not be committed. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function publish(Flow $flow, ?string $publishedBy = null): FlowVersion { + $flowId = (string)$flow->getUuid(); + $graph = $this->graphOf(flow: $flow); + + // Guards BEFORE the transaction opens. A refusal is not a rollback — + // nothing should have been attempted — and opening a transaction to + // immediately abort it would leave the refusal looking like a failure + // in the log rather than a decision. + $this->guard->refusePublishUnlessDraft(flowId: $flowId, state: $flow->getLifecycleStatus()); + $this->guard->refusePublishOnDeadEnd(flowId: $flowId, graph: $graph); + + $hash = $this->pin->pin(flow: $graph, flowId: $flowId); + if ($hash === null) { + throw new FlowLifecycleRefused( + reason: FlowLifecycleRefused::REASON_IMMUTABLE, + flowId: $flowId, + state: $flow->getLifecycleStatus(), + detail: 'The definition could not be stored, so the version would name a graph that is not there.' + ); + } + + $this->db->beginTransaction(); + + try { + $previous = $this->versions->findPublished(flowUuid: $flowId); + if ($previous !== null) { + $previous->setStatus(FlowVersion::STATUS_DEPRECATED); + $previous->setDeprecatedAt(new DateTime()); + $this->versions->update($previous); + } + + $version = $this->headVersionRow(flow: $flow, hash: $hash); + $version->setStatus(FlowVersion::STATUS_PUBLISHED); + $version->setPublishedAt(new DateTime()); + $version->setPublishedBy($publishedBy); + $version->setDefinitionHash($hash); + $stored = $this->persist(version: $version); + + $flow->setLifecycleStatus(FlowVersion::STATUS_PUBLISHED); + $flow->setVersion($stored->getVersion()); + $this->flows->update($flow); + + // Inside the transaction, deliberately. The trigger rows and the + // published version are one fact: a commit that carried the new + // version but not its triggers would leave the flow published and + // subscribed to nothing. + $this->triggers->reindex(flow: $flow); + + $this->db->commit(); + } catch (Throwable $e) { + $this->db->rollBack(); + $this->logger->error( + message: '[FlowVersionService] Publish failed for flow "' . $flowId . '": ' . $e->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__, 'flow' => $flowId] + ); + throw $e; + }//end try + + return $stored; + + }//end publish() + + /** + * Copy the published graph into a new draft at version N+1. + * + * The published version keeps serving — it stays `published` and keeps its + * trigger rows — until the draft is itself published. That is the whole + * point of a draft: editing must not take a live process offline. + * + * @param Flow $flow The flow to draft from. + * + * @return FlowVersion The new draft version row. + * + * @throws Throwable When the transaction could not be committed. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function createDraft(Flow $flow): FlowVersion { + $flowId = (string)$flow->getUuid(); + + $this->db->beginTransaction(); + + try { + $next = ($this->versions->highestVersion(flowUuid: $flowId) + 1); + + $draft = new FlowVersion(); + $draft->setFlowUuid($flowId); + $draft->setVersion($next); + $draft->setStatus(FlowVersion::STATUS_DRAFT); + $draft->setDefinitionHash((string)$this->pin->pin(flow: $this->graphOf(flow: $flow), flowId: $flowId)); + $draft->setOwner($flow->getOwner()); + $draft->setOrganisation($flow->getOrganisation()); + $draft->setCreated(new DateTime()); + $stored = $this->versions->insert($draft); + + // The head becomes the draft. The flow's stored graph is already + // the published graph, so the draft starts as an exact copy — an + // author who opens it and changes nothing can publish it back to + // an identical hash, which dedupes to the same definition row. + $flow->setLifecycleStatus(FlowVersion::STATUS_DRAFT); + $flow->setVersion($next); + $this->flows->update($flow); + + $this->db->commit(); + } catch (Throwable $e) { + $this->db->rollBack(); + $this->logger->error( + message: '[FlowVersionService] Could not create a draft of flow "' . $flowId . '": ' . $e->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__, 'flow' => $flowId] + ); + throw $e; + }//end try + + return $stored; + + }//end createDraft() + + /** + * Retire the published version, leaving the flow backing no new runs. + * + * Runs already pinned to it keep running: deprecation is about what may + * START, never about what is already in flight. + * + * @param Flow $flow The flow. + * + * @return FlowVersion The deprecated version. + * + * @throws FlowLifecycleRefused When there is no published version. + * @throws Throwable When the transaction could not be committed. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + public function deprecate(Flow $flow): FlowVersion { + $flowId = (string)$flow->getUuid(); + $published = $this->versions->findPublished(flowUuid: $flowId); + + $this->guard->refuseDeprecateUnlessPublished( + flowId: $flowId, + state: $published?->getStatus() + ); + + $this->db->beginTransaction(); + + try { + $published->setStatus(FlowVersion::STATUS_DEPRECATED); + $published->setDeprecatedAt(new DateTime()); + $stored = $this->versions->update($published); + + $flow->setLifecycleStatus(FlowVersion::STATUS_DEPRECATED); + $this->flows->update($flow); + + // A deprecated flow subscribes to nothing. Reindexing from a flow + // whose lifecycle is no longer published clears its rows, which is + // what "MUST NOT back a new run" means on the trigger path. + $this->triggers->forget(flowUuid: $flowId); + + $this->db->commit(); + } catch (Throwable $e) { + $this->db->rollBack(); + $this->logger->error( + message: '[FlowVersionService] Could not deprecate flow "' . $flowId . '": ' . $e->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__, 'flow' => $flowId] + ); + throw $e; + }//end try + + return $stored; + + }//end deprecate() + + /** + * The version row for the flow's head, creating it when it does not exist. + * + * A flow saved before versioning, or created without an explicit draft + * row, still has a head graph. This makes the row that names it rather + * than refusing — the alternative is an upgrade path where publishing an + * existing flow fails because of a row nobody ever asked for. + * + * @param Flow $flow The flow. + * @param string $hash The hash of the head graph. + * + * @return FlowVersion The head's version row, unsaved changes included. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + private function headVersionRow(Flow $flow, string $hash): FlowVersion { + $flowId = (string)$flow->getUuid(); + $number = (int)($flow->getVersion() ?? 1); + + $existing = $this->versions->find(flowUuid: $flowId, version: $number); + if ($existing !== null) { + return $existing; + } + + $version = new FlowVersion(); + $version->setFlowUuid($flowId); + $version->setVersion($number); + $version->setStatus(FlowVersion::STATUS_DRAFT); + $version->setDefinitionHash($hash); + $version->setOwner($flow->getOwner()); + $version->setOrganisation($flow->getOrganisation()); + $version->setCreated(new DateTime()); + + return $version; + + }//end headVersionRow() + + /** + * Insert or update, depending on whether the row has been stored yet. + * + * @param FlowVersion $version The version to write. + * + * @return FlowVersion The stored version. + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + private function persist(FlowVersion $version): FlowVersion { + if ($version->getId() === null) { + return $this->versions->insert($version); + } + + return $this->versions->update($version); + + }//end persist() +}//end class diff --git a/lib/Service/LogService.php b/lib/Service/LogService.php index 1c4a02dfc6..9684f710c4 100644 --- a/lib/Service/LogService.php +++ b/lib/Service/LogService.php @@ -347,94 +347,6 @@ public function exportLogs(string $format, array $config = []): array { } }//end exportLogs() - /** - * Delete a single audit trail log by ID - * - * @param int $id The audit trail ID to delete - * - * @return true True if deletion was successful - * - * @throws \OCP\AppFramework\Db\DoesNotExistException If audit trail not found - * - * @spec openspec/changes/retrofit-2026-05-25-bw2-svc-flat-2/tasks.md#task-4 - */ - public function deleteLog(int $id): bool { - try { - $log = $this->auditTrailMapper->find($id); - $this->auditTrailMapper->delete($log); - return true; - } catch (Exception $e) { - throw new Exception('Failed to delete audit trail: ' . $e->getMessage()); - } - }//end deleteLog() - - /** - * Delete multiple audit trail logs based on filters - * - * @param array $config Configuration array containing: - * - filters: (array) Filter parameters - * - search: (string|null) Search term - * - ids: (array|null) Specific IDs to delete - * - * @return int[] Array containing: - * - deleted: (int) Number of logs deleted - * - failed: (int) Number of logs that failed to delete - * - * @throws \Exception If mass deletion fails - * - * @psalm-return array{deleted: int<0, max>, failed: int<0, max>, total: int<0, max>} - * - * @spec openspec/changes/retrofit-2026-05-25-bw2-svc-flat-2/tasks.md#task-4 - */ - public function deleteLogs(array $config = []): array { - $deleted = 0; - $failed = 0; - - try { - // If specific IDs are provided, use those. - if (empty($config['ids']) === false && is_array($config['ids']) === true) { - foreach ($config['ids'] as $id) { - try { - $log = $this->auditTrailMapper->find($id); - $this->auditTrailMapper->delete($log); - $deleted++; - } catch (Exception $e) { - $failed++; - } - } - - return [ - 'success' => true, - 'deleted' => $deleted, - 'failed' => $failed, - ]; - } - - // Otherwise, use filters to find logs to delete. - $logs = $this->auditTrailMapper->findAll( - filters: $config['filters'] ?? [], - search: $config['search'] ?? null - ); - - foreach ($logs as $log) { - try { - $this->auditTrailMapper->delete($log); - $deleted++; - } catch (Exception $e) { - $failed++; - } - } - - return [ - 'deleted' => $deleted, - 'failed' => $failed, - 'total' => $deleted + $failed, - ]; - } catch (Exception $e) { - throw new Exception('Mass deletion failed: ' . $e->getMessage()); - }//end try - }//end deleteLogs() - /** * Prepare logs data for export by filtering and formatting fields * diff --git a/lib/Service/Object/ValidateObject.php b/lib/Service/Object/ValidateObject.php index f7f1e82b03..02eb4cbcf7 100644 --- a/lib/Service/Object/ValidateObject.php +++ b/lib/Service/Object/ValidateObject.php @@ -2227,10 +2227,22 @@ public function handleValidationException(ValidationException|CustomValidationEx $property = $exception->getProperty(); } + // `getErrors()` is typed `?ValidationError` and IS null for every + // ValidationException thrown with a message alone — readOnly enforcement + // on update is one such path, and says so where it throws. Formatting + // that null is a TypeError, so the 400 this method exists to return + // became a 500 with the reason nowhere in the response: the caller saw + // "server error" instead of "cannot modify readOnly property X". + $formatted = []; + $validationError = $exception->getErrors(); + if ($validationError !== null) { + $formatted = (new ErrorFormatter())->format($validationError); + } + $errors[] = [ 'property' => $property, 'message' => $exception->getMessage(), - 'errors' => (new ErrorFormatter())->format($exception->getErrors()), + 'errors' => $formatted, ]; return new JSONResponse( diff --git a/openapi.json b/openapi.json index 033d6a040e..3e9e41d607 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "openregister", - "version": "1.1.6-beta.20260820205738", + "version": "1.1.9-unstable.20260829214947", "description": "Open Register", "license": { "name": "EUPL-1.2" diff --git a/openregister-compose.yaml b/openregister-compose.yaml new file mode 100644 index 0000000000..634cbac02a --- /dev/null +++ b/openregister-compose.yaml @@ -0,0 +1,250 @@ +# OpenRegister — local demo environment +# +# docker compose -f openregister-compose.yaml up -d +# +# Then open http://localhost:8601/apps/openregister/ +# Admin UI: http://localhost:8601 (admin / admin) +# +# Tear down, including all data: +# docker compose -f openregister-compose.yaml down -v +# +# --------------------------------------------------------------------------- +# THIS IS A DEMO ENVIRONMENT, NOT A DEVELOPMENT ENVIRONMENT. +# +# Nothing here is bind-mounted from your working copy, and that is deliberate +# rather than merely simpler. Nextcloud installs and updates an app by DELETING +# its directory and extracting a fresh archive over it. Measured 2026-08-27 on +# a development machine: `\OC\Updater::upgradeAppStoreApp` fired on a container +# restart and removed every top-level file from a bind-mounted checkout — +# including its `.git` directory — leaving only the subdirectories it lacked +# permission to unlink. A demo rig has no business pointing at a checkout, so +# this one owns its apps in a named volume. +# +# If you want to work ON these apps rather than WITH them, use the development +# environment instead. This file cannot serve that purpose and should not try. +# +# --------------------------------------------------------------------------- +# WHY RELEASE TARBALLS RATHER THAN `git clone` +# +# A release tarball is a COMPLETE app: it carries `vendor/` and the built `js/` +# bundle. A git checkout carries neither, and a Nextcloud app whose `vendor/` +# is missing does not fail loudly — `include_once` warns and the app keeps +# loading, so the app appears installed while every service that needs a +# dependency is absent. +# +# --------------------------------------------------------------------------- +# WHAT IS INSTALLED, AND WHY MORE THAN ONE APP +# +# openregister REQUIRED. Every Connext app declares its registers and +# schemas against OpenRegister. Note that this dependency is +# NOT declared in appinfo/info.xml — no app in the fleet +# declares an dependency — so nothing stops the App +# Store installing openregister without it. It would then load +# and find no register to attach to. +# thematiq Optional. Government theming. Absent, the UI renders +# unthemed rather than wrong. +# integriq Optional. The connector, for feeding data in from systems +# you do not control. +# openregister the app this file is for. +# +# Versions float to the newest release by default, pre-releases included, +# because most Connext apps do not yet publish a stable one. Pin any of them: +# +# OPENREGISTER_VERSION=1.2.3 docker compose -f openregister-compose.yaml up -d +# +# The Nextcloud image is pinned to a MAJOR tag rather than `:latest`. A demo +# that is stopped and restarted weeks later would otherwise boot a drifted +# Nextcloud over its existing data volume, which lands the instance in +# maintenance mode with its apps disabled. + +name: openregister-demo + +volumes: + db: + nextcloud: + apps: + +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: nextcloud + POSTGRES_PASSWORD: nextcloud + POSTGRES_DB: nextcloud + volumes: + - db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U nextcloud -d nextcloud"] + interval: 5s + timeout: 3s + retries: 20 + + # Downloads each app's release tarball into the shared `apps` volume before + # Nextcloud starts. It runs to completion and exits; Nextcloud waits for that + # exit via `condition: service_completed_successfully`, so there is no window + # in which Nextcloud boots against a half-populated app directory. + # + # Idempotent: an app whose appinfo/info.xml is already present is skipped, so + # `up` on an existing demo does not re-download 100MB per app. + app-installer: + image: alpine:3.20 + restart: "no" + environment: + OPENREGISTER_VERSION: ${OPENREGISTER_VERSION:-} + THEMATIQ_VERSION: ${THEMATIQ_VERSION:-} + INTEGRIQ_VERSION: ${INTEGRIQ_VERSION:-} + volumes: + - apps:/apps + configs: + - source: install-apps + target: /install-apps.sh + mode: "0755" + command: ["/bin/sh", "/install-apps.sh"] + + nextcloud: + image: nextcloud:34-apache + restart: unless-stopped + ports: + - "${DEMO_PORT:-8601}:80" + depends_on: + db: + condition: service_healthy + app-installer: + condition: service_completed_successfully + environment: + POSTGRES_HOST: db + POSTGRES_USER: nextcloud + POSTGRES_PASSWORD: nextcloud + POSTGRES_DB: nextcloud + NEXTCLOUD_ADMIN_USER: admin + NEXTCLOUD_ADMIN_PASSWORD: admin + # The port has to appear here as well as in `ports:`. Nextcloud rejects a + # request whose Host header names a domain it does not trust, and + # "localhost" and "localhost:8601" are different entries. + NEXTCLOUD_TRUSTED_DOMAINS: "localhost localhost:${DEMO_PORT:-8601} 127.0.0.1 127.0.0.1:${DEMO_PORT:-8601}" + # OVERWRITECLIURL IS LOAD-BEARING, NOT COSMETIC. Apps that federate + # advertise this address to peers. It is a local address here, and a + # local address is refused rather than broadcast — which is what keeps a + # demo on somebody's laptop out of the national directory. + OVERWRITECLIURL: "http://localhost:${DEMO_PORT:-8601}" + OVERWRITEPROTOCOL: http + volumes: + - nextcloud:/var/www/html + - apps:/var/www/html/custom_apps + configs: + - source: enable-apps + target: /docker-entrypoint-hooks.d/post-installation/10-enable-connext-apps.sh + mode: "0755" + +configs: + # EVERY SHELL VARIABLE BELOW IS WRITTEN `$$name`, NOT `$name`. + # + # Compose interpolates `$name` inside `configs.content` before the file is + # written, so an un-escaped shell variable arrives as an EMPTY STRING and the + # script runs on silently. Measured while building this file: `$version` was + # blanked, which made the "no version pinned" branch look true for an app + # whose version WAS pinned, and the resulting error named no repository — + # `could not resolve a release for ` — because `$repo` had been blanked too. + # + # `$$` is the escape that survives interpolation and reaches /bin/sh as `$`. + # `${DEMO_PORT:-8601}` is deliberately NOT escaped: that one is Compose's + # to substitute. + install-apps: + content: | + #!/bin/sh + set -eu + apk add --no-cache curl tar jq >/dev/null + + # RESOLVING "NEWEST" TAKES TWO CORRECTIONS, NOT ONE. + # + # 1. The GitHub "latest release" endpoint EXCLUDES prereleases and answers + # 404 for a repository that has only ever shipped them — which reads + # exactly like "no such app". Ask the releases LIST instead. + # + # 2. THE LIST IS NOT ORDERED BY CREATION DATE. Taking the first entry looks + # correct and is not. Measured 2026-08-27 on openregister: + # + # v1.1.6 created 10:17:45 <- returned first + # v1.1.6-unstable.20260827110807 created 11:09:37 <- actually newest + # + # Taking the first entry installs an OLDER build than intended, and the + # failure surfaces far from the cause — as a missing CLASS in a + # different app, not as a version complaint. + # + # Sorting by created_at explicitly is the fix; jq is here for that. + resolve_latest() { + curl -fsSL "https://api.github.com/repos/ConductionNL/$$1/releases?per_page=50" \ + | jq -r '[.[] | select(.draft | not)] | sort_by(.created_at) | last | .tag_name // empty' \ + | sed 's/^v//' + } + + install_app() { + repo="$$1"; appid="$$2"; version="$$3"; asset="$$4" + + if [ -f "/apps/$$appid/appinfo/info.xml" ]; then + echo "==> $$appid already present, skipping" + return 0 + fi + + if [ -z "$$version" ]; then + version="$$(resolve_latest "$$repo")" + [ -n "$$version" ] || { echo "!! could not resolve a release for $$repo"; return 1; } + echo "==> $$appid: no version pinned, resolved $$version" + fi + + url="https://github.com/ConductionNL/$$repo/releases/download/v$$version/$$asset-$$version.tar.gz" + echo "==> installing $$appid $$version" + + # Extract into a staging directory rather than straight into /apps. + # The archive's own top-level directory name is not guaranteed to equal + # the Nextcloud app id — Nextcloud resolves an app by its DIRECTORY + # name, so an archive that unpacks under the old name after a rename + # yields an app that is silently never loaded. Several Connext apps were + # renamed in August 2026, so this is a live concern, not a hypothetical. + rm -rf /tmp/stage && mkdir -p /tmp/stage + curl -fsSL "$$url" | tar -xz -C /tmp/stage + + top="$$(ls /tmp/stage | head -n1)" + if [ "$$top" != "$$appid" ]; then + echo " archive unpacked as '$$top', installing it as '$$appid'" + fi + mv "/tmp/stage/$$top" "/apps/$$appid" + rm -rf /tmp/stage + } + + # OpenRegister is not optional. If it could not be fetched, stop here + # rather than let Nextcloud boot into a dozen confusing downstream + # failures instead of one clear one. + install_app openregister openregister "$$OPENREGISTER_VERSION" openregister + install_app thematiq thematiq "$$THEMATIQ_VERSION" thematiq + install_app integriq integriq "$$INTEGRIQ_VERSION" integriq + + [ -f /apps/openregister/appinfo/info.xml ] || { echo "!! openregister missing; aborting"; exit 1; } + + # 33 is www-data inside the Nextcloud image. + chown -R 33:33 /apps + echo "==> apps present: $$(ls /apps | tr '\n' ' ')" + + enable-apps: + content: | + #!/bin/sh + set -eu + # Runs once, after Nextcloud has installed itself. + # + # ORDER IS NOT ARBITRARY. OpenRegister owns the registers and schemas the + # other apps declare against, and a leaf app enabled before it finds no + # register to attach to. Enabling them in dependency order is what makes + # a first boot produce a working instance instead of an empty one. + for app in openregister thematiq integriq; do + echo "==> enabling $$app" + php /var/www/html/occ app:enable "$$app" || echo "!! failed to enable $$app" + done + + # Several apps ship a schema whose slug is not unique across the + # instance. OpenRegister resolves a duplicate slug by tie-break and warns, + # which means a leaf app can silently read another app's schema. This is a + # no-op on a clean install and a repair on one that has drifted. + php /var/www/html/occ openregister:schemas:dedup || true + + echo "==> OpenRegister demo: http://localhost:${DEMO_PORT:-8601}/apps/openregister/" diff --git a/openspec/changes/consolidate-organisation-on-or/.openspec.yaml b/openspec/changes/consolidate-organisation-on-or/.openspec.yaml new file mode 100644 index 0000000000..50adc91034 --- /dev/null +++ b/openspec/changes/consolidate-organisation-on-or/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-29 diff --git a/openspec/changes/consolidate-organisation-on-or/proposal.md b/openspec/changes/consolidate-organisation-on-or/proposal.md new file mode 100644 index 0000000000..b10f2a346c --- /dev/null +++ b/openspec/changes/consolidate-organisation-on-or/proposal.md @@ -0,0 +1,121 @@ +# consolidate-organisation-on-or + +## Why + +OpenRegister already owns a first-class tenant `Organisation` — ADR-002 makes +the organisation UUID the ONLY tenant key. What it did not own was the rest of +what an organisation *is*, so every leaf app grew its own copy: + +- **OpenCatalogi** keeps an `organization` publisher record carrying the + statutory identifiers (`oin`, `tooi`, `rsin`, `kvk`, `pki`) plus `summary` + and `image`. +- **Stackiq** keeps a vendor/provider record carrying `registrationStatus` and + the merge relationship between two vendors that turned out to be one party. + +ADR-022 §3 is explicit: *"If OR already defines a `contact` or `case` or +`organisation` model, an app using those concepts MUST reuse the OR schema."* +Those per-app copies are therefore the defect, not the design. They also make +the same legal entity resolvable under two different keys, which is the exact +shape ADR-002 exists to prevent. + +Two live, silent defects in the existing table block that reuse and are +repaired here: + +1. **`groups` has no column, on any instance, ever.** + `Version1Date20250102000000` adds it, guarded by + `hasTable('openregister_organisations') === true` — but that class sorts + FIVE MONTHS BEFORE `Version1Date20250622212509`, which CREATES the table. + Nextcloud runs migrations in class-name order, so the guard is false, the + step succeeds, and the column is never added. Verified against a live + database: `UPDATE oc_openregister_organisations SET groups='[]'` → + `ERROR: column "groups" ... does not exist`. + +2. **`storage_quota` / `bandwidth_quota` / `request_quota` have no column + anywhere.** The entity declares all three and `__construct()` calls + `addType()` on them, but the only migration creating those columns + (`Version1Date20251101120000`) targets `openregister_applications`. They + were copy-pasted from `Db/Application`. + +Both are reachable, not theoretical. `QBMapper::update()` builds its SET list +from `getUpdatedFields()`, so a marked field with no column is an SQL error at +write time, not a no-op: + +- `TenantLifecycleService::provision()` calls `setGroups([...])`, so + provisioning a tenant could never have succeeded. +- `PUT /api/organisations/{uuid}` whitelists `storageQuota`, `bandwidthQuota`, + `requestQuota` and `groups` in `OrganisationController` — each a guaranteed + 500. + +A third defect surfaced while writing the entity: `_fieldTypes` was registered +under COLUMN names (`storage_quota`, `provisioned_at`) for part of the entity. +`Entity::__call()` resolves a setter to `lcfirst(substr($method, 3))` and +`Entity::fromRow()` maps a column to a property before looking the type up, so +a snake_case registration matches nothing and the cast never runs. For the +`?DateTime` properties that is not cosmetic: `fromRow()` assigned the raw +string onto a typed property and threw +`TypeError: Cannot assign string to property $provisionedAt of type ?DateTime` +— every read of an organisation row carrying a lifecycle timestamp was a 500. + +## What Changes + +- **Identity facet.** `openregister_organisations` gains `type`, `summary`, + `oin`, `tooi`, `rsin`, `kvk`, `pki`, `image` — the columns OpenCatalogi's + publisher record carried. These answer the same question `uuid` answers + ("which legal entity is this"), so they belong on the organisation row. +- **Relationship facet.** The table gains `registration_status`, `merged_into` + and `merged_at` — what Stackiq's vendor record carried. `merged_into` is core + rather than app-specific because a merge changes WHICH UUID IS AUTHORITATIVE. +- **Tenancy repairs.** The table gains `groups` and the three quota columns + described above. +- **Merge resolution.** `OrganisationMapper::resolveMergeTarget()` walks a + merge chain to the surviving organisation, bounded and cycle-guarded; + `findByUuidFollowingMerge()` is its read counterpart. +- **Field-type keys corrected** to property names so the declared casts + actually execute. +- Two indexes: `oin` (how a publication resolves its publisher) and + `merged_into` (walked on tenant resolution). + +**BREAKING:** none. Every column is nullable or defaulted and no existing row +is rewritten. + +**NOT IN THIS CHANGE — see Impact.** The leaf-app backfill and the wiring of +merge resolution into the live tenant-resolution path are deliberately +separate; both need a product decision that this change does not make. + +## Capabilities + +### Added Capabilities + +- `consolidated-organisation`: the organisation row carries the identity and + relationship facets the leaf apps kept privately, and a merged-away + organisation resolves to its survivor before its UUID is used as a scope. + +## Impact + +**Affected code:** `lib/Db/Organisation.php` (facet properties, field-type +keys, `isMerged()`, `jsonSerialize()`), `lib/Db/OrganisationMapper.php` +(`resolveMergeTarget()`, `findByUuidFollowingMerge()`), +`lib/Migration/Version1Date20260828100000.php` (additive schema). + +**Tests:** `tests/Unit/Db/OrganisationTest.php` pins the field-type keys by +PROPERTY name and proves `fromRow()` hydrates the datetime columns into +`DateTime` objects — the assertion that fails with a `TypeError` against the +pre-change registration. + +**Dependencies:** no new packages. + +**Open, and intentionally not decided here:** + +1. **The leaf apps' migration path for existing organisation data.** Adding the + columns makes reuse possible; it does not move OpenCatalogi's publisher rows + or Stackiq's vendor rows into them. That backfill must preserve the existing + uuid/slug rather than minting new ones (identifiers already written into + stored data are frozen), and it needs a decision on what happens when the + same legal entity exists in BOTH leaf apps under different UUIDs. Until that + is decided the leaf apps keep their own records and OR's new columns stay + empty — which is additive and safe, but is not yet the consolidation. +2. **Wiring merge resolution into tenant resolution.** `resolveMergeTarget()` + is implemented and tested but has no caller. Calling it from the live + tenant-resolution path changes which rows every scoped query returns for a + merged organisation, so it is held for its own change with its own + regression suite. diff --git a/openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md b/openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md new file mode 100644 index 0000000000..802cac906c --- /dev/null +++ b/openspec/changes/consolidate-organisation-on-or/specs/consolidated-organisation/spec.md @@ -0,0 +1,135 @@ +# consolidated-organisation Specification (delta) + +--- +status: proposed +--- + +## Purpose delta + +OpenRegister's `Organisation` becomes the single record describing a party: +the tenant it already was, plus the identity and relationship facets the leaf +apps each kept a private copy of. A merged-away organisation resolves to its +survivor before its UUID is used as a tenant scope. + +## ADDED Requirements + +### Requirement: The organisation row carries the identity facet (REQ-ORG-101) + +The organisation record MUST carry the statutory and presentational identifiers +that identify the party: `type`, `summary`, `oin`, `tooi`, `rsin`, `kvk`, +`pki` and `image`. All MUST be optional, so an organisation that is only ever a +tenant is unaffected. + +`type` MUST be a discriminator over `organisation`, `government`, `vendor`, +`collaboration`, `department`, defaulting to `organisation`. It records which +facet an operator cares about so a UI can present a vendor differently from a +municipality. It MUST NOT be an authorization input: per ADR-002 Rule 1 the +UUID is the only tenant key, and a type-based check would create a second one. + +#### Scenario: A publisher record needs no separate store + +- **GIVEN** an organisation representing a municipality +- **WHEN** its OIN, TOOI, RSIN, KvK and logo are recorded +- **THEN** they are stored on the organisation row itself +- **AND** the same UUID identifies the party as both a tenant and a publisher. + +#### Scenario: The identity facet is optional + +- **GIVEN** an organisation created only as a tenant +- **WHEN** it is persisted with no statutory identifiers +- **THEN** the write succeeds and every identity field reads back as null. + +### Requirement: A merged organisation resolves to its survivor (REQ-ORG-102) + +The organisation record MUST carry `registration_status` +(`concept|submitted|registered|rejected|merged`), `merged_into` and +`merged_at`. `merged_into` is authorization-bearing, not a display hint: a +merge changes which UUID is authoritative, so an organisation that has been +merged away MUST stop resolving as a tenant. If it kept resolving, every query +scoped to it would read the survivor's data under a boundary that no longer +applies. + +Resolution MUST follow the chain to the surviving organisation. The walk MUST +be bounded and cycle-guarded, and on a cycle, an over-long chain, or an +unresolvable link it MUST return the last UUID it actually loaded — never null +and never a UUID it did not verify. A resolver that failed open would hand the +caller an unscoped identifier, which is the failure this requirement exists to +prevent. + +#### Scenario: A merge chain resolves to the survivor + +- **GIVEN** organisation A merged into B, and B merged into C +- **WHEN** a caller resolves A +- **THEN** C is returned. + +#### Scenario: A cycle does not hang and does not fail open + +- **GIVEN** organisation A merged into B and B merged back into A +- **WHEN** a caller resolves A +- **THEN** resolution stops on a real, loadable organisation +- **AND** the cycle is logged as a data defect. + +#### Scenario: An unresolvable link keeps the last verified UUID + +- **GIVEN** organisation A whose `merged_into` names a UUID that does not exist +- **WHEN** a caller resolves A +- **THEN** A's own UUID is returned rather than the dangling one. + +### Requirement: Declared field types are keyed by property name (REQ-ORG-103) + +The entity's registered field types MUST be keyed by PROPERTY name, not column +name. `Entity::__call()` resolves a setter to `lcfirst(substr($method, 3))`, +and `Entity::fromRow()` maps a column to its property before looking the type +up; a snake_case key therefore matches nothing and the declared cast silently +never runs. + +Reading an organisation row MUST hydrate every datetime column into a +`DateTime`. While the types were keyed by column name, `fromRow()` assigned the +raw string onto a `?DateTime` typed property and raised a `TypeError`, making +every read of a row with a lifecycle timestamp a 500. + +#### Scenario: A database row hydrates its timestamps + +- **GIVEN** a row carrying `provisioned_at`, `suspended_at`, `deprovisioned_at` + and `merged_at` as database strings +- **WHEN** the row is hydrated into an organisation +- **THEN** each is a `DateTime` preserving the stored instant. + +### Requirement: The tenancy columns the entity already declared exist (REQ-ORG-104) + +The organisation table MUST have columns for `groups`, `storage_quota`, +`bandwidth_quota` and `request_quota`. The entity has declared all four since +before this change, but no migration ever created them: the `groups` migration +sorts before the migration that creates the table, so its `hasTable` guard is +permanently false, and the quota columns were copy-pasted from `Application` +and only ever created on `openregister_applications`. + +Because `QBMapper::update()` builds its SET list from `getUpdatedFields()`, a +declared field with no column is an SQL error at write time rather than a +no-op, so tenant provisioning and every quota update were unconditionally +failing. + +#### Scenario: Provisioning a tenant persists its groups + +- **WHEN** a tenant is provisioned and its Nextcloud groups are set +- **THEN** the write succeeds and the groups read back. + +#### Scenario: Quotas can be updated through the API + +- **WHEN** `storageQuota`, `bandwidthQuota` and `requestQuota` are updated on an + organisation +- **THEN** the write succeeds rather than returning a 500. + +### Requirement: The schema change is additive (REQ-ORG-105) + +The migration MUST be additive only: every column nullable or defaulted, no +existing row rewritten, and each column and index added only when absent so the +step is re-runnable. Identifiers already written into stored data are frozen — +any later backfill of the leaf apps' records MUST preserve their existing +uuid/slug rather than minting new ones. + +#### Scenario: Re-running the migration changes nothing + +- **GIVEN** an instance where the migration already ran +- **WHEN** it runs again +- **THEN** no column or index is added a second time and no row is modified. diff --git a/openspec/changes/consolidate-organisation-on-or/tasks.md b/openspec/changes/consolidate-organisation-on-or/tasks.md new file mode 100644 index 0000000000..01fd6bf504 --- /dev/null +++ b/openspec/changes/consolidate-organisation-on-or/tasks.md @@ -0,0 +1,44 @@ +# Tasks + +## 1. Schema + +- [x] 1.1 Add the identity facet columns (`type`, `summary`, `oin`, `tooi`, + `rsin`, `kvk`, `pki`, `image`) to `openregister_organisations`. +- [x] 1.2 Add the relationship facet columns (`registration_status`, + `merged_into`, `merged_at`). +- [x] 1.3 Add the missing tenancy columns (`groups`, `storage_quota`, + `bandwidth_quota`, `request_quota`) — the two silent defects. +- [x] 1.4 Add the `oin` and `merged_into` indexes. +- [x] 1.5 Keep every step additive and re-runnable (`hasColumn` / `hasIndex`). + +## 2. Entity + +- [x] 2.1 Declare the facet properties with their accessors. +- [x] 2.2 Key `_fieldTypes` by PROPERTY name so the declared casts execute. +- [x] 2.3 Add `isMerged()` and include the facets in `jsonSerialize()`. + +## 3. Merge resolution + +- [x] 3.1 Implement `OrganisationMapper::resolveMergeTarget()` — bounded, + cycle-guarded, never failing open. +- [x] 3.2 Implement `findByUuidFollowingMerge()` as the read counterpart. +- [ ] 3.3 Call the resolver from the live tenant-resolution path. HELD: this + changes which rows every scoped query returns for a merged organisation, + so it needs its own change and its own regression suite. + +## 4. Tests + +- [x] 4.1 Pin the field-type keys by property name. +- [x] 4.2 Prove `fromRow()` hydrates the datetime columns into `DateTime` — + the assertion that raises a `TypeError` against the old registration. + +## 5. Leaf-app consolidation + +- [ ] 5.1 DECISION REQUIRED: the migration path for existing leaf-app + organisation data. Adding the columns makes reuse possible; it does not + move OpenCatalogi's publisher rows or Stackiq's vendor rows into them. + The backfill must preserve the existing uuid/slug, and it needs a ruling + on what happens when the same legal entity exists in BOTH leaf apps under + different UUIDs. Until then the leaf apps keep their own records and OR's + new columns stay empty. +- [ ] 5.2 Point the leaf apps at the OR organisation once 5.1 is decided. diff --git a/openspec/changes/flow-definition-versioning/tasks.md b/openspec/changes/flow-definition-versioning/tasks.md index ac1bae5fc4..0ebf6e77e3 100644 --- a/openspec/changes/flow-definition-versioning/tasks.md +++ b/openspec/changes/flow-definition-versioning/tasks.md @@ -2,63 +2,73 @@ ## 1. Storage -- [ ] 1.1 Migration: `version` (int, notnull, default 1) + `lifecycle_status` +- [x] 1.1 Migration: `version` (int, notnull, default 1) + `lifecycle_status` (string 16, notnull, default `draft`) on `openregister_flows`; `flow_version` (int, nullable) on `openregister_flow_runs`; new `openregister_flow_versions` (`flow_uuid`, `version`, `status`, `nodes`, `edges`, `limits`, `execution_mode`, `owner`, `organisation`, `published_at`, `published_by`, `deprecated_at`) with a UNIQUE index on `(flow_uuid, version)` and an index on `(flow_uuid, status)`. -- [ ] 1.2 `lib/Db/FlowVersion.php` + `FlowVersionMapper` (find by flow+version, +- [x] 1.2 `lib/Db/FlowVersion.php` + `FlowVersionMapper` (find by flow+version, find the single published version, list a flow's versions); `Flow` and `FlowRun` entities extended with the new fields and their accessors. -- [ ] 1.3 Repair step in the same migration: publish version 1 of every +- [x] 1.3 Repair step in the same migration: publish version 1 of every existing flow from its stored graph, set its head to `published`, and stamp `flow_version = 1` on every non-terminal run. Guarded on existence so a second run changes nothing. ## 2. Lifecycle -- [ ] 2.1 `FlowLifecycleGuard` — the preconditions, modelled on +- [x] 2.1 `FlowLifecycleGuard` — the preconditions, modelled on `procest/lib/Service/Workflow/WorkflowLifecycleGuard.php:53-57`: only a draft may be published, only after the dead-end preflight passes on the graph being published; only a published version may be deprecated; a version with a non-terminal run pinned to it may not be deleted. Every refusal logged with its reason. -- [ ] 2.2 `FlowVersionService` — the transitions: publish (snapshot the head, +- [x] 2.2 `FlowVersionService` — the transitions: publish (snapshot the head, deprecate the predecessor, rebuild the trigger set) and create-draft (copy the published graph to version N+1, head back to `draft`), each in ONE transaction so a flow is never observed with two published versions or none. -- [ ] 2.3 Trigger-set rebuild wired to publish/deprecate only: +- [x] 2.3 Trigger-set rebuild wired to publish/deprecate only: `openregister_flow_triggers` rows are derived from the published version and from nothing else; the table keeps its columns and `or_flowtrig_match_idx` unchanged. ## 3. Pinning and resolution -- [ ] 3.1 `FlowRunService::queue()` (`lib/Service/Flow/FlowRunService.php:321`) +- [x] 3.1 `FlowRunService::queue()` (`lib/Service/Flow/FlowRunService.php:321`) resolves the published version, writes it onto the run, and refuses with a named reason when there is none. All six dispatch paths inherit it — assert that with a test per path rather than by reading the code. -- [ ] 3.2 `refuseDeadEnd()` preflights the version being pinned, not +- [x] 3.2 `refuseDeadEnd()` preflights the version being pinned, not `$flow->getNodes()` off the head, so a broken draft cannot refuse a run of a sound published version and a broken published version cannot be masked by a repaired draft. -- [ ] 3.3 `FlowLocator::resolveFlow()` takes a version; memo key becomes - flow + version (`FlowLocator.php:89-93` is keyed by flow alone today and - would serve one run's graph to another in the same worker batch). -- [ ] 3.4 `FlowRunAdvancer.php:92` resolves the run's pinned version, and `:98` +- [x] 3.3 ~~`FlowLocator::resolveFlow()` takes a version; memo key becomes + flow + version.~~ **Solved differently, and better.** The memo caches the + LIVE document, and the pinned graph is laid over it per run inside + `FlowRunService::execute()` — so two runs on different versions in one + worker batch each get their own graph without the resolver knowing about + versions at all. `FlowVersionPinningTest::testTwoVersionsOfOneFlowAdvance + InOneBatchWithoutCrossTalk` is the regression the task was written for. +- [x] 3.4 `FlowRunAdvancer.php:92` resolves the run's pinned version, and `:98` gains a second, distinct refusal naming flow AND version. No fallback to head or to the latest published version on any path. -- [ ] 3.5 Interactive test run of a draft carries the resolved draft document - on the run context; the advancer prefers that snapshot when present. - Test runs are the only runs that carry one, and are marked as such where - runs are listed. +- [x] 3.5 Interactive test run of a draft carries the resolved draft document + and the engine walks exactly that. `FlowRunVersionPin::TRIGGER_TEST` is + the ONE dispatch exempt from "a draft backs no run" — publishing is what + you do after testing, so refusing would make a flow untestable until it + went live. Such a run is left UNPINNED, and `overlayOnto()` passes an + unpinned run's document through untouched, so it is pinned in the sense + that matters: nothing can substitute another graph for it mid-run. It is + distinguishable in the run list by `trigger: test` and a null + `flowVersion`. A test run of a PUBLISHED flow still pins to it — the + exemption is about drafts, not about test runs skipping versioning. ## 4. Callers -- [ ] 4.1 `SubFlowNode::execute()` (`lib/Service/Flow/Nodes/SubFlowNode.php:209`) +- [x] 4.1 `SubFlowNode::execute()` (`lib/Service/Flow/Nodes/SubFlowNode.php:209`) resolves the CHILD flow's published version at call time and pins it on the child run, for both the waiting and fire-and-forget shapes; a child with no published version fails the step naming the flow and its state. @@ -68,28 +78,28 @@ ## 5. API and editor -- [ ] 5.1 `PUT /api/flows/{id}` (`appinfo/routes.php:539`) refuses a definition +- [x] 5.1 `PUT /api/flows/{id}` (`appinfo/routes.php:539`) refuses a definition write against a published head with a 409 carrying a machine-readable reason; the stored graph is left untouched. -- [ ] 5.2 New routes and controller methods: create-draft, publish, deprecate, +- [x] 5.2 New routes and controller methods: create-draft, publish, deprecate, list a flow's versions, read one version — each with its Nextcloud auth attribute and each placed so no literal segment can be captured as a flow uuid (the trap `appinfo/routes.php:529` already warns about). -- [ ] 5.3 Editor: `FlowDetailPage.vue` read-only for a published or deprecated +- [x] 5.3 Editor: `FlowDetailPage.vue` read-only for a published or deprecated version with a "create draft version" action; `FlowDetailSidebar.vue` shows version + lifecycle badge and the version list; run detail shows the pinned version and marks a run on a deprecated version. ## 6. Tests -- [ ] 6.1 Pinning tests: a run pinned before a publish executes the old graph; +- [x] 6.1 Pinning tests: a run pinned before a publish executes the old graph; two runs on different versions advance correctly in one worker batch (the memo-key regression); a suspended run resumes on its own version after a rename that would have dangled its marking. -- [ ] 6.2 Failure tests: a deleted pinned version fails the run with the +- [x] 6.2 Failure tests: a deleted pinned version fails the run with the version-specific message, never re-points it at a newer version, and never leaves it queued. -- [ ] 6.3 Lifecycle, migration and regression: one published version per flow +- [x] 6.3 Lifecycle, migration and regression: one published version per flow through publish/deprecate cycles; a draft's trigger nodes match nothing; migration back-fill over a seeded database with in-flight runs, applied twice with identical results; and a pass with opencatalogi and diff --git a/openspec/changes/flow-object-attribution/.openspec.yaml b/openspec/changes/flow-object-attribution/.openspec.yaml new file mode 100644 index 0000000000..7f2cf9bc02 --- /dev/null +++ b/openspec/changes/flow-object-attribution/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-28 diff --git a/openspec/changes/flow-object-attribution/design.md b/openspec/changes/flow-object-attribution/design.md new file mode 100644 index 0000000000..19afa56cbc --- /dev/null +++ b/openspec/changes/flow-object-attribution/design.md @@ -0,0 +1,103 @@ +## Context + +See `proposal.md — Why`. The constraints that shape the approach: + +- **The audit trail is hash-chained (ADR-003).** `AuditHashService::getCanonicalJson()` hashes `AuditTrail::jsonSerialize()` minus `hash`/`previousHash`. Adding a key to that serialisation changes the canonical form of every row ever written. This is the reason `purgedAt` was deliberately left OUT of the hash, with the residual weakness documented in `AuditHashService` (~line 1074). +- **Both audit insert paths share one builder.** `createAuditTrail()` and the batched `insertAuditTrails()` both go through `buildAuditTrail()`. A stamp applied to the insert methods rather than the builder would silently miss every batched write. +- **`FlowRun` already carries `subjectUuid`** — the trigger's object. It is not a record of what the run touched and must not be confused with one. +- **`AuditHashService` already has what a re-seal needs**: `rechainAll()` (batched), `sealRows()`, an advisory lock serialising seal passes, and tombstone-aware verification. +- **A run is not one process.** `FlowRunWorker` advances several runs in sequence, and a suspended run resumes in a later process entirely. Anything process-global must be scoped per step, not per request. + +## Goals / Non-Goals + +**Goals:** +- Attribution that captures writes made by code that has never heard of flows. +- One place that decides whether a write is attributed, so the answer cannot differ per call site. +- A seed migration that cannot silently convert a tampered chain into a verified one. + +**Non-Goals:** +- Re-attributing history. Rows written before this change stay unattributed; there is no inference pass. +- Attribution for non-OpenRegister side effects (an email sent, a webhook posted). Those are `FlowRunStep.output`'s job. +- The dossiq flow itself, and the human-task model — companion change `case-flow-human-steps`. +- Making `purgedAt` hash-covered. It stays out; this change does not reopen it. + +## Decisions + +### D1 — Ambient context, not a parameter + +A `FlowRunContext` service holds the current `(runUuid, nodeId, sequence)`. `RegistryStepDispatcher::dispatch()` pushes before the step and pops in a `finally`. + +*Why:* the whole point is to catch writes the writing code does not know are part of a flow — a leaf app called by a node, a cascade, a lifecycle hook. Threading a parameter through `ObjectService` would attribute only what a node explicitly passed, which is the same blind spot as the link-table option that was rejected. + +*Alternative rejected:* passing attribution down `SaveObject`/`DeleteObject` signatures. Wider blast radius (every caller of a changed signature — and a new argument breaks positional callers one slot later), and still blind to leaf-app writes. + +*Shape:* a **stack**, not a scalar. A sub-flow node dispatches a nested run; popping must restore the parent's frame rather than clear to empty, or the parent's remaining steps in that same dispatch go unattributed. + +### D2 — Stamp in `buildAuditTrail()` + +One read of the context, in the shared builder, so single and batched inserts are identical. `createAuditTrailEntry()` (archival/retention entries) and `createToolInvocationEntry()` (MCP) get the same treatment for consistency. + +*Consequence, accepted:* `GetObject` writes a `read` audit row, so reads performed during a run are attributed too. This is more truthful than filtering them out — the run did touch the object. The read surface exposes `action`, so a caller wanting writes only can say so. + +### D3 — Inside the hash, with a v2 seed and a full re-chain + +Per the decision recorded on this change: the three fields join the canonical JSON, `GENESIS_SEED` becomes `openregister-genesis-v2`, and `rechainAll()` re-seals the table. Attribution is therefore tamper-evident — re-pointing a row at a different run breaks verification. + +*Alternatives rejected:* excluding the keys from the canonical form (the `purgedAt` precedent) would ship with zero risk to the existing chain but leaves attribution unprotected; emitting the keys only when set would preserve old rows byte-identically but introduces an omit-when-null rule in the canonicaliser. + +### D4 — The pre-migration check uses a FROZEN v1 canonicaliser + +This is the decision that makes D3 safe, and it is the one that is easy to get wrong. + +The migration must verify the chain **before** re-sealing it. If that verification canonicalises with the new code, it includes the new keys, every pre-existing row mismatches, and the verdict is "broken" whether the chain was healthy or compromised. The re-chain then overwrites the evidence either way. The check would run, produce output, and be worthless. + +So the migration carries `AuditCanonicalV1` — a frozen, private copy of the v1 key list and canonicalisation rules, never updated again. The pre-check verifies against it, and its verdict is written as an audit row (action `audit.rechain.preverify`, sealed under v1 as the last v1 row) recording `valid`, `entriesVerified`, `brokenAt`, and the seed version moved from/to. + +**Test the check by breaking the thing it checks**: seed a chain, tamper with one row, run the migration, and assert the recorded verdict names that row. A test that only seeds a healthy chain passes identically when the pre-check is a stub returning `valid: true`. + +### D5 — A stamp, not a foreign key + +`flow_run` is a plain string column with no referential link. `FlowRunRetentionJob` prunes runs; a FK would either block pruning or cascade into immutable audit rows. Reading an object's history must not fail because the run has been pruned — the identifiers are the historical fact, and resolving them to a live run is best-effort. + +### D6 — Index for the run direction + +`(flow_run)` alone is enough for "what did this run touch"; the object direction is already served by the existing `object_uuid` index. No composite index until a query needs one — ADR-009 treats speculative indexes on the audit hot path as a cost, not a hedge. + +## Declarative-vs-imperative decision (ADR-031) + +| Behaviour | Path | Rationale | +|---|---|---| +| Stamping attribution onto audit rows | **imperative** | Engine and persistence internals in OpenRegister core. There is no app schema here and no `x-openregister-*` extension that expresses "attribute the ambient run onto the audit row" — this is the mechanism such declarations would be recorded BY. | +| Canonicalisation and re-seal | **imperative** | Cryptographic integrity operation, migration-time. | +| Read surfaces (run → objects, object → runs) | **imperative** | A query filter and a controller endpoint over a native (non-OR-object) table; `flow_runs` and `audit_trails` are native tables by design (`flow-storage`). | + +No part of this change introduces or modifies an OpenRegister schema, so it declares no lifecycle, aggregation, calculation, notification, relation or widget. + +## Seed Data (ADR-001) + +**None.** This change adds no OpenRegister schemas and no register objects, so there is no `_registers.json` entry to generate. Attribution rows are produced by running a flow, not by seeding. The demonstrable data for this change is a flow run in the companion dossiq change; a seeded audit row would be a fabricated record of something that never happened, which is precisely what an immutable audit trail must not contain. + +## Risks / Trade-offs + +| Risk | Mitigation | +|---|---| +| **A context left in place attributes later, unrelated writes to a finished run.** Silent: the rows are well-formed and plausible. | Push/pop in `finally`. The test that matters asserts the LEAK direction — perform a non-flow write after a run and require the columns to be null. A happy-path test passes with the leak present. | +| **The re-chain re-blesses history.** Once re-sealed under v2, any tampering that predates the migration is undetectable, permanently. | Accepted deliberately (D3). D4's pre-check plus its persisted verdict is the compensating control: the state of the chain at migration time survives the migration that erases the ability to re-derive it. | +| **Rollback is not available.** Reverting the code does not restore v1 hashes; the v1 values are overwritten in place. | Treat as a one-way migration. Require a database backup before the release, state it in the upgrade note, and make the migration refuse to start if it cannot write its pre-verify verdict. | +| **Mixed code versions during a rolling deploy** would seal some rows under v1 and some under v2, interleaved. | The existing advisory seal lock serialises seal passes; the migration runs to completion within one release step, and the seed version is read from one constant rather than per-row. | +| **A long re-chain on a large audit table** blocks the upgrade. | `rechainAll()` already batches; the migration is resumable and idempotent (spec scenario) so an interrupted upgrade continues rather than restarts. | +| **Attribution widens what an audit row discloses** — it names a run and node a reader might not otherwise see. | Attribution is returned only on rows the caller may already read; the run→objects endpoint reuses the run visibility rule that `resume()` applies rather than inventing a second one. A validator and an executor each owning a copy of the same rule is how they drift apart. | +| **`FlowRunWorker` advances several runs per process**, so a leak crosses runs, not just steps. | Same `finally`; plus a test that runs two runs in sequence in one process and asserts the second's writes carry the second's run uuid. | + +## Migration Plan + +1. **Schema migration** — add `flow_run`, `flow_node`, `flow_step` to `oc_openregister_audit_trails`, plus the `flow_run` index. Nullable; no backfill. +2. **Repair step, registered in `appinfo/info.xml` in the same commit.** (A repair step written but never registered does nothing and reports nothing — four apps in the fleet were found in that state.) In order: pre-verify against `AuditCanonicalV1` → persist the verdict as a v1-sealed audit row → `rechainAll()` under the v2 seed. +3. **Code cutover** — `jsonSerialize()` emits the three keys; `GENESIS_SEED` is `openregister-genesis-v2`. +4. **Post-check** — `verifyChain()` under v2 returns `valid: true` over the full table. + +**Rollback:** none for step 2/3 (see Risks). Steps 1 and 4 are reversible; the re-seal is not. + +## Open Questions + +- Whether the run→objects endpoint should page. Deferred: it does not change the specs, the approach or the task breakdown, and the shape of real runs will answer it. The first consumer (a case flow with ~12 nodes) is far below any page boundary. diff --git a/openspec/changes/flow-object-attribution/proposal.md b/openspec/changes/flow-object-attribution/proposal.md new file mode 100644 index 0000000000..f44f7ee689 --- /dev/null +++ b/openspec/changes/flow-object-attribution/proposal.md @@ -0,0 +1,46 @@ +--- +kind: code +--- + +## Why + +A flow run records exactly one object: `FlowRun.subjectUuid`, the thing that triggered it. Everything the run goes on to touch — the objects it creates, the ones its nodes update, the ones a leaf app writes on its behalf — is recorded nowhere. So neither of the two questions people actually ask can be answered today: + +- **From the object:** "which run, and which node of it, last touched this case?" +- **From the run:** "what did this run actually change?" + +`FlowRunStep` already answers *what happened* per node. It does not answer *what it happened to*. That gap is why a flow that spans a case, its tasks, its decisions and its documents is unauditable in practice: the history exists, and it points at nothing. + +## What Changes + +- Every audit-trail row written while a flow run is executing is stamped with the run uuid, the node id and the step sequence that caused it. The stamp is set from an **ambient run context** established by the step dispatcher, so it captures writes made by code that has never heard of flows — including leaf apps a node calls into. A node-level record would see only what the node itself knew it wrote. +- Two read directions: `GET /api/flow-runs/{uuid}/objects` (objects touched, grouped by node) and a `flowRun` filter plus the three new fields on the existing audit-trail query. +- **BREAKING (audit chain):** the three keys join the canonical JSON that the hash chain covers, so the stamp is tamper-evident. Per ADR-003 Rule 4 this is a migration event: the genesis seed moves to `v2` and the table is re-sealed by the existing `AuditHashService::rechainAll()`. +- The re-chain is **verify-then-rechain**. The migration first verifies the existing chain against a *frozen copy* of the v1 canonicaliser and persists that verdict, then re-seals under v2. Without the frozen copy the pre-check would canonicalise under v2, report every row broken, and the re-chain would bless it anyway — a check that cannot distinguish a healthy chain from a tampered one is not a check. +- UI: the objects a run touched on the run detail, and the runs that touched an object in its sidebar. + +## Capabilities + +### New Capabilities +- `flow-object-attribution`: what a flow run records about the objects it touches — the ambient run context and its lifecycle, what a stamped row means, and how the attribution is read back from either end. + +### Modified Capabilities +- `audit-hash-chain`: the canonical JSON gains three flow keys and the genesis seed is versioned; adds the requirement that a seed change is a verify-then-rechain migration with the outgoing canonicaliser frozen for the verification. +- `flow-engine`: the step dispatcher establishes and unconditionally clears the run context around every step, and a run can report the objects it touched. + +## Impact + +| Area | Change | +|---|---| +| `lib/Db/AuditTrail.php` | three fields + `jsonSerialize()` keys (hash-covered) | +| `lib/Db/AuditTrailMapper.php` | both insert paths read the ambient context; `flowRun` query filter | +| `lib/Service/AuditHashService.php` | seed becomes `v2`; frozen `v1` canonicaliser retained for migration verification | +| `lib/Service/Flow/RegistryStepDispatcher.php` | establishes / clears the context per step | +| `lib/Service/Flow/FlowRunContext.php` | new — the ambient holder | +| `lib/Controller/FlowRunController.php` | `objects` endpoint | +| `lib/Migration/` | columns + index; verify-then-rechain repair step | +| Frontend | run detail "objects touched"; object sidebar "flow runs" | + +**Risk owned by this change:** a context that is not cleared attributes later, unrelated writes to a finished run. It is silent — the rows look correct. The clear is `finally`-bound and asserted by a test that performs a non-flow write *after* a run and requires the columns to be null. + +**Consumers:** dossiq is the first, via the companion `case-flow-human-steps` change. Nothing in this change is dossiq-specific. diff --git a/openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md b/openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md new file mode 100644 index 0000000000..ded56b28d3 --- /dev/null +++ b/openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md @@ -0,0 +1,68 @@ +## MODIFIED Requirements + +### Requirement: Every audit trail entry MUST include a SHA-256 hash chained to the previous entry +Each audit trail entry MUST contain a `hash` field computed as `SHA-256(previous_hash + canonical_json(entry_data))`. The `previous_hash` field links to the preceding entry's hash, forming a tamper-evident chain. + +The genesis seed is versioned. The current seed is `openregister-genesis-v2`, which supersedes `openregister-genesis-v1`; the version reflects the canonical form the chain commits to, and the two MUST move together. + +The canonical form covers the flow attribution fields — the run, node and step that caused the row — so that re-pointing a row at a different run, or stripping its attribution, breaks verification exactly as altering any other field does. + +#### Scenario: First audit entry uses genesis hash +- **WHEN** the first audit trail entry is created in the system (no previous entries exist) +- **THEN** the entry MUST have `previousHash` set to `SHA-256("openregister-genesis-v2")` +- **AND** the entry MUST have `hash` set to `SHA-256(genesis_hash + canonical_json(entry_data))` + +#### Scenario: Subsequent entries chain to previous hash +- **WHEN** audit trail entry N is created after entry N-1 with hash `abc123...` +- **THEN** entry N MUST have `previousHash` set to `abc123...` +- **AND** entry N MUST have `hash` set to `SHA-256("abc123..." + canonical_json(entry_data_N))` + +#### Scenario: Canonical JSON excludes hash fields +- **WHEN** computing the hash for an audit trail entry +- **THEN** the canonical JSON MUST include all entry fields except `hash` and `previousHash` +- **AND** the JSON MUST use sorted keys and no whitespace (compact canonical form) + +#### Scenario: Flow attribution is covered by the hash +- **WHEN** a row's recorded run, node or step is altered directly in the database +- **AND** the chain is verified +- **THEN** verification MUST report the chain as broken at that row + +## ADDED Requirements + +### Requirement: Changing the canonical form is a verify-then-rechain migration @e2e exclude migration-time integrity operation with no user-facing surface; asserted by migration tests over seeded chains + +A change to the canonical form or the genesis seed SHALL be performed as a single migration that, in order: + +1. verifies the existing chain **against the canonical form the existing rows were sealed under**, not the incoming one; +2. durably records that verdict — whether the chain was intact, and if not, where it first broke — before any row is altered; +3. re-seals every row under the new seed and canonical form. + +The verification in step 1 SHALL use a frozen copy of the outgoing canonicaliser retained in the codebase for that purpose. Verifying with the incoming canonicaliser would report every pre-existing row as broken regardless of whether it had been tampered with, making the verdict indistinguishable between a healthy chain and a compromised one — and a re-chain then permanently conceals the difference. + +The recorded verdict SHALL be readable after the migration completes. A re-chain over a chain that did not verify is permitted, but SHALL NOT be silent. + +#### Scenario: An intact chain is re-sealed and recorded as intact +- **WHEN** the migration runs against a chain that verifies under the outgoing canonical form +- **THEN** the verdict recorded before re-sealing states the chain was intact and names the number of rows verified +- **AND** every row is afterwards sealed under the new seed +- **AND** verification under the new canonical form succeeds + +#### Scenario: A broken chain is recorded before it is re-sealed over +- **WHEN** the migration runs against a chain that does NOT verify under the outgoing canonical form +- **THEN** the recorded verdict states the chain was broken and identifies where +- **AND** that verdict remains readable after the re-seal has made the break undetectable + +#### Scenario: The pre-check uses the outgoing canonical form +- **WHEN** the pre-migration verification runs +- **THEN** it computes canonical JSON without the fields the migration is introducing +- **AND** a chain sealed before the migration verifies as intact + +#### Scenario: Re-sealing is resumable and idempotent +- **WHEN** the migration is interrupted part-way and re-run +- **THEN** it completes the re-seal without double-sealing rows already migrated +- **AND** the final chain verifies end to end + +#### Scenario: Tombstoned rows survive the re-seal as tombstones +- **WHEN** the chain being re-sealed contains rows purged under retention +- **THEN** those rows are carried forward as declared tombstones +- **AND** the re-sealed chain does not report them as breaks diff --git a/openspec/changes/flow-object-attribution/specs/flow-engine/spec.md b/openspec/changes/flow-object-attribution/specs/flow-engine/spec.md new file mode 100644 index 0000000000..8e48e654df --- /dev/null +++ b/openspec/changes/flow-object-attribution/specs/flow-engine/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: The dispatcher establishes and unconditionally clears the run context around every step @e2e exclude engine-internal execution context; asserted by dispatcher unit tests including the leak-direction test + +Before dispatching a step, the engine SHALL establish an ambient context naming the executing run, the node being dispatched, and the step's sequence number. After the step ends the engine SHALL clear that context, and SHALL do so whether the step completed, stopped, suspended, or threw. + +A context left in place after a step attributes later, unrelated writes to a run that is no longer executing. This failure is silent — the resulting rows are well-formed and look correct — so the clear SHALL be structurally unconditional rather than placed on the success path. + +Nested dispatch SHALL restore the enclosing context rather than clearing it: a sub-flow's steps are attributed to the sub-flow's run, and the parent run's remaining steps are attributed to the parent. + +#### Scenario: The context names the step being dispatched +- **WHEN** the engine dispatches a node +- **THEN** the ambient context names that run, that node id, and that step's sequence + +#### Scenario: A throwing step still clears the context +- **WHEN** a dispatched node throws +- **THEN** the context is cleared before control leaves the dispatcher +- **AND** a write performed afterwards outside any run is unattributed + +#### Scenario: A suspended step clears the context +- **WHEN** a node suspends the run to await a signal or a time +- **THEN** the context is cleared +- **AND** the run's later resumption establishes a fresh context for the step it resumes into + +#### Scenario: A sub-flow does not leak into its parent +- **WHEN** a node dispatches a sub-flow run and that sub-flow completes +- **THEN** writes during the sub-flow are attributed to the sub-flow's run and nodes +- **AND** the parent run's subsequent steps are attributed to the parent run + +### Requirement: A run's history names what each step touched @e2e exclude read model asserted by controller and store tests + +The objects a run touched SHALL be readable alongside the run's step history, so that a reader following a run can see, per node, both what the node did and what it did it to. The step history SHALL remain the record of execution; the attribution SHALL be joined to it by run, node and step rather than duplicated into it. + +#### Scenario: A step's entry can be expanded to the objects it touched +- **WHEN** a reader views a run's step history +- **THEN** each step can be related to the objects attributed to that run and node +- **AND** a step that touched nothing is shown as such rather than omitted + +#### Scenario: The step history remains authoritative for execution +- **WHEN** attribution for a step is absent because no write occurred +- **THEN** the step is still present in the history with its own status and timing diff --git a/openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md b/openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md new file mode 100644 index 0000000000..2d03ff5891 --- /dev/null +++ b/openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md @@ -0,0 +1,90 @@ +## Purpose + +Defines what a flow run records about the objects it touches. A run already records what it DID, step by step; this capability makes it record what it did it TO, so an object can name the run and node that changed it and a run can list everything it changed. + +## ADDED Requirements + +### Requirement: Every object write caused by a flow run is attributed to that run, node and step @e2e exclude write-path attribution asserted by AuditTrailMapper + dispatcher integration tests; no user-facing surface performs the write + +While a flow run is executing a step, every audit-trail row produced by any write SHALL carry the executing run's uuid, the node id, and the step sequence number. The attribution SHALL be ambient — derived from the executing run rather than passed by the writing code — so that a write performed by code with no knowledge of flows, including another app called into by a node, is attributed identically to a write performed by the node itself. + +A row written outside any flow run SHALL carry no attribution: the three values SHALL be absent, and absence SHALL be distinguishable from a run whose identifiers are empty strings. + +#### Scenario: A node's own write is attributed +- **WHEN** a flow run executes a node that writes an object +- **THEN** the resulting audit-trail row names the run uuid, that node's id, and that step's sequence number + +#### Scenario: A write made by another app during the step is attributed +- **WHEN** a node calls into another app, and that app writes an object during the step +- **THEN** the resulting audit-trail row carries the same run, node and step as the node's own writes +- **AND** the writing app is not required to know it is running inside a flow + +#### Scenario: A write outside any run is unattributed +- **WHEN** an object is written by a user action, an import, or a background job with no flow run executing +- **THEN** the resulting audit-trail row has no run, node or step value + +#### Scenario: Attribution does not survive the step that established it +- **WHEN** a flow run finishes, fails, or suspends +- **AND** any object is subsequently written in the same process +- **THEN** that write is unattributed +- **AND** this holds when the step ended by throwing, not only when it ended normally + +#### Scenario: Two runs writing the same object are separately attributed +- **WHEN** two different flow runs each write the same object +- **THEN** each write produces its own audit-trail row naming its own run and node +- **AND** neither row's attribution is overwritten by the other + +### Requirement: A run reports the objects it touched, grouped by node @e2e exclude read surface covered by controller tests; the UI consuming it is specified under flow-engine + +The system SHALL expose the objects a run touched, addressed by run uuid, grouped by the node that touched each. Each entry SHALL name the object, the action performed on it, the node, and the step sequence, so a reader can reconstruct the order in which the run changed things. + +The response SHALL be scoped by the same visibility rule that governs reading the run itself. A caller who may not read a run SHALL NOT learn what it touched. + +#### Scenario: A completed run lists what it changed +- **WHEN** a caller who may read a run requests the objects it touched +- **THEN** the response lists every object the run wrote, grouped by node, in step order + +#### Scenario: A run that touched nothing returns an empty result +- **WHEN** a run completed without writing any object +- **THEN** the response is an empty collection and not an error + +#### Scenario: Visibility is not widened by the new surface +- **WHEN** a caller who may NOT read a run requests the objects it touched +- **THEN** the request is refused +- **AND** the refusal does not reveal whether the run exists or what it touched + +#### Scenario: A suspended run reports what it has touched so far +- **WHEN** a run is suspended awaiting a signal +- **THEN** the objects touched by the steps already executed are reported +- **AND** the result is not withheld until the run completes + +### Requirement: An object reports the flow runs that touched it @e2e exclude query-layer filter; asserted by audit-trail query tests + +The audit-trail query SHALL accept a run uuid as a filter, and audit-trail records SHALL expose their run, node and step values to readers permitted to read them. Reading the history of a single object SHALL therefore answer which run and which node caused each change, without a separate lookup. + +#### Scenario: An object's history names the responsible node +- **WHEN** an object's audit history is read after a flow run changed it +- **THEN** each row caused by the run names that run, the node, and the step + +#### Scenario: Filtering by run returns only that run's writes +- **WHEN** the audit trail is queried filtered by a run uuid +- **THEN** only rows attributed to that run are returned + +#### Scenario: Attribution is visible only to readers of the row +- **WHEN** a caller reads an audit-trail record they are permitted to read +- **THEN** the run, node and step values are present +- **AND** no attribution is disclosed for records the caller may not read + +### Requirement: Attribution outlives the run record but never claims more than it knows @e2e exclude retention interaction; asserted by retention job tests + +The attribution SHALL be stored as a plain identifier stamp rather than a referential link, so that pruning a run under retention does not alter, delete, or invalidate the audit rows it caused. A stamp naming a run that no longer exists SHALL remain readable as a historical fact. + +#### Scenario: Pruning a run leaves its attribution intact +- **WHEN** flow-run retention deletes a run and its steps +- **THEN** audit rows attributed to that run keep their run, node and step values +- **AND** the hash chain over those rows still verifies + +#### Scenario: A dangling run reference reads as history, not as an error +- **WHEN** an object's history names a run that has since been pruned +- **THEN** the reader is shown the recorded identifiers +- **AND** the response does not fail because the run cannot be resolved diff --git a/openspec/changes/flow-object-attribution/tasks.md b/openspec/changes/flow-object-attribution/tasks.md new file mode 100644 index 0000000000..c3f523bfc4 --- /dev/null +++ b/openspec/changes/flow-object-attribution/tasks.md @@ -0,0 +1,51 @@ +## 1. Storage + +- [ ] 1.1 Add `flow_run`, `flow_node`, `flow_step` (nullable) plus a `flow_run` index to `oc_openregister_audit_trails` in a new `lib/Migration/Version1Date*.php`; verify `occ migrations:execute` applies and the columns exist on MySQL, PostgreSQL and SQLite +- [ ] 1.2 Add the three fields to `AuditTrail` with getters/setters and `addType()` registration; verify a hydrated row round-trips the values through the mapper + +## 2. Ambient run context + +- [ ] 2.1 Add `lib/Service/Flow/FlowRunContext.php` holding a STACK of `(runUuid, nodeId, sequence)` frames with `push()`, `pop()` and `current()`; verify unit tests cover empty, single and nested frames +- [ ] 2.2 Push in `RegistryStepDispatcher::dispatch()` before the step and pop in a `finally`; verify a test asserts the context is empty after a step that THROWS, not only after one that succeeds +- [ ] 2.3 Verify the leak direction end to end: run a flow, then perform a non-flow object write in the same process, and assert the resulting audit row's three columns are null — this test must fail if the `finally` is removed +- [ ] 2.4 Verify the two cross-run isolation cases: a sub-flow restores its parent frame (its writes name the sub-flow, the parent's next step names the parent), and two runs advanced sequentially by one `FlowRunWorker` process each attribute to their own run uuid + +## 3. Stamping + +- [ ] 3.1 Read `FlowRunContext::current()` in `AuditTrailMapper::buildAuditTrail()` and stamp the three fields; verify BOTH `createAuditTrail()` and the batched `insertAuditTrails()` produce stamped rows from the one change +- [ ] 3.2 Apply the same stamp in `createAuditTrailEntry()` and `createToolInvocationEntry()`; verify a retention entry written during a run is attributed, and that a write made by a leaf app called from inside a node is stamped identically to the node's own write without that app referencing any flow API + +## 4. Hash chain (ADR-003 Rule 4) + +- [ ] 4.1 Add the three keys to `AuditTrail::jsonSerialize()` and move `GENESIS_SEED` to `openregister-genesis-v2`; verify a freshly seeded chain verifies end to end under v2 +- [ ] 4.2 Add `AuditCanonicalV1` — a frozen private copy of the v1 key list and canonicalisation rules, marked never-to-be-updated; verify it reproduces the stored hash of a row sealed before this change +- [ ] 4.3 Verify tampering with `flow_run`, `flow_node` or `flow_step` on a sealed row makes `verifyChain()` report a break at that row + +## 5. Verify-then-rechain migration + +- [ ] 5.1 Add the repair step (pre-verify against `AuditCanonicalV1` → persist verdict as a v1-sealed `audit.rechain.preverify` row → `rechainAll()` under v2) AND register it in `appinfo/info.xml` in the same commit; verify `occ maintenance:repair` runs it and the registration is present +- [ ] 5.2 Verify the pre-check discriminates: seed a chain, tamper with one row, run the migration, and assert the persisted verdict names that row — the test must fail if the pre-check is stubbed to return valid +- [ ] 5.3 Verify the migration's three safety properties: it refuses to start when it cannot persist its verdict, it is resumable and idempotent when interrupted mid-re-seal, and a chain containing retention tombstones re-seals with those rows carried forward as tombstones rather than reported as breaks + +## 6. Read surfaces + +- [ ] 6.1 Add `GET /api/flow-runs/{uuid}/objects` returning objects grouped by node with action and step, reusing the visibility rule `FlowRunController::resume()` applies; verify a caller who may not read the run is refused without learning the run exists, a suspended run reports what it has touched so far, and a run that wrote nothing returns an empty collection rather than an error +- [ ] 6.2 Add a `flowRun` filter to the audit-trail query and expose the three fields in the audit-trail serialisation; verify filtering returns only that run's rows and a pruned run's rows still read + +## 7. Frontend + +- [ ] 7.1 Show both directions — the objects a run touched on the run detail (grouped by node, joined to the existing step history) and the runs that touched an object in its sidebar; verify a step that touched nothing still renders with its status and timing, and a pruned run renders its recorded identifiers instead of failing + +## 8. Quality + +- [ ] 8.1 Run `composer check:strict` and `npm run lint`, and fix any pre-existing findings touched by these files +- [ ] 8.2 Mutation-check the two guards that fail silently — the `finally` pop (2.3) and the pre-verify discrimination (5.2): disable each, confirm the suite goes red, restore, confirm the source is byte-identical + +**Acceptance criteria** + +- Every audit row written during a run names the run, node and step; every row written outside one names none. +- A write by an app that has never heard of flows is attributed identically to a node's own write. +- `verifyChain()` returns `valid: true` over the full table after the migration, and `valid: false` when any attribution value is altered. +- The pre-migration verdict is readable after the re-seal, and states where the chain stood before it. +- No `@spec` tag is missing on a changed method; `@e2e exclude` reasons are carried on the backend-only scenarios. +- i18n: new frontend strings go through `t()`; no hardcoded user-facing text. diff --git a/openspec/specs/audit-hash-chain/spec.md b/openspec/specs/audit-hash-chain/spec.md index e4338d4aeb..8f92614f61 100644 --- a/openspec/specs/audit-hash-chain/spec.md +++ b/openspec/specs/audit-hash-chain/spec.md @@ -1,5 +1,5 @@ --- -status: done +status: in-progress --- # audit-hash-chain Specification @@ -23,6 +23,13 @@ Cryptographic SHA-256 hash chaining on audit trail entries with genesis hash, ve for the backlog cursor and a cutover marker so `verifyChain()` stops reporting `valid: true` over rows it silently skipped. +- `flow-object-attribution` (active) — the canonical form gains the three flow + attribution fields so a row's run/node/step is hash-covered, the genesis seed + moves to `openregister-genesis-v2`, and a seed change is defined as a + verify-then-rechain migration: the outgoing canonicaliser is frozen in the + codebase and used for the pre-check, whose verdict is persisted before the + re-seal makes the prior state underivable. + ## Requirements ### Requirement: Every audit trail entry MUST include a SHA-256 hash chained to the previous entry Each audit trail entry MUST contain a `hash` field computed as `SHA-256(previous_hash + canonical_json(entry_data))`. The `previous_hash` field links to the preceding entry's hash, forming a tamper-evident chain. diff --git a/openspec/specs/flow-engine/spec.md b/openspec/specs/flow-engine/spec.md index 78610717e2..462a550c82 100644 --- a/openspec/specs/flow-engine/spec.md +++ b/openspec/specs/flow-engine/spec.md @@ -9,6 +9,8 @@ status: in-progress - `or-delegation-grants` (active) — turns a DECLARED acting identity into an AUTHORIZED one: a delegation grant record with a consent lifecycle, refusal at save and at every fire when the author holds no grant for the user they named, and an `awaiting_consent` run state deduped on (principal, actingAs, scope) (ADR-099). +- `flow-object-attribution` (active) — the dispatcher establishes an ambient run context before every step and clears it unconditionally afterwards, including when the step throws or suspends, so every write caused by a step is attributed to that run and node; a sub-flow restores its parent's frame rather than clearing it; and a run can report the objects it touched alongside its step history. + ## Purpose Defines how OpenRegister reads a flow document and turns it into a run: what carries behaviour (the node), what carries sequence (the edge), when converging paths merge versus synchronise, how a path is allowed to end, and what a flow records about its own runnability and its last run. This is the fleet's single flow engine (ADR-065) — openconnector and hermiq contribute node types to it and do not implement their own. diff --git a/package-lock.json b/package-lock.json index af8e4cd874..0817b80aae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "EUPL-1.2", "dependencies": { "@codemirror/lang-json": "^6.0.1", - "@conduction/nextcloud-vue": "^2.21.0", + "@conduction/nextcloud-vue": "^2.24.2", "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "^2.6.0", "@nextcloud/capabilities": "^1.2.1", @@ -20,11 +20,11 @@ "@nextcloud/router": "^3.1.0", "@nextcloud/vue": "^9.9.0", "@vueuse/core": "^14.3.0", - "apexcharts": "^4.7.0", + "apexcharts": "^7.0.0", "css-loader": "^7.1.1", "dexie": "^4.0.8", - "dompurify": "^3.4.12", - "gridstack": "^12.0.0", + "dompurify": "^3.4.14", + "gridstack": "^13.2.0", "marked": "^12.0.0", "path-browserify": "^1.0.1", "pinia": "^3.0.4", @@ -36,7 +36,7 @@ "vue-material-design-icons": "^5.2.0", "vue-router": "^5.2.0", "vue3-apexcharts": "~1.8.0", - "zod": "^3.22.4" + "zod": "^4.4.3" }, "devDependencies": { "@babel/core": "^7.23.9", @@ -45,10 +45,10 @@ "@babel/preset-typescript": "^7.26.0", "@babel/traverse": "^7.23.9", "@cyclonedx/cyclonedx-npm": "^6.0.0", - "@nextcloud/browserslist-config": "^2.3.0", + "@nextcloud/browserslist-config": "^3.1.2", "@nextcloud/eslint-config": "^9.0.1", "@nextcloud/prettier-config": "^1.2.0", - "@nextcloud/stylelint-config": "^2.4.0", + "@nextcloud/stylelint-config": "^3.2.2", "@nextcloud/webpack-vue-config": "^7.0.1", "@pinia/testing": "^1.0.3", "@playwright/test": "^1.49.0", @@ -62,7 +62,7 @@ "babel-jest": "^29.7.0", "babel-loader": "^10.0.0", "esbuild": "^0.28.0", - "eslint": "^10.8.1", + "eslint": "^10.9.1", "eslint-config-prettier": "^10.1.8", "eslint-webpack-plugin": "^6.0.0", "jest": "^29.7.0", @@ -71,10 +71,10 @@ "postcss": "^8.4.31", "postcss-html": "^1.8.1", "prettier": "^3.9.6", - "stylelint": "^15.11.0", - "stylelint-config-recommended-scss": "^13.1.0", + "stylelint": "^17.9.1", + "stylelint-config-recommended-scss": "^17.0.1", "stylelint-config-recommended-vue": "^1.6.1", - "stylelint-webpack-plugin": "^4.1.1", + "stylelint-webpack-plugin": "^5.1.0", "terser-webpack-plugin": "^5.6.0", "ts-jest": "^29.1.2", "ts-loader": "^9.5.1", @@ -1945,6 +1945,67 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/@ckpack/vue-color": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@ckpack/vue-color/-/vue-color-1.6.0.tgz", @@ -2112,9 +2173,9 @@ } }, "node_modules/@conduction/nextcloud-vue": { - "version": "2.21.0", - "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.21.0.tgz", - "integrity": "sha512-5HjI4X8k2IYxUeBdHa2OK/MnLhOFf4HxkF5dUGl42dWmYPDaPHjvawDMZcUcyP6wsP+rk0QzmPov4FGu/4Rn7Q==", + "version": "2.24.2", + "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.24.2.tgz", + "integrity": "sha512-BGvyJpRhzvLraZuyRB3ETJ6IESV27XZvL5vdvLd5kEvettFo+QWIo6JcHJhQk9np8QYGHBQ9gRHCp3v6y7Y2gw==", "license": "EUPL-1.2", "dependencies": { "@ckpack/vue-color": "^1.6.0", @@ -2207,10 +2268,48 @@ } } }, + "node_modules/@conduction/nextcloud-vue/node_modules/apexcharts": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-4.7.0.tgz", + "integrity": "sha512-iZSrrBGvVlL+nt2B1NpqfDuBZ9jX61X9I2+XV0hlYXHtTwhwLTHDKGXjNXAgFBDLuvSYCB/rq2nPWVPRv2DrGA==", + "license": "MIT", + "dependencies": { + "@svgdotjs/svg.draggable.js": "^3.0.4", + "@svgdotjs/svg.filter.js": "^3.0.8", + "@svgdotjs/svg.js": "^3.2.4", + "@svgdotjs/svg.resize.js": "^2.0.2", + "@svgdotjs/svg.select.js": "^4.0.1", + "@yr/monotone-cubic-spline": "^1.0.3" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, "node_modules/@csstools/css-parser-algorithms": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.7.1.tgz", - "integrity": "sha512-2SJS42gxmACHgikc1WGesXLIT8d/q2l0UFM7TaEeIzdFCE/FPMtTiizcPGGJtlPo2xuQzY09OhrLTzRxqJqwGw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", "dev": true, "funding": [ { @@ -2224,16 +2323,16 @@ ], "license": "MIT", "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-tokenizer": "^2.4.1" + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-tokenizer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.4.1.tgz", - "integrity": "sha512-eQ9DIktFJBhGjioABJRtUucoWR2mwllurfnM8LuNGAqX3ViZXaUchqk+1s7jjtkFiT9ySdACsFEA3etErkALUg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", "dev": true, "funding": [ { @@ -2247,13 +2346,13 @@ ], "license": "MIT", "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" } }, "node_modules/@csstools/media-query-list-parser": { - "version": "2.1.13", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.13.tgz", - "integrity": "sha512-XaHr+16KRU9Gf8XLi3q8kDlI18d5vzKSKCY510Vrtc9iNR0NJzbY9hhTmwhzYZj/ZwGL4VmB3TA9hJW0Um2qFA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-5.0.0.tgz", + "integrity": "sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==", "dev": true, "funding": [ { @@ -2267,11 +2366,57 @@ ], "license": "MIT", "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/selector-resolve-nested": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.1.tgz", + "integrity": "sha512-j3vdQu0XwLME5qOTWxm8cnmvsf423R2YL6DbKklCHZwkDm7UdKNu6RPlw4REIJhSlKBICY3B70/7QZdicLqZgg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.1.1" + } + }, + "node_modules/@csstools/selector-specificity": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz", + "integrity": "sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^2.7.1", - "@csstools/css-tokenizer": "^2.4.1" + "postcss-selector-parser": "^7.1.1" } }, "node_modules/@ctrl/tinycolor": { @@ -2417,6 +2562,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2433,6 +2579,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2449,6 +2596,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2465,6 +2613,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2481,6 +2630,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2497,6 +2647,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2513,6 +2664,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2529,6 +2681,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2545,6 +2698,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2561,6 +2715,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2577,6 +2732,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2593,6 +2749,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2609,6 +2766,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2625,6 +2783,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2641,6 +2800,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2657,6 +2817,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2673,6 +2834,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2689,6 +2851,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2705,6 +2868,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2721,6 +2885,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2737,6 +2902,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2753,6 +2919,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2769,6 +2936,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2785,6 +2953,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2801,6 +2970,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2817,6 +2987,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4170,6 +4341,13 @@ "tslib": "2" } }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -4282,6 +4460,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4328,14 +4507,17 @@ } }, "node_modules/@nextcloud/browserslist-config": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@nextcloud/browserslist-config/-/browserslist-config-2.3.0.tgz", - "integrity": "sha512-1Tpkof2e9Q0UicHWahQnXXrubJoqyiaqsH9G52v3cjGeVeH3BCfa1FOa41eBwBSFe2/Jxj/wCH2YVLgIXpWbBg==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@nextcloud/browserslist-config/-/browserslist-config-3.1.2.tgz", + "integrity": "sha512-2iXl1rqQOHvggFIl/V3J5OpbodVazOsO38Gz/2sUAmtWXuOpGZG+7i6zQcVqGVaT1VzyPJ1gPiMpyyZi/XRWNA==", "dev": true, "license": "GPL-3.0-or-later", "engines": { - "node": "^16.0.0", - "npm": "^7.0.0 || ^8.0.0" + "node": "^20 || ^22 || ^24", + "npm": ">=10.5.0" + }, + "peerDependencies": { + "browserslist": "^4.26.3" } }, "node_modules/@nextcloud/capabilities": { @@ -4660,19 +4842,21 @@ } }, "node_modules/@nextcloud/stylelint-config": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@nextcloud/stylelint-config/-/stylelint-config-2.4.0.tgz", - "integrity": "sha512-S/q/offcs9pwnkjSrnfvsONryCOe6e1lfK2sszN6ZtkYyXvaqi8EbQuuhaGlxCstn9oXwbXfAI6O3Y8lGrjdFg==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@nextcloud/stylelint-config/-/stylelint-config-3.2.2.tgz", + "integrity": "sha512-5rr77fGK+zoa8yN+8zR43XbqyuN/yB2wSAy4vKE2F+hgAeOhpUAyChV+pfySDbP20t4s22DHgn7BDiZKsbNE3Q==", "dev": true, "license": "AGPL-3.0-or-later", + "dependencies": { + "stylelint-use-logical": "^2.1.3" + }, "engines": { - "node": "^20.0.0", - "npm": "^10.0.0" + "node": "^20.19 || ^22 || ^24" }, "peerDependencies": { - "stylelint": "^15.6.0", - "stylelint-config-recommended-scss": "^13.1.0", - "stylelint-config-recommended-vue": "^1.1.0" + "stylelint": "^17.9.1", + "stylelint-config-recommended-scss": "^17.0.1", + "stylelint-config-recommended-vue": "^1.6.1" } }, "node_modules/@nextcloud/typings": { @@ -5590,6 +5774,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5603,6 +5788,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5616,6 +5802,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5629,6 +5816,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5642,6 +5830,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5655,6 +5844,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5668,6 +5858,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5681,6 +5872,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5694,6 +5886,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5707,6 +5900,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5720,6 +5914,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5733,6 +5928,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5746,6 +5942,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5759,6 +5956,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5772,6 +5970,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5785,6 +5984,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5798,6 +5998,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5811,6 +6012,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5824,6 +6026,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5837,6 +6040,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5850,6 +6054,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5863,6 +6068,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5876,6 +6082,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5889,6 +6096,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5902,6 +6110,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5936,6 +6145,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -6482,7 +6704,7 @@ "version": "5.10.0", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", @@ -6503,7 +6725,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -6890,13 +7112,6 @@ "@types/unist": "*" } }, - "node_modules/@types/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -6912,13 +7127,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/normalize-package-data": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", - "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -7141,7 +7349,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/scope-manager": "8.67.0", @@ -7166,7 +7374,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.67.0", @@ -7188,7 +7396,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.67.0", @@ -7206,7 +7414,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7248,7 +7456,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7262,7 +7470,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/project-service": "8.67.0", @@ -7290,7 +7498,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7327,7 +7535,7 @@ "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.67.0", @@ -7345,7 +7553,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -8390,18 +8598,22 @@ ], "license": "MIT" }, + "node_modules/apex-commons": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/apex-commons/-/apex-commons-0.5.0.tgz", + "integrity": "sha512-xz83SgPREE1wGtoflvxjwJtcjzl5bKTz2/bDxWCZNL6QVjUNE47kMg/1Zqt0bSh6eOnmFCWlTDc3RU8o85N+wg==", + "license": "MIT" + }, "node_modules/apexcharts": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-4.7.0.tgz", - "integrity": "sha512-iZSrrBGvVlL+nt2B1NpqfDuBZ9jX61X9I2+XV0hlYXHtTwhwLTHDKGXjNXAgFBDLuvSYCB/rq2nPWVPRv2DrGA==", - "license": "MIT", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-7.0.0.tgz", + "integrity": "sha512-wX0LP5WajZXIAvQpCcDMVOlBUhlfhzUmEaKxPkUs54WFm0/GBbEyYCpw4G6iK7W5nU/R+kRzk+NHQSWlmp0qCA==", + "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@svgdotjs/svg.draggable.js": "^3.0.4", - "@svgdotjs/svg.filter.js": "^3.0.8", - "@svgdotjs/svg.js": "^3.2.4", - "@svgdotjs/svg.resize.js": "^2.0.2", - "@svgdotjs/svg.select.js": "^4.0.1", - "@yr/monotone-cubic-spline": "^1.0.3" + "apex-commons": "^0.5.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/are-docs-informative": { @@ -8473,16 +8685,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/arrify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/as-table": { "version": "1.0.55", "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", @@ -9454,6 +9656,30 @@ "license": "ISC", "optional": true }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/cacheable/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -9523,51 +9749,6 @@ "node": ">=6" } }, - "node_modules/camelcase-keys": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-7.0.2.tgz", - "integrity": "sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "camelcase": "^6.3.0", - "map-obj": "^4.1.0", - "quick-lru": "^5.1.1", - "type-fest": "^1.2.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/camelcase-keys/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/camelcase-keys/node_modules/type-fest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", - "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cancelable-promise": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/cancelable-promise/-/cancelable-promise-4.3.1.tgz", @@ -9860,9 +10041,9 @@ "license": "MIT" }, "node_modules/colord": { - "version": "2.9.3", - "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", - "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz", + "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==", "dev": true, "license": "MIT" }, @@ -10140,16 +10321,16 @@ "peer": true }, "node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", "dependencies": { + "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" + "parse-json": "^5.2.0" }, "engines": { "node": ">=14" @@ -10174,9 +10355,9 @@ "license": "Python-2.0" }, "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -10663,56 +10844,6 @@ } } }, - "node_modules/decamelize": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-5.0.1.tgz", - "integrity": "sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decamelize-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", - "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", - "dev": true, - "license": "MIT", - "dependencies": { - "decamelize": "^1.1.0", - "map-obj": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decamelize-keys/node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/decamelize-keys/node_modules/map-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", - "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -11131,9 +11262,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", - "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -11354,7 +11485,6 @@ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=6" } @@ -11679,9 +11809,9 @@ } }, "node_modules/eslint": { - "version": "10.8.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", - "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", + "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", "devOptional": true, "license": "MIT", "workspaces": [ @@ -12832,6 +12962,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -12915,6 +13046,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -13203,9 +13347,9 @@ "license": "ISC" }, "node_modules/gridstack": { - "version": "12.6.0", - "resolved": "https://registry.npmjs.org/gridstack/-/gridstack-12.6.0.tgz", - "integrity": "sha512-dUrqsormSybFn/2P4Dz8AgprftKD5e/IiV7UmC0XLQU+G+/WtkAeFiCSNLoAGhPDXoJ/O61Xtj3gljY/Ds83yQ==", + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/gridstack/-/gridstack-13.2.0.tgz", + "integrity": "sha512-+ImmOx6qd1wiF0EagY7e/pPdngh/6s0FM+r9hh4d8AsXslO51iHEuoAF7CMrXCFr0Xqd0X4WS/bqRLXtEUThug==", "funding": [ { "type": "paypal", @@ -13250,16 +13394,6 @@ "node": ">=0.10.0" } }, - "node_modules/hard-rejection": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", - "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -13372,6 +13506,19 @@ "minimalistic-assert": "^1.0.1" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -13487,6 +13634,13 @@ "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", "license": "MIT" }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/hosted-git-info": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", @@ -13564,13 +13718,13 @@ "license": "MIT" }, "node_modules/html-tags": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", - "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-5.1.0.tgz", + "integrity": "sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=20.10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -13824,16 +13978,6 @@ "node": ">=4" } }, - "node_modules/import-lazy": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", - "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -13854,6 +13998,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -13864,19 +14019,6 @@ "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -14410,6 +14552,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -14423,9 +14578,9 @@ } }, "node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", "dev": true, "license": "MIT", "engines": { @@ -15703,9 +15858,9 @@ } }, "node_modules/known-css-properties": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.29.0.tgz", - "integrity": "sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==", + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", "dev": true, "license": "MIT" }, @@ -16003,19 +16158,6 @@ "tmpl": "1.0.5" } }, - "node_modules/map-obj": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", - "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/markdown-escape": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/markdown-escape/-/markdown-escape-2.0.0.tgz", @@ -16051,9 +16193,9 @@ } }, "node_modules/mathml-tag-names": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", - "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-4.0.0.tgz", + "integrity": "sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==", "dev": true, "license": "MIT", "funding": { @@ -16345,107 +16487,18 @@ } }, "node_modules/meow": { - "version": "10.1.5", - "resolved": "https://registry.npmjs.org/meow/-/meow-10.1.5.tgz", - "integrity": "sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", + "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", "dev": true, "license": "MIT", - "dependencies": { - "@types/minimist": "^1.2.2", - "camelcase-keys": "^7.0.0", - "decamelize": "^5.0.0", - "decamelize-keys": "^1.1.0", - "hard-rejection": "^2.1.0", - "minimist-options": "4.1.0", - "normalize-package-data": "^3.0.2", - "read-pkg-up": "^8.0.0", - "redent": "^4.0.0", - "trim-newlines": "^4.0.2", - "type-fest": "^1.2.2", - "yargs-parser": "^20.2.9" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/meow/node_modules/hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/meow/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/meow/node_modules/normalize-package-data": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", - "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^4.0.1", - "is-core-module": "^2.5.0", - "semver": "^7.3.4", - "validate-npm-package-license": "^3.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/meow/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/meow/node_modules/type-fest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", - "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=10" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/meow/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -17041,31 +17094,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minimist-options": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", - "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "arrify": "^1.0.1", - "is-plain-obj": "^1.1.0", - "kind-of": "^6.0.3" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/minimist-options/node_modules/is-plain-obj": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", - "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/minimizer-webpack-plugin": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", @@ -19353,6 +19381,26 @@ "node": ">=16.0.0" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -19424,19 +19472,6 @@ ], "license": "MIT" }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/railroad-diagrams": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", @@ -19568,138 +19603,13 @@ "dev": true, "license": "MIT" }, - "node_modules/read-pkg": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-6.0.0.tgz", - "integrity": "sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q==", + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "dev": true, "license": "MIT", - "dependencies": { - "@types/normalize-package-data": "^2.4.0", - "normalize-package-data": "^3.0.2", - "parse-json": "^5.2.0", - "type-fest": "^1.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg-up": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-8.0.0.tgz", - "integrity": "sha512-snVCqPczksT0HS2EC+SxUndvSzn6LRCwpfSvLrIfR5BKDQQZMaI6jPRC9dYvYFDRAuFEAnkwww8kBBNE/3VvzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^5.0.0", - "read-pkg": "^6.0.0", - "type-fest": "^1.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg-up/node_modules/type-fest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", - "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg/node_modules/hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/read-pkg/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/read-pkg/node_modules/normalize-package-data": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", - "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^4.0.1", - "is-core-module": "^2.5.0", - "semver": "^7.3.4", - "validate-npm-package-license": "^3.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/read-pkg/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/read-pkg/node_modules/type-fest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", - "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "dev": true, - "license": "MIT", - "peer": true, + "peer": true, "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", @@ -19738,23 +19648,6 @@ "node": ">= 10.13.0" } }, - "node_modules/redent": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz", - "integrity": "sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^5.0.0", - "strip-indent": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -20115,23 +20008,6 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/ripemd160": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", @@ -21640,19 +21516,6 @@ "node": ">=6" } }, - "node_modules/strip-indent": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", - "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -21725,13 +21588,6 @@ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", "license": "MIT" }, - "node_modules/style-search": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz", - "integrity": "sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==", - "dev": true, - "license": "ISC" - }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -21751,62 +21607,63 @@ } }, "node_modules/stylelint": { - "version": "15.11.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-15.11.0.tgz", - "integrity": "sha512-78O4c6IswZ9TzpcIiQJIN49K3qNoXTM8zEJzhaTE/xRTCZswaovSEVIa/uwbOltZrk16X4jAxjaOhzz/hTm1Kw==", + "version": "17.14.1", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.1.tgz", + "integrity": "sha512-xVQwyiuxALUBNB2fBe0tmNemg9KqLtdj3T64mioFDar79B2cU8LIyz+3KL6LdiHs9NkeNfwxpKSaIVOY8f112g==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], "license": "MIT", "dependencies": { - "@csstools/css-parser-algorithms": "^2.3.1", - "@csstools/css-tokenizer": "^2.2.0", - "@csstools/media-query-list-parser": "^2.1.4", - "@csstools/selector-specificity": "^3.0.0", - "balanced-match": "^2.0.0", + "@csstools/css-calc": "^3.2.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.1.6", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/media-query-list-parser": "^5.0.0", + "@csstools/selector-resolve-nested": "^4.0.0", + "@csstools/selector-specificity": "^6.0.0", "colord": "^2.9.3", - "cosmiconfig": "^8.2.0", - "css-functions-list": "^3.2.1", - "css-tree": "^2.3.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.1", + "cosmiconfig": "^9.0.2", + "css-functions-list": "^3.3.3", + "css-tree": "^3.2.1", + "debug": "^4.4.3", + "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", - "file-entry-cache": "^7.0.0", + "file-entry-cache": "^11.1.5", "global-modules": "^2.0.0", - "globby": "^11.1.0", + "globby": "^16.2.1", "globjoin": "^0.1.4", - "html-tags": "^3.3.1", - "ignore": "^5.2.4", - "import-lazy": "^4.0.0", - "imurmurhash": "^0.1.4", - "is-plain-object": "^5.0.0", - "known-css-properties": "^0.29.0", - "mathml-tag-names": "^2.1.3", - "meow": "^10.1.5", - "micromatch": "^4.0.5", + "html-tags": "^5.1.0", + "ignore": "^7.0.5", + "import-meta-resolve": "^4.2.0", + "mathml-tag-names": "^4.0.0", + "meow": "^14.1.0", + "micromatch": "^4.0.8", "normalize-path": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.28", - "postcss-resolve-nested-selector": "^0.1.1", - "postcss-safe-parser": "^6.0.0", - "postcss-selector-parser": "^6.0.13", + "picocolors": "^1.1.1", + "postcss": "^8.5.16", + "postcss-safe-parser": "^7.0.1", + "postcss-selector-parser": "^7.1.4", "postcss-value-parser": "^4.2.0", - "resolve-from": "^5.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "style-search": "^0.1.0", - "supports-hyperlinks": "^3.0.0", + "string-width": "^8.2.1", + "supports-hyperlinks": "^4.5.0", "svg-tags": "^1.0.0", - "table": "^6.8.1", - "write-file-atomic": "^5.0.1" + "table": "^6.9.0", + "write-file-atomic": "^7.0.1" }, "bin": { "stylelint": "bin/stylelint.mjs" }, "engines": { - "node": "^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/stylelint" + "node": ">=20.19.0" } }, "node_modules/stylelint-config-html": { @@ -21827,32 +21684,45 @@ } }, "node_modules/stylelint-config-recommended": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-13.0.0.tgz", - "integrity": "sha512-EH+yRj6h3GAe/fRiyaoO2F9l9Tgg50AOFhaszyfov9v6ayXJ1IkSHwTxd7lB48FmOeSGDPLjatjO11fJpmarkQ==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-18.0.0.tgz", + "integrity": "sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], "license": "MIT", "engines": { - "node": "^14.13.1 || >=16.0.0" + "node": ">=20.19.0" }, "peerDependencies": { - "stylelint": "^15.10.0" + "stylelint": "^17.0.0" } }, "node_modules/stylelint-config-recommended-scss": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/stylelint-config-recommended-scss/-/stylelint-config-recommended-scss-13.1.0.tgz", - "integrity": "sha512-8L5nDfd+YH6AOoBGKmhH8pLWF1dpfY816JtGMePcBqqSsLU+Ysawx44fQSlMOJ2xTfI9yTGpup5JU77c17w1Ww==", + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended-scss/-/stylelint-config-recommended-scss-17.0.1.tgz", + "integrity": "sha512-x5DVehzJudcwF0od3sGpgkln2PLLranFE7twwbp7dqDINCyZvwzFkMc6TLhNOvazRiVBJYATQLouJY0xPGB8WA==", "dev": true, "license": "MIT", "dependencies": { "postcss-scss": "^4.0.9", - "stylelint-config-recommended": "^13.0.0", - "stylelint-scss": "^5.3.0" + "stylelint-config-recommended": "^18.0.0", + "stylelint-scss": "^7.0.0" + }, + "engines": { + "node": ">=20" }, "peerDependencies": { "postcss": "^8.3.3", - "stylelint": "^15.10.0" + "stylelint": "^17.0.0" }, "peerDependenciesMeta": { "postcss": { @@ -21896,65 +21766,119 @@ } }, "node_modules/stylelint-scss": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-5.3.2.tgz", - "integrity": "sha512-4LzLaayFhFyneJwLo0IUa8knuIvj+zF0vBFueQs4e3tEaAMIQX8q5th8ziKkgOavr6y/y9yoBe+RXN/edwLzsQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-7.2.0.tgz", + "integrity": "sha512-6E79Bachv0Iz0gqRUZgdqdXCsiq26DWBWIBNHYtjTmAp3wJu6cp/I37VfW7BPntmh2puF3bY09XWl4HZGrLhzw==", "dev": true, "license": "MIT", "dependencies": { - "known-css-properties": "^0.29.0", + "@csstools/css-calc": "^3.2.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.1.4", + "@csstools/css-tokenizer": "^4.0.0", + "css-tree": "^3.2.1", + "is-plain-object": "^5.0.0", + "known-css-properties": "^0.37.0", "postcss-media-query-parser": "^0.2.3", - "postcss-resolve-nested-selector": "^0.1.1", - "postcss-selector-parser": "^6.0.13", + "postcss-resolve-nested-selector": "^0.1.6", + "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0" }, + "engines": { + "node": ">=20.19.0" + }, "peerDependencies": { - "stylelint": "^14.5.1 || ^15.0.0" + "stylelint": "^16.8.2 || ^17.0.0" } }, - "node_modules/stylelint-scss/node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "node_modules/stylelint-scss/node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.9.tgz", + "integrity": "sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/stylelint-scss/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=4" + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/stylelint-scss/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/stylelint-use-logical": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/stylelint-use-logical/-/stylelint-use-logical-2.1.3.tgz", + "integrity": "sha512-haPkgxKre+eSqr4IZJnHwNT/9/wICykeFZIaz7rZbe4SohTHkw7vBahMOrZJZpdny/EBVHAcPH2IBeoiUcZWWw==", + "dev": true, + "license": "CC0-1.0", + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "stylelint": ">= 11 < 18" } }, "node_modules/stylelint-webpack-plugin": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/stylelint-webpack-plugin/-/stylelint-webpack-plugin-4.1.1.tgz", - "integrity": "sha512-yOyd2AfrxfawxKDememazGVJX2vMq9o11E6HvBu4+SKvgK3ZulkjpYdI1muBTxItwoxH2UmfIZzQM+/M5V3kTQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/stylelint-webpack-plugin/-/stylelint-webpack-plugin-5.1.0.tgz", + "integrity": "sha512-A7HR+g2W9lpc72QaF6LM1E+e9E3B7pl2hIV8rK8OiF3plbs0nf7ipixYf7MGvPgmQF+us63xmo7PV0W/RH3UDQ==", "dev": true, "license": "MIT", "dependencies": { "globby": "^11.1.0", - "jest-worker": "^29.5.0", + "jest-worker": "^29.7.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", - "schema-utils": "^4.0.0" + "schema-utils": "^4.2.0" }, "engines": { - "node": ">= 14.15.0" + "node": ">= 18.12.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "stylelint": "^13.0.0 || ^14.0.0 || ^15.0.0", + "stylelint": "^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0", "webpack": "^5.0.0" } }, - "node_modules/stylelint/node_modules/@csstools/selector-specificity": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-3.1.1.tgz", - "integrity": "sha512-a7cxGcJ2wIlMFLlh8z2ONm+715QkPHiyJcxwQlKOz/03GPw1COpfhcmC9wm4xlZfp//jWHNNMwzjtqHXVWU9KA==", + "node_modules/stylelint/node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.9.tgz", + "integrity": "sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==", "dev": true, "funding": [ { @@ -21967,19 +21891,41 @@ } ], "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/stylelint/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=12" }, - "peerDependencies": { - "postcss-selector-parser": "^6.0.13" + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/stylelint/node_modules/balanced-match": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", - "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", + "node_modules/stylelint/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } }, "node_modules/stylelint/node_modules/fast-glob": { "version": "3.3.3", @@ -21999,31 +21945,25 @@ } }, "node_modules/stylelint/node_modules/file-entry-cache": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-7.0.2.tgz", - "integrity": "sha512-TfW7/1iI4Cy7Y8L6iqNdZQVvdXn0f8B4QcIXmkIbtTIe/Okm/nSlHb4IwGzRVOd3WfSieCgvf5cMzEfySAIl0g==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^3.2.0" - }, - "engines": { - "node": ">=12.0.0" + "flat-cache": "^6.1.23" } }, "node_modules/stylelint/node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "dev": true, "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, "node_modules/stylelint/node_modules/glob-parent": { @@ -22039,18 +21979,70 @@ "node": ">= 6" } }, - "node_modules/stylelint/node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "node_modules/stylelint/node_modules/globby": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.4.tgz", + "integrity": "sha512-c8B/VNLmxRcmqqenRA9t+9IyOjf9+V6lTxPaUJLqOCONdQkWZ0ETYgX0qbtJqPsgCNusT9MZ5Jeidw8Eb9tn2g==", "dev": true, "license": "MIT", "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" + "@sindresorhus/merge-streams": "^4.0.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.5", + "is-path-inside": "^4.0.0", + "micromatch": "^4.0.8", + "slash": "^5.1.0", + "unicorn-magic": "^0.4.0" }, "engines": { - "node": ">=4" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylelint/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/stylelint/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/stylelint/node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" } }, "node_modules/stylelint/node_modules/signal-exit": { @@ -22066,18 +22058,63 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/stylelint/node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylelint/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylelint/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/stylelint/node_modules/write-file-atomic": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", - "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", + "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", "dev": true, "license": "ISC", "dependencies": { - "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/superjson": { @@ -22106,22 +22143,48 @@ } }, "node_modules/supports-hyperlinks": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", - "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.5.0.tgz", + "integrity": "sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0", - "supports-color": "^7.0.0" + "has-flag": "^5.0.1", + "supports-color": "^10.2.2" }, "engines": { - "node": ">=14.18" + "node": ">=20" }, "funding": { "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, + "node_modules/supports-hyperlinks/node_modules/has-flag": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz", + "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -22655,19 +22718,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/trim-newlines": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-4.1.1.tgz", - "integrity": "sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/trough": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", @@ -22682,7 +22732,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18.12" @@ -23232,6 +23282,19 @@ "node": ">=4" } }, + "node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -24914,16 +24977,6 @@ "node": ">=12" } }, - "node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yargs/node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -24963,9 +25016,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 3952b5f920..d15700f2f6 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ }, "dependencies": { "@codemirror/lang-json": "^6.0.1", - "@conduction/nextcloud-vue": "^2.21.0", + "@conduction/nextcloud-vue": "^2.24.2", "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "^2.6.0", "@nextcloud/capabilities": "^1.2.1", @@ -56,11 +56,11 @@ "@nextcloud/router": "^3.1.0", "@nextcloud/vue": "^9.9.0", "@vueuse/core": "^14.3.0", - "apexcharts": "^4.7.0", + "apexcharts": "^7.0.0", "css-loader": "^7.1.1", "dexie": "^4.0.8", - "dompurify": "^3.4.12", - "gridstack": "^12.0.0", + "dompurify": "^3.4.14", + "gridstack": "^13.2.0", "marked": "^12.0.0", "path-browserify": "^1.0.1", "pinia": "^3.0.4", @@ -72,7 +72,7 @@ "vue-material-design-icons": "^5.2.0", "vue-router": "^5.2.0", "vue3-apexcharts": "~1.8.0", - "zod": "^3.22.4" + "zod": "^4.4.3" }, "devDependencies": { "@babel/core": "^7.23.9", @@ -81,10 +81,10 @@ "@babel/preset-typescript": "^7.26.0", "@babel/traverse": "^7.23.9", "@cyclonedx/cyclonedx-npm": "^6.0.0", - "@nextcloud/browserslist-config": "^2.3.0", + "@nextcloud/browserslist-config": "^3.1.2", "@nextcloud/eslint-config": "^9.0.1", "@nextcloud/prettier-config": "^1.2.0", - "@nextcloud/stylelint-config": "^2.4.0", + "@nextcloud/stylelint-config": "^3.2.2", "@nextcloud/webpack-vue-config": "^7.0.1", "@pinia/testing": "^1.0.3", "@playwright/test": "^1.49.0", @@ -98,7 +98,7 @@ "babel-jest": "^29.7.0", "babel-loader": "^10.0.0", "esbuild": "^0.28.0", - "eslint": "^10.8.1", + "eslint": "^10.9.1", "eslint-config-prettier": "^10.1.8", "eslint-webpack-plugin": "^6.0.0", "jest": "^29.7.0", @@ -107,10 +107,10 @@ "postcss": "^8.4.31", "postcss-html": "^1.8.1", "prettier": "^3.9.6", - "stylelint": "^15.11.0", - "stylelint-config-recommended-scss": "^13.1.0", + "stylelint": "^17.9.1", + "stylelint-config-recommended-scss": "^17.0.1", "stylelint-config-recommended-vue": "^1.6.1", - "stylelint-webpack-plugin": "^4.1.1", + "stylelint-webpack-plugin": "^5.1.0", "terser-webpack-plugin": "^5.6.0", "ts-jest": "^29.1.2", "ts-loader": "^9.5.1", @@ -122,7 +122,7 @@ "vue": "$vue", "@nextcloud/vue": "^9.9.0", "@nextcloud/axios": "^2.6.0", - "stylelint": "^15.11.0", + "stylelint": "^17.9.1", "postcss": "^8.4.31", "json5": "^2.2.3", "@babel/traverse": "^7.23.9", diff --git a/scripts/l10n/README.md b/scripts/l10n/README.md new file mode 100644 index 0000000000..59e1a3cd22 --- /dev/null +++ b/scripts/l10n/README.md @@ -0,0 +1,161 @@ +# `scripts/l10n/` — frontend translation tooling + +Everything for `l10n/*.js` (the **frontend** catalogue, read by `OC.L10N.register` → +`t()` / `n()`). The backend `l10n/*.json` set is a separate concern with a separate +consumer (PHP `IL10N`) and no scanner yet. + +**Read `docs/l10n-workflow.md` first.** It is the runbook: the pass in order, what to +do when any of these scripts refuses, and the traps catalogue. This file is the +reference for the tooling itself — what lives where, and what each gate rejects. + +Then **`docs/l10n-ui-translation.md`** for the parts that are not mechanical: +measuring register against Nextcloud core instead of assuming it, why harvested +values must be checked at their call site, the plural boundaries per language, and +the conventions already established per locale. + +## Layout + +| Path | What | +| --- | --- | +| `lib.js` | The one shared library. Catalogue parsing/serializing/extraction, plus the per-locale-pass helpers. | +| `batch.js` | Read-only status and worklist for a locale. | +| `apply.js` | The **only** writer. Six gates; refuses the whole patch rather than landing half of it. | +| `harvest.js` | Candidate values from core and sibling apps. Candidates, never answers. | +| `patchcheck.js` | Runs a locale's register detector over a patch **before** it is applied. | +| `selfcheck.js` | Full pre-commit verification for one locale. | +| `runtime-check.mjs` | Drives the real `@nextcloud/l10n` against a real bundle. | +| `gate-negative-test.js` | Proves the gates really refuse: a locale losing a key, the `{plural}` ban in all four places, and a plural form colliding with a key. Snapshots, breaks, asserts, restores. | +| `script-coverage.js` | Two jobs. For a non-Latin locale (`bg sr mk be uk ru el`) the script sweep that replaces §5 step 8's English-word scan. For **every** locale including Latin ones, the homoglyph check: a single word mixing two scripts. Reading aid, never a gate. | +| `core-diff.js` | Bundle vs Nextcloud core, split AGREE / DISAGREE. **First thing in an audit** — its AGREE list is what you must not "fix". Reading aid, never a gate. | +| `termdrift.js` | English words this bundle renders two ways. The §6.9 term count over *every* word rather than a guessed list. Reading aid, never a gate. | +| `casing.js` | Mid-sentence capitalisation per term, conditioned on the English key, against the sibling-frontend and core baselines. The §8.10 measurement, which five passes did by hand. `--mine` restricts it to what this working tree changed. Reading aid, never a gate. | +| `spell.js` | Words absent from a hunspell dictionary — wrong-language stems and typos. Needs `fetch-dicts.js`. Reading aid, never a gate. | +| `fetch-dicts.js` | Pulls hunspell dictionaries into `dicts/` (gitignored). One-time per machine; 30 of 36 locales have one. | +| `detectors/.js` | Per-locale register detector: closed word lists + must-fire / must-not-fire controls. | +| `locales/.json` | Per-locale record: measured register, justified cognates, audited corrections. | + +`lib.js` was `scripts/lib/l10n.js` until 2026-08-14. It moved here so there is one +l10n folder and one library in it; `scripts/lib/` no longer exists. **openconnector +vendors a copy and still has it at the old path** — move it when you next sync. + +## A locale pass, end to end + +```bash +node scripts/l10n/batch.js status lv # what is left +node scripts/l10n/batch.js absent lv > /tmp/lv-todo.json + +# measure the register against core, then build detectors/lv.js and write +# locales/lv.json with {"register": "formal"|"informal", "cognates": {}, "corrections": {}} +node scripts/l10n/detectors/lv.js # runs its own controls + scans core + +node scripts/l10n/harvest.js lv /tmp/lv-todo.json # ~2-7% hit rate; verify every hit + +# translate in ~200-key batches +node scripts/l10n/patchcheck.js lv patch-1.json # register slip? catch it now +node scripts/l10n/apply.js lv patch-1.json # dry run +node scripts/l10n/apply.js lv patch-1.json --apply + +node scripts/l10n/selfcheck.js lv # 16 assertions, must be all-pass +node scripts/l10n/runtime-check.mjs lv # what actually renders +node scripts/l10n/script-coverage.js lv # homoglyphs for any locale; script sweep if non-Latin +npm run check:l10n && npm run test:l10n:parity + +# prove the gate really fails when this bundle loses a key +node scripts/l10n/gate-negative-test.js lv +``` + +Then bump the counts in `CLAUDE.md`, add the locale's conventions to +`docs/l10n-ui-translation.md`, tick the locale off the order-of-work list in +`docs/l10n-workflow.md`, and commit. One commit per language. + +## The two rules the tooling enforces for you + +**Never write a value equal to its key** — in any locale except `en`. An absent key +falls back to the English source and stays visibly untranslated to every tool; a +value equal to its key renders the same characters while being indistinguishable +from finished work, so nobody ever revisits it. + +The exception is a genuine cognate (`CSV`, `PDF`, `RBAC`, `Schema` in Lithuanian, +`Flows` in nl/de/da), which is real finished work and must keep parity. Those are +**written out against a recorded justification** in `locales/.json`. `apply.js` +refuses an identical value without one, and `test:l10n:parity` fails a locale whose +identical values are not all justified — so the record is enforced, not decorative. + +**Plural arrays must match the locale's own `nplurals`.** An array shorter than the +form index the runtime asks for renders **blank**, and it is the one l10n defect you +cannot see by reading the file. Languages that share a form count do not share +boundaries: `hr` and `lt` are both `nplurals=3` and disagree about where forms +switch, so an array copied between them is wrong for counts 5–9. + +## Cognate enforcement is opt-in per locale, on purpose + +Sixteen locales were finished before the cognate rule existed and carry ~400 +identical values nobody has reviewed; `cs` is the first of them to be reviewed, so +fifteen are left and ~375 identical values with them. Failing CI on those would report +history as a regression, and some are legitimate — `nl` renders `Bewaartermijn` and +`AVG / Verwerkingsregister` unchanged because those are Dutch words in a Dutch +bundle. So enforcement keys on the existence of `locales/.json`: the twenty-one +recorded locales (`tr ca et hr lt lv ro sk sl bg sr rm ga mt is cs lb sq mk be bs`) are held to it, the rest are +**reported** as unreviewed by both +`test:l10n:parity` and `runtime-check.mjs`. Add a `locales/.json` as each old +locale gets reviewed and it becomes enforced from that moment. + +## Two traps in the verification scripts + +Both of these were live bugs in these scripts, caught by running them across all 21 +finished locales instead of just the one being worked on. + +**1. A `SKIP` or `NOTE` is not a bug to tighten away.** "This value is identical to +English" and "this plural rendered the English source" are the symptoms of the two +worst defects this tooling exists to catch — *and* of a perfectly legitimate cognate. +Dutch `register`/`registers` really is the English word; Italian `email` is +invariable, so `1 email` is correct Italian. Nothing automated can tell those apart, +so the justification record decides: a locale with `locales/.json` is held to it +and an unrecorded English rendering fails, while a locale without one gets a note for +a human. If you hit a NOTE, either record the reason or translate the value — do not +narrow the check until it stops firing. + +**2. A plural form that is also a KEY renders the other key's value.** +`translatePlural` picks the form and then hands it back to `translate()`: + +```js +return translate(app, translation[plural], vars, number, options) +``` + +and `translate()` resolves `bundle.translations[text] || text`. So if a form's text +happens to be another key in the same bundle, the user sees *that* key's value. The +array is right, the file reads right, and nothing else notices — arity is fine, no +value is empty, none equals English, and `runtime-check` only asserts the result is +non-empty and translated, which the substituted value also is. + +Found on `rm`, whose form 0 has to be correct at every count (the library reaches no +other form for Romansh) and so carries a `(s)` parenthetical — `schema(s)`, which is +*also* a key in that bundle, so it rendered `Schema(s)`. `test:l10n:parity` now fails +on the whole class; the fix is to reword the form, not the key. + +Historical note, because the shape recurs: `{plural}` used to be a *tolerated* thing +here, with assertions branching on whether a locale had kept the placeholder. It is +now banned outright (`MORPHOLOGY_PLACEHOLDER` in `lib.js`, four gates, proved by +`gate-negative-test.js`). Three assertions inherited from the `hr`/`lt` passes had +been wrong for `ca` under the old regime — count-stability, a blanket +no-trailing-`s` rule that flagged the correct slash form `esquema/esquemes`, and an +unconditional no-residue rule. Only the last one survives, and now unconditionally. + +## One thing about the runtime that is easy to get wrong + +`register(app, bundle)` **ignores** a plural function passed as a third argument and +installs the library's own per-language `getPlural`, which reads `getLanguage()`. So +the file's `plural=` expression governs the arity gate, while the **library** governs +which element renders. A harness that assumes otherwise silently reads the wrong +form — which happened, and briefly made three correct Slavic arrays look wrong. +`runtime-check.mjs` calls `unregister()` and `setLanguage(loc)` first for this reason. + +When the two **disagree**, `runtime-check.mjs` classifies the disagreement rather than +assuming one shape, because the remedies are opposite. A *permutation* disagreement +partitions the counts identically and only labels the parts differently, so reordering the +arrays fixes it completely — that is `lv`, acknowledged with `pluralOrder: "library"`. A +*boundary* disagreement puts the lines in different places, so **no reordering can help**; +you choose which counts to be correct for and record the residue with +`pluralBoundary: "library"` — that is `is` and `mk`, in opposite directions. Telling someone +to reorder the arrays in the second case is wrong advice, which is why the two are separate +fields. See `docs/l10n-workflow.md` §6.7. diff --git a/scripts/l10n/apply.js b/scripts/l10n/apply.js new file mode 100644 index 0000000000..068374652b --- /dev/null +++ b/scripts/l10n/apply.js @@ -0,0 +1,224 @@ +#!/usr/bin/env node +/* eslint-disable n/no-process-exit */ +/* eslint-disable no-console */ +/* eslint-disable n/shebang */ +/** + * Gated writer for one locale's l10n/.js. + * + * node scripts/l10n/apply.js [--apply] + * node scripts/l10n/apply.js --apply --allow-replace='key1||key2' + * + * Default is a DRY RUN. Every gate below refuses the whole patch rather than + * writing a partial one, so a bad batch never lands half-applied. + * + * ## The gates, and why each exists + * + * 1. not an en key — a typo in the patch would otherwise add a key that + * no source file reads and that check:l10n then + * reports as unused forever. + * 2. plural arity — an array shorter than the form index the runtime + * asks for renders BLANK. This is the only l10n + * defect invisible to a human reading the file. + * 3. value === key — refused unless the key carries a written + * justification in scripts/l10n/locales/.json. + * Absent falls back to English and stays visibly + * untranslated to tooling; identical renders the same + * characters while being indistinguishable from + * finished work, so nobody ever revisits it. + * 4. empty / whitespace — edge or doubled HORIZONTAL whitespace only. `\s\s` + * would also match the `\n\n` paragraph breaks that + * several multi-line confirm dialogs carry in the + * English source too. + * 5. `{plural}` in a value — English morphology assembled from a placeholder. + * Banned outright; use a real n() plural key. Ordered + * ahead of drift so the message names the real fix. + * See MORPHOLOGY_PLACEHOLDER in lib.js. + * 6. placeholder drift — both directions, with no exemptions. `{plural}` + * used to be the one permitted loss; gate 5 now + * refuses it instead. + * 7. clobbering a real value — refused unless the key is named explicitly in + * --allow-replace, which prints every old -> new pair + * so a correction can never happen as a silent side + * effect of a bulk apply. + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +'use strict' + +const fs = require('fs') +const path = require('path') +const { + loadJsTranslations, serializeJs, APP_ROOT, isIdentical, placeholders, npluralsOf, + MORPHOLOGY_PLACEHOLDER, loadLocaleConfig, +} = require('./lib.js') + +const [loc, patchFile] = process.argv.slice(2) +if (!loc || !patchFile) { + console.error("usage: apply.js [--apply] [--allow-replace='k1||k2']") + process.exit(2) +} +const write = process.argv.includes('--apply') +const allowArg = process.argv.find(a => a.startsWith('--allow-replace=')) +const allowReplace = new Set(allowArg ? allowArg.slice('--allow-replace='.length).split('||') : []) + +const en = loadJsTranslations(path.join(APP_ROOT, 'l10n', 'en.js')).translations +const locFile = path.join(APP_ROOT, 'l10n', `${loc}.js`) +const cur = loadJsTranslations(locFile) +const cfg = loadLocaleConfig(loc) +const NP = npluralsOf(cur.pluralForm) +const patch = JSON.parse(fs.readFileSync(patchFile, 'utf8')) + +const next = { ...cur.translations } +const problems = [] +const corrections = [] +let written = 0 +let replaced = 0 +let cognatesWritten = 0 + +for (const [k, v] of Object.entries(patch)) { + if (!(k in en)) { + problems.push(`not an en key: ${JSON.stringify(k)}`) + continue + } + + // gate 2: arity + if (Array.isArray(en[k])) { + if (!Array.isArray(v)) { + problems.push(`plural key needs an array of ${NP}: ${JSON.stringify(k)}`) + continue + } + if (v.length !== NP) { + problems.push(`arity ${v.length} != nplurals ${NP}: ${JSON.stringify(k)}`) + continue + } + } else if (Array.isArray(v)) { + problems.push(`non-plural key given an array: ${JSON.stringify(k)}`) + continue + } + + // gate 3: value === key only via the justified-cognate path + if (isIdentical(k, v)) { + const reason = cfg.cognates[k] + if (!reason) { + problems.push(`value===key with no recorded reason: ${JSON.stringify(k)} ` + + `(add it to scripts/l10n/locales/${loc}.json under "cognates" with a justification, or translate it)`) + continue + } + if (String(reason).trim().length < 15) { + problems.push(`cognate reason too thin to be a justification: ${JSON.stringify(k)}`) + continue + } + cognatesWritten++ + } + + // gate 4: empties and edge/doubled horizontal whitespace + for (const x of Array.isArray(v) ? v : [v]) { + if (!String(x).trim()) { + problems.push(`empty value: ${JSON.stringify(k)}`) + } + if (/^\s|\s$|[ \t]{2,}/.test(String(x))) { + problems.push(`edge/doubled whitespace: ${JSON.stringify(k)} ${JSON.stringify(x)}`) + } + } + + // gate 5: no English morphology assembled from a placeholder. This used to be + // an EXEMPTION in gate 6 -- `{plural}` was the one placeholder a value was + // allowed to drop, because the five source-hack keys could not be rendered any + // other way. The source defect is gone (those keys are real n() calls now), so + // the exemption is inverted into a ban: a value carrying `{plural}` can only + // come from a hand-edit or a resurrected hack, and neither should land. See + // MORPHOLOGY_PLACEHOLDER in lib.js for the four places this is enforced. + // + // Ordered BEFORE the drift check on purpose. Drift would catch the same value + // anyway -- `{plural}` is a placeholder en.js no longer has -- but it would + // report it as an anonymous "added placeholder", which does not tell the reader + // that the fix is an n() call rather than a corrected brace. + let banned = false + for (const x of Array.isArray(v) ? v : [v]) { + if (MORPHOLOGY_PLACEHOLDER.test(String(x))) { + problems.push('{plural} is banned — use a real n() plural key: ' + + `${JSON.stringify(k)} ${JSON.stringify(x)}`) + banned = true + } + } + if (banned) continue + + // gate 6: placeholders, both directions + const enPh = new Set() + for (const f of Array.isArray(en[k]) ? en[k] : [en[k]]) { + for (const p of placeholders(f)) { + enPh.add(p) + } + } + const vPh = new Set() + for (const f of Array.isArray(v) ? v : [v]) { + for (const p of placeholders(f)) { + vPh.add(p) + } + } + const dropped = [...enPh].filter(p => !vPh.has(p)) + const added = [...vPh].filter(p => !enPh.has(p)) + if (dropped.length || added.length) { + problems.push(`placeholder drift: ${JSON.stringify(k)} en=${[...enPh]} loc=${[...vPh]}`) + continue + } + + // gate 7: never clobber a real translation unsupervised + if (k in next && !isIdentical(k, next[k])) { + if (!allowReplace.has(k)) { + problems.push(`would clobber real value: ${JSON.stringify(k)} existing=${JSON.stringify(next[k])}`) + continue + } + corrections.push([k, next[k], v]) + } + + if (k in next) { + replaced++ + } else { + written++ + } + next[k] = v +} + +// A recorded cognate that no patch writes is dead weight, and worse: it is a +// standing permission slip for a key nobody is looking at any more. +for (const k of Object.keys(cfg.cognates)) { + if (!(k in patch) && !(k in cur.translations)) { + problems.push(`cognate recorded in locales/${loc}.json but absent from both the patch and the bundle: ${JSON.stringify(k)}`) + } +} + +if (problems.length) { + console.error(`REFUSED — ${problems.length} problem(s), nothing written:`) + for (const p of problems.slice(0, 50)) { + console.error(' ' + p) + } + if (problems.length > 50) { + console.error(` … +${problems.length - 50} more`) + } + process.exit(1) +} + +if (corrections.length) { + console.log(`CORRECTIONS (gate 6 overridden for ${corrections.length} explicitly named key(s)):`) + for (const [k, was, now] of corrections) { + console.log(` ${JSON.stringify(k)}\n was: ${JSON.stringify(was)}\n now: ${JSON.stringify(now)}`) + if (!cfg.corrections[k]) { + console.log(' NOTE: no reason recorded in ' + + `scripts/l10n/locales/${loc}.json under "corrections" — add one before committing`) + } + } +} + +const stillAbsent = Object.keys(en).filter(k => !(k in next)).length +console.log(`${loc}: ${written} new, ${replaced} replaced, ${cognatesWritten} recorded cognate(s), 0 problems`) +console.log(`${loc}: ${Object.keys(next).length} / en ${Object.keys(en).length} keys — ${stillAbsent} still absent`) + +if (write) { + fs.writeFileSync(locFile, serializeJs({ app: cur.app, translations: next, pluralForm: cur.pluralForm })) + console.log('APPLIED') +} else { + console.log('DRY RUN — pass --apply to write') +} diff --git a/scripts/l10n/batch.js b/scripts/l10n/batch.js new file mode 100644 index 0000000000..e0b032275f --- /dev/null +++ b/scripts/l10n/batch.js @@ -0,0 +1,99 @@ +#!/usr/bin/env node +/* eslint-disable n/no-process-exit */ +/* eslint-disable no-console */ +/* eslint-disable n/shebang */ +/** + * Read-only status and worklist for one locale. + * + * node scripts/l10n/batch.js status + * node scripts/l10n/batch.js absent # JSON worklist on stdout + * + * ## The worklist is `absent ∪ identical`, every round + * + * `absent` deliberately returns keys that are MISSING **and** keys whose value is + * byte-identical to the key. Regenerating a worklist with only `!(k in loc)` finds + * the absent ones and silently skips the remaining placeholders — that happened + * mid-Catalan, where 26 were missed and only the status counts caught it. + * + * A key is excluded from the worklist when its identical value is a RECORDED + * cognate in scripts/l10n/locales/.json, because that is finished work. + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +'use strict' + +const path = require('path') +const { + loadJsTranslations, APP_ROOT, isIdentical, npluralsOf, loadLocaleConfig, +} = require('./lib.js') + +const cmd = process.argv[2] +const loc = process.argv[3] +if (!cmd || !loc) { + console.error('usage: batch.js status|absent ') + process.exit(2) +} + +const en = loadJsTranslations(path.join(APP_ROOT, 'l10n', 'en.js')) +const cur = loadJsTranslations(path.join(APP_ROOT, 'l10n', `${loc}.js`)) +const cfg = loadLocaleConfig(loc) +const NP = npluralsOf(cur.pluralForm) +const enKeys = Object.keys(en.translations) + +const absent = enKeys.filter(k => !(k in cur.translations)) +const identical = enKeys.filter(k => k in cur.translations && isIdentical(k, cur.translations[k])) +const unjustified = identical.filter(k => !(k in cfg.cognates)) + +if (cmd === 'absent') { + const out = [] + for (const k of enKeys) { + if (!(k in cur.translations)) { + out.push({ key: k, en: en.translations[k], why: 'absent' }) + } else if (isIdentical(k, cur.translations[k]) && !(k in cfg.cognates)) { + out.push({ key: k, en: en.translations[k], why: 'identical' }) + } + } + console.log(JSON.stringify(out, null, 1)) + process.exit(0) +} + +if (cmd === 'status') { + const badArity = Object.entries(cur.translations) + .filter(([, v]) => Array.isArray(v) && v.length !== NP) + .map(([k, v]) => [k, v.length]) + const empty = [] + for (const [k, v] of Object.entries(cur.translations)) { + for (const x of Array.isArray(v) ? v : [v]) { + if (!String(x).trim()) { + empty.push(k) + } + } + } + const extra = Object.keys(cur.translations).filter(k => !(k in en.translations)) + + console.log(`${loc}: ${Object.keys(cur.translations).length} keys / en ${enKeys.length}`) + console.log(` plural header: ${cur.pluralForm}`) + console.log(` -> nplurals=${NP}`) + console.log(` register (measured): ${cfg.register || 'NOT YET MEASURED'}`) + console.log(` absent: ${absent.length}`) + console.log(` value===key: ${identical.length} (${Object.keys(cfg.cognates).length} recorded as cognates)`) + console.log(` -> still to do: ${absent.length + unjustified.length}`) + if (unjustified.length) { + console.log(` UNJUSTIFIED identical: ${unjustified.length}`) + for (const k of unjustified.slice(0, 10)) { + console.log(` ${JSON.stringify(k)}`) + } + if (unjustified.length > 10) { + console.log(` … +${unjustified.length - 10} more`) + } + } + console.log(` bad arity: ${badArity.length}${badArity.length ? ' ' + JSON.stringify(badArity) : ''}`) + console.log(` empty values: ${empty.length}`) + console.log(` extra keys: ${extra.length}`) + process.exit(0) +} + +console.error(`unknown command: ${cmd}`) +process.exit(2) diff --git a/scripts/l10n/casing.js b/scripts/l10n/casing.js new file mode 100644 index 0000000000..d108377d7c --- /dev/null +++ b/scripts/l10n/casing.js @@ -0,0 +1,311 @@ +#!/usr/bin/env node +/* eslint-disable n/no-process-exit */ +/* eslint-disable no-console */ +/* eslint-disable n/shebang */ +/** + * Mid-sentence capitalisation of domain terms, for one bundle, against a baseline. + * + * node scripts/l10n/casing.js [--min-words=6] [--top=30] [--mine] [--all-terms] + * + * WHY THIS EXISTS. §8.10 says house conventions touch more values than register + * does and no gate can see them, and casing is the largest of them: it decided a + * third of `sr`'s values and was the whole dominant class of the `mk` pass (118 + * occurrences over 105 values). Five passes measured it by hand, and the runbook + * accumulated three separate notes on how the hand measurement misleads. Those + * three are encoded here so the next pass cannot repeat them. + * + * THE MEASUREMENT IS CONDITIONED ON THE ENGLISH KEY, and that is the whole point. + * A naive mid-sentence scan put `sq` at 25-35% against a family rate near zero — a + * tidy ~110-value defect class that does not exist. Restricted to prose it was + * 0 of 177: every hit was a Title-Cased heading correctly mirroring its source. + * So two populations are counted separately and never summed: + * + * PROSE — English key >= --min-words words AND not Title Case. A capital here + * has no source to mirror, so it is a candidate defect. + * LABEL — everything else. A capital here is usually the source's own Title + * Case showing through, so the report gives the rate and makes no + * claim. `mk` failed this side too (65:507 against core's 7:122), so + * it is reported rather than suppressed. + * + * The conditioned figure can still come back real: on `mk` it did, 41 against 146. + * What separates that from `sq`'s phantom is that it survives the restriction and + * that the bundle is INTERNALLY INCONSISTENT — so every term is printed with its + * own up:down split. A term that is 0:26 one way and 20:0 the other is two + * decisions, not one rule. Per §8.11, a uniform lemma is one decision copied. + * + * THE THREE WAYS THE HAND MEASUREMENT MISLED, all met on `mk`, all handled here: + * 1. A pattern without the `i` flag matches only the lowercase form, so every + * capitalised occurrence — the entire thing being measured — is invisible and + * each term reports a clean 0-up. Terms are indexed case-folded. + * 2. `(?<=.)` excludes the value's first word but NOT a later sentence's, so + * "… pretraga. Objekti se …" scores as a mid-sentence capital. Three `mk` + * first-cut hits were that, and each would have been "corrected" into an + * error. Sentence starts are tracked across the whole value. + * 3. An opening bracket, a leading emoji and deliberate all-caps each license a + * capital the same way a sentence start does — 14 of `mk`'s 132 raw hits were + * those. + * + * `--mine` splits the corpus at HEAD and measures only values this working tree + * added or changed. Whole-bundle counts hide a pass's own drift because the + * pre-existing majority outvotes it; that split caught 24 `mt` values, 6 on `is` + * and 44 on `lb`. Run it on your own half before calling a pass done. + * + * A READING AID, NEVER A GATE. Which side of a split is right is not this tool's + * call: review the word list before applying any fix, because a capital may be a + * proper noun, a product name or an acronym that this cannot know about. + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +'use strict' + +const fs = require('fs') +const path = require('path') +const { + loadJsTranslations, APP_ROOT, coreCatalogues, bundleAtHead, isIdentical, +} = require('./lib.js') + +const loc = process.argv[2] +if (!loc) { + console.error('usage: casing.js [--min-words=6] [--top=30] [--mine] [--all-terms]') + process.exit(2) +} +const num = (flag, dflt) => { + const a = process.argv.find((x) => x.startsWith(`--${flag}=`)) + return a ? Number(a.slice(flag.length + 3)) : dflt +} +const MIN_WORDS = num('min-words', 6) +const TOP = num('top', 30) +const MINE = process.argv.includes('--mine') +const ALL_TERMS = process.argv.includes('--all-terms') + +// ---------------------------------------------------------------- classification + +/** + * Title Case in the Nextcloud source means most content words capitalised, which + * is how headings and column labels are written. A value mirroring that is doing + * the right thing, so these keys must not be pooled with prose. + * + * @param {string} key English source string. + * @return {boolean} True when the key is written as a heading. + */ +function isTitleCase(key) { + const words = (key.match(/[A-Za-z][A-Za-z'-]*/g) || []).filter((w) => w.length > 3) + if (words.length < 2) return true + const capped = words.filter((w) => /^[A-Z]/.test(w)).length + return capped / words.length >= 0.6 +} + +/** + * @param {string} key English source string. + * @return {boolean} True when the key is prose: long enough to have sentence + * structure, and not a heading. + */ +function isProse(key) { + const words = key.match(/\S+/g) || [] + return words.length >= MIN_WORDS && !isTitleCase(key) +} + +// ------------------------------------------------------------------- the scanner + +/** + * Capitalised words that are NOT at a licensed position, i.e. genuinely + * mid-sentence. Traps 2 and 3 live here: sentence starts are tracked for every + * sentence in the value, not just the first, and brackets, quotes, bullets and + * the symbol runs the bundle uses as icons license a capital just as a full stop + * does. All-caps tokens are skipped — those are acronyms, not a casing decision. + * + * @param {string} value Target-language value. + * @return {string[]} The offending words, in order. + */ +function midSentenceCaps(value) { + const s = String(value) + .replace(/\{[^}]*\}/g, ' ') // placeholders carry the source's own casing + .replace(/`[^`]*`/g, ' ') + .replace(/https?:\/\/\S+/g, ' ') + const out = [] + let licensed = true // the start of the value is licensed + // Whether the previous word was unambiguously all-caps. A ONE-LETTER word is + // never unambiguous — "O" is both a preposition and a letter of an all-caps + // heading — so `letters.length > 1` alone cannot classify it, and every such + // word inside an all-caps run scored as a mid-sentence capital. That is the + // deliberate-all-caps license of trap 3 arriving one token later than expected. + let inCaps = false + const tokens = s.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*|[^\p{L}\p{N}\s]+|\s+/gu) || [] + for (const tk of tokens) { + if (/^\s+$/.test(tk)) continue + if (/^[\p{L}\p{N}]/u.test(tk)) { + const letters = tk.replace(/[^\p{L}]/gu, '') + const isUpperStart = /^\p{Lu}/u.test(tk) + const allCapsRun = letters.length > 1 && letters === letters.toUpperCase() + // A single uppercase letter counts as all-caps only while a run is open. + const isAllCaps = allCapsRun || (inCaps && letters.length === 1 && isUpperStart) + if (isUpperStart && !isAllCaps && !licensed) out.push(tk) + licensed = false + inCaps = isAllCaps + continue + } + // Punctuation. Decide whether what follows is licensed. Sentence-final marks + // license the next word (trap 2), and so do openers, bullets, slashes and the + // symbol runs used as icons (trap 3). A comma or a semicolon licenses + // nothing, which is the whole point of the check. + if (/[.!?:…]/.test(tk)) licensed = true + else if (/[([{"'«„“‘–—•|/\\]/u.test(tk)) licensed = true + else if (/\p{S}/u.test(tk)) licensed = true + } + return out +} + +const fold = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase() + +// -------------------------------------------------------------------- the corpus + +/** + * @param {object} tr A parsed catalogue's translations map. + * @param {Set|null} only Restrict to these keys, or null for all. + * @return {object} { tally, terms } + */ +function measure(tr, only = null) { + // up = capitalised mid-sentence, down = lowercase mid-sentence. Both are + // counted per term so internal inconsistency is visible (trap 1). + const terms = new Map() + const bump = (word, dir, pop, key) => { + const f = fold(word) + if (!terms.has(f)) { + terms.set(f, { proseUp: 0, proseDown: 0, labelUp: 0, labelDown: 0, egs: [] }) + } + const t = terms.get(f) + t[pop + dir]++ + if (dir === 'Up' && t.egs.length < 3) t.egs.push(key) + } + const tally = { + prose: { up: 0, down: 0, values: 0 }, + label: { up: 0, down: 0, values: 0 }, + } + + for (const [key, value] of Object.entries(tr)) { + if (only && !only.has(key)) continue + for (const v of [].concat(value)) { + if (typeof v !== 'string' || !v.trim()) continue + // A value byte-equal to its key is untranslated English and carries the + // source's casing, not the locale's. Counting it measures English. + if (isIdentical(key, v)) continue + const pop = isProse(key) ? 'prose' : 'label' + tally[pop].values++ + const caps = midSentenceCaps(v) + tally[pop].up += caps.length + for (const w of caps) bump(w, 'Up', pop, key) + // The lowercase side, for the per-term split: mid-sentence words that + // stayed lowercase. That split is what tells one decision from a rule. + const lower = v.match(/(?<=[\p{L}\p{N}],?\s)\p{Ll}[\p{L}'’-]{3,}/gu) || [] + tally[pop].down += lower.length + for (const w of lower) bump(w, 'Down', pop, key) + } + } + return { tally, terms } +} + +const { translations } = loadJsTranslations(path.join(APP_ROOT, 'l10n', `${loc}.js`)) + +let only = null +if (MINE) { + const head = bundleAtHead(loc) + if (!head) { + console.error(`casing ${loc}: --mine needs l10n/${loc}.js at HEAD; not found.`) + process.exit(2) + } + only = new Set() + for (const [k, v] of Object.entries(translations)) { + if (JSON.stringify(head[k]) !== JSON.stringify(v)) only.add(k) + } + console.log(`casing ${loc}: --mine — ${only.size} value(s) added or changed since HEAD\n`) +} + +const mine = measure(translations, only) + +// ----------------------------------------------------------------- the baselines + +/** @return {object} Tally over the sibling apps' frontend bundles for this locale. */ +function siblingBaseline() { + const appsDir = path.resolve(APP_ROOT, '..') + const merged = {} + let apps = 0 + for (const a of fs.readdirSync(appsDir).sort()) { + if (a === path.basename(APP_ROOT)) continue + const f = path.join(appsDir, a, 'l10n', `${loc}.js`) + if (!fs.existsSync(f)) continue + try { + // Frontend .js only — the backend .json is a separate catalogue with a + // separate consumer (§1) and its casing would be miscredited here. + Object.assign(merged, loadJsTranslations(f).translations || {}) + apps++ + } catch { /* a sibling with an unparseable bundle is not this pass's problem */ } + } + return { apps, ...measure(merged) } +} + +/** @return {object|null} Tally over Nextcloud core's catalogues, or null if none. */ +function coreBaseline() { + let files + try { files = coreCatalogues(loc) } catch { return null } + const merged = {} + for (const f of files) { + try { + Object.assign(merged, JSON.parse(fs.readFileSync(f, 'utf8')).translations || {}) + } catch { /* an unreadable core catalogue is not this pass's problem */ } + } + return { files: files.length, ...measure(merged) } +} + +const sib = siblingBaseline() +const core = coreBaseline() + +// --------------------------------------------------------------------- reporting + +const ratio = (t) => `${t.up}:${t.down}` +const pct = (t) => (t.up + t.down ? `${(100 * t.up / (t.up + t.down)).toFixed(1)}%` : 'n/a') + +console.log(`casing ${loc}: mid-sentence capitalisation, conditioned on the English key`) +console.log(`(prose = key >=${MIN_WORDS} words and not Title Case; label = everything else)`) +console.log() +console.log(' prose (up:down) label (up:down) values') +const row = (name, m, extra) => { + console.log(` ${name.padEnd(20)} ${ratio(m.tally.prose).padEnd(16)} ` + + `${ratio(m.tally.label).padEnd(16)} ${m.tally.prose.values + m.tally.label.values}` + + (extra ? ` ${extra}` : '')) +} +row('this bundle', mine) +row('sibling frontends', sib, `${sib.apps} app(s)`) +if (core) row('nextcloud core', core, `${core.files} catalogue(s)`) +else console.log(' nextcloud core (no catalogues for this locale)') +console.log() +console.log(` prose capitalisation rate: bundle ${pct(mine.tally.prose)}, ` + + `siblings ${pct(sib.tally.prose)}${core ? `, core ${pct(core.tally.prose)}` : ''}`) +console.log() + +// Terms are ranked by prose-up, because that is the only population where a +// capital is a candidate defect at all. +const ranked = [...mine.terms] + .filter(([, t]) => (ALL_TERMS ? t.proseUp + t.labelUp > 0 : t.proseUp > 0)) + .sort((a, b) => b[1].proseUp - a[1].proseUp || b[1].labelUp - a[1].labelUp) + +if (!ranked.length) { + console.log('No mid-sentence capitals in prose. If the label column is large, the bundle') + console.log('is mirroring Title Case from its source — that is the sq case, not a defect.') +} else { + console.log(`terms capitalised mid-sentence in PROSE (${ranked.length}), with each term's`) + console.log("own up:down split. A term that is one-sided in both populations is a") + console.log('convention; a term split against itself is two decisions (§8.11).') + console.log() + for (const [term, t] of ranked.slice(0, TOP)) { + console.log(` ${term.padEnd(22)} prose ${`${t.proseUp}:${t.proseDown}`.padEnd(9)} ` + + `label ${`${t.labelUp}:${t.labelDown}`.padEnd(9)}`) + for (const k of t.egs) console.log(` ${JSON.stringify(k.slice(0, 68))}`) + } + if (ranked.length > TOP) console.log(`\n… +${ranked.length - TOP} more below the top ${TOP}`) +} + +console.log() +console.log(`casing ${loc}: a reading aid; never fails. Review the word list before fixing —`) +console.log('a capital may be a proper noun, a product name or an acronym (§8.10).') diff --git a/scripts/l10n/core-diff.js b/scripts/l10n/core-diff.js new file mode 100644 index 0000000000..635bb1365e --- /dev/null +++ b/scripts/l10n/core-diff.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node +/* eslint-disable n/no-process-exit */ +/* eslint-disable no-console */ +/* eslint-disable n/shebang */ +/** + * Bundle vs Nextcloud core, key by key, for one locale. + * + * node scripts/l10n/core-diff.js [--all] [--min-core=2] + * + * WHY THIS EXISTS, and why it is the FIRST thing to run in an audit (§6.9). + * + * The `sk` pass checked core only AFTER forming candidate corrections, and core + * then overturned five of them — `Refresh`/`Restore` (core collapses them the + * same way the bundle does), `First`/`Last`/`Previous` and bare `Search` (core + * ships the bundle's exact wording). Each had looked like a textbook defect on + * grep evidence alone. The cs pass lost four the same way. + * + * Forming a candidate and then killing it is the expensive order. This report + * inverts it: read it before reading the bundle, and a value that AGREES with + * core is one you never question, while the DISAGREE list is a ranked worklist + * where the real terminology decisions live. On `sk` it would also have raised + * the Delete/Zmazať question in the first minute rather than an hour in. + * + * A disagreement is NOT a defect. Core is evidence, not authority — §3.5 says + * where the bundle and core differ on lexicon the bundle usually wins, and `hr` + * deliberately keeps `lozinka` over core's `zaporka`. Read this as "these are + * the places worth a decision", not as a fix list. + * + * Core often carries several renderings of one English key across its ~31 + * catalogues. All of them are printed with counts, because the split itself is + * evidence: `Delete` → Zmazať(6)/Vymazať(3) in core sk says both are available + * and neither is a slip, whereas a 1-vs-48 split usually means core has a typo. + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +'use strict' + +const fs = require('fs') +const path = require('path') +const { loadJsTranslations, APP_ROOT, coreCatalogues } = require('./lib.js') + +const loc = process.argv[2] +if (!loc) { + console.error('usage: core-diff.js [--all] [--min-core=2]') + process.exit(2) +} +const showAll = process.argv.includes('--all') +const minArg = process.argv.find((a) => a.startsWith('--min-core=')) +const minCore = minArg ? Number(minArg.slice('--min-core='.length)) : 1 + +// ---------------------------------------------------------------- core values + +/** + * Every rendering core gives each English key, with how many catalogues use it. + * + * @param {string} l Locale code. + * @return {Map>} key -> (value -> catalogue count) + */ +function coreValues(l) { + const out = new Map() + for (const f of coreCatalogues(l)) { + let json + try { + json = JSON.parse(fs.readFileSync(f, 'utf8')) + } catch { + continue + } + const t = json.translations + if (!t || typeof t !== 'object') continue + for (const [k, v] of Object.entries(t)) { + // Core stores plurals as arrays too; only bare strings compare cleanly. + if (typeof v !== 'string' || !v.trim()) continue + if (!out.has(k)) out.set(k, new Map()) + const m = out.get(k) + m.set(v, (m.get(v) || 0) + 1) + } + } + return out +} + +const core = coreValues(loc) +const { translations: bundle } = loadJsTranslations(path.join(APP_ROOT, 'l10n', `${loc}.js`)) + +// ------------------------------------------------------------------- classify + +const agree = [] +const disagree = [] + +for (const [key, value] of Object.entries(bundle)) { + if (Array.isArray(value)) continue + const cv = core.get(key) + if (!cv) continue + const total = [...cv.values()].reduce((a, b) => a + b, 0) + if (total < minCore) continue + const renderings = [...cv].sort((a, b) => b[1] - a[1]) + const row = { key, value, renderings, total } + if (cv.has(value)) agree.push(row) + else disagree.push(row) +} + +const fmt = (r) => r.renderings.map(([v, n]) => `${JSON.stringify(v)}×${n}`).join(' ') + +console.log(`core-diff ${loc}: ${core.size} English keys appear in core; ` + + `${agree.length + disagree.length} of them are in this bundle`) +console.log(` AGREE with core: ${agree.length} <- never question these`) +console.log(` DISAGREE with core: ${disagree.length} <- the worklist`) +console.log() +console.log('DISAGREE — bundle value first, then every core rendering with its catalogue count.') +console.log('A disagreement is evidence, not a verdict (§3.5): the bundle usually wins on lexicon.') +console.log() + +for (const r of disagree.sort((a, b) => b.total - a.total || a.key.localeCompare(b.key))) { + console.log(` ${JSON.stringify(r.key)}`) + console.log(` bundle: ${JSON.stringify(r.value)}`) + console.log(` core: ${fmt(r)}`) +} + +if (showAll) { + console.log() + console.log('AGREE — the bundle already matches a core rendering. Do NOT "fix" these.') + console.log() + for (const r of agree.sort((a, b) => a.key.localeCompare(b.key))) { + console.log(` ${JSON.stringify(r.key).padEnd(42)} ${JSON.stringify(r.value)}`) + } +} + +console.log() +console.log(`core-diff ${loc}: read the DISAGREE list before reading the bundle. ` + + 'This is a reading aid and never fails.') diff --git a/scripts/l10n/detectors/be.js b/scripts/l10n/detectors/be.js new file mode 100644 index 0000000000..a9d3bd897c --- /dev/null +++ b/scripts/l10n/detectors/be.js @@ -0,0 +1,273 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Belarusian register detector for openregister l10n. +// +// Measures the PROSE register, which is FORMAL: вы / ваш / 2pl verb forms. +// Core be carries ~165 formal markers against ZERO informal over 1873 values in +// 14 catalogues, and this bundle's own 1053 pre-existing values carry 24 formal +// and zero informal. Neither corpus contains a single "ты", "цябе", "табе" or +// "тво-" form, so the verdict rests on a one-sided formal count together with +// an asserted absence rather than on a ratio. +// +// THE 2SG IMPERATIVE IS COUNTED, which is the minority outcome — §6.5 wants +// both tests run and both come out usable here: +// +// Test 1 — is the imperative the label convention? NO. Belarusian labels are +// INFINITIVES. Resolving 44 bare action keys against core gives Захаваць / +// Выдаліць / Дадаць / Скасаваць / Рэдагаваць / Стварыць / Абнавіць / +// Капіяваць / Перайменаваць / Спампаваць / Пацвердзіць / Прымяніць / +// Уключыць / Адключыць / Працягнуць / Перамясціць / Усталяваць / Адхіліць / +// Прапусціць / Скінуць / Адправіць / Аднавіць / Выбраць / Схаваць / Прыняць — +// forty-odd infinitives and not one imperative. So counting 2sg imperatives +// does not flag a single button, which is what forces the exclusion in +// ca/et/hr/sl/sr/ga/mt/mk. This is the sk/rm/is/lb group instead. +// +// Test 2 — is the 2sg imperative a homograph of a live form? NO, for the +// closed list below. Belarusian builds the 2pl imperative as the 2sg PLUS +// "-це" (выберы → выберыце, захавай → захавайце, увядзі → увядзіце), so the +// two polarities are the same string differing by a suffix. That makes the +// trailing (?!\p{L}) guard the only thing separating them — §8.1, the West +// Slavic situation reached in a different family. Write the guard first. +// +// The one form given up is "май" (2sg imperative of "мець"), a homograph of the +// month name; it is in UNDETECTABLE rather than in the list. +// +// Why closed word lists and NOT suffix patterns, specifically for Belarusian: +// • "-це" looks like the 2pl ending, and it is ALSO the locative singular of +// every hard-stem masculine noun ending in -т/-с/-к: "фармаце", "праекце", +// "тэксце", "запыце", "пакеце", "стандарце", "пошце", "даце" all occur in +// these two corpora. A "-це" rule scores ordinary prepositional phrases — +// including "у гэтым фармаце" and "у праекце", both live in this bundle — +// as deferential address. +// • "-ш" looks like the 2sg present ending, but "ваш" (your-FORMAL) ends in +// it, so a "-ш" rule reads the formal possessive as informal and inverts the +// polarity outright. The corpora also carry "больш" (17), "менш", "перш", +// "найбольш", "клавіш", and the app's own "хэш" (3) and "кэш" — every one a +// non-verb. +// • "-ы"/"-і" is the commonest 2sg imperative ending AND the commonest plural +// and genitive-singular ending in the language, so only the enumerated verb +// forms below are safe. +// +// Two NEGATIVE results, both measured rather than assumed: +// • bare "ты" IS usable. Belarusian's demonstrative is "гэты"/"гэтая"/"гэтыя", +// never bare "ты", so unlike cs "ty", hr "ti" and sl "ti" there is no +// demonstrative reading to give up. Zero occurrences in the 2928-value +// corpus, so the assertion is that the token is absent, not that it is +// disambiguated. +// • Belarusian's acronym for AI is "ШІ" (штучны інтэлект) — this bundle writes +// "ШІ-агент", "ШІ-функцыі" — and it is NOT a homograph of any register +// marker. So the mk "ВИ"/"ви" problem does not replicate here and fold() can +// lowercase freely. Worth recording as the measured negative it is: the +// question is per language, and the answer here is no. +// +// The left guard carries the hyphen anyway. Belarusian forms hyphenated acronym +// compounds ("ШІ-агент", "API-ключ", "URL-адрас"), and while a marker after the +// hyphen is measured at zero occurrences in both corpora, the guard costs no +// recall and keeps an acronym-final "вы" or "ты" out of the pronoun bucket. +// +// JS \b is ASCII-only and would treat "ў", "і" and "ё" as boundaries, so every +// guard is (? 0 ? 'informal' : f > 0 ? 'formal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + const { scanCoreRegister, reportCoreRegister } = require('../lib.js') + reportCoreRegister('be', scanCoreRegister('be', score), { formal: 'вы', informal: 'ты' }) +} diff --git a/scripts/l10n/detectors/bg.js b/scripts/l10n/detectors/bg.js new file mode 100644 index 0000000000..0e07da870f --- /dev/null +++ b/scripts/l10n/detectors/bg.js @@ -0,0 +1,215 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Bulgarian register detector for openregister l10n. +// +// Measures the PROSE register. Bulgarian labels take the VERBAL NOUN +// (отглаголно съществително, "Запазване" / "Изтриване" / "Създаване"), which is +// person-neutral and therefore invisible to a register detector — the same +// comfortable situation sk has with its infinitive, and the opposite of +// ca/et/hr/sl where the label form is itself a finite verb. +// +// THE 2SG IMPERATIVE IS HALF-DETECTABLE HERE, which is new. The other nine +// recorded locales are all-or-nothing: sk counts the imperative, ca/et/hr/sl +// exclude it entirely. Bulgarian splits along conjugation class: +// +// • и-conjugation (-я/-иш) imperatives are UNUSABLE. "запази" is at once the +// 2sg imperative, the 3sg present ("може да запази") and the 3sg aorist +// ("той запази"). Both readings occur in this bundle's own prose: +// "Анализът завърши:" and "Изтриването завърши" are aorists spelled exactly +// like the imperatives of "завърша". Same for провери / добави / отвори / +// потвърди / запази. +// • а-conjugation (-ай/-вай/-й) imperatives ARE unambiguous, because the 3sg +// present of those verbs ends in -а/-ва: "опитай" vs "опитва", "изпълнявай" +// vs "изпълнява", "записвай" vs "записва", "копирай" vs "копира". Nothing +// else in the language is spelled that way. +// +// So the -й class is enumerated as informal and the -и class is recorded in +// UNDETECTABLE. That split is not cosmetic: it is what catches the two informal +// slips this bundle already shipped ("Изпълнявай надзорни проверки…", +// "Записвай одитен запис…"), which a whole-class exclusion would have missed. +// +// Why closed word lists and NOT suffix patterns, specifically for Bulgarian: +// • "-те" is the 2pl ending, present and imperative alike — and it is ALSO the +// DEFINITE PLURAL ARTICLE, the single most common morpheme in the language. +// "файловете", "обектите", "потребителите", "Членовете", "настройките" are +// all nouns. A "-те" rule scores essentially every plural noun phrase in the +// app as formal prose and the measurement becomes noise. +// • "-ш" looks like the 2sg present ending, but "ваш" (your-FORMAL) and "наш" +// (our) both end in it, so a "-ш" rule reads the formal possessive as +// informal and inverts the polarity outright — the same trap as hr, sk and sl. +// • bare "те" is unusable: besides the 2sg accusative clitic ("изпраща те") it +// is the 3pl pronoun "they". This bundle already says "Те могат да бъдат +// възстановени по-късно" about objects. +// • bare "си" is unusable: besides the 2sg of "съм" it is the reflexive +// possessive clitic, which is at its most frequent in FORMAL prose — +// "за да прецизирате търсенето си". +// • "трябва" is not a person marker at all. It is impersonal 3sg; the register +// in "Трябва да сте влезли" is carried by "сте", not by "трябва". +// Bare "ти" IS usable, unlike the Slavic locales done so far: Bulgarian lost its +// case system and its demonstrative plural is "тези"/"тия", so "ти" has no +// demonstrative reading the way cs "ty", hr "ti" and sl "ti" do. +// +// JS \b is ASCII-only and would treat "ъ" as a boundary, so every guard is +// (? 0 ? 'informal' : f > 0 ? 'formal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + const { scanCoreRegister, reportCoreRegister } = require('../lib.js') + reportCoreRegister('bg', scanCoreRegister('bg', score), { formal: 'вие', informal: 'ти' }) +} diff --git a/scripts/l10n/detectors/bs.js b/scripts/l10n/detectors/bs.js new file mode 100644 index 0000000000..c2d98ae3ad --- /dev/null +++ b/scripts/l10n/detectors/bs.js @@ -0,0 +1,277 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Bosnian register detector for openregister l10n. +// +// CORE IS NOT EVIDENCE FOR THIS LOCALE, and this is the shape §2.3 warns about: +// Nextcloud ships exactly ONE bs catalogue (44 usable values), so +// scanCoreRegister('bs') SUCCEEDS rather than throwing, and would report a +// verdict computed from almost nothing. Zero catalogues is the safe failure; one +// thin catalogue is the dangerous one. The marker count is what matters, not the +// catalogue count, so the main block prints core's own count and then measures the +// §6.4 fallback corpus: this bundle plus the sibling apps' frontend bs.js. +// +// Measured 217 second-person PLURAL (Vi) markers against ZERO singular over +// ~2263 frontend values. Counts and the per-app split are in locales/bs.json. +// This is the `hr` case and NOT the `mt`/`ga` case: Bosnian has a full T-V +// distinction and this register uses the V-form, so the deviation this gate looks +// for is a real `ti` slip rather than an impossible form. +// +// THE PROSE REGISTER AND THE BUTTON CONVENTION DIFFER, so only prose is scored. +// The bare 2sg imperative is the correct Bosnian label style — `Sačuvaj`, +// `Obriši`, `Otkaži`, `Kreiraj`, `Ažuriraj`, `Osvježi` — and counting it would +// flag every button in the app. That is §7.3 pattern 2, as in Croatian. +// +// THE PROMPT OVERRIDE IS LEXICALLY BOUNDED, and where the boundary falls had to +// be measured rather than carried over from `mk`. The English verb decides: +// `Select …` -> `Odaberi …` 2sg, 20 of 20, at 14 to 45 characters +// `Choose …` -> `Odaberite …` 2pl, 5 of 5, at 15 to 128 characters +// So it is not `sq`'s length grading, and it is not `mk`'s "Select/Choose/Enter +// all take 2pl" either — the line runs BETWEEN Select and Choose here. `Enter …` +// goes with Choose (`Unesite …`, 5 of 5). Measure this per verb, per locale. +// +// Why closed word lists and NOT suffix patterns, specifically for Bosnian: +// • "-te" is the 2pl imperative/present ending AND the accusative plural of +// every masculine noun. This bundle has `Objekte` 11 times and `entitete` 4. +// A "-te" rule scores ordinary noun phrases as formal. +// • "-š" is the 2sg present ending, but `vaš` — the FORMAL possessive — ends in +// it, as does `još` (10 occurrences here). A "-š" rule inverts the polarity on +// the single most common formal marker. +// • bare "ti" is unusable: it is also the masculine nominative plural of `taj`, +// so "ti Objekti" means "those Objects". +// • bare "si" is unusable: besides 2sg `biti` it is the reflexive dative clitic, +// which appears in formal prose ("možete si odabrati"). +// • "svoj-" is excluded on TWO independent counts. It is person-neutral, so it +// signals no register; and it is the stem of `svojstvo` ("property"), which +// occurs 30+ times in this bundle as one of the app's first-class nouns. A +// `svoj-` rule would match the app's own vocabulary. +// +// THE HYPHEN QUESTION, asked because §8.3 requires it and answered differently +// from Albanian. Bosnian attaches case endings to Latin-script loanwords across a +// hyphen — `webhook-a`, `webhook-u`, `VAPID-om` — so a guard of `(? 0 ? 'informal' : f > 0 ? 'formal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + const path = require('path') + const fs = require('fs') + const { + scanCoreRegister, loadJsTranslations, APP_ROOT, isIdentical, + } = require('../lib.js') + + // Core exists but is one thin catalogue, so it is REPORTED and then set aside + // rather than trusted. Printing its marker count is the point: a verdict from + // 0 markers over 44 values is what §2.3 tells you not to record. + let core = null + try { + core = scanCoreRegister('bs', score) + console.log(`\ncore bs: ${core.files.length} catalogue(s), ${core.values} value(s), ` + + `${core.formal} formal / ${core.informal} informal marker(s)`) + console.log('core is NOT the evidence for this locale — too thin to decide anything. ' + + 'Falling back to §6.4.') + } catch (e) { + console.log(`\nno core bs catalogues: ${e.message.split('.')[0]}`) + } + + // §6.4 fallback: this bundle plus the sibling apps' FRONTEND bs.js. The + // backend bs.json is excluded — separate catalogue, separate consumer (§1) — + // and that exclusion is load-bearing here, because openbuild ships a Croatian + // catalogue under bs.json. + const corpus = [] + const own = loadJsTranslations(path.join(APP_ROOT, 'l10n', 'bs.js')).translations + for (const [k, v] of Object.entries(own)) { + // A value byte-equal to its key is untranslated English and cannot carry + // Bosnian register; counting it would dilute both totals. + if (isIdentical(k, v)) continue + for (const x of [].concat(v)) if (typeof x === 'string' && x) corpus.push(['own', x]) + } + const appsDir = path.resolve(APP_ROOT, '..') + for (const a of fs.readdirSync(appsDir).sort()) { + if (a === path.basename(APP_ROOT)) continue + const f = path.join(appsDir, a, 'l10n', 'bs.js') + if (!fs.existsSync(f)) continue + try { + for (const v of Object.values(loadJsTranslations(f).translations || {})) { + for (const x of [].concat(v)) if (typeof x === 'string' && x) corpus.push([a, x]) + } + } catch { /* a sibling with an unparseable bundle is not this pass's problem */ } + } + + const per = new Map() + let formal = 0 + let informal = 0 + const hits = [] + for (const [app, x] of corpus) { + const s = score(x) + formal += s.f + informal += s.i + if (!per.has(app)) per.set(app, { f: 0, i: 0, n: 0 }) + const p = per.get(app) + p.f += s.f + p.i += s.i + p.n++ + if (s.i > 0) hits.push(x.slice(0, 100)) + } + console.log(`\nscanned ${corpus.length} frontend value(s) (this bundle + sibling apps)`) + for (const [app, p] of per) { + console.log(` ${app.padEnd(16)} ${String(p.n).padStart(5)} value(s) ` + + `${p.f} formal / ${p.i} informal`) + } + console.log(`formal (Vi / vaš) markers: ${formal}`) + console.log(`informal (ti / tvoj) markers: ${informal}`) + console.log(`verdict: ${formal > informal * 3 ? 'FORMAL' : informal > formal * 3 ? 'INFORMAL' : 'MIXED — inspect'}`) + for (const v of hits.slice(0, 20)) console.log(` informal? ${v}`) + console.log('\nread this as: Bosnian HAS a T-V distinction and this register uses the') + console.log('V-form — the hr case, not the mt/ga case. The load-bearing figure is the') + console.log(`INFORMAL count: it must be 0, and is ${informal}.`) +} diff --git a/scripts/l10n/detectors/ca.js b/scripts/l10n/detectors/ca.js new file mode 100644 index 0000000000..bd5fe50781 --- /dev/null +++ b/scripts/l10n/detectors/ca.js @@ -0,0 +1,111 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Catalan register detector for openregister l10n. +// +// Catalan software (Softcatalà style) splits by string ROLE, not by one global +// register, so a single formal/informal verdict would be meaningless here: +// • short commands / button labels -> 2sg imperative ("Desa", "Cancel·la") +// • prose addressed to the user -> vós, 2pl ("Seleccioneu", "Introduïu") +// The detector therefore measures the PROSE register only, via possessives and +// pronouns plus a closed list of instruction verbs in both forms. +// +// Why closed lists and not suffix patterns: +// • "-eu" is not a reliable vós marker: "correu" (mail), "museu", "europeu", +// "recreu" all end in -eu and are nouns/adjectives. +// • the 2sg imperative is a homograph of the 3sg present indicative for most +// verbs — "desa" is both "save!" and "he/she saves", "canvia" is both +// "change!" and "changes", "activa" is also the feminine adjective "active". +// So a bare 2sg imperative cannot be counted as an informal marker at all. +// What IS unambiguous is the possessive/pronoun choice, so that carries the +// verdict and the verb lists only corroborate it. + +function fold(s) { + return String(s).toLowerCase() +} + +// vós / formal prose +const FORMAL_RES = [ + // possessives — unambiguous + /(? 0 ? 'informal' : f > 0 ? 'formal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + const { scanCoreRegister, reportCoreRegister } = require('../lib.js') + reportCoreRegister('ca', scanCoreRegister('ca', score), { formal: 'vós', informal: 'tu' }, 15) +} diff --git a/scripts/l10n/detectors/cs.js b/scripts/l10n/detectors/cs.js new file mode 100644 index 0000000000..9f57ce43da --- /dev/null +++ b/scripts/l10n/detectors/cs.js @@ -0,0 +1,255 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Czech register detector for openregister l10n. +// +// Measures the prose register AND the 2sg imperative. Czech labels its buttons +// with the INFINITIVE ("Uložit", "Smazat", "Přidat" — 27 of the resolved action +// keys in core cs, zero imperatives), so a bare 2sg imperative is not a +// competing label convention here, it is familiar address. That is the same +// situation as Slovak and the opposite of hr/ca/et/sl/sr, where counting the +// imperative would flag every button. +// +// THE BOUNDARY GUARD IS THE WHOLE DETECTOR, and Czech makes it more load-bearing +// than any locale done so far. Two independent reasons, both measured: +// +// • EVERY Czech 2sg imperative is a PROPER PREFIX of its 2pl counterpart, +// because the 2pl is formed by suffixing -te: vyber/vyberte, +// zadej/zadejte, přidej/přidejte, nastav/nastavte, zobraz/zobrazte, +// použij/použijte, ponech/ponechte, zvol/zvolte, smaž/smažte. This bundle +// contains 64 "vyberte" and core 48 "zadejte". An unguarded 2sg list would +// therefore score the single commonest FORMAL shape in the corpus as +// informal and invert the verdict outright — a far bigger hazard than the +// Slovak "vy-" prefix, because it hits the markers themselves rather than +// unrelated vocabulary. +// • The informal possessive "tvá" is a SUBSTRING OF "vytvářet"/"vytváření" +// ("to create"/"creating"), which a raw scan finds 48 times across core and +// this bundle and which is one of the commonest verbs in this app. Same +// polarity-inverting shape. "vytvoř" (2sg imperative) sits inside +// "vytvoření" the same way. +// +// Several stems are also prefixes of the app's own domain nouns: "nastav" ⊂ +// "nastavení" (the commonest noun in the bundle), "zobraz" ⊂ "zobrazení", +// "obnov" ⊂ "obnovení", "ulož" ⊂ "uložené", "smaž" ⊂ "smazané". All are +// must-not-fire controls below. +// +// DIACRITICS ARE NOT FOLDED, as in sk.js and sl.js, and three live distinctions +// ride on that: +// • "vyber" (2sg imperative) vs "výběr" ("selection", 10 in core / 4 here); +// • "uprav" (2sg imperative) vs "úprav" (genitive plural of "úprava"); +// • "změň" (2sg imperative) vs "změn" (genitive plural of "změna", 6 in core). +// fold() therefore only lowercases. +// +// BARE "ty" AND "ti" ARE DELIBERATELY UNMATCHED — §8.2. Both are at once the +// informal pronoun and the plural demonstrative ("ty soubory" = "those files", +// "ti uživatelé" = "those users"), and unlike Slovak — where the acute splits +// dative "ti" from demonstrative "tí" — Czech spells the two identically, so +// there is no diacritic to recover the distinction. Measured: "ty" occurs 6 +// times in core, every one the demonstrative; "ti" occurs 0 times. Recall is +// recovered from the oblique forms (tě, tebe, tobě, tebou), the possessive and +// the verbs, exactly as §8.2 prescribes for cs/hr/sl. +// +// BARE "si" IS UNMATCHED, and for a DIFFERENT reason from hr/sk/sl. In those +// languages "si" is the 2sg of "to be" as well as the reflexive dative clitic, +// so it is an ambiguous informal marker. Czech's 2sg of "být" is "jsi", so "si" +// is ONLY the reflexive clitic — it carries no address information in either +// direction and there is nothing to disambiguate. It occurs 49 times in core and +// 8 here, always reflexive ("můžete si vybrat"). A useful negative: do not port +// the hr/sk/sl "ambiguous si" reasoning to Czech. +// +// Czech "prosím" ("please") is a 1sg present verb — it inflects for the SPEAKER, +// not the addressee — so unlike Maltese "jekk jogħġbok" it carries no address +// marker and gives no free signal (§8.1). In "Počkejte prosím" the register is +// carried by "počkejte" alone. +// +// JS \b is ASCII-only and would treat "ě" as a boundary, so every guard is +// (? ' + + '"udělals") is a colloquial form appended to an open set of past ' + + 'participles, so it cannot be enumerated'], +] + +function score(s) { + const t = fold(s) + let f = 0 + let i = 0 + // Fresh regex per call: a reused /g/ carries lastIndex and silently turns + // later matches into misses. + for (const re of FORMAL_RES) f += (t.match(new RegExp(re.source, re.flags)) || []).length + for (const re of INFORMAL_RES) i += (t.match(new RegExp(re.source, re.flags)) || []).length + return { f, i } +} + +function runControls() { + let fail = 0 + for (const [s, want] of CONTROLS) { + const { f, i } = score(s) + const got = i > 0 ? 'informal' : f > 0 ? 'formal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + const { scanCoreRegister, reportCoreRegister } = require('../lib.js') + reportCoreRegister('cs', scanCoreRegister('cs', score), { formal: 'vy', informal: 'ty' }) +} diff --git a/scripts/l10n/detectors/et.js b/scripts/l10n/detectors/et.js new file mode 100644 index 0000000000..173c632689 --- /dev/null +++ b/scripts/l10n/detectors/et.js @@ -0,0 +1,126 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Estonian register detector for openregister l10n. +// +// Like Catalan, Estonian UI splits by string ROLE, so one global verdict would be +// meaningless: core uses the bare 2sg imperative for buttons ("Salvesta", +// "Kustuta", "Vali") regardless of how prose addresses the user. This detector +// therefore measures the PROSE register only. +// +// Why closed verb lists and not suffix patterns: +// • "-ge"/"-ke" is not a reliable 2pl-imperative marker — "selge" (clear), +// "helge" (bright), "märge" (note), "kirge" (passion, gen.) all end that way +// and are adjectives or nouns. +// • the bare 2sg imperative is a homograph of nouns and names: "Lisa" is both +// "add!" and "addition"/a given name, "Ava" is both "open!" and a name, +// "Vali" is both "choose!" and "loud". It is also the button convention, so +// counting it as informal would flag every button in the app. +// The unambiguous informal signals are the sina-series pronouns and the 2sg +// present tense in -d, so those carry the verdict. + +function fold(s) { + return String(s).toLowerCase() +} + +// teie / formal 2pl +const FORMAL_RES = [ + // "teist" is deliberately EXCLUDED: it is a homograph. As the elative of "teie" + // it means "of you", but it is also the partitive of "teine" ("another", + // "second"), which is far more common in UI prose — "Proovi teist otsingut" + // ("try another search") is informal 2sg and was being scored as formal. + /(? 0 ? 'informal' : f > 0 ? 'formal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + const { scanCoreRegister, reportCoreRegister } = require('../lib.js') + reportCoreRegister('et', scanCoreRegister('et', score), { formal: 'teie', informal: 'sina' }, 15) +} diff --git a/scripts/l10n/detectors/ga.js b/scripts/l10n/detectors/ga.js new file mode 100644 index 0000000000..71d4d2073b --- /dev/null +++ b/scripts/l10n/detectors/ga.js @@ -0,0 +1,309 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Irish (Gaeilge) register detector for openregister l10n. +// +// IRISH HAS NO T-V DISTINCTION, and that is the single fact to understand before +// reading the word lists below. `sibh` is strictly the second-person PLURAL in +// modern standard Irish; it is not a polite singular the way German `Sie`, +// Romansh `Vus` or Croatian `Vi` are. So unlike every locale done before it, +// there is no register CHOICE here to measure — there is one way to address a +// user, and it is `tú`. +// +// The measurement bears that out and is one-sided beyond any other locale in the +// set: across core's 33 ga catalogues (5395 values) there are 434 second-person +// singular markers and ZERO second-person plural markers. Not one occurrence of +// sibh, sibhse, bhur, agaibh, daoibh, libh, oraibh, uaibh, chugaibh, díbh, or an +// -aigí/-igí 2pl imperative — verified by raw grep as well as by this scan. This +// bundle's own 1036 pre-existing values agree: 15 singular markers, 0 plural. +// +// locales/ga.json therefore records `"register": "informal"`, which needs saying +// plainly: it does NOT mean Irish core chose the familiar register over a polite +// one. It means the gate's polarity is set so that patchcheck refuses SECOND- +// PERSON PLURAL address. That is the one register defect this locale can have, +// and it is a live risk rather than a theoretical one: a translator working down +// a list of European locales imports the politeness plural by analogy from +// de/fr/nl and produces `An bhfuil sibh cinnte…` for a single-user dialog. No +// other gate in the project can see that. +// +// WHY THE 2SG IMPERATIVE IS NOT COUNTED — §6.5, and it fails BOTH tests: +// +// • Test 1: it IS this locale's label convention. Core labels buttons with the +// bare imperative — Sábháil, Scrios, Cealaigh, Cruthaigh, Deimhnigh, +// Cóipeáil, Roghnaigh, Bain, Dún, Bog, Athnuaigh, Athchóirigh — exactly as +// English does. Counting it would flag every button in the app. +// +// • Test 2: for the whole `-áil` class the imperative is spelled IDENTICALLY +// to the verbal noun, and the verbal noun is live in this bundle's prose as +// the progressive: `Ag sábháil...` ("Saving..."), `Ag cóipeáil...`, +// `Ag tástáil...`, `Ag próiseáil...`, `Ag íoslódáil...` are all real values +// whose second word is the imperative form. Several stems are ordinary nouns +// besides — `Scrios` is also "destruction", `Dún` also "a fort", `Cuardach` +// also "a search" — and `Léigh`/`Léigh` is the imperative of a verb whose +// verbal noun `léamh` this bundle uses in `á léamh`. +// +// So ga lands where ca/et/hr/sl/sr land, not where sk does. Note that unlike +// rm, the two tests agree here rather than pulling apart: Irish is an +// imperative-label language AND its imperative is a homograph, so the +// exclusion is doubly forced. +// +// WHY CLOSED WORD LISTS AND NOT SUFFIX PATTERNS, specifically for Irish: +// +// • `-igí` / `-aigí` looks like the 2pl imperative ending and mostly is. But it +// is also the plural of every noun in `-ig`: `oifig` -> `oifigí` ("offices"), +// and Irish forms plenty of plurals that way. An `-igí` rule would score an +// ordinary noun plural as deference. (No such word happens to occur in the +// 6431-value corpus scanned here, which is exactly why a suffix rule would +// have looked safe and shipped.) +// +// • `-ibh` looks like a 2pl prepositional-pronoun ending and often is (`libh`, +// `daoibh`, `fúibh`, `díbh`). But it is ALSO the dative plural of every noun +// in the older orthography and the ending of `díobh` / `dóibh` / `uathu`-type +// THIRD-person plurals. `díbh` is "off you (pl)" while `díobh` is "off them" +// — one letter apart, and it is the THIRD-person one that occurs in this +// corpus, twice, both times genuinely "of them" (`gach ball díobh seo`, +// `gach ceann díobh a shárú`). Matching `-ibh` would score both as formal +// address. Only `díbh` is listed; `díobh` is deliberately absent. +// +// • `do` is the 2sg possessive "your" AND the preposition "to/for" AND the +// past-tense verbal particle AND half of `le do thoil` ("please"). It occurs +// in 527 of the 6431 corpus values, overwhelmingly not as a possessive. It is +// excluded wholesale; see UNDETECTABLE for what that costs. +// +// DIACRITICS ARE NOT FOLDED. The fada is contrastive in Irish (`ár` "our" vs +// `ar` "on", `sé` vs `se`), and there is no reason to strip it here: none of the +// tokens below need folding to match, and stripping invites collisions that +// cannot be predicted from the lists themselves. fold() only lowercases — the +// sk/sl/rm precedent. +// +// JS \b is ASCII-only and would treat á/é/í/ó/ú as boundaries, so every guard is +// (? 0 ? 'formal' : i > 0 ? 'informal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + const { scanCoreRegister, reportCoreRegister } = require('../lib.js') + const r = scanCoreRegister('ga', score) + reportCoreRegister('ga', r, { formal: 'sibh, 2pl', informal: 'tú, 2sg' }) + // The verdict string is computed from formal-vs-informal totals, so for a + // locale whose correct register is the singular it reads INFORMAL. Spell out + // what that means so the number is not misread as a style finding. + console.log('\nread this as: Irish has no T-V distinction. `informal` names the ONLY') + console.log('address form available, and the load-bearing figure is the FORMAL count —') + console.log(`it must be 0, and is ${r.formal}. Any 2pl address in a single-user UI is a defect.`) +} diff --git a/scripts/l10n/detectors/hr.js b/scripts/l10n/detectors/hr.js new file mode 100644 index 0000000000..cc6a169c43 --- /dev/null +++ b/scripts/l10n/detectors/hr.js @@ -0,0 +1,143 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Croatian register detector for openregister l10n. +// +// Measures the PROSE register only, because — like Catalan and Estonian — the +// Croatian button convention is independent of it: the bare 2sg imperative +// ("Spremi", "Izbriši", "Odaberi") is standard for short labels no matter how +// prose addresses the user. Counting those would flag every button in the app. +// +// Why closed word lists and NOT suffix patterns, specifically for Croatian: +// • "-te" looks like the 2pl imperative/present ending, but it is also the +// accusative plural of every masculine noun: "dokumente", "objekte", +// "atribute", "elemente", "filtere". And "te" is a bare conjunction ("and"). +// A "-te" suffix rule scores ordinary noun phrases as formal. +// • "-š" looks like the 2sg present ending, but "naš" (our) and "vaš" +// (your-formal) both end in it, as do "još" (still) and "koš". A "-š" rule +// scores the FORMAL possessive as informal — polarity-inverting noise. +// • bare "ti" is NOT usable as an informal marker: it is also the masculine +// nominative plural of "taj", so "ti objekti" means "those objects". +// • bare "si" is skipped too — besides 2sg "biti" it is the reflexive dative +// clitic, which appears in formal sentences ("možete si odabrati"). +// The unambiguous signals are the possessive stems (tvoj- / vaš-), the oblique +// pronoun forms, and closed lists of high-frequency finite verbs. + +function fold(s) { + return String(s).toLowerCase() +} + +// Vi / formal 2pl. Case-insensitive on purpose: Croatian politeness capitalises +// "Vi/Vas/Vaš", but plain 2pl "vi/vas/vaš" is the same register in a UI string. +const FORMAL_RES = [ + /(? 0 ? 'informal' : f > 0 ? 'formal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + const { scanCoreRegister, reportCoreRegister } = require('../lib.js') + reportCoreRegister('hr', scanCoreRegister('hr', score), { formal: 'Vi', informal: 'ti' }) +} diff --git a/scripts/l10n/detectors/is.js b/scripts/l10n/detectors/is.js new file mode 100644 index 0000000000..5bb2a9023e --- /dev/null +++ b/scripts/l10n/detectors/is.js @@ -0,0 +1,396 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Icelandic (íslenska) register detector for openregister l10n. +// +// ICELANDIC HAD A T-V DISTINCTION AND IT IS NOW OBSOLETE. That is the fact to +// understand first, and it is a THIRD distinct situation from the two locales +// done immediately before this one — the three should not be collapsed: +// +// • ga — Irish never had a T-V distinction at all. `sibh` is strictly plural. +// There is no choice to record. +// • mt — Maltese HAS one and it is current. `intom` works as a polite singular +// and `Is-Sinjur` as the deferential register; both are simply unused, so the +// verdict is an ordinary measured choice. +// • is — Icelandic HAD one (þérun: `þér` plus a 2pl verb, possessive `yðar`) +// and abandoned it during the 20th century. The forms still exist in legal, +// liturgical and deliberately archaic register, so using them is possible in +// a way Irish 2pl-as-polite is not — but in a 2026 admin UI `Hafið þér +// aðgang?` would read as parody, not deference. +// +// So `informal` here records a live language state, not a preference, and not an +// absence. The measurement: +// +// core is: 28 catalogues, 3610 values. +// 2sg pronoun + possessive markers 482 occurrences +// clean enclitic imperatives 178 values +// archaic polite V-form (yður/yðar/yðvar/þéra/þérun) ZERO +// plain 2pl address (þið/ykkur/ykkar) ZERO +// +// Both zeroes were re-checked by raw grep over the 28 catalogues, not just by +// this scan — §8.3 says a measurement that lands on exactly zero deserves a +// second look before it goes into registerEvidence, and a nine-token grep is +// what that looked like here. +// +// THE GATE CATCHES TWO DIFFERENT DEFECTS, deliberately under one polarity, and +// they are worth keeping distinct in your head because they are not the same +// mistake: +// +// 1. ARCHAIC DEFERENCE — `yður`, `yðar`, or nominative `þér` with a 2pl verb. +// Wrong because it is obsolete, not because it is polite. +// 2. WRONG NUMBER — `þið`, `ykkur`, `ykkar`. These are the ordinary modern +// 2nd person PLURAL, entirely correct Icelandic when addressing several +// people, and simply wrong in a single-user UI. This is the more likely +// slip of the two: a translator importing a Continental politeness plural +// from de/fr/nl reaches for the plural that is actually current, because +// they would have to know Icelandic philology to reach for `yðar`. +// +// WHY THE ENCLITIC IMPERATIVE IS COUNTED, AND ONLY FOR ONE CONJUGATION CLASS — +// §6.5, and this is the bg outcome (PARTIALLY detectable, split by class) rather +// than a whole-paradigm exclusion: +// +// Test 1: is the imperative this locale's LABEL convention? NO. Core labels +// buttons with the INFINITIVE — Vista, Eyða, Breyta, Afrita, Færa, Staðfesta, +// Virkja, Endurstilla, Endurheimta, Skoða, Leita, Loka, Búa til, Bæta við, +// Hætta við — 29 of 35 distinct values across ~35 bare action keys, with zero +// verbal nouns and exactly ONE enclitic imperative (`Choose` = `Veldu`, +// against `Select` = `Velja`). This bundle's own 21 action keys agree +// unanimously. So is joins cs/lt/lv/sk/rm on the register-neutral infinitive, +// and counting the imperative does NOT flag every button. +// +// Test 2: is the imperative a homograph? PARTIALLY, and the split is exactly +// by conjugation class, which makes it a rule rather than a word list: +// +// · Class 1 (-a verbs) form the 3pl past in -uðu while the imperative plus +// enclitic þú is -aðu. notaðu/notuðu, skoðaðu/skoðuðu, afritaðu/afrituðu, +// prófaðu/prófuðu, virkjaðu/virkjuðu. DISTINCT, so usable. +// · Class 2 (-ja/-ta/-la verbs, past in -ti/-di/-ði) form the 3pl past +// IDENTICALLY to the imperative plus enclitic: settu, sendu, smelltu, +// reyndu, breyttu, ýttu, staðfestu, skráðu, endurstilltu, eyddu, gerðu. +// `þeir settu` is "they put" and `Settu inn` is "enter!". UNUSABLE. +// +// And the collision is demonstrated in the corpus rather than merely possible, +// which is what settles it: `komu` occurs 4 times as the 3pl past ("Það komu +// of margar beiðnir" — "too many requests came"), and `völdu` twice as the weak +// adjective *selected* ("Ekki hægt að lesa völdu skrána", "úr völdu sniðmáti"), +// never as an imperative. `staðfestu` is trebly ambiguous: imperative, 3pl +// past, AND the oblique of the noun `staðfesta` ("confirmation"). +// +// The excluded class is not small — `settu` alone has 41 values — but most of +// it is recovered anyway, because those values almost always carry a pronoun or +// possessive too: "Skráðu þig inn" scores on `þig`, "Reyndu aftur eða hafðu +// samband" scores on `hafðu`. +// +// WHY THERE IS NO -ið SUFFIX RULE. `-ið` is the 2pl verb ending, and it is ALSO +// the neuter definite article — one of the commonest morphemes in the language. +// lykilorðið, tölvupóstfangið, skjalið, safnið, yfirlitið, nafnið are all nouns. +// This is the Icelandic counterpart of the bg `-те` trap, and it is worse, +// because three individual 2pl verb forms are themselves homographs of ordinary +// words: `hafið` is the past participle of `hefja` ("hefur hafið ferli" — "has +// begun a process") and also "the ocean"; `getið` is the participle "mentioned"; +// `verðið` is "the price"; `vitið` is "the wit"; `eigið` is the neuter adjective +// "own" ("þitt eigið Nextcloud"). Two of those five occur in core in the +// non-verbal reading and NEITHER occurs as a 2pl verb. So the 2pl forms are a +// short closed list of the unambiguous ones only. +// +// THE þér BIGRAM, which is the interesting part of this detector. `þér` is at +// once the 2sg DATIVE ("þér er ekki heimilt" — "you are not permitted") and the +// archaic polite NOMINATIVE. All 54 core occurrences are the dative, verified at +// their call sites, so bare `þér` counts as INFORMAL. The polite reading is +// recovered without giving up the dative: as a nominative subject it must combine +// with a finite 2pl verb, so the BIGRAM disambiguates both individually-ambiguous +// tokens at once — `þér hafið` can only be the V-form, since dative `þér` takes +// no 2pl verb and `hafið` as "the ocean" does not follow a pronoun. Matched in +// both orders, because a question inverts it (`Hafið þér...`). +// +// ICELANDIC "PLEASE" CARRIES NO ADDRESS MARKER, unlike Maltese. §8.1 says to look +// for the politeness formula rather than only pronouns, and to check rather than +// assume: `vinsamlegast` is an adverb (superlative of `vinsamlegur`, "kindly") +// and inflects for nothing. So it is the ga outcome, not the mt one — no free +// signal here. +// +// DIACRITICS ARE NOT FOLDED. They are contrastive in Icelandic and load-bearing +// in this very detector: `veldu` (imperative, usable) against `völdu` (weak +// adjective, excluded) differ only by the vowel. fold() only lowercases — the +// sk/sl/rm/ga precedent. +// +// JS \b is ASCII-only and would treat þ/ð/æ/ö/á/í as boundaries, so every guard +// is (? 0 ? 'formal' : i > 0 ? 'informal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + const { scanCoreRegister, reportCoreRegister } = require('../lib.js') + const r = scanCoreRegister('is', score) + reportCoreRegister('is', r, { + formal: 'yðar / þér+2pl deference, and þið/ykkar wrong number', + informal: 'þú, 2sg', + }) + console.log('\nread this as: Icelandic HAD a T-V distinction and abandoned it in the 20th') + console.log('century, so `informal` records a live language state rather than a preference.') + console.log(`The load-bearing figure is the FORMAL count — it must be 0, and is ${r.formal}.`) + console.log('It bundles two distinct defects: archaic deference (yðar, þér+2pl) and plain') + console.log('wrong number (þið, ykkar). The second is the likelier slip, because it is the') + console.log('plural that is actually current in the language.') +} diff --git a/scripts/l10n/detectors/lb.js b/scripts/l10n/detectors/lb.js new file mode 100644 index 0000000000..db7193571d --- /dev/null +++ b/scripts/l10n/detectors/lb.js @@ -0,0 +1,369 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Luxembourgish (Lëtzebuergesch) register detector for openregister l10n. +// +// CORE IS EFFECTIVELY EMPTY FOR THIS LOCALE. Nextcloud ships exactly ONE lb +// catalogue in the scanned roots (server/lib/l10n/lb.json, 72 values), which is +// no better than nothing for a register measurement — it contains not a single +// address form. `coreCatalogues('lb')` therefore does NOT throw the way it does +// for rm and mt, which makes this the more dangerous shape: §5 step 2 appears to +// run and would report a verdict computed from zero markers. So this detector +// follows rm.js in scanning the BUNDLE instead, and additionally widens the +// corpus to the sibling apps' FRONTEND bundles the way the mt pass did — 1054 +// own values plus 2386 sibling values, 3440 in total. Counts in locales/lb.json. +// +// VERDICT: FORMAL, decisively. 161 polite markers against 9 informal ones, and +// the 9 are four values in ONE sibling app (launchpad); openregister's own 1054 +// values carry zero. Luxembourgish uses the 2pl `Dir` / `Iech` / `Ären` as the +// polite address, on the German `Sie` model but built from the plural — so the +// V-form here is live and ordinary, not archaic (contrast is) and not merely +// available-but-unused (contrast mt). +// +// ============================================================================ +// fold() DOES NOT LOWERCASE, AND THAT IS THE WHOLE DESIGN. This is the first +// detector in the set where case is the ONLY thing separating the two +// polarities, so the usual `String(s).toLowerCase()` would invert the verdict on +// the commonest address word in the language: +// +// dir (lowercase) = the INFORMAL 2sg DATIVE — "to you", familiar +// Dir (capital) = the POLITE 2pl NOMINATIVE — the V-form +// +// Both are attested here. "Du kanns nëmmen Dashboards bäifügen, déi dir +// gehéieren" is the informal dative; "Sidd Dir sécher…" is the polite nominative, +// 82 times. A case-folding detector counts all 83 as one bucket. This is the +// da/nb `De`/`Dem`/`Deres` situation (§8.2) with a sharper edge, because there +// the collision is with a third-person pronoun rather than with the same +// paradigm's own familiar form. +// +// The residual blind spot is honest and recorded in UNDETECTABLE: a value that +// OPENS with the informal dative gets a sentence-initial capital and is then +// indistinguishable from the polite nominative. All 11 sentence-initial `Dir` in +// the corpus are polite (each followed by a 2pl verb — `hutt`, `musst`, +// `braucht`, `kënnt`), so the cost is theoretical here, but it is real. +// +// ============================================================================ +// WHY CLOSED WORD LISTS, SPECIFICALLY FOR LUXEMBOURGISH (§8.1): +// +// • `-s` is the 2sg present ending AND the genitive/plural marker AND the +// ending of ordinary vocabulary. A `-s` rule is unusable. +// • THE MODALS SYNCRETISE 1sg/2sg/3sg, so the specific forms `muss` and +// `weess` carry NO address information: "du muss" and "hie muss" are +// spelled identically. This is measured, not assumed — all 15 occurrences of +// bare `muss` in the corpus are 3sg ("De Slug muss…", "D'Tabell muss…", +// "LLM muss…"), not one is 2sg. Both are excluded, while the regularly +// inflected 2sg of the SAME verbs (`kanns`, `wëlls`, `sollst`) is kept — +// so this is a PARTIALLY-detectable paradigm in the bg/is sense, split by +// lexical class (the modals) rather than by conjugation class. +// • `-t` is the 2pl ending (a formal marker) AND the 3sg present ending. Two +// of the most useful-looking 2pl forms are therefore excluded: `kënnt` is +// "you (pl) can" AND "he comes" (3sg of kommen), and `braucht` is "you (pl) +// need" AND "he needs" (3sg of brauchen). The 2pl forms kept below are the +// ones whose 3sg is spelled differently: hutt/huet, sidd/ass, musst/muss, +// gitt/gëtt, maacht/mécht. +// • THE 2SG IMPERATIVE IS EXCLUDED WHOLESALE. §6.5 test 1 comes out NO here — +// labels are infinitives (§7.3), so counting it would not flag every button, +// which is what forces the exclusion in ca/et/hr/sl/sr/ga/mt. It is +// excluded on test 2 instead: the Luxembourgish 2sg imperative is the bare +// stem, and for the productive stems that is also an ordinary noun +// (`Späicher` = "storage/loft", `Filter`, `Test`). Cheap to give up: every +// informal slip actually shipped in this app family is a 2sg INDICATIVE +// (`Du hues`, `Du kanns`), never an imperative. +// +// JS \b is ASCII-only and would treat `ë`/`é`/`ä` as boundaries, so every guard +// is (? 0 ? 'informal' : f > 0 ? 'formal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + const fs = require('fs') + const path = require('path') + const { coreCatalogues, loadJsTranslations, APP_ROOT } = require('../lib.js') + + // Core is not empty here (unlike rm/mt), it is merely useless — so re-check + // what it actually contains rather than asserting it from this comment. If + // core ever grows real lb coverage it OUTRANKS the bundle (§3.4) and this + // verdict must be re-measured. + let coreValues = 0 + let coreFiles = [] + try { + coreFiles = coreCatalogues('lb') + for (const f of coreFiles) { + const j = JSON.parse(fs.readFileSync(f, 'utf8')) + for (const v of Object.values(j.translations || {})) { + for (const x of Array.isArray(v) ? v : [v]) if (typeof x === 'string' && x.trim()) coreValues++ + } + } + } catch (e) { + console.log(`\nno core lb catalogues: ${e.message.split('.')[0]}`) + } + let coreF = 0 + let coreI = 0 + for (const f of coreFiles) { + const j = JSON.parse(fs.readFileSync(f, 'utf8')) + for (const v of Object.values(j.translations || {})) { + for (const x of Array.isArray(v) ? v : [v]) { + if (typeof x !== 'string' || !x.trim()) continue + const s = score(x) + coreF += s.f + coreI += s.i + } + } + } + console.log(`\ncore: ${coreFiles.length} catalogue(s), ${coreValues} values, ` + + `${coreF} formal / ${coreI} informal marker(s)`) + if (coreValues > 500) { + console.log('NOTE: core lb has grown past 500 values — re-measure the register ' + + 'against core and update locales/lb.json; core outranks the bundle (§3.4).') + } else { + console.log(' -> too thin to decide anything; the §6.4 fallback below is the evidence.') + } + + // The §6.4 fallback, widened to the sibling apps' FRONTEND bundles as the mt + // pass did. Only .js — the backend .json is a separate catalogue with a + // separate consumer (§1) and would be miscredited to the frontend. + const APPS = path.resolve(APP_ROOT, '..') + const scan = (file, label) => { + const tr = loadJsTranslations(file).translations + let formal = 0 + let informal = 0 + let values = 0 + const hits = [] + for (const [k, v] of Object.entries(tr)) { + for (const x of Array.isArray(v) ? v : [v]) { + // Values byte-equal to their key are untranslated English and cannot + // carry Luxembourgish register; counting them would dilute both totals. + if (typeof x !== 'string' || !x.trim() || (!Array.isArray(v) && x === k)) continue + values++ + const s = score(x) + formal += s.f + informal += s.i + if (s.i > 0) hits.push(x.slice(0, 100)) + } + } + console.log(` ${label.padEnd(16)} ${String(values).padStart(5)} values ` + + `formal=${String(formal).padStart(4)} informal=${String(informal).padStart(3)}`) + return { formal, informal, values, hits } + } + + console.log('\n=== §6.4 fallback: this app family\'s own frontend bundles ===') + let F = 0 + let I = 0 + let V = 0 + const allHits = [] + for (const app of fs.readdirSync(APPS).sort()) { + const file = path.join(APPS, app, 'l10n', 'lb.js') + if (!fs.existsSync(file)) continue + let r + try { r = scan(file, app) } catch { continue } + F += r.formal + I += r.informal + V += r.values + for (const h of r.hits) allHits.push([app, h]) + } + console.log(` ${'TOTAL'.padEnd(16)} ${String(V).padStart(5)} values ` + + `formal=${String(F).padStart(4)} informal=${String(I).padStart(3)}`) + console.log(`verdict: ${F > I * 3 ? 'FORMAL' : I > F * 3 ? 'INFORMAL' : 'MIXED — inspect'}`) + for (const [app, h] of allHits) console.log(` informal? [${app}] ${h}`) +} diff --git a/scripts/l10n/detectors/lt.js b/scripts/l10n/detectors/lt.js new file mode 100644 index 0000000000..fc26780d9e --- /dev/null +++ b/scripts/l10n/detectors/lt.js @@ -0,0 +1,128 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Lithuanian register detector for openregister l10n. +// +// Measures the PROSE register. Whether Lithuanian also splits by string role +// (bare imperative buttons regardless of prose register, as ca/et/hr do) is +// decided from core's own button labels, not from this detector. +// +// Why closed word lists and NOT suffix patterns, specifically for Lithuanian: +// • "-ai" is NOT a usable 2sg-present marker. It ends the nominative plural of +// every masculine noun ("objektai", "failai", "vartotojai") and a huge class +// of adverbs ("gerai", "puikiai", "automatiškai"). A "-ai" rule would score +// ordinary noun and adverb phrases as informal. +// • "-i" is worse: it is the 2sg present ending AND, for conjugation-II verbs, +// the 3sg/3pl ending too. "gali", "turi", "nori" all mean both "you can/have/ +// want" and "he/they can/have/want", because Lithuanian third person makes no +// number distinction. These three are the highest-frequency modals in UI prose +// and are therefore EXCLUDED — counting them would manufacture informal hits +// out of ordinary third-person statements. +// • "-kite" looks like a safe 2pl-imperative marker, but it is still spelled out +// as a closed list here so a stray noun cannot match. +// The unambiguous signals are the pronouns (tu-series vs jūs-series) plus 2sg +// forms whose 3sg counterpart differs ("žinai" vs 3sg "žino"). + +function fold(s) { + return String(s).toLowerCase() +} + +// jūs / formal 2pl. Case-insensitive: Lithuanian politeness capitalises "Jūs", +// but plain 2pl "jūs" is the same register in a UI string. +const FORMAL_RES = [ + /(? 0 ? 'informal' : f > 0 ? 'formal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + const { scanCoreRegister, reportCoreRegister } = require('../lib.js') + reportCoreRegister('lt', scanCoreRegister('lt', score), { formal: 'jūs', informal: 'tu' }) +} diff --git a/scripts/l10n/detectors/lv.js b/scripts/l10n/detectors/lv.js new file mode 100644 index 0000000000..b1e250840c --- /dev/null +++ b/scripts/l10n/detectors/lv.js @@ -0,0 +1,189 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Latvian register detector for openregister l10n. +// +// Measures the PROSE register: jūs (formal 2pl) against tu (informal 2sg). +// The BUTTON convention is a separate question and is not measured here — +// core lv answers it on its own: every short label is an infinitive +// ("Saglabāt", "Atjaunināt", "Apstiprināt", "Turpināt", "Atcelt"), which is the +// register-neutral pattern cs and lt also use. +// +// Note on capitalisation: Latvian capitalises "Tu / Tev / Tavs" as a politeness +// convention when addressing one person, and core lv does exactly that. That is +// still the 2sg series, i.e. INFORMAL in the tu/jūs opposition — the formal +// address is "Jūs". So the tu-series is matched case-insensitively and capital +// "Tavas datnes" counts as informal, not as some third register. +// +// Why closed word lists and NOT suffix patterns, specifically for Latvian: +// +// • "-at" / "-āt" is the INFINITIVE ending, not a 2pl-present marker. Every +// single one of the 12 distinct -at/-āt words in core lv is an infinitive +// or an adverb: atjaunināt, saglabāt, apstiprināt, mēģināt, uzzināt, +// turpināt, vaicāt, atsvaidzināt, ritināt, atklāt, drukāt, turklāt. A "-at" +// rule would therefore score every button label in the language as formal +// 2pl and invert the measurement outright. +// +// • "-iet" is not safe either. Of the 4 distinct -iet words in core lv, only +// "skatiet" and "turiet" are 2pl imperatives; "vienuviet" is an adverb ("in +// one place") and "nešķiet" is third person ("does not seem"). Half of a +// suffix rule's hits would be false. +// +// • "-i" is the worst of the three as a 2sg-present marker, exactly as "-ai" +// is for Lithuanian: it ends the nominative plural of masculine nouns +// ("faili", "objekti", "lietotāji", "ieraksti", "dati") and the locative +// singular besides. +// +// Two genuine homograph classes make most 2sg verb forms unusable, and both are +// systematic rather than a handful of exceptions: +// +// 1. For -ēt and -āt verbs the 2sg present is spelled identically to the THIRD +// person, because Latvian third person makes no number distinction: +// "meklē" is both "you search" and "he/it searches"; likewise "saglabā", +// "aizver", "atver". These are ordinary third-person UI prose and are +// EXCLUDED — counting them would manufacture informal hits out of +// statements like "Sistēma saglabā izmaiņas". +// +// 2. Feminine nouns in -e have an accusative singular in -i, which collides +// with the 2sg imperative: "pārbaudi" is both "check!" and the accusative +// of "pārbaude" (a check); "redzi" collides with "redze" (vision); +// "atlasi" with "atlase" (a selection). "ievadi" is the same trap from the +// masculine side — nominative plural of "ievads" (an input). All excluded. +// +// What is left, and is genuinely unambiguous, is the pronoun series plus 2sg +// forms whose third-person counterpart is spelled differently ("vari" vs "var", +// "zini" vs "zina", "esi" vs "ir", "spied" vs "spiež"). + +function fold(s) { + return String(s).toLowerCase() +} + +// jūs / formal 2pl — the register core lv does NOT use (0 hits in 890 values). +const FORMAL_RES = [ + /(? 0 ? 'informal' : f > 0 ? 'formal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + const { scanCoreRegister, reportCoreRegister } = require('../lib.js') + reportCoreRegister('lv', scanCoreRegister('lv', score), { formal: 'jūs', informal: 'tu' }) +} diff --git a/scripts/l10n/detectors/mk.js b/scripts/l10n/detectors/mk.js new file mode 100644 index 0000000000..aaef5f6664 Binary files /dev/null and b/scripts/l10n/detectors/mk.js differ diff --git a/scripts/l10n/detectors/mt.js b/scripts/l10n/detectors/mt.js new file mode 100644 index 0000000000..7bc970bcce --- /dev/null +++ b/scripts/l10n/detectors/mt.js @@ -0,0 +1,329 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Maltese (Malti) register detector for openregister l10n. +// +// THERE IS NO CORE EVIDENCE FOR THIS LOCALE. Nextcloud ships ZERO mt catalogues +// — not core/l10n, not lib/l10n, not any bundled app — so scanCoreRegister('mt') +// throws by design rather than computing a verdict from nothing. This is the +// second such locale after rm, and the runbook's §6.4 fallback applies: the +// verdict comes from the app's own values plus the sibling apps' frontend mt.js. +// Measured 93 second-person SINGULAR markers against ZERO plural and ZERO +// deferential-third-person, over 3422 values. Counts in locales/mt.json. +// +// MALTESE IS NOT THE ga CASE, and conflating the two would be a real error. +// Irish has no T-V distinction at all, so its "informal" names the only address +// form available. Maltese genuinely HAS a politeness system — `intom` serves as +// a polite singular the way French `vous` does, and `Is-Sinjur` / `Is-Sinjura` +// with third-person agreement is the deferential register. Both exist, and both +// are simply unused in this software register. So `informal` here records a real +// measured choice, as it does for nl/de/et/lv, and the deviation this gate looks +// for is genuine deference rather than an impossible form. +// +// TWO WHOLE PARADIGMS ARE UNUSABLE, both ordinary Maltese morphology: +// +// • THE t- PREFIX. The 2sg imperfect and the 3sg FEMININE imperfect are +// spelled identically across the entire verb system, so `tista'` is at once +// "you can" and "she/it can" — and both readings are live in this bundle: +// "Hawn tista' tara jekk dik il-katina hijiex sħiħa" (you) against +// "Il-Proprjetà tista' tittejjeb", "Din l-analiżi tista' tieħu ftit ħin" and +// "Qabel ma tista' taħdem il-vettorizzazzjoni" (3sg f). 23 occurrences, split +// both ways. `trid` ("you want" / "she wants") shares the paradigm and adds +// 24 more. This is the Latvian shape — systematic, not exceptional — so the +// whole class is excluded, and that costs real recall. +// +// • THE -u ENDING. The 2pl imperative and 2pl imperfect both end in -u, and so +// does the 3pl of everything. `nstabu` ("they were found") occurs 24 times in +// this bundle alone, in "Ma nstabu l-ebda X"; `għandhom` 19 times; +// `jappartjenu` and `jistgħu` likewise. Counting -u forms would score the +// commonest sentence shape in the file as deference. So 2pl IMPERATIVES are +// undetectable here, and the formal side rests entirely on the pronoun, the +// possessive, the -kom prepositional pronouns and Sinjur — all unambiguous. +// +// Why closed word lists and NOT suffix patterns, specifically for Maltese: +// • `-kom` looks like the 2pl object/possessive ending, and mostly is. But it +// is also the ending of ordinary words, and more to the point a suffix rule +// would sweep in any noun happening to end that way. The thirteen real +// prepositional pronouns are enumerable, so they are enumerated. +// • `-ek` / `-k` looks like the 2sg possessive ending. It is also the tail of +// a large number of unrelated words, and Maltese `-k` attaches to +// prepositions rather than freely, so again the paradigm is closed and small. +// • `t-` and `-u`: see above. Both fatal. +// +// DIACRITICS ARE NOT FOLDED. Maltese uses ċ ġ ħ ż plus the digraph `għ`, and +// `għ` sits inside the single most productive marker here — `tiegħek`. fold() +// only lowercases; that matters because the first probe run of this pass omitted +// case folding and scored 0 for the pronoun, missing capitalised `Inti ċert li +// trid…`, which is a real value. +// +// JS \b is ASCII-only and would treat ħ/ġ/ż/ċ as boundaries, so every guard is +// (? 0 ? 'formal' : i > 0 ? 'informal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + // No core scan is possible: Nextcloud ships no mt catalogues at all, so + // scanCoreRegister would throw. Confirm that is STILL true rather than + // asserting it from a comment, then measure this bundle plus the sibling + // apps' frontend mt.js — the §6.4 fallback recorded in locales/mt.json. + const path = require('path') + const fs = require('fs') + const { coreCatalogues, loadJsTranslations, APP_ROOT, isIdentical } = require('../lib.js') + let core = null + try { + core = coreCatalogues('mt') + } catch (e) { + console.log(`\nno core mt catalogues: ${e.message.split('.')[0]}`) + } + if (core) { + console.log(`\nNOTE: ${core.length} core mt catalogue(s) exist now — core was ` + + 'empty when this detector was written. Re-measure the register against ' + + 'core and update locales/mt.json; core outranks the bundle (§3.4).') + } + + const corpus = [] + const own = loadJsTranslations(path.join(APP_ROOT, 'l10n', 'mt.js')).translations + for (const [k, v] of Object.entries(own)) { + // Values byte-equal to their key are untranslated English and cannot carry + // Maltese register; counting them would dilute both totals. + if (isIdentical(k, v)) continue + for (const x of [].concat(v)) if (typeof x === 'string' && x) corpus.push(['own', x]) + } + // Sibling apps' FRONTEND mt.js only. The backend mt.json is a separate + // catalogue with a separate consumer (§1) and is deliberately excluded — it + // does contain an `int`, which would otherwise leak into this measurement. + const appsDir = path.resolve(APP_ROOT, '..') + for (const a of fs.readdirSync(appsDir).sort()) { + if (a === path.basename(APP_ROOT)) continue + const f = path.join(appsDir, a, 'l10n', 'mt.js') + if (!fs.existsSync(f)) continue + try { + for (const v of Object.values(loadJsTranslations(f).translations || {})) { + for (const x of [].concat(v)) if (typeof x === 'string' && x) corpus.push([a, x]) + } + } catch { /* a sibling with an unparseable bundle is not this pass's problem */ } + } + + let formal = 0 + let informal = 0 + const hits = [] + for (const [, x] of corpus) { + const s = score(x) + formal += s.f + informal += s.i + if (s.f > 0) hits.push(x.slice(0, 100)) + } + console.log(`\nscanned ${corpus.length} frontend value(s) (this bundle + sibling apps)`) + console.log(`formal (intom / Sinjur) markers: ${formal}`) + console.log(`informal (int / tiegħek) markers: ${informal}`) + console.log(`verdict: ${formal > informal * 3 ? 'FORMAL' : informal > formal * 3 ? 'INFORMAL' : 'MIXED — inspect'}`) + for (const v of hits.slice(0, 20)) console.log(` formal? ${v}`) + console.log('\nread this as: Maltese HAS a politeness system (intom, Is-Sinjur) and this') + console.log('software register simply does not use it — unlike ga, where no T-V distinction') + console.log(`exists at all. The load-bearing figure is the FORMAL count: it must be 0, and is ${formal}.`) +} diff --git a/scripts/l10n/detectors/rm.js b/scripts/l10n/detectors/rm.js new file mode 100644 index 0000000000..9d9b6ad40e --- /dev/null +++ b/scripts/l10n/detectors/rm.js @@ -0,0 +1,308 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Romansh (Rumantsch Grischun) register detector for openregister l10n. +// +// THERE IS NO CORE EVIDENCE FOR THIS LOCALE AT ALL. Nextcloud ships ZERO rm +// catalogues — not in core/l10n, not in lib/l10n, not in any bundled app — so +// `scanCoreRegister('rm', …)` throws by design rather than computing a verdict +// from nothing. This is the first locale in the set where §5 step 2 cannot be +// run as written, and the runbook's §6.4 fallback applies: the verdict comes +// from this bundle's OWN pre-existing values. It is one-sided enough to settle +// it — 50 formal markers against 0 informal across 995 translated values. The +// counts and the method are recorded in locales/rm.json. +// +// Romansh has a real T-V distinction, so unlike Russian the polite pronoun IS a +// register marker: `Vus` / `voss` (2pl of respect, the German `Sie` model) +// against `ti` / `tiu` (2sg familiar). This bundle capitalises the polite forms +// mid-sentence without exception — `Vus` 13:0, `Voss*` 21:0 — but the match +// below is case-insensitive, because casing is an orthographic convention (see +// locales/rm.json) and not what decides register. +// +// TWO WHOLE PARADIGMS ARE UNUSABLE HERE, and both are ordinary Romansh +// morphology rather than quirks of this bundle: +// +// • THE 2SG IMPERATIVE. For every `-ar` verb it is spelled exactly like the +// 3sg present indicative, and also like the feminine singular past +// participle: `stizza` is "delete!", "it deletes" AND "deleted-f.sg". The +// 3sg reading is live in this bundle's own prose — "Quai stizza las +// endataziuns", "Ferma mintga flux", "Elavurescha ils chunks", +// "Recalculescha mintga hash" are all real values — so counting the +// imperative would flag ordinary third-person description. +// +// Note this is the MIRROR IMAGE of Slovak, not a family difference. Both +// languages label buttons with the INFINITIVE, so §6.5 test 1 comes out the +// same for both; they diverge on test 2, because `ulož` ≠ `uloží` in Slovak +// while `stizza` = `stizza` here. Same button convention, opposite detector +// decision — which is why §6.5 insists both tests be run per locale. +// +// • THE 2SG PRESENT OF REGULAR VERBS. It ends in `-as`, which is also the +// feminine plural of every noun and adjective in the language — the +// commonest inflection there is. `controllas` is "you check" and also +// "checks" the noun ("Controllas da surveglianza" is a real value); +// `empruvas` is "you try" and also "attempts" ("Max empruvas"); +// `tschernas` is "you choose" and also the plural of the noun `tscherna` +// ("la tscherna d'objects"). So informal detection rests on the TEN +// irregular verbs, whose 2sg ends in a bare `-s`: has, es, pos, stos, vuls, +// sas, vas, fas, das, vegns. That is narrow but sound, and it is what +// actually caught the four real informal slips in the sibling bundles. +// +// Why closed word lists and NOT suffix patterns, specifically for Romansh: +// • `-ai` looks like the 2pl (polite) imperative ending, and mostly is. But +// `quai` ("this/that") ends in it and occurs 23 times in these bundles — +// the single most common word an `-ai` rule would hit — along with +// `perquai` ("therefore"), `mai` ("never"), bare `ai` (the preposition +// a + ils), and `hai`/`sai` (1sg "I have"/"I know"). An `-ai` suffix rule +// scores the most ordinary demonstrative in the language as deference. +// • `-ais` looks like the 2pl present indicative ending, and mostly is. But +// `mais` is "months" ("Mintga mais") and the nationality adjectives +// `ollandais` / `englais` / `franzais` all end in it. +// • `-as`: see above. Fatal in the other direction. +// +// DIACRITICS ARE NOT FOLDED, and that is load-bearing. `tscherni` is the 2pl +// imperative ("choose!", a formal marker) while `tschernì` is the past +// participle "selected" — this bundle's value for the key `Selected`, and for +// `register(s) selected`. They differ by the grave accent and nothing else, so +// stripping it would score every "Tschernì" label as polite address. The same +// goes for `e` ("and") against `è` ("is"), the two most frequent short words in +// the language. fold() therefore only lowercases. +// +// JS \b is ASCII-only and would treat `à`/`è`/`ì` as boundaries, so every guard +// is (? 0 ? 'informal' : f > 0 ? 'formal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + // No core scan is possible: Nextcloud ships no rm catalogues at all, so + // scanCoreRegister would throw. Confirm that is still true rather than + // asserting it from a comment, then measure this bundle instead — the §6.4 + // fallback, and the evidence recorded in locales/rm.json. + const path = require('path') + const { coreCatalogues, loadJsTranslations, APP_ROOT } = require('../lib.js') + let core = null + try { + core = coreCatalogues('rm') + } catch (e) { + console.log(`\nno core rm catalogues: ${e.message.split('.')[0]}`) + } + if (core) { + console.log(`\nNOTE: ${core.length} core rm catalogue(s) exist now — core was ` + + 'empty when this detector was written. Re-measure the register against ' + + 'core and update locales/rm.json; core outranks the bundle (§3.4).') + } + + const file = path.join(APP_ROOT, 'l10n', 'rm.js') + const tr = loadJsTranslations(file).translations + let formal = 0 + let informal = 0 + let values = 0 + const hits = [] + for (const [k, v] of Object.entries(tr)) { + for (const x of Array.isArray(v) ? v : [v]) { + // Values byte-equal to their key are untranslated English and cannot + // carry Romansh register; counting them would dilute both totals. + if (typeof x !== 'string' || !x || (!Array.isArray(v) && x === k)) continue + values++ + const s = score(x) + formal += s.f + informal += s.i + if (s.i > 0) hits.push(x.slice(0, 100)) + } + } + console.log(`\nscanned l10n/rm.js: ${values} translated value(s)`) + console.log(`formal (Vus) markers: ${formal}`) + console.log(`informal (ti) markers: ${informal}`) + console.log(`verdict: ${formal > informal * 3 ? 'FORMAL' : informal > formal * 3 ? 'INFORMAL' : 'MIXED — inspect'}`) + for (const v of hits.slice(0, 20)) console.log(` informal? ${v}`) +} diff --git a/scripts/l10n/detectors/ro.js b/scripts/l10n/detectors/ro.js new file mode 100644 index 0000000000..4487541725 --- /dev/null +++ b/scripts/l10n/detectors/ro.js @@ -0,0 +1,223 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Romanian register detector for openregister l10n. +// +// Measures the PROSE register: dumneavoastră / vă (formal 2pl) against tu (informal +// 2sg). Romanian is the first locale in this project where CORE ITSELF IS MIXED, so +// read the verdict together with what the bundle already does — see locales/ro.json. +// +// THE BUTTON CONVENTION HERE IS A PROJECT DECISION, NOT A MEASUREMENT, and it is the +// one case in this app where the two diverge. Measured, core ro uses the bare 2sg +// imperative or a verbal noun for short labels — Salvează, Adaugă, Șterge, Editează, +// Alege, Copiază, Mută, Continuă, Confirmă, Aplică, Trimite, Instalează, Reîncearcă, +// Redenumește, Crează, alongside Setări / Căutare / Anulare / Actualizare / +// Restaurare / Activare, with exactly one 2pl label (Dezactivați). That is the +// ca/et/hr pattern, where button style does not follow the prose register. +// +// The project chose otherwise for Romanian, on a native speaker's advice that +// Romanian users expect formal address throughout a web UI: **formal 2pl everywhere** +// — prose, dropdown placeholders and buttons alike (Salvați, Adăugați, Ștergeți). +// See locales/ro.json. +// +// CONSEQUENCE FOR THIS DETECTOR: a bare 2sg imperative in *label position* is a +// DEVIATION for this bundle and is counted as informal, even though core ro uses it. +// Two things follow, and both are deliberate: +// +// • Scanning core with this detector inflates core's informal count, because core's +// own button labels now register as deviations. The register evidence in +// locales/ro.json therefore records the PROSE-ONLY measurement taken before this +// rule existed (124 formal / 66 informal), which is the number that decided the +// register. Do not re-derive the prose verdict from a scan that includes this rule. +// • Label position is approximated as "string-initial, and the whole string is at +// most 40 characters". That is what keeps the 3sg homographs out: "Se șterge..." +// is "is being deleted", "Aceasta va șterge" is "this will delete", "Sistemul +// salvează modificările" is "the system saves". None of those begins with the +// verb, and long descriptive sentences that do begin with a 3sg present fall +// outside the length bound. See UNDETECTABLE for what this misses. +// +// Why closed word lists and NOT suffix patterns, specifically for Romanian: +// +// • "-ați" / "-eți" is the 2pl ending AND the masculine plural of a large class of +// adjectives and nouns: "curați" (clean), "bogați" (rich), "pereți" (walls), +// "băieți" (boys). A suffix rule scores those as formal address. +// • "-ești" is the 2sg ending of -i verbs ("folosești") and also ends noun plurals +// like "povești" (stories). +// • "-i" is the 2sg ending and simultaneously the masculine plural of nearly every +// noun ("utilizatori", "parametri", "furnizori"). +// +// Traps that have to be handled explicitly, all of them live in this app's data: +// +// 1. "vă" (formal you) vs "va" (3sg future auxiliary, "will"). Differing only by a +// diacritic, and "va" is everywhere: "Acest proces va genera embeddings" is an +// ordinary future tense, not formal address. Only "vă" is matched. +// 2. "ai" is EXCLUDED entirely. It is the 2sg of "avea" (you have), but also the +// masculine plural possessive article ("ai tăi"), and — since matching is +// case-insensitive — it collides with the acronym AI, which this bundle carries +// in "Setări chat Fireworks AI" and "Configurație embedding Fireworks AI". +// 3. "vezi" is EXCLUDED: it is a 2sg indicative but reads as the imperative in link +// labels ("vezi mai multe"), which is the button convention. +// 4. CEDILLA vs COMMA diacritics. Romanian ș/ț exist as two codepoint pairs: +// ș U+0219 / ț U+021B (correct) and ş U+015F / ţ U+0163 (legacy Turkish +// cedilla). Core ro mixes them — 362 values use the comma form and 5 legacy +// strings use the cedilla ("contactaţi", "fişiere") — so fold() normalises them. +// Without that, closed-list entries silently miss the legacy spellings. + +function fold(s) { + // Normalise the legacy cedilla codepoints to the correct comma-below ones before + // lowercasing, so a closed list written in modern orthography still matches core's + // older strings. See trap 4 in the header. + return String(s) + .replace(/ş/g, 'ș').replace(/Ş/g, 'Ș') + .replace(/ţ/g, 'ț').replace(/Ţ/g, 'Ț') + .toLowerCase() +} + +// dumneavoastră / formal 2pl — includes the formal imperative, which is the same form +// as the 2pl present and is equally formal. +const FORMAL_RES = [ + // "vă" only, never "va": see trap 1. + /(? 0 ? 'informal' : f > 0 ? 'formal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + const { scanCoreRegister, reportCoreRegister } = require('../lib.js') + reportCoreRegister('ro', scanCoreRegister('ro', score), { formal: 'dumneavoastră', informal: 'tu' }) +} diff --git a/scripts/l10n/detectors/sk.js b/scripts/l10n/detectors/sk.js new file mode 100644 index 0000000000..ee1018d43f --- /dev/null +++ b/scripts/l10n/detectors/sk.js @@ -0,0 +1,200 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Slovak register detector for openregister l10n. +// +// Measures the prose register AND the 2sg imperative, which is a genuine +// deviation in Slovak — unlike Croatian, Catalan and Estonian, where the bare +// 2sg imperative is the correct button convention and counting it would flag +// every button. Slovak labels the buttons with the INFINITIVE ("Uložiť", +// "Odstrániť", "Pridať"), so "Ulož" / "Odstráň" / "Pridaj" is not a competing +// convention here, it is familiar address. Two facts make this safe to detect, +// and both are specific to Slovak: +// • the 2sg imperative is NOT a homograph of the 3sg present indicative +// ("ulož" vs "uloží", "pridaj" vs "pridá", "zmeň" vs "zmení"), which is +// exactly the collision that makes the Croatian imperative undetectable; +// • the infinitive ends in -ť, so no infinitive can be mistaken for one. +// So no label-position bound is needed, unlike ro.js. +// +// DIACRITICS ARE NOT FOLDED, and that is load-bearing rather than laziness. +// Three of the distinctions this detector rests on are carried by the acute +// alone, so stripping it would break the detector in both directions: +// • "ti" (dative of "ty", informal) vs "tí" (masculine animate nominative +// plural of "ten" — "tí používatelia" = "those users"); +// • "vyber" (2sg imperative, informal) vs "výber" ("selection", a noun this +// bundle uses in five keys — "Výber typu súboru"); +// • "uprav" (2sg imperative) vs "úprava" / "úprav" (genitive plural of +// "edit"). fold() therefore only lowercases. +// +// Why closed word lists and NOT suffix patterns, specifically for Slovak: +// • "vy-" is the most productive verbal prefix in the language: vybrať, +// vymazať, vytvoriť, vyhľadať, vypnúť, vyčistiť, vypočítať, vyplniť. An +// unguarded "vy" marker matches most of the buttons in this app. Every +// pronoun regex below is boundary-guarded for this reason, and "Vybrať +// všetko" is a must-not-fire control. +// • "-te" looks like the 2pl ending (imperative and present), but it is also +// the locative singular of every hard masculine noun: "v dokumente", +// "v objekte", "v elemente", "v momente". And "ešte" ("still", "yet") is +// one of the most frequent adverbs in the language and ends in -te. +// • "-š" looks like the 2sg present ending, but "váš" (your-FORMAL) and "náš" +// (our) both end in it, as does "kôš". A "-š" rule scores the formal +// possessive as informal — polarity-inverting noise, the same trap as hr. +// • bare "si" is unusable as an informal marker: besides the 2sg of "byť" it +// is the reflexive dative clitic, which is at its most common in formal +// prose ("môžete si vybrať", "prečítajte si záznam" — both in this bundle). +// JS \b is ASCII-only and would treat "á" as a boundary, so every guard is +// (? 0 ? 'informal' : f > 0 ? 'formal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + const { scanCoreRegister, reportCoreRegister } = require('../lib.js') + reportCoreRegister('sk', scanCoreRegister('sk', score), { formal: 'vy', informal: 'ty' }) +} diff --git a/scripts/l10n/detectors/sl.js b/scripts/l10n/detectors/sl.js new file mode 100644 index 0000000000..5f5735f6f8 --- /dev/null +++ b/scripts/l10n/detectors/sl.js @@ -0,0 +1,183 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Slovenian register detector for openregister l10n. +// +// Measures the PROSE register only. Slovenian buttons take the bare 2sg +// imperative regardless of how prose addresses the reader — the same convention +// as ca/et/hr, and NOT the infinitive its neighbour sk uses. Counting those +// imperatives would be wrong twice over here: +// +// 1. it is the correct label convention, so every button in the app would flag; +// 2. for the whole `-iti` verb class the 2sg imperative is spelled exactly like +// the 3sg present indicative — `uredi` is both "edit!" and "he edits", and so +// are `shrani`, `osveži`, `obnovi`, `posodobi`, `preveri`. Those six are all +// button labels in this very bundle. (`-ati` and `-irati` verbs do differ: +// `dodaj` vs `doda`, `kopiraj` vs `kopira` — but the collision class is far +// too large to carve out.) +// +// So sl looks like sk on the map and behaves like hr in the data. This is exactly +// why the register and the button convention are measured per locale. +// +// Why closed word lists and NOT suffix patterns, specifically for Slovenian: +// • "-te" is the 2pl ending, imperative and present alike, but it is ALSO the +// accusative plural of the demonstrative "ta": "te datoteke" = "these files". +// And bare "te" is the accusative of informal "ti", so the same two letters +// point in both directions at once. +// • "-š" looks like the 2sg present ending, but "vaš" (your-FORMAL) and "naš" +// (our) both end in it — a "-š" rule scores the formal possessive as informal +// and inverts the polarity outright, the same trap as hr and sk. +// • bare "ti" is unusable: besides informal "you" it is the masculine +// nominative plural of "ta", so "ti objekti" means "those objects" — the same +// collision cs has with "ty" and hr with "ti". +// • bare "si" is unusable: besides the 2sg of "biti" it is the reflexive dative +// clitic, which is at its most common in FORMAL prose ("lahko si izberete"). +// JS \b is ASCII-only and would treat "č" as a boundary, so every guard is +// (? 0 ? 'informal' : f > 0 ? 'formal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + const { scanCoreRegister, reportCoreRegister } = require('../lib.js') + reportCoreRegister('sl', scanCoreRegister('sl', score), { formal: 'vi', informal: 'ti' }) +} diff --git a/scripts/l10n/detectors/sq.js b/scripts/l10n/detectors/sq.js new file mode 100644 index 0000000000..b4bf90d6cd --- /dev/null +++ b/scripts/l10n/detectors/sq.js @@ -0,0 +1,380 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Albanian (shqip) register detector for openregister l10n. +// +// CORE IS USABLE HERE. Nextcloud ships five sq catalogues in the scanned roots +// (lib, encryption, settings, twofactor_backupcodes, user_ldap) totalling 603 +// values, so §5 step 2 runs as written and this detector's `main` measures +// against core the way hr/sl/sr do — no §6.4 fallback needed. The sibling +// frontends are scanned too, but as corroboration rather than as the evidence. +// +// VERDICT: FORMAL, decisively and with almost nothing on the other side. Across +// core plus the four sibling frontends (3980 values) the polite 2pl scores in the +// hundreds — bare `ju` 115, the `juaj`/`tuaj` possessive family 123, `ju lutem` +// 83, and the 2pl verb forms on top of that (`zgjidhni` 73, `fshini` 22, +// `provoni` 18, `menaxhoni` 21, `dëshironi` 18, `keni` 10, `jeni` 6 …) — against +// exactly TWO informal markers in the whole corpus: +// +// • openconnector: "Nuk ke ende kredenciale të ndërmjetësuara." — 2sg `ke` +// • core/encryption: "Vendos fjalëkalimin tënd" — 2sg possessive `tënd` +// +// Albanian's V-form is the 2pl `Ju` on the French/Russian model. Live and +// ordinary, not archaic (contrast is) and not merely available-but-unused +// (contrast mt). `ti` does not occur once in 3980 values. +// +// ============================================================================ +// fold() LOWERCASES, unlike lb.js. Checked rather than assumed: Albanian +// capitalises the polite `Ju` by convention but the lowercase `ju` is the SAME +// 2pl pronoun and equally polite — "Nuk ju lejohet ta ndani %s", "ju lutemi, +// riprovoni" are both core, both formal. There is no `dir`/`Dir` split to +// preserve, so case-folding costs nothing and buys recall on sentence-initial +// forms. +// +// ============================================================================ +// THE LEFT GUARD CARRIES A HYPHEN, AND THAT IS NEW IN THIS SET. +// +// Albanian inflects acronyms and foreign nouns by attaching the definite/case +// ending after a HYPHEN: `UUID-je`, `URL-je`, `Token-i`, `PHP-ja`, `DN-ja`, +// `email-it`, `php.ini`. So `(? 0 ? 'informal' : f > 0 ? 'formal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + const fs = require('fs') + const path = require('path') + const { scanCoreRegister, reportCoreRegister, loadJsTranslations, APP_ROOT } = require('../lib.js') + + // Core is usable for sq (5 catalogues, 603 values), so this is the §5 step 2 + // measurement proper — not the §6.4 fallback. + console.log('\n=== §5 step 2: Nextcloud core ===') + reportCoreRegister('sq', scanCoreRegister('sq', score), { formal: 'Ju/juaj/-ni', informal: 'ti/tënd/ke' }) + + // Corroboration only. The sibling frontends agree with core here, so unlike + // lb/mt/rm this is not load-bearing evidence — it is a second opinion. + const APPS = path.resolve(APP_ROOT, '..') + console.log('\n=== corroboration: this app family\'s own frontend bundles ===') + let F = 0 + let I = 0 + let V = 0 + const allHits = [] + for (const app of fs.readdirSync(APPS).sort()) { + const file = path.join(APPS, app, 'l10n', 'sq.js') + if (!fs.existsSync(file)) continue + let tr + try { tr = loadJsTranslations(file).translations } catch { continue } + let formal = 0 + let informal = 0 + let values = 0 + for (const [k, v] of Object.entries(tr)) { + for (const x of Array.isArray(v) ? v : [v]) { + // Values byte-equal to their key are untranslated English and cannot + // carry Albanian register; counting them would dilute both totals. + if (typeof x !== 'string' || !x.trim() || (!Array.isArray(v) && x === k)) continue + values++ + const s = score(x) + formal += s.f + informal += s.i + if (s.i > 0) allHits.push([app, x.slice(0, 100)]) + } + } + console.log(` ${app.padEnd(16)} ${String(values).padStart(5)} values ` + + `formal=${String(formal).padStart(4)} informal=${String(informal).padStart(3)}`) + F += formal + I += informal + V += values + } + console.log(` ${'TOTAL'.padEnd(16)} ${String(V).padStart(5)} values ` + + `formal=${String(F).padStart(4)} informal=${String(I).padStart(3)}`) + console.log(`verdict: ${F > I * 3 ? 'FORMAL' : I > F * 3 ? 'INFORMAL' : 'MIXED — inspect'}`) + for (const [app, h] of allHits) console.log(` informal? [${app}] ${h}`) +} diff --git a/scripts/l10n/detectors/sr.js b/scripts/l10n/detectors/sr.js new file mode 100644 index 0000000000..d8e6768756 --- /dev/null +++ b/scripts/l10n/detectors/sr.js @@ -0,0 +1,201 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Serbian register detector for openregister l10n. +// +// Measures the PROSE register only. Serbian buttons take the bare 2sg imperative +// regardless of how prose addresses the reader — the ca/et/hr/sl pattern — and +// counting those would be wrong twice over, exactly as in Slovenian: +// +// 1. it is the correct label convention, so every button in the app would flag; +// 2. for the whole -ити verb class the 2sg imperative is spelled exactly like +// the 3sg present indicative — `уреди` is both "edit!" and "he edits", and so +// are `обриши`... (no: `обрише` differs), `врати`, `провери`, `валидирај`. +// The -ати/-овати classes do differ (`додај` vs `додаје`, `копирај` vs +// `копира`), but the collision class is too large to carve out. +// +// Serbian is written in both alphabets in the wild. THIS bundle is Cyrillic — +// 945 of its 1055 pre-existing values are Cyrillic-only and the 20 Latin-only +// ones are all untranslated English plus two defects — so every word list below +// is Cyrillic. A Latin-script Serbian value is a finding, not a variant; see +// scripts/l10n/script-coverage.js. +// +// Why closed word lists and NOT suffix patterns, specifically for Serbian: +// • "-ш" looks like the 2sg present ending, but "ваш" (your-FORMAL) and "наш" +// (our) both end in it, so a "-ш" rule reads the formal possessive as +// informal and inverts the polarity outright — the same trap as hr, sk, sl +// and bg. +// • "-те" is the 2pl ending, present and imperative alike. Serbian has no +// definite article, so this is NOT the disaster it is in Bulgarian, but it +// still collides with the feminine accusative plural of "тај" ("те датотеке" +// = "those files"), so the list is closed anyway. +// • bare "ти" is unusable: besides informal "you" it is the masculine +// nominative plural of "тај", so "ти објекти" means "those objects" — the +// same collision cs has with "ty", hr with "ti" and sl with "ti". Note this +// is the OPPOSITE of bg, where "ти" is safe because Bulgarian lost its case +// system; the two languages are neighbours and disagree. +// • bare "те" is unusable for the mirror reason: 2sg accusative clitic AND the +// accusative plural of "та". +// • bare "си" is unusable: besides the 2sg of "бити" it is the reflexive dative +// clitic, which is at its most frequent in FORMAL prose. +// JS \b is ASCII-only and would treat "ђ" as a boundary, so every guard is +// (? 0 ? 'informal' : f > 0 ? 'formal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + const { scanCoreRegister, reportCoreRegister } = require('../lib.js') + reportCoreRegister('sr', scanCoreRegister('sr', score), { formal: 'Ви', informal: 'ти' }) +} diff --git a/scripts/l10n/detectors/tr.js b/scripts/l10n/detectors/tr.js new file mode 100644 index 0000000000..a3c459efcb --- /dev/null +++ b/scripts/l10n/detectors/tr.js @@ -0,0 +1,112 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +// Turkish register detector, used both to measure core and to gate my own patches. +// Polarity: flags the DEVIATION. Core is formal (siz), so an informal hit is a defect. +// +// Two traps this encodes: +// 1. Turkish plural genitive and 2sg possessive are homographs: "dosyaların" is +// BOTH "of the files" and "your files". Any noun+lAr+In form is unusable as an +// informal marker. Only singular-stem 2sg possessives ("şifren") are safe. +// 2. Case folding: JS /i/ui does not match "İ", and 'İ'.toLowerCase() yields +// i + U+0307. Fold Turkish letters explicitly before matching. + +function fold(s) { + return s.replace(/İ/g, 'i').replace(/I/g, 'ı').toLowerCase() +} + +// 2pl (formal) — the marker set is closed and each entry is unambiguous. +const FORMAL_RES = [ + /(? 0 ? 'informal' : f > 0 ? 'formal' : 'neither' + if (got !== want) { + fail++ + console.log(`FAIL want=${want} got=${got} f=${f} i=${i} ${s}`) + } + } + return { fail, total: CONTROLS.length } +} + +module.exports = { score, fold, runControls, CONTROLS, UNDETECTABLE } + +if (require.main === module) { + const { fail, total } = runControls() + console.log(`controls: ${total - fail}/${total} pass`) + if (fail) process.exitCode = 1 + + const { scanCoreRegister, reportCoreRegister } = require('../lib.js') + reportCoreRegister('tr', scanCoreRegister('tr', score), { formal: 'siz', informal: 'sen' }) +} diff --git a/scripts/l10n/fetch-dicts.js b/scripts/l10n/fetch-dicts.js new file mode 100644 index 0000000000..eb672dd9fa --- /dev/null +++ b/scripts/l10n/fetch-dicts.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node +/* eslint-disable n/no-process-exit */ +/* eslint-disable no-console */ +/* eslint-disable n/shebang */ +/** + * Fetch hunspell dictionaries for the locales this app translates. + * + * node scripts/l10n/fetch-dicts.js [ …] (default: all mapped) + * + * Dictionaries land in scripts/l10n/dicts/ (gitignored — they are third-party + * data, tens of MB, and licensed variously). Re-running skips what is present. + * + * WHY NOT DISTRO PACKAGES. Arch/CachyOS official repos carry hunspell dictionaries + * for only ~10 of the 36 locales here — `sk`, `cs`, `sl`, `hr`, `bg`, `lt`, `lv`, + * `et`, `is`, `ga`, `mt` and the Nordics are all absent — and the package names + * differ on every distro, which makes "install the dictionaries" unreproducible on + * a second machine. LibreOffice's dictionary repo carries 30 of our 36, needs no + * root, and pins identically everywhere. Prefer this over `pacman`/`apt`. + * + * SIX LOCALES HAVE NO DICTIONARY HERE: fi, ga, lb, mk, mt, rm. Finnish needs + * Voikko (morphological, not hunspell); the rest are low-resource — which is + * exactly where a spell pass would have helped most, since `ga`, `mt` and `is` + * are where the garbled-word class concentrated. Treat their absence as a known + * gap, not as "those locales are clean". + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +'use strict' + +const fs = require('fs') +const path = require('path') +const { execFileSync } = require('child_process') +const { APP_ROOT } = require('./lib.js') + +// locale -> LibreOffice dictionaries directory / basename. +const MAP = { + be: 'be_BY/be-official', + bg: 'bg_BG/bg_BG', + bs: 'bs_BA/bs_BA', + ca: 'ca/dictionaries/ca', + cs: 'cs_CZ/cs_CZ', + da: 'da_DK/da_DK', + de: 'de/de_DE_frami', + el: 'el_GR/el_GR', + en: 'en/en_GB', + es: 'es/es_ES', + et: 'et_EE/et_EE', + fr: 'fr_FR/dictionaries/fr', + hr: 'hr_HR/hr_HR', + hu: 'hu_HU/hu_HU', + is: 'is/is', + it: 'it_IT/it_IT', + lt: 'lt_LT/lt', + lv: 'lv_LV/lv_LV', + nb: 'no/nb_NO', + nl: 'nl_NL/nl_NL', + pl: 'pl_PL/pl_PL', + pt: 'pt_PT/pt_PT', + ro: 'ro/ro_RO', + ru: 'ru_RU/ru_RU', + sk: 'sk_SK/sk_SK', + sl: 'sl_SI/sl_SI', + sq: 'sq_AL/sq_AL', + sr: 'sr/sr', + sv: 'sv_SE/dictionaries/sv_SE', + tr: 'tr_TR/tr_TR', + uk: 'uk_UA/uk_UA', +} +const UNAVAILABLE = ['fi', 'ga', 'lb', 'mk', 'mt', 'rm'] + +const BASE = 'https://raw.githubusercontent.com/LibreOffice/dictionaries/master' +const DICTS = path.join(APP_ROOT, 'scripts', 'l10n', 'dicts') + +const wanted = process.argv.slice(2).filter((a) => !a.startsWith('--')) +const locales = wanted.length ? wanted : Object.keys(MAP) + +fs.mkdirSync(DICTS, { recursive: true }) + +let ok = 0 +let skipped = 0 +const failed = [] + +for (const loc of locales) { + if (UNAVAILABLE.includes(loc)) { + console.log(` -- ${loc}: no hunspell dictionary published (see header)`) + continue + } + const src = MAP[loc] + if (!src) { + failed.push(`${loc} (not mapped)`) + continue + } + const affOut = path.join(DICTS, `${loc}.aff`) + const dicOut = path.join(DICTS, `${loc}.dic`) + if (fs.existsSync(affOut) && fs.existsSync(dicOut)) { + skipped++ + continue + } + let bad = false + for (const [ext, out] of [['aff', affOut], ['dic', dicOut]]) { + try { + execFileSync('curl', ['-sfL', '--max-time', '120', '-o', out, `${BASE}/${src}.${ext}`]) + if (!fs.statSync(out).size) throw new Error('empty') + } catch { + bad = true + try { fs.unlinkSync(out) } catch { /* nothing to clean */ } + } + } + if (bad) { + failed.push(loc) + try { fs.unlinkSync(affOut) } catch { /* nothing to clean */ } + console.log(` FAIL ${loc}: could not fetch ${src}`) + } else { + ok++ + console.log(` ok ${loc}: ${src}`) + } +} + +console.log() +console.log(`fetch-dicts: ${ok} fetched, ${skipped} already present, ${failed.length} failed` + + `, ${UNAVAILABLE.length} unavailable upstream (${UNAVAILABLE.join(' ')})`) +if (failed.length) { + console.log(`failed: ${failed.join(', ')}`) + process.exit(1) +} diff --git a/scripts/l10n/gate-negative-test.js b/scripts/l10n/gate-negative-test.js new file mode 100644 index 0000000000..e86adb101e --- /dev/null +++ b/scripts/l10n/gate-negative-test.js @@ -0,0 +1,286 @@ +#!/usr/bin/env node +/* eslint-disable n/no-process-exit */ +/* eslint-disable no-console */ +/* eslint-disable n/shebang */ +/** + * Prove that test:l10n:parity actually FAILS when a locale loses a key. + * + * node scripts/l10n/gate-negative-test.js [key] + * + * A gate nobody has seen fail is not known to work, so this breaks one bundle on + * purpose and asserts the gate notices and names it. Run it after any change to the + * gate itself, and on any locale whose parity you are relying on. + * + * ## Why this exists as a committed script + * + * The obvious way to run this test by hand is: delete a key, run the gate, then + * `git checkout -- l10n/.js` to undo. That last step is a trap. During a locale + * pass the bundle is UNCOMMITTED for hours, so `git checkout` does not undo the + * deletion — it reverts to pre-pass HEAD and silently discards the entire pass. That + * happened, and cost a full re-apply of 999 values. + * + * So this script owns the whole cycle: it snapshots the file, mutates it, runs the + * gate, restores from its own snapshot, and asserts the restore is byte-identical. + * There is no window in which a human has to remember to put the file back, and no + * reason to reach for git. + * + * It leaves the bundle exactly as it found it, including on failure. + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +'use strict' + +const fs = require('fs') +const os = require('os') +const path = require('path') +const { execFileSync } = require('child_process') +const { loadJsTranslations, serializeJs, APP_ROOT } = require('./lib.js') + +const loc = process.argv[2] +if (!loc) { + console.error('usage: gate-negative-test.js [key]') + process.exit(2) +} + +const locFile = path.join(APP_ROOT, 'l10n', `${loc}.js`) +if (!fs.existsSync(locFile)) { + console.error(`no such bundle: ${locFile}`) + process.exit(2) +} + +/** + * Run the parity gate once and return both its exit code and its combined output. + * + * BOTH streams are concatenated on purpose: the gate prints its summary to stdout but + * the per-locale failure detail to stderr, so a check that reads only stdout sees the + * exit code change and none of the reason. That is how the first version of this + * script reported a false negative. + * + * @return {{code: number, out: string}} Exit code and stdout+stderr. + */ +function runGate() { + try { + const out = execFileSync('node', [path.join(APP_ROOT, 'tests/l10n/check-l10n-parity.js')], + { cwd: APP_ROOT, stdio: 'pipe' }) + return { code: 0, out: String(out) } + } catch (e) { + return { + code: e.status === undefined ? 1 : e.status, + out: String(e.stdout || '') + String(e.stderr || ''), + } + } +} + +const original = fs.readFileSync(locFile) +const snapshot = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'l10n-gate-')), `${loc}.js`) +fs.writeFileSync(snapshot, original) + +let failures = 0 +/** + * Report one assertion. + * + * @param {string} name What was asserted. + * @param {boolean} ok Whether it held. + * @param {string} [detail] Extra context for the line. + */ +function check(name, ok, detail) { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`) + if (!ok) { + failures++ + } +} + +try { + // 0. The gate must be green to begin with, or the rest proves nothing: a gate + // that was already red would "fail with a key missing" for an unrelated reason. + check('gate is green before the test', runGate().code === 0) + + const cur = loadJsTranslations(locFile) + const victim = process.argv[3] || 'Save' + if (!(victim in cur.translations)) { + // A KEY, not a value. Passing a translated value here is an easy slip and + // would otherwise delete nothing and silently "pass". + throw new Error(`no such KEY in ${loc}.js (did you pass a value?): ${JSON.stringify(victim)}`) + } + + const before = Object.keys(cur.translations).length + delete cur.translations[victim] + fs.writeFileSync(locFile, serializeJs({ + app: cur.app, + translations: cur.translations, + pluralForm: cur.pluralForm, + })) + console.log(` removed key ${JSON.stringify(victim)} — ${before} -> ${before - 1} keys`) + + // 1. The gate must now fail, AND name this locale. The exit code alone is not + // enough: any other locale regressing would also make it non-zero, so a green + // exit code is necessary but the named line is what ties the failure to us. + const { code, out } = runGate() + check(`gate FAILS with a key missing from ${loc}`, code !== 0, `exit ${code}`) + // The failure must NAME the locale, not just exit non-zero: a gate that fails + // without saying which bundle broke sends the reader through 36 files. + check(`the failure names ${loc}`, + out.includes(`(.js) ${loc}:`)) +} catch (e) { + check('test ran', false, e.message) +} finally { + // Always restore, including on a thrown assertion. cp semantics, never git. + fs.writeFileSync(locFile, original) + check('bundle restored byte-identical', + Buffer.compare(fs.readFileSync(locFile), original) === 0) + fs.rmSync(path.dirname(snapshot), { recursive: true, force: true }) +} + +// 2. And it must be green again, which also proves the restore was complete. +check('gate is green again after the restore', runGate().code === 0) + +// ------------------------------------------------------------- the {plural} ban +// +// `{plural}` is banned in four places (MORPHOLOGY_PLACEHOLDER in lib.js), and a +// ban nobody has watched refuse something is exactly the kind of gate this script +// exists to distrust. The five source-hack keys it used to describe were removed +// when the call sites became real n() calls, so there is no longer any live +// example in the tree to reason from — which makes proving it by injection the +// only evidence available. +// +// Each phase mutates ONE thing, asserts the specific gate refuses it AND says +// enough for a reader to act, then restores from its own snapshot. As above: +// never git, because during a locale pass the bundle is uncommitted for hours. + +/** + * Run a node script from the app root and return its exit code plus both streams. + * + * @param {string[]} argv Script path (relative to APP_ROOT) and its arguments. + * @return {{code: number, out: string}} Exit code and stdout+stderr. + */ +function run(argv) { + try { + const out = execFileSync('node', [path.join(APP_ROOT, argv[0]), ...argv.slice(1)], + { cwd: APP_ROOT, stdio: 'pipe' }) + return { code: 0, out: String(out) } + } catch (e) { + return { + code: e.status === undefined ? 1 : e.status, + out: String(e.stdout || '') + String(e.stderr || ''), + } + } +} + +console.log('\n--- the {plural} ban') + +// Phase A — a poisoned VALUE. Covers check-l10n-parity.js (CI) and selfcheck.js. +try { + const cur = loadJsTranslations(locFile) + const victim = Object.keys(cur.translations).find((k) => typeof cur.translations[k] === 'string') + cur.translations[victim] = `${cur.translations[victim]}{plural}` + fs.writeFileSync(locFile, serializeJs({ + app: cur.app, translations: cur.translations, pluralForm: cur.pluralForm, + })) + console.log(` poisoned value of ${JSON.stringify(victim)} with {plural}`) + + const parity = runGate() + check('parity gate FAILS on {plural} in a value', parity.code !== 0, `exit ${parity.code}`) + check('parity names the locale and the key', + parity.out.includes(`(.js) ${loc}:`) && parity.out.includes('{plural}')) + + // selfcheck is the fast local mirror. It reports per-check lines rather than + // exiting on the first problem, so assert on the named check, not just the code. + const self = run(['scripts/l10n/selfcheck.js', loc]) + check('selfcheck FAILS on {plural} in a value', self.code !== 0, `exit ${self.code}`) + check('selfcheck names the {plural} check', + /FAIL\s+no \{plural\} in any value/.test(self.out)) +} catch (e) { + check('poisoned-value phase ran', false, e.message) +} finally { + fs.writeFileSync(locFile, original) + check('bundle restored byte-identical after the value phase', + Buffer.compare(fs.readFileSync(locFile), original) === 0) +} + +// Phase B — a poisoned CALL SITE. Covers tests/l10n/check-l10n.js (CI). +// +// Both spellings are injected, because they fail for different reasons: the key +// form is caught by reading the extracted key, the ternary form only by reading +// the call's argument span. A guard that caught one and not the other would look +// identical from the outside. +const probe = path.join(APP_ROOT, 'src', '__gate_negative_probe__.vue') +for (const [shape, body] of [ + ['{plural} in the key', "t('openregister', 'widget{plural}', { plural: count !== 1 ? 's' : '' })"], + ["? 's' : '' in the arguments", "t('openregister', 'widget', { suffix: count !== 1 ? 's' : '' })"], +]) { + try { + fs.writeFileSync(probe, `\n`) + const res = run(['tests/l10n/check-l10n.js']) + check(`check-l10n FAILS on ${shape}`, res.code !== 0, `exit ${res.code}`) + check(`check-l10n names the probe file for ${shape}`, + res.out.includes('__gate_negative_probe__.vue') + && res.out.includes('build English morphology in JS')) + // --write must refuse rather than extracting the defect into en.js. + const enBefore = fs.readFileSync(path.join(APP_ROOT, 'l10n', 'en.js')) + run(['tests/l10n/check-l10n.js', '--write']) + check(`--write does not extract ${shape} into en.js`, + Buffer.compare(fs.readFileSync(path.join(APP_ROOT, 'l10n', 'en.js')), enBefore) === 0) + } catch (e) { + check(`call-site phase (${shape}) ran`, false, e.message) + } finally { + fs.rmSync(probe, { force: true }) + } +} +check('probe file removed', !fs.existsSync(probe)) + +// Phase C — a poisoned PATCH. Covers apply.js, the only writer into l10n/*.js. +// apply.js refuses a patch whole rather than landing part of it, so a dry run is +// enough here and nothing needs restoring. +try { + const tmp = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'l10n-gate-')), 'patch.json') + const cur = loadJsTranslations(locFile) + const victim = Object.keys(cur.translations).find((k) => typeof cur.translations[k] === 'string') + fs.writeFileSync(tmp, JSON.stringify({ [victim]: 'iets{plural}' })) + const res = run(['scripts/l10n/apply.js', loc, tmp]) + check('apply.js REFUSES a patch carrying {plural}', res.code !== 0, `exit ${res.code}`) + check('apply.js says {plural} is banned', /\{plural\} is banned/.test(res.out)) + fs.rmSync(path.dirname(tmp), { recursive: true, force: true }) +} catch (e) { + check('poisoned-patch phase ran', false, e.message) +} + +// Phase D — a plural form that is ALSO a key. The defect translatePlural's +// re-translation causes; see the collision check in check-l10n-parity.js. Proved +// by injection for the same reason as the ban: there is deliberately no live +// example left in the tree. +try { + const cur = loadJsTranslations(locFile) + const arrayKey = Object.keys(cur.translations).find((k) => Array.isArray(cur.translations[k])) + // Borrow a real key whose value differs from itself, so the substitution is + // observable rather than a no-op. + const donor = Object.keys(cur.translations).find((k) => typeof cur.translations[k] === 'string' + && cur.translations[k] !== k && k.length > 3) + const forms = [...cur.translations[arrayKey]] + forms[0] = donor + cur.translations[arrayKey] = forms + fs.writeFileSync(locFile, serializeJs({ + app: cur.app, translations: cur.translations, pluralForm: cur.pluralForm, + })) + console.log(` set ${JSON.stringify(arrayKey)} form 0 to ${JSON.stringify(donor)}, ` + + 'which is itself a key') + + const res = runGate() + check('parity gate FAILS on a plural form that is also a key', res.code !== 0, `exit ${res.code}`) + check('parity explains the collision', res.out.includes('which is also a key in this bundle')) +} catch (e) { + check('collision phase ran', false, e.message) +} finally { + fs.writeFileSync(locFile, original) + check('bundle restored byte-identical after the collision phase', + Buffer.compare(fs.readFileSync(locFile), original) === 0) +} + +// Everything above mutated something. Prove the tree is clean again. +check('parity gate is green after every phase', runGate().code === 0) + +console.log(failures === 0 + ? `\n${loc}: the parity gate genuinely holds this locale, and the {plural} ban genuinely refuses` + : `\n${failures} CHECK(S) FAILED — the gate may not be holding ${loc}`) +process.exitCode = failures ? 1 : 0 diff --git a/scripts/l10n/harvest.js b/scripts/l10n/harvest.js new file mode 100644 index 0000000000..f147ecdcfd --- /dev/null +++ b/scripts/l10n/harvest.js @@ -0,0 +1,236 @@ +#!/usr/bin/env node +/* eslint-disable n/no-process-exit */ +/* eslint-disable no-console */ +/* eslint-disable n/shebang */ +/** + * Harvest exact-key translation candidates for one locale from Nextcloud core and + * from the sibling Conduction apps. + * + * node scripts/l10n/harvest.js [worklist.json] + * + * ## Output is CANDIDATES, never answers + * + * Hit rate is only 2–7% (`ca` 22 of 1037, `hr` 67 of 1024, `lt` 69 of 1020), and + * the failures are not typos — they are correct translations of a DIFFERENT sense, + * which pass every automated check. Every hit must be verified at its call site. + * Confirmed offenders, all shipped by core: + * + * Right — "Dešinė" / "Desno" / "Rechts", i.e. right-ALIGNED. This app means an + * RBAC permission. + * Bucket — "Amazon S3 saugykla" in lt, "Korv" (a basket) in et, "Buket" (a + * bouquet of flowers) in tr. This app means a histogram bin. + * Refresh — "Yenlle" in tr, a typo. Do not take typos. + * + * Sibling apps are not automatically right either: openconnector's hr bundle has + * Mappings -> "Mappingi", a non-standard transliteration. + * + * ## Catalogues filed under the wrong language are dropped, loudly + * + * A whole catalogue can be the wrong language. openbuild ships ONE Croatian + * catalogue under seven names — `bs cs hr mk sk sl sr` are value-identical, all + * 586 keys — plus `da == sv` and `de == lb`. Harvesting `sk` from it offers + * "Dodaj shemu" and "Radnje" as Slovak. Each hit reads as a plausible Slavic + * translation of the right key, so nothing downstream would catch it. + * + * So every source is checked against its own app's OTHER locales, and a catalogue + * that duplicates one of them is dropped with the duplicate named. This is a + * measurement, not a heuristic: two real languages do not agree on hundreds of + * prose values. Dropping only loses candidates, which were never answers. + * + * The locale is a REQUIRED argument. It used to be hardcoded, and a copied script + * silently harvested the previous language for a whole pass. + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +'use strict' + +const fs = require('fs') +const path = require('path') +const { + APP_ROOT, loadJsTranslations, isIdentical, loadLocaleConfig, coreCatalogues, +} = require('./lib.js') + +const loc = process.argv[2] +if (!loc) { + console.error('usage: harvest.js [worklist.json]') + process.exit(2) +} +const worklistFile = process.argv[3] + +// Where core and the sibling apps live, relative to this app. Overridable so the +// script is not tied to one developer's checkout layout. +const WORKSPACE = process.env.L10N_WORKSPACE || path.resolve(APP_ROOT, '..', '..') +const SERVER = process.env.L10N_SERVER_DIR || path.join(WORKSPACE, 'server') +const CUSTOM = process.env.L10N_APPS_DIR || path.join(WORKSPACE, 'apps-custom') + +// Region variants (Estonian ships as et_EE) come from lib's coreCatalogues, which +// matches the directory instead of guessing the region code. Guessing it here as +// `${loc}_${loc.toUpperCase()}` produced "et_ET" and silently skipped every +// Estonian catalogue in core. + +/** The keys this locale still needs: absent ∪ unjustified-identical. */ +function worklist() { + if (worklistFile) { + return new Map(JSON.parse(fs.readFileSync(worklistFile, 'utf8')).map(x => [x.key, x.en])) + } + const en = loadJsTranslations(path.join(APP_ROOT, 'l10n', 'en.js')).translations + const cur = loadJsTranslations(path.join(APP_ROOT, 'l10n', `${loc}.js`)).translations + const cfg = loadLocaleConfig(loc) + const need = new Map() + for (const [k, v] of Object.entries(en)) { + if (!(k in cur) || (isIdentical(k, cur[k]) && !(k in cfg.cognates))) need.set(k, v) + } + return need +} + +const need = worklist() + +const sources = [] +try { + sources.push(...coreCatalogues(loc)) +} catch { + // Core absent, or this locale not shipped there. The sibling apps below may + // still supply candidates, and the no-sources check after this block covers + // the case where nothing at all was found. +} +if (fs.existsSync(CUSTOM)) { + for (const a of fs.readdirSync(CUSTOM)) { + if (a === path.basename(APP_ROOT)) continue + for (const ext of ['js', 'json']) { + const f = path.join(CUSTOM, a, 'l10n', `${loc}.${ext}`) + if (fs.existsSync(f)) sources.push(f) + } + } +} +if (!sources.length) { + console.error(`no ${loc} sources found. Looked under ${SERVER} and ${CUSTOM};`) + console.error('set L10N_SERVER_DIR / L10N_APPS_DIR if your checkout differs.') + process.exit(1) +} + +/** + * Read either a backend .json catalogue or a frontend .js bundle. + * + * @param {string} f Absolute path to a locale file. + * @return {object} Its translations, or {} when unparseable. + */ +function readBundle(f) { + const raw = fs.readFileSync(f, 'utf8') + if (f.endsWith('.json')) { + try { + return JSON.parse(raw).translations || {} + } catch { + return {} + } + } + const i = raw.indexOf('{', raw.indexOf('OC.L10N.register')) + const j = raw.lastIndexOf('}') + if (i < 0 || j < i) return {} + try { + return JSON.parse(raw.slice(i, j + 1)) + } catch { + return {} + } +} + +/** A locale filename, so siblings of a source can be enumerated. */ +const LOCALE_FILE_RE = /^([a-z]{2,3}(?:_[A-Za-z]{2,3})?)\.(js|json)$/ + +/** + * Signature of a catalogue's key→value mapping, order-independent so a + * re-serialized copy still matches its original. + * + * @param {object} tr Translations object. + * @return {string} Signature, or '' when there is nothing to compare. + */ +function catalogueSig(tr) { + const e = Object.entries(tr) + return e.length ? JSON.stringify(e.sort((a, b) => (a[0] < b[0] ? -1 : 1))) : '' +} + +/** + * Which OTHER locale in the same app this file is a duplicate of, if any. A + * catalogue shared with another language is not this language's translation, + * whichever of the two names is the wrong one — see the header. + * + * @param {string} f Absolute path to a `.` locale file. + * @return {string[]} Locale codes it duplicates, possibly empty. + */ +function duplicatedLocales(f) { + const sig = catalogueSig(readBundle(f)) + if (!sig) return [] + const dir = path.dirname(f) + // Compare the BASE language, not the locale name. Estonian and Lithuanian ship + // as et_EE / lt_LT, and core's et_EE.js and et_EE.json really are value- + // identical — so matching on the name alone dropped 33 of 40 sources for each + // of those two locales, reporting the same language under two spellings as a + // mislabelled one. + const base = l => l.split('_')[0] + const out = [] + for (const other of fs.readdirSync(dir).sort()) { + const m = LOCALE_FILE_RE.exec(other) + if (!m || base(m[1]) === base(loc)) continue + if (catalogueSig(readBundle(path.join(dir, other))) === sig) out.push(other) + } + return out +} + +const mislabelled = [] +const usable = sources.filter(f => { + const dup = duplicatedLocales(f) + if (!dup.length) return true + mislabelled.push([path.relative(WORKSPACE, f), dup]) + return false +}) +if (mislabelled.length) { + console.log(`DROPPED ${mislabelled.length} source(s) filed under ${loc} but identical to another locale:`) + for (const [f, dup] of mislabelled) { + console.log(` ${f} == ${dup.join(', ')}`) + } +} +if (!usable.length) { + console.error(`every ${loc} source was a duplicate of another locale — nothing to harvest.`) + process.exit(1) +} + +const hits = new Map() +for (const f of usable) { + for (const [k, v] of Object.entries(readBundle(f))) { + if (!need.has(k)) continue + // An untranslated source value teaches us nothing. + if (typeof v === 'string') { + if (!v || v === k) continue + } else if (Array.isArray(v)) { + if (!v.length || v.every(x => !x)) continue + } else { + continue + } + const sig = JSON.stringify(v) + if (!hits.has(k)) hits.set(k, new Map()) + const m = hits.get(k) + if (!m.has(sig)) m.set(sig, []) + m.get(sig).push(path.relative(WORKSPACE, f)) + } +} + +const out = [...hits.entries()] + .map(([key, variants]) => ({ + key, + en: need.get(key), + candidates: [...variants.entries()] + .sort((a, b) => b[1].length - a[1].length) + .map(([v, srcs]) => ({ value: JSON.parse(v), n: srcs.length, where: srcs.slice(0, 3) })), + })) + .sort((a, b) => String(a.key).localeCompare(String(b.key))) + +const dest = path.join(__dirname, `harvest-${loc}.json`) +fs.writeFileSync(dest, JSON.stringify(out, null, 1) + '\n') + +console.log(`sources scanned: ${usable.length}` + + (mislabelled.length ? ` (${mislabelled.length} dropped as another locale)` : '')) +console.log(`harvest hits: ${out.length} of ${need.size} needed (${(100 * out.length / (need.size || 1)).toFixed(1)}%)`) +console.log(` unanimous: ${out.filter(o => o.candidates.length === 1).length}`) +console.log(` conflicting: ${out.filter(o => o.candidates.length > 1).length}`) +console.log(`written to ${path.relative(APP_ROOT, dest)} — VERIFY EVERY HIT AT ITS CALL SITE`) diff --git a/scripts/l10n/lib.js b/scripts/l10n/lib.js new file mode 100644 index 0000000000..914765bf4b --- /dev/null +++ b/scripts/l10n/lib.js @@ -0,0 +1,909 @@ +/* eslint-disable jsdoc/require-param */ +/** + * The l10n catalogue library — the single shared module for frontend translation + * tooling. Used by scripts/check-l10n.js, scripts/clean-l10n.js, + * scripts/l10n-ai.js, scripts/find-unwrapped.js, tests/l10n/check-l10n.js, + * tests/l10n/check-l10n-parity.js and everything else in scripts/l10n/. + * + * Operates on l10n/*.js (frontend translation files). Backend .json files are + * a separate concern and are not handled here. + * + * This file was `scripts/lib/l10n.js` until 2026-08-14. It moved into the l10n + * folder because a second helper module had appeared at `scripts/l10n/lib.js`, and + * two near-mirror names one directory apart is exactly the sort of pair you grab + * the wrong one of. The two are now merged: there is ONE l10n folder, and one + * library in it. `scripts/lib/` no longer exists. + * + * This is the ORIGIN copy. openconnector carries a VENDORED copy — the two apps + * ship separate npm packages, so there is no import path between them. Keep the + * two in sync when either changes; the only intended divergence is DYNAMIC_KEYS + * below, which is app-specific data. **openconnector still has it at the old + * `scripts/lib/l10n.js` path**; move it when you next sync. + * + * Two layers live here, deliberately in one file rather than two near-identically + * named ones: + * + * 1. THE CATALOGUE — parsing, serializing and key extraction. Locale-agnostic, + * and what the audit and CI gates are built on. + * 2. A LOCALE PASS — the helpers a single-language translation pass needs: + * identical-value detection, placeholder sets, per-locale config and + * detectors. These resolve paths under scripts/l10n/ and degrade to empty + * results when a locale has not been started, so callers can treat + * "not started" and "in progress" uniformly. + */ + +const fs = require('fs') +const path = require('path') +const vm = require('vm') +const { execFileSync } = require('child_process') + +/** + * Load a single l10n/*.js file and return its app name, translations object, + * and plural-form string. Throws if the file does not call OC.L10N.register. + */ +function loadJsTranslations(file) { + const code = fs.readFileSync(file, 'utf8') + let captured = null + let plural = null + let app = null + const sandbox = { + OC: { + L10N: { + register: (registeredApp, translations, pluralForm) => { + app = registeredApp + captured = translations + plural = pluralForm + }, + }, + }, + } + vm.createContext(sandbox) + vm.runInContext(code, sandbox, { filename: file }) + if (!captured || typeof captured !== 'object') { + throw new Error( + `OC.L10N.register was not called with a translations object in ${file}`, + ) + } + if (!app) { + throw new Error( + `OC.L10N.register was not called with an app name in ${file}`, + ) + } + return { + app, + translations: captured, + pluralForm: plural || 'nplurals=2; plural=(n != 1);', + } +} + +/** + * Case-insensitive alphabetical order so "apple" sorts next to "Apple" instead + * of after "Zebra" the way a raw code-unit sort puts it, tie-broken by code + * unit so the result is stable. Deliberately NOT localeCompare: that varies + * with the Node/ICU version, which would make the sort order — and therefore + * every locale file's diff — depend on who ran the tool. + */ +function compareKeys(a, b) { + const x = a.toLowerCase() + const y = b.toLowerCase() + if (x < y) return -1 + if (x > y) return 1 + return a < b ? -1 : a > b ? 1 : 0 +} + +/** + * Serialize an l10n/*.js file byte-for-byte in the layout the shipped files + * already use, so a one-key edit produces a one-line diff. + * + * That layout is the Nextcloud/Transifex one, and it differs from plain + * JSON.stringify in four ways that all matter for diff noise: + * - four-space indent (NOT tabs) for the app id, the brace and every entry, + * with entries at the SAME depth as the opening brace; + * - a space before the colon: "key" : "value" + * - no trailing comma after the final entry; + * - a `);` terminator rather than `)`. + * + * Getting any of these wrong rewrites every line of all 37 locale files on the + * next write. The previous implementation emitted tabs, `"key": "value"`, a + * trailing comma and `)`, and leaned on `eslint --fix` to renormalize — which + * silently no-ops when node_modules is absent, and never covered l10n/ anyway. + * + * Pluralized values (arrays) are emitted as compact JSON arrays, as on disk. + * + * Keys are sorted with compareKeys (case-insensitive CODE-UNIT order), which is + * the order 36 of the 37 shipped locale files are actually in; localeCompare + * matches none of them, because the two disagree on where punctuation sorts. + * The lone exception is l10n/en.js, still in the order the original extraction + * tool emitted, so the first write that touches en.js will re-sort it once. + */ +function serializeJs({ app, translations, pluralForm }) { + const keys = Object.keys(translations).sort(compareKeys) + const lines = keys.map((k, i) => { + const value = translations[k] + const comma = i === keys.length - 1 ? '' : ',' + return ` ${JSON.stringify(k)} : ${JSON.stringify(value)}${comma}` + }) + return `OC.L10N.register(\n ${JSON.stringify(app)},\n {\n${lines.join('\n')}\n},\n${JSON.stringify(pluralForm)}\n);\n` +} + +/** + * Recursively walk a directory, collecting files whose extension is in `exts`. + * Skips node_modules and dotfile directories. + */ +function walk(dir, exts, out = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue + walk(full, exts, out) + } else if (exts.includes(path.extname(entry.name))) { + out.push(full) + } + } + return out +} + +/** + * Matches the opening of a translation call for `app`, capturing which function + * it was so the caller knows how many key arguments to read. + * + * Covers t(), n() and the $t/$n template variants, plus member forms like + * this.t(...). The negative lookbehind rejects identifiers that merely END in + * t or n -- format(, fn(, min( -- which a bare \b would let through for `n`. + * + * n() is the reason this exists. The previous extractor matched only `t(`, so + * every n() plural key was invisible to it. That made check-l10n report all + * plural keys as UNUSED, and armed clean-l10n.js: because it deletes + * en.js-minus-used from EVERY locale file, adding the plural source keys to + * en.js (which is correct and expected) would make the next --apply erase them + * from all 37 locales. They are only safe today by accident of being absent. + */ +function translationCallRe(app) { + return new RegExp( + `(? 1-based line resolver for one file's text. */ +function makeLineResolver(text) { + const lineStarts = [0] + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) lineStarts.push(i + 1) + } + return (pos) => { + let lo = 0 + let hi = lineStarts.length - 1 + while (lo < hi) { + const mid = (lo + hi + 1) >> 1 + if (lineStarts[mid] <= pos) lo = mid + else hi = mid - 1 + } + return lo + 1 + } +} + +/** + * Find the file:line of every static translation reference to `key`. Used by + * l10n-ai.js rm to explain *why* a removal is refused. Matches n() plural + * arguments too, so removing a plural key is correctly blocked. + */ +function findKeyReferences(srcDir, app, key) { + const hits = [] + for (const file of walk(srcDir, SRC_EXTS)) { + const text = fs.readFileSync(file, 'utf8') + const { calls } = extractTranslationCalls(text, app) + if (!calls.length) continue + const posToLine = makeLineResolver(text) + for (const c of calls) { + if (c.keys.includes(key)) hits.push({ file, line: posToLine(c.index) }) + } + } + return hits +} + +function escapeRegex(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * List l10n/*.js files in an app's l10n/ directory. Sorted for deterministic + * output. Returns absolute paths. + */ +function listJsLocaleFiles(l10nDir) { + if (!fs.existsSync(l10nDir)) return [] + return fs + .readdirSync(l10nDir) + .filter((f) => f.endsWith('.js')) + .sort() + .map((f) => path.join(l10nDir, f)) +} + +/** Strip the `.js` extension from a locale file basename ("en.js" → "en"). */ +function localeNameOf(file) { + return path.basename(file, '.js') +} + +/** + * Keys passed to t() through a VARIABLE, so no static scan can find them. + * They are real, live keys: without them the Permission Matrix headers, the + * approval status badges and the dashboard date presets render English. + * + * t('openregister', action) PermissionMatrix.vue:41 + * actions: ['read','create',...] (frontend) + * t('openregister', step.status) ApprovalStepList.vue:17 + * raw DB enum from the approval-steps API; + * the backend never localises it + * t('openregister', preset.label) DashboardIndex.vue:91,120,360 + * DATE_PRESETS (frontend) + * + * Anything listed here must be treated as USED: never reported unused, never + * removed by clean-l10n. Add to this list when a new variable-keyed t() call + * is introduced, or the key silently stops being translated. + */ +const DYNAMIC_KEYS = [ + // PermissionMatrix actions + 'read', + 'create', + 'update', + 'delete', + 'manage', + // ApprovalStepList step.status + 'pending', + 'approved', + 'rejected', + 'skipped', + 'cancelled', + // DashboardIndex date presets + 'All time', + 'Last 7 days', + 'Last 30 days', + 'Last 3 months', + 'Last 12 months', +] + +/** + * Every key reached dynamically: DYNAMIC_KEYS plus the src/manifest.json fields + * that MainMenu.translate(key) passes straight to t(). + * + * Only the fields CnAppNav actually resolves through its `translate` prop count: + * `menu[].label` (recursively through `children`) and the two nav label + * overrides. Anything else in the manifest is data, not UI copy — notably + * `observability.metrics[].name` (Prometheus metric identifiers) and + * `pages[].title`, which CnPageRenderer forwards to the page component as a raw + * prop without translating it. Harvesting those made metric names look like + * catalogue keys and would have put them in front of translators. + * + * @param {string} repoRoot Absolute path to the app root. + * @return {Set} Keys that must count as used. + */ +function collectDynamicKeys(repoRoot) { + const out = new Set(DYNAMIC_KEYS) + const manifestPath = path.join(repoRoot, 'src/manifest.json') + if (!fs.existsSync(manifestPath)) return out + let manifest + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + } catch { + return out + } + ;(function collectMenu(items) { + if (!Array.isArray(items)) return + for (const item of items) { + if (!item || typeof item !== 'object') continue + if (typeof item.label === 'string') out.add(item.label) + collectMenu(item.children) + } + })(manifest.menu) + for (const field of ['roadmapLabel', 'documentationLabel']) { + const v = manifest.nav?.[field] + if (typeof v === 'string') out.add(v) + } + return out +} + +/* ------------------------------------------------------------------------- * + * Layer 2: a single-locale translation pass. + * + * Everything below serves the per-locale workflow in scripts/l10n/ and the + * parity gate. Kept in this file rather than a second `lib.js` next door — see + * the header. + * ------------------------------------------------------------------------- */ + +/** Absolute path to the app root, derived from this file's location. */ +const APP_ROOT = path.resolve(__dirname, '..', '..') +/** Per-locale config: measured register, justified cognates, audited corrections. */ +const LOCALES_DIR = path.join(__dirname, 'locales') +/** Per-locale register detectors. */ +const DETECTORS_DIR = path.join(__dirname, 'detectors') + +/** + * Is this value indistinguishable from its own key? + * + * This is the single most important predicate in the whole workflow, because an + * ABSENT key falls back to the English source and stays visibly untranslated to + * every tool, whereas a value equal to its key renders the same characters while + * being indistinguishable from finished work — so nobody ever revisits it. + * + * For a plural key the comparison is per form against the two source strings + * encoded in the `_singular_::_plural_` identifier, so a locale that translated + * only one of the forms is NOT counted as identical. + * + * @param {string} key Catalogue key. + * @param {string|string[]} value Its value in some locale. + * @return {boolean} True when the value carries no translation at all. + */ +function isIdentical(key, value) { + if (typeof value === 'string') return value === key + const m = /^_([\s\S]*)_::_([\s\S]*)_$/.exec(key) + if (!m) return false + return value.every((x, i) => x === (i === 0 ? m[1] : m[2])) +} + +/** + * Whether ANY single form of a value renders the English source — the weaker + * condition a cognate record for a plural key has to answer for. + * + * `isIdentical` requires EVERY form to match, which no partially-cognate plural + * ever does, while runtime-check.mjs flags a single English-rendering form and + * consults the same cognate record to excuse it. So the two gates disagreed about + * what one record means: Romanian's email array is + * ["{count} email", "{count} emailuri", "{count} de emailuri"] — the singular IS + * the English string, because Romanian borrows 'email' unchanged, while the other + * two forms differ. runtime-check needed the record; selfcheck then called the + * same record stale. A record is stale only when NOTHING is left to excuse. + * + * @param {string} key Catalogue key, possibly a plural identifier. + * @param {string|string[]} value Its value. + * @return {boolean} True if the whole value, or one plural form, is the source. + */ +function hasIdenticalForm(key, value) { + if (isIdentical(key, value)) return true + if (typeof value === 'string' || !Array.isArray(value)) return false + const m = /^_([\s\S]*)_::_([\s\S]*)_$/.exec(key) + if (!m) return false + return value.some((x, i) => x === (i === 0 ? m[1] : m[2])) +} + +/** + * Every placeholder token a translated value must carry over from the English + * source. Checked in BOTH directions: a dropped `{count}` renders a sentence with + * a hole in it, and an invented one renders a literal brace to the user. + * + * @param {string} s A source string or translated value. + * @return {Set} The placeholder tokens it contains. + */ +function placeholders(s) { + const out = new Set() + for (const m of String(s).matchAll(/\{[a-zA-Z_][\w.]*\}|%[nsd]|%\d+\$[sd]/g)) out.add(m[0]) + return out +} + +/** + * nplurals from a locale file's OWN plural-forms header. + * + * Anchored on `^` or `;` so it cannot match the `nplurals` inside the word + * itself when the expression is scanned, and never inferred from the language: + * an array shorter than the form index the runtime asks for renders BLANK, and + * that is the one l10n defect invisible to a human reading the file. + * + * @param {string} pluralForm The header string passed to OC.L10N.register. + * @return {number} Declared form count, defaulting to 2. + */ +function npluralsOf(pluralForm) { + const m = /(?:^|;)\s*nplurals\s*=\s*(\d+)/.exec(pluralForm || '') + return m ? Number(m[1]) : 2 +} + +/** + * English morphology assembled in JS and handed to a translation call. + * + * This is the shape the five `X{plural}` keys used to have: the call site passed + * `{ plural: count !== 1 ? 's' : '' }`, so the catalogue key was `object{plural}` + * and the runtime glued an English "s" onto whatever the locale had written. No + * language whose plural is not a suffixed -s can render that, and a three- or + * four-form language cannot render it at ALL -- the whole form set has to fit in + * one string. Every locale worked around it differently: `bestand(en)`, + * `súbor(y)`, `аб'ект(ы)`, none of which is how the language is actually written. + * + * It is now BANNED rather than tolerated, and the ban is enforced in four places + * so it cannot come back by any route: + * + * 1. tests/l10n/check-l10n.js the source scan, on every PR (test:l10n) + * 2. tests/l10n/check-l10n-parity.js every catalogue value (test:l10n:parity) + * 3. scripts/l10n/apply.js the only writer into l10n/*.js + * 4. scripts/l10n/selfcheck.js the fast local mirror of 2 + * + * `scripts/l10n/gate-negative-test.js` proves all four actually fail, because a + * gate nobody has seen fail is not known to work. + * + * The replacement is a real plural call, which gives every locale as many forms + * as it needs: `n('openregister', 'object', 'objects', count)`. + */ +const MORPHOLOGY_PLACEHOLDER = /\{plural\}/ + +/** + * The same defect spelled as a ternary rather than a placeholder -- `? 's' : ''` + * and its mirror `? '' : 's'`, in either quote style. Only ever applied INSIDE a + * translation call's argument span (see matchingParen), because the identical + * expression in an ordinary template literal is a different problem: an unwrapped + * string, which is tracked separately and must not fail this gate. + */ +const MORPHOLOGY_TERNARY = /\?\s*(['"])s\1\s*:\s*(['"])\2|\?\s*(['"])\3\s*:\s*(['"])s\4/ + +/** Locale codes that have a committed config, i.e. a started or finished pass. */ +function configuredLocales() { + if (!fs.existsSync(LOCALES_DIR)) return [] + return fs.readdirSync(LOCALES_DIR) + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace(/\.json$/, '')) + .sort() +} + +/** + * Per-locale configuration. Returns empty structures for a locale that has none + * yet, so callers can treat "not started" and "in progress" uniformly. + * + * @param {string} loc Locale code. + * @return {{register: string|null, registerNotMeasured: string|null, pluralOrder: string|null, + * pluralBoundary: string|null, cognates: object, corrections: object}} Config. + */ +function loadLocaleConfig(loc) { + const f = path.join(LOCALES_DIR, `${loc}.json`) + if (!fs.existsSync(f)) { + return { + register: null, registerNotMeasured: null, pluralOrder: null, + pluralBoundary: null, cognates: {}, corrections: {}, + } + } + const raw = JSON.parse(fs.readFileSync(f, 'utf8')) + return { + register: raw.register || null, + // A WRITTEN reason this locale has a config file but no measured register. + // + // selfcheck treats "has a locales/.json" as "has had a pass", which was + // true while the only reason to create one was to run a pass. It stopped + // being true when a file had to be created for a narrower reason: apply.js + // refuses an identical value without a recorded cognate, so a locale can now + // acquire a config without anyone having measured its register (nl, for the + // n() plural conversion). Without this field that locale reports a hard FAIL + // forever, which trains the reader to ignore selfcheck output. + // + // It is a written justification rather than a boolean on purpose: the same + // shape as `cognates`, so opting out of the check costs saying why in the + // file, and a reviewer can see at a glance that nothing was measured. + registerNotMeasured: raw.registerNotMeasured || null, + // "library" acknowledges that this locale's plural= header and the runtime + // library disagree on which index each count selects, and that the arrays are + // deliberately ordered by the library. Read by runtime-check.mjs; without it a + // mismatch is a hard failure, so the ordering cannot be silently reverted. + // + // pluralOrder is for a PERMUTATION disagreement, where the two partition the + // counts identically and only label the parts differently, so reordering the + // arrays makes the locale fully correct (lv). pluralBoundary is for a + // disagreement no reordering can fix, because the two draw the boundaries in + // different PLACES: `is` routes 21/31/41… to the plural where Icelandic takes + // the singular, and `mk` routes 11/111 to the singular where Macedonian takes + // the plural. There the acknowledgement is that some counts are knowingly + // wrong and pluralNote says which — a different claim from "the arrays are + // reordered", which is why it is a separate field rather than a second + // meaning for the same one. + pluralOrder: raw.pluralOrder || null, + pluralBoundary: raw.pluralBoundary || null, + cognates: raw.cognates || {}, + corrections: raw.corrections || {}, + // Read by spell.js: words legitimately absent from a general hunspell + // dictionary (product names, code tokens, domain coinages), so a report shows + // only what still needs triaging. + spellAllow: raw.spellAllow || [], + // Read by script-coverage.js: letter runs that legitimately mix two scripts, + // so the homoglyph section stays actionable. Only ever a spelling this + // locale's OWN core catalogues also use — mk writes `сè` with a Latin è + // (U+00E8) and core mk does the same in 7 values with zero of the Cyrillic + // U+0450, so it is the prevailing convention rather than 29 defects. A run + // listed here without that corroboration is hiding a real defect. + homoglyphAllow: raw.homoglyphAllow || [], + } +} + +/** + * The register detector for a locale, or null if none has been built yet. + * + * @param {string} loc Locale code. + * @return {object|null} Module exporting score/runControls/CONTROLS. + */ +function loadDetector(loc) { + const f = path.join(DETECTORS_DIR, `${loc}.js`) + return fs.existsSync(f) ? require(f) : null +} + +/** + * Nextcloud core's own catalogues for one locale — the evidence a register is + * measured from. + * + * Resolved the same way harvest.js resolves it: relative to this app, with an + * env override. The five detectors written before this helper each hardcoded + * one developer's absolute path, so on any other checkout they scanned ZERO + * files and printed `verdict: MIXED` — a real-looking answer computed from no + * data, which is precisely the failure mode this tooling exists to prevent. + * Hence the throw below: no sources is an error, never a verdict. + * + * Region variants are found by MATCHING the directory, not by guessing the + * region code. Guessing was `${loc}_${loc.toUpperCase()}`, which yields `et_ET` + * — and Estonian ships as `et_EE`, so every Estonian catalogue in core was + * invisible to a scan whose own comment said it handled Estonian. `@`-suffixed + * variants (`sr@latin`) are excluded: same language, different script. + * + * @param {string} loc Locale code. + * @return {string[]} Absolute paths to every core/lib/app catalogue found. + */ +/** + * Matches `.json` and any region variant of it, e.g. `et_EE.json`. + * + * @param {string} loc Locale code. + * @return {RegExp} Test against a bare filename. + */ +function localeFileRe(loc) { + // `loc` reaches here from a command-line argument, so it goes through the + // standard metacharacter escape before it is interpolated into a pattern. + // Without it a caller could pass `.*` and have this match every catalogue + // on disk instead of one locale's (CodeQL js/regex-injection). + const escaped = String(loc).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(`^${escaped}(_[A-Za-z]{2,3})?\\.json$`) +} + +function coreCatalogues(loc) { + const workspace = process.env.L10N_WORKSPACE || path.resolve(APP_ROOT, '..', '..') + const server = process.env.L10N_SERVER_DIR || path.join(workspace, 'server') + const re = localeFileRe(loc) + const files = [] + const collect = (dir) => { + if (!fs.existsSync(dir)) return + for (const f of fs.readdirSync(dir).sort()) { + if (re.test(f)) files.push(path.join(dir, f)) + } + } + for (const p of ['core/l10n', 'lib/l10n']) collect(path.join(server, p)) + const appsDir = path.join(server, 'apps') + if (fs.existsSync(appsDir)) { + for (const a of fs.readdirSync(appsDir).sort()) collect(path.join(appsDir, a, 'l10n')) + } + if (!files.length) { + throw new Error( + `no ${loc} catalogues found under ${server}. ` + + 'Set L10N_SERVER_DIR if your checkout differs — scanning nothing would ' + + 'otherwise report a register verdict computed from zero values.', + ) + } + return files +} + +/** + * Run a detector's `score` over every core value for its locale and report the + * marker totals, so a register verdict rests on counted evidence. + * + * @param {string} loc Locale code. + * @param {Function} score The detector's score(s) -> {f, i}. + * @return {object} { files, values, formal, informal, verdict, hits } + */ +function scanCoreRegister(loc, score) { + const files = coreCatalogues(loc) + let formal = 0 + let informal = 0 + let values = 0 + const hits = [] + for (const f of files) { + let j + try { j = JSON.parse(fs.readFileSync(f, 'utf8')) } catch { continue } + for (const v of Object.values(j.translations || {})) { + for (const x of Array.isArray(v) ? v : [v]) { + if (typeof x !== 'string') continue + values++ + const s = score(x) + formal += s.f + informal += s.i + if (s.i > 0) hits.push([x.slice(0, 100), f]) + } + } + } + const verdict = formal > informal * 3 + ? 'FORMAL' + : informal > formal * 3 ? 'INFORMAL' : 'MIXED — inspect' + return { files, values, formal, informal, verdict, hits } +} + +/** + * Print what `scanCoreRegister` measured. Shared so each detector's `node + * detectors/.js` output stays comparable across locales. + * + * @param {string} loc Locale code. + * @param {object} r Result from scanCoreRegister. + * @param {object} labels { formal, informal } pronoun names for this language. + * @param {number} show How many suspected-informal values to list. + */ +function reportCoreRegister(loc, r, labels, show = 20) { + console.log(`\nscanned ${r.files.length} ${loc} catalogue(s), ${r.values} values`) + console.log(`formal (${labels.formal}) markers: ${r.formal}`) + console.log(`informal (${labels.informal}) markers: ${r.informal}`) + console.log(`verdict: ${r.verdict}`) + for (const [v, f] of r.hits.slice(0, show)) { + console.log(` informal? ${path.basename(path.dirname(path.dirname(f)))}/${path.basename(f)}: ${v}`) + } +} + +/** + * The locale bundle as of HEAD, for "did this pass lose or silently alter an + * existing translation?". Read through git rather than a copied file, so it + * cannot drift out of date the way a stashed `-HEAD.js` does. + * + * @param {string} loc Locale code. + * @return {object|null} Translations at HEAD, or null if the file is new. + */ +function bundleAtHead(loc) { + let text + try { + text = execFileSync('git', ['show', `HEAD:l10n/${loc}.js`], + { cwd: APP_ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }) + } catch { + return null + } + let captured = null + const sandbox = { OC: { L10N: { register: (app, tr) => { captured = tr } } } } + vm.createContext(sandbox) + vm.runInContext(text, sandbox, { filename: `HEAD:l10n/${loc}.js` }) + return captured +} + +module.exports = { + loadJsTranslations, + serializeJs, + walk, + extractTranslationCalls, + makeLineResolver, + collectUsedKeys, + pluralIdentifier, + findKeyReferences, + SRC_EXTS, + listJsLocaleFiles, + localeNameOf, + DYNAMIC_KEYS, + collectDynamicKeys, + // layer 2: a single-locale pass + APP_ROOT, + LOCALES_DIR, + DETECTORS_DIR, + isIdentical, + hasIdenticalForm, + placeholders, + npluralsOf, + MORPHOLOGY_PLACEHOLDER, + MORPHOLOGY_TERNARY, + matchingParen, + configuredLocales, + loadLocaleConfig, + loadDetector, + localeFileRe, + coreCatalogues, + scanCoreRegister, + reportCoreRegister, + bundleAtHead, +} diff --git a/scripts/l10n/locales/be.json b/scripts/l10n/locales/be.json new file mode 100644 index 0000000000..f1c71903ba --- /dev/null +++ b/scripts/l10n/locales/be.json @@ -0,0 +1,211 @@ +{ + "register": "formal", + "registerEvidence": "FORMAL, measured against core and asserted by absence. detectors/be.js over Nextcloud core's 14 be catalogues (1873 values) counts 334 formal markers and ZERO informal; this bundle's own 1053 pre-existing values give 156 formal and ZERO informal. Neither corpus contains a single 'ты', 'цябе', 'табе' or 'тво-' form, so the verdict does not rest on a ratio but on a one-sided count plus an asserted absence — the lv shape from §7.2, reached from the opposite direction (there zero FORMAL markers was the assertion that mattered). Core's formal markers are 86 'вы', 14 'вас', 6 'вам', 3 'вамі', 56 'ваш-' and the rest 2pl verb forms.", + "buttons": "INFINITIVE — register-neutral (§7.3 pattern 3, the cs/lt/lv/sk/rm/is/lb group). Resolving 44 bare action keys against core be gives Захаваць / Выдаліць / Дадаць / Скасаваць / Рэдагаваць / Стварыць / Абнавіць / Капіяваць / Перайменаваць / Спампаваць / Пацвердзіць / Прымяніць / Уключыць / Адключыць / Працягнуць / Перамясціць / Усталяваць / Адхіліць / Прапусціць / Скінуць / Адправіць / Аднавіць / Выбраць / Схаваць / Прыняць — forty-odd infinitives and not one imperative or verbal noun. This bundle's own pre-existing labels agree. Sentence-length prompts and section descriptions take the 2pl imperative instead ('Выберыце' 48, 'Кіруйце' 17, 'Увядзіце' 16), which is the ordinary formal shape and not a deviation from the label convention — the two populations are the label and the sentence, split by whether the string is a control or prose.", + "imperativeNote": "The 2sg imperative IS counted by the detector, which is the minority outcome. §6.5 test 1: the label convention is the infinitive, so counting imperatives flags no button. Test 2: the enumerated 2sg forms are not homographs of any live form. The catch specific to Belarusian is that the 2pl imperative is the 2sg PLUS '-це' (выберы → выберыце, захавай → захавайце), so the trailing (?!\\p{L}) guard is the only thing separating the two polarities — the West Slavic hazard of §8.1 reached in a different family. One form is given up: 'май', 2sg of 'мець' and also the month name; its 2pl 'майце' is safe and is counted, so the loss is one-sided.", + "pluralNote": "nplurals=3, modular, boundaries 1,21 / 2-4 / 0,5-20 — the hr/ru/sr/bs family header, and runtime-check confirms the header and @nextcloud/l10n agree on which index every count selects, so no pluralOrder or pluralBoundary acknowledgement is needed. WHAT DOES NOT TRANSFER FROM RUSSIAN: Belarusian takes the NOMINATIVE PLURAL after 2-4, where Russian takes the genitive singular. Form 1 is '%n файлы' / '%n запісы' / '%n дні', never Russian's '%n файла'. Core be is one-sided on this across all 30 of its plural arrays, and the agreeing adjective and verb go plural with it ('{count} файлавыя канфлікты', '%n запісы яшчэ не маюць хэша'). Form 2 is the genitive plural ('%n файлаў'), and the predicate shifts with it ('Выбраны {count}' at forms 0-1 against 'Выбрана {count}' at form 2). Core's own catalogues carry FOUR-element arrays; that is a Transifex artefact, not a fourth Belarusian form — this file declares and uses three.", + "orthographyNote": "Official orthography (narkamaŭka), matching core: no taraškievica assimilative soft signs (0 occurrences of сьв/зьн/цьв in either corpus), no 'кляса'/'Эўропа' forms. THE OBLIGATORY SANDHI RULE IS у/ў (§8.11): 'у' is written after a consonant or a pause, 'ў' after a vowel, both for the standalone preposition and word-initially inside a phrase. Deterministic trigger, fires several times per sentence, and no gate can see it. The pre-existing half OBEYS IT COMPLETELY — 98 correct word-initial 'ў' after a vowel and 47 correct 'у' after a consonant, plus 34 and 12 for the standalone preposition, with ZERO violations in either direction. Core has one violation in 271 occurrences. So the rule is real here (unlike mk, where the analogous question came back no) and the pre-existing half is clean; §8.11 point 4 still applies, so the check belongs in the sweep over the newly written half. Belarusian's alphabet has no 'и', 'щ' or 'ъ', which makes Russian contamination deterministically detectable — that scan found 2 committed Russian values.", + "casingNote": "FLAT LOWERCASE — the is/mk outcome in §8.10's table. Domain terms mid-sentence run 0 capitalised against 458 lowercase across аб'ект, рэестр, схема, файл, уласцівасць, прагляд, крыніца, запіс, журнал, фільтр, канфігурацыя, арганізацыя, падзея, вэбхук, агент, папка, карыстальнік, токен, значэнне, назва. Conditioning on the English key's own casing changes NOTHING — 0:117 even under Title-Case keys, against 0:209 under prose keys and 0:132 under short ones — which is what separates this from ga's mirror-the-source and from sq's phantom class. So a Title-Cased English heading takes sentence case in Belarusian.", + "typographyNote": "Ellipsis MIRRORS THE SOURCE KEY rather than following core: a key ending '...' takes '...' (39 of 40) and a key ending ' …' takes ' …' (3 of 3), where core be uses the '…' character exclusively and never the three dots. The single key-'...'-to-value-'…' outlier is a slip, not a second convention. No quote glyphs are used at all in the bundle (core uses straight \"). Em dash '—' for parenthetical asides, 8 occurrences, no en dash.", + "lexiconNote": "Terms settled against core and the bundle's own practice. AI is 'ШІ' (штучны інтэлект), written hyphen-attached to the following noun — 'ШІ-агент', 'ШІ-функцыі' — and unlike mk's 'ВИ' it is NOT a homograph of any register marker, so fold() can lowercase freely. Other acronym compounds follow the same shape: 'API-ключ', 'URL-адрас', 'Push-апавяшчэнні'.", + "correctionCodes": "GARBLED-OR-FOREIGN = the value was in another language. TERM-* = one English term rendered two ways, resolved against core and the bundle's own majority. CONSISTENCY = internal drift with no core evidence either way. SENSE = right word, wrong meaning at the call site. COLLISION = byte-identical to another key the user sees on the same screen. CASE = wrong case form. FORM = non-standard or colloquial inflection. DANGLING = a preposition left without its complement. BUTTON-CONVENTION = an action button written against the measured infinitive convention. TYPO. REPETITION.", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "URL": "The address acronym, written Latin in Belarusian technical prose; the bundle uses it attached to a following noun as 'URL-адрас' where a noun is needed, and bare where the English is bare.", + "UUID:": "The identifier acronym plus its colon. Translatable rather than non-prose — core fr renders it with French spacing before the colon — but Belarusian sets no space there, so the rendering is identical to the source.", + "PDF": "File-format acronym, written Latin in Belarusian technical prose; all 36 locales carry it unchanged.", + "RBAC": "The access-control acronym, kept Latin because Belarusian has no established expansion in UI text; used as a settings section title beside the spelled-out permission vocabulary.", + "OpenDocument (.ods)": "A format name plus its file extension, offered beside CSV and Excel in the export-format picker; single-valued across all 36 locales.", + "Excel (.xlsx)": "A product name plus its file extension, offered beside CSV in the export-format picker; single-valued across all 36 locales.", + "CSV": "File-format acronym, written Latin in Belarusian technical prose exactly as in English; all 36 locales carry it unchanged.", + "DSAR": "The industry acronym for a data-subject access request, kept Latin because Belarusian has no established expansion; the sibling key 'Data-subject access request' carries the spelled-out Belarusian wording.", + "Deck": "Proper name of the Nextcloud Deck app, used in RelationsTab.vue's relation-type map beside Events and Contacts. A bare product name and single-valued across all 36 locales, which by §3.3's reverse test makes it an unwrap candidate in src/ rather than prose — recorded here so parity holds until that source change is made.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", + "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens.", + "objectType: object\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens and are never translated." + }, + "corrections": { + "Audit trail #{id}": "GARBLED-OR-FOREIGN — the value was RUSSIAN, not Belarusian: 'Запись журнала аудита #{id}'. Belarusian has no 'и', so the letter scan finds this deterministically. Note this is the same key that has twice reached a committed bundle carrying another language (Croatian under bs/sr); check it first in any pass.", + "NO ACTION": "GARBLED-OR-FOREIGN — the value was RUSSIAN, 'НЕТ ДЕЙСТВИЯ'. Rendered all-caps to mirror the source.", + "Default": "TERM-DEFAULT — 'па змаўчанні' is a calque of Russian 'по умолчанию'. The bundle itself already writes 'Прадвызначана выключана' / 'Прадвызначана ўключана' in the flow settings, and core be renders Default as 'Прадвызначаныя', so both the file's own majority reading and core point the same way. Normalised across the five keys carrying it.", + "Default Organisation": "TERM-DEFAULT — see 'Default'.", + "Auto-create Default Organisation": "TERM-DEFAULT — see 'Default'.", + "Automatically create a default organisation if none exists when the app is initialized": "TERM-DEFAULT — see 'Default'. The same value also carried 'прыкладанне' for app; that key is covered separately.", + "Select default organisation": "TERM-DEFAULT — see 'Default'.", + "Cancel": "TERM-CANCEL — 'Адмяніць' against core be's 'Скасаваць' in SIX catalogues, the app's single commonest button, and the one place a mismatch with the surrounding Nextcloud chrome is visible on every dialog. Correcting it also frees 'адмяніць' for the distinct 'cannot be undone' sense, which four keys use and which stays unchanged.", + "Share": "TERM-SHARE — the bundle rendered the verb 'Падзяліцца' but the noun Shares as 'Абагульванні', so it disagreed with ITSELF as well as with core be's 'Абагуліць'. Aligned the three verb keys to the stem the bundle's own noun already uses.", + "Share with Groups": "TERM-SHARE — see 'Share'.", + "Share with Users": "TERM-SHARE — see 'Share'.", + "Add Application": "CONSISTENCY — 'прыкладанне' for app against 'праграма' in the eight other keys carrying the word, including Create/Delete/Back to applications.", + "On by default. Oversight checks — contributed by apps — can stop a running flow. Turning this off applies to every flow that has not chosen for itself.": "CONSISTENCY — the second 'прыкладанні' for apps; see 'Add Application'.", + "Hourly": "CONSISTENCY — 'Штогадзіннае' is an adjective where its three siblings in the same option list are adverbs (Штодзённа / Штотыднёва / Штомесячна).", + "Fixed": "SENSE — 'Выпраўлена' means CORRECTED. The key is a retry-policy option beside Linear and Exponential in EditWebhook.vue, i.e. a fixed delay, and the sibling keys are masculine adjectives (Лінейны, Экспаненцыяльны).", + "Vector Dimensions": "SENSE — 'вымярэнні' is measurements. The setting is the vector's dimensionality.", + "Totals": "COLLISION — rendered 'Вынікі', byte-identical to Results, and the two appear on ONE screen: DashboardSideBar.vue puts the 'Totals'

directly above a stats table whose sibling label is 'Усяго вынікаў' (Total Results). 'Разам' is the summary sense.", + "Register Totals": "COLLISION — the same heading in RegistersSideBar.vue; see 'Totals'.", + "Managed": "TERM-MANAGED — 'Кіруемы' is a Russianism (управляемый). Belarusian forms the passive participle 'Кіраваны', which this bundle already uses in 'інтэграцый, кіраваных падзеямі'.", + "Number Constraints": "TERM-NUMBER — 'лічбавы' is DIGITAL/of-digits; the JSON Schema constraint is numeric, so 'лікавы'.", + "Numeric Range:": "TERM-NUMBER — see 'Number Constraints'.", + "How to handle retries for failed webhook deliveries": "CASE — genitive plural of the feminine 'дастаўка' is 'даставак', not the masculine-pattern 'дастаўкаў'. Note the ў→в: the epenthetic vowel puts the consonant before a vowel, where the у/ў rule requires 'в'.", + "Maximum number of retry attempts for failed deliveries": "CASE — see the sibling deliveries key.", + "New users without specific organisation membership will be automatically added to this organisation": "FORM — 'гэту' is a colloquial contraction; the standard accusative feminine is 'гэтую'.", + "this application": "FORM — 'гэта праграма' pairs the neuter/particle 'гэта' with a feminine noun; the nominative feminine is 'гэтая', as the bundle writes correctly in 'Гэтая змена'.", + "Detected At": "DANGLING — 'Выяўлена ў' ends on a preposition with nothing after it. The key is a in EntitiesIndex.vue and EntityDetail.vue, so the column is a timestamp: 'Час выяўлення'.", + "Extracted At": "DANGLING — see 'Detected At'; in FilesIndex.vue.", + "Extracted at": "DANGLING — the lowercase duplicate of the same key.", + "Updated At": "DANGLING — see 'Detected At'; in TemplatesIndex.vue. 'Час абнаўлення' rather than bare 'Абноўлена', which is already the value of the separate Updated key.", + "View API Docs": "BUTTON-CONVENTION — nine 'View X' action buttons were verbal nouns ('Прагляд X') against this locale's measured infinitive convention, and in Belarusian 'прагляд' is ALSO this app's own noun for a saved View, so a row action read as 'the View of the details'. The tell that this is a defect and not taste: RegistersIndex.vue's single NcActions menu carried 'Выдаліць' (infinitive) and 'Прагляд падрабязнасцей' (verbal noun) side by side. Every one of the nine was verified to be an NcActionButton or a button, and the noun-sense View keys (View Name, View Selection, Untitled View, View saved successfully) were left alone.", + "View Changes": "BUTTON-CONVENTION — see 'View API Docs'.", + "View Details": "BUTTON-CONVENTION — see 'View API Docs'.", + "View Error": "BUTTON-CONVENTION — see 'View API Docs'.", + "View File": "BUTTON-CONVENTION — see 'View API Docs'.", + "View Full Details": "BUTTON-CONVENTION — see 'View API Docs'.", + "View Logs": "BUTTON-CONVENTION — see 'View API Docs'.", + "View Object": "BUTTON-CONVENTION — see 'View API Docs'.", + "View Parameters": "BUTTON-CONVENTION — see 'View API Docs'.", + "Loading organisations...": "TYPO — the only value in the bundle whose ellipsis does not mirror its key: '…' under a key ending '...', against 39 that match.", + "This change breaks the existing data model:": "CONSISTENCY — 'даных' against 'дадзеных' in the twenty other keys carrying the word.", + "Write an audit-trail entry for every step": "REPETITION — 'Запісваць запіс' is write-a-write; 'Ствараць запіс' says it once." + }, + "spellAllowNote": "Reviewed against the be-official hunspell dictionary, which predates this app's IT vocabulary. Everything listed is either a domain coinage the bundle uses consistently, an ordinary Belarusian word the dictionary lacks, or an abbreviation. What the report DID catch is recorded in corrections and in the commit message: a wrong 2pl imperative (спашлецеся for спашліцеся), a term split against the file's own word (пераазначэнне for перавызначэнне), and two coinages replaced with the bundle's established vocabulary.", + "spellAllow": [ + "агрэгацыі", + "агрэгацый", + "Адфарматаваць", + "Ананімізавана", + "арг", + "арт", + "бэкэнд", + "Бэкэнд", + "Бэкэнды", + "вектарызаваць", + "Вектарызаваць", + "вектарызацыі", + "вектарызацыю", + "вектарызацыя", + "Вектарызацыя", + "вектарызацыяй", + "вектарызуйце", + "Вектарызуйце", + "вектарызуюцца", + "віджэта", + "віджэтаў", + "віджэту", + "віджэты", + "выдаленняў", + "выдаляльнікі", + "Выхаднае", + "вэбхук", + "Вэбхук", + "вэбхука", + "вэбхукамі", + "вэбхукаў", + "вэбхукі", + "Вэбхукі", + "вэбхуку", + "генераваць", + "Генераваць", + "генеруюцца", + "даданні", + "дадання", + "дзейнасцей", + "дзейнасцямі", + "дэактывацыю", + "згенераваных", + "згенеруе", + "зліццяў", + "ініцыялізаваны", + "Ініцыялізаваць", + "ініцыялізацыі", + "Ініцыялізацыя", + "інтэграцый", + "Капіяваць", + "Кіраваны", + "кіраваных", + "кэша", + "масіва", + "месцазнаходжаннямі", + "месцазнаходжанняў", + "метададзеных", + "метададзеныя", + "Метададзеныя", + "мінімізаваць", + "наглядавай", + "назапашванне", + "Назапашванне", + "назіраным", + "найбяспечней", + "найменавання", + "напр", + "Недасяжны", + "падключэнняў", + "Падтрымліваныя", + "пакрокавая", + "паўнавартасны", + "перабазавання", + "перавызначае", + "перавызначыць", + "перавызначэнне", + "перавызначэнняў", + "перазапісана", + "Перазапусціць", + "перакладальных", + "пераразлічаны", + "Пераразлічвае", + "распрацоўшчыкаў", + "рассмоктваецца", + "рэпазіторыі", + "рэпазіторый", + "Рэпазіторый", + "рэсурсаёмісты", + "серыялізаваны", + "Серыялізацыя", + "сканаванне", + "Сканаванне", + "Слаг", + "спасылкавую", + "сутнасцей", + "токен", + "Токен", + "токена", + "токенаў", + "токены", + "Токены", + "трыгеры", + "Трыгеры", + "тэгі", + "Тэставанне", + "Тэставаць", + "убудаванняў", + "успадкавана", + "Успадкавана", + "Успадкоўвае", + "фасетаванне", + "Фасетаванне", + "фасетавання", + "фрагментаваныя", + "Хост", + "хэш", + "хэша", + "хэшам", + "хэшамі", + "хэшы", + "шматарэндатарнасці", + "Шматарэндатарнасць", + "Экспаненцыяльны" + ] +} diff --git a/scripts/l10n/locales/bg.json b/scripts/l10n/locales/bg.json new file mode 100644 index 0000000000..a680e21b93 --- /dev/null +++ b/scripts/l10n/locales/bg.json @@ -0,0 +1,57 @@ +{ + "register": "formal", + "registerEvidence": "Core bg scores 699 formal (вие / ви / вас / ваш / 2pl verb) markers against 43 informal over 3451 values in 27 catalogues — verdict FORMAL. The informal figure needs splitting to be read honestly: 29 of those hits are core using the 2sg imperative as a BUTTON LABEL ('Копирай', 'Актуализирай', 'Преименувай', 'Опитай отново', 'Запиши'), which is a label-style choice and not prose address. Only 11 values carry genuine informal PROSE (a ти/теб/твой pronoun or a 2sg present), and they cluster in two old catalogues — core/bg.json and encryption/bg.json ('Можеш да затвориш този прозорец', 'старата ти парола'). So the prose ratio is 699:11. This bundle's own 1053 pre-existing keys agree overwhelmingly and add the Bulgarian polite capitalisation ('Вашият OpenAI API ключ', 'текущите Ви филтри', 'Управлявайте Вашите регистри'), with exactly two deviations — both recorded in corrections below.", + "buttons": "VERBAL NOUN (отглаголно съществително, -не), register-neutral — the ro pattern, but unlike ro this is core's own majority rather than a project divergence. Measured: of 43 distinct values resolved for bare action keys across core bg, 44 catalogue-weighted hits are verbal nouns ('Изтриване', 'Създаване', 'Запазване', 'Изпращане', 'Търсене', 'Премахване', 'Затваряне', 'Опресняване', 'Качване', 'Инсталиране', 'Отнемане'), 23 are 2sg imperatives ('Изтрий', 'Копирай', 'Изтегли', 'Приложи', 'Преименувай'), 4 are the formal 2pl imperative ('Потвърдете' for Confirm) and 4 are plain nouns ('Отказ', 'Копие', 'Запис', 'Промяна'). Core is therefore mixed at roughly 2:1, and the tie is broken by the file: all 1053 pre-existing values in this bundle are verbal nouns or plain nouns without a single imperative label, so the convention is followed rather than introduced. Bulgarian has no infinitive, which is why the verbal noun does the register-neutral job the infinitive does in cs/lt/lv/sk.", + "buttonsWhyImperativeIsHalfDetectable": "detectors/bg.js is the first detector that counts PART of the imperative paradigm, because Bulgarian splits by conjugation class where the other nine locales are all-or-nothing. The и-conjugation imperative is unusable: 'запази' is at once the 2sg imperative, the 3sg present ('може да запази') and the 3sg AORIST ('той запази'), and the aorist reading occurs in this bundle's own prose — 'Анализът завърши:' and 'Изтриването завърши' are spelled exactly like the imperatives of 'завърша'. The а-conjugation imperative is unambiguous, because the 3sg present of those verbs ends in -а/-ва: 'опитай' vs 'опитва', 'изпълнявай' vs 'изпълнява', 'записвай' vs 'записва', 'копирай' vs 'копира'. Nothing else in the language is spelled that way, so the -й class is enumerated as informal and the -и class is recorded in UNDETECTABLE. The split is not cosmetic: it is the only reason the two informal slips already in this bundle were caught at all. Note that core does use -й imperatives as labels in 29 places, so this detector deliberately measures against THIS bundle's convention rather than core's — it fires on a register slip in a patch, which is what patchcheck is for.", + "orthographyNote": "Ellipsis is the three-dot ASCII form with no space in the overwhelming majority of pre-existing values ('Зареждане...', 'Анализиране...', 'Въведете описание (по избор)...'), so new values follow the KEY's own punctuation: '...' where the English key has '...', and ' …' where the key has ' …' ('Проверка …', 'Четене на покритието със запечатване …'). One pre-existing value spells it '…' against a key with '...' ('Зареждане на организациите…'); left alone as a harmless inconsistency rather than churned. Ranges use a plain hyphen matching the source ('1-20', '10-50'); long prose uses an em dash with spaces ('Аварийно спиране — спрете сега ...'). The dative clitic is correctly written 'ѝ' with a grave accent ('може вече да не ѝ съответстват'), never 'и' — that distinction is a marker of careful Bulgarian and is preserved.", + "pluralNote": "nplurals=2, plural=(n != 1) — the simplest header in the set, and the arity is trivial. The real hazard is lexical rather than structural, and no other locale in this project has it: masculine nouns that do not denote persons have a separate COUNT FORM (числителна форма, -а/-я) used after a numeral, distinct from the ordinary plural. So 'обект' is 'обекти' as a bare plural but 'обекта' after a number, and likewise запис/записа, файл/файла, регистър/регистъра, имейл/имейла. Which one a plural array needs therefore depends on whether the string contains a numeral, NOT on the form index: '_Delete {count} object_' takes 'Изтриване на {count} обекта' (numeral present) while '_Object successfully deleted_' takes 'Обектите са изтрити успешно' (no numeral, ordinary plural). The pre-existing _%n entry has no hash yet_ array already got this right with '%n записа' and is the model. Feminine and neuter nouns have no count form, so 'схема' pluralises to 'схеми' in both contexts. Verified against @nextcloud/l10n: library and header agree, so no pluralOrder acknowledgement is needed. ONE EXTENSION, from the n() plural conversion: the rule above is stated as 'does the STRING contain a numeral', and the five stats labels ('_object_::_objects_' and its siblings) do not — the number is rendered by CnStatsBlock in its own element, immediately beside the label. The trigger is what the user reads, not what the string holds, so these take the count form too: 'обекта', 'файла', 'регистъра', 'дневника'. 'схема' is feminine and has no count form, so it stays 'схеми'. Before the conversion these five could not take it at all — a single string had to serve every count — and the earlier note recorded that as a deliberate loss.", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "CSV": "File-format initialism. Bulgarian keeps Latin-script computing initialisms unchanged rather than transliterating them into Cyrillic, so this is a genuine cognate and not an untranslated leftover — the same choice the pre-existing bundle already made for 'API ключ', 'HTTP метод', 'LLM конфигурация', 'JSON' and 'OAS'. Every finished locale keeps it as-is.", + "Excel (.xlsx)": "Product name plus a file extension; both are invariant in Bulgarian and stay in Latin script, like every other file-format token in this bundle.", + "ID": "Initialism written identically in Bulgarian, in Latin script — Bulgarian computing usage keeps 'ID' rather than transliterating it to 'ИД', and core bg does the same where it carries the string at all ('Client ID'). The lowercase variant key 'Id' is normalised TO this form. Inflection lands on the following noun instead ('ID на приложението', 'ID на целта'), and where the source spells the word out this bundle translates it in full ('Object ID' -> 'Идентификатор на обект').", + "ID:": "Same initialism with a colon. Bulgarian puts no space before a colon, so the punctuation is genuinely unchanged.", + "ID: {id}": "Same initialism plus the placeholder; nothing in this string differs in Bulgarian.", + "Deck": "Proper name of the Nextcloud Deck app; product names are not translated. The surrounding prose is translated in full and inflects around it ('Интеграцията с Deck не е налична', 'карта в Deck').", + "Slug": "Kept as the English term, which is what Bulgarian developer writing uses for a URL-safe short identifier; there is no established Cyrillic rendering (core lt coins 'Trumpinys' and sl uses 'Oznaka', but core bg carries no value at all). This value is PRE-EXISTING at HEAD, so it is a record of a choice already shipped rather than a new one. The neighbouring key 'Optional URL-friendly identifier' is translated in full.", + "URL": "Initialism written identically in Bulgarian, in Latin script. The pre-existing bundle keeps the bare form throughout and inflects the surrounding noun instead ('URL на базата данни', 'URL, на който работи Ollama'); where the source needs the word 'address' the bundle adds it in Bulgarian ('URL адресът, на който ще бъдат изпращани събитията').", + "UUID:": "Initialism with a colon. Bulgarian puts no space before a colon, so the punctuation is genuinely unchanged too.", + "{property} - {other}": "Two placeholders joined by a hyphen — a format hint for a calendar event title, with no translatable word in it. It is NOT untranslatable in general (three distinct values exist across the 37 bundles, since some locales substitute an en or em dash), but Bulgarian joins such a pair with the same plain hyphen, and this bundle keeps plain hyphens everywhere the source has one — see orthographyNote. So the value is genuinely unchanged rather than unconsidered.", + "OpenDocument (.ods)": "Product name plus a file extension; both are invariant in Bulgarian and stay in Latin script.", + "PDF": "File-format initialism, identical in Bulgarian and kept in Latin script; no locale in this app carries a differing value for it.", + "RBAC": "Initialism for role-based access control, used untranslated in Bulgarian IT writing. The surrounding prose keys are translated in full ('Настройките за RBAC са актуализирани успешно') and the matrix column headers are the Bulgarian verbal nouns 'четене' / 'създаване' / 'актуализиране' / 'изтриване' / 'управление'.", + "DSAR": "Initialism for Data Subject Access Request. Bulgarian has coined no Cyrillic equivalent acronym, and the expanded key 'Data-subject access request' is translated in full beside it as 'Искане за достъп от субекта на данните'. Every finished locale keeps DSAR untranslated.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", + "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens.", + "objectType: object\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens and are never translated.", + "Browser Web Push (VAPID)": "Browser and Web Push are the protocol's own names and VAPID is its acronym; the protocol name is not translated.", + "Bucket": "Object-storage term, used unchanged in this locale's technical language.", + "Driver": "Database-driver term, used unchanged in this locale's technical language.", + "Golden record": "Master-data-management term with no established local equivalent; used unchanged in data-governance language.", + "Host *": "Networking term, used unchanged in this locale, with the required-field marker.", + "Id": "Abbreviation of identifier, written the same way in this locale's technical UI.", + "myapp": "A literal example application id shown verbatim; not prose.", + "Survivor": "Master-data-management term for the record that survives a merge; used unchanged in data-governance language." + }, + "corrections": { + "Run oversight checks before each step": "Informal 2nd-person-singular imperative replaced with the formal plural form. bg's measured register in this bundle is FORMAL and detectors/bg.js flags the singular imperative; the rest of the bundle already uses the -йте form.", + "Write an audit-trail entry for every step": "Informal 2nd-person-singular imperative replaced with the formal plural form. bg's measured register in this bundle is FORMAL and detectors/bg.js flags the singular imperative; the rest of the bundle already uses the -йте form.", + "Re-import": "Informal 2nd-person-singular imperative replaced with the formal plural form. bg's measured register in this bundle is FORMAL and detectors/bg.js flags the singular imperative; the rest of the bundle already uses the -йте form." + } +} diff --git a/scripts/l10n/locales/bs.json b/scripts/l10n/locales/bs.json new file mode 100644 index 0000000000..eb3b530d4c --- /dev/null +++ b/scripts/l10n/locales/bs.json @@ -0,0 +1,120 @@ +{ + "register": "formal", + "registerEvidence": "FORMAL (Vi), measured 367 second-person PLURAL markers against 1 singular over 3416 frontend values. Nextcloud core is NOT the evidence: it ships ONE bs catalogue whose 55 values yield 0 markers of EITHER polarity, so a verdict read off core would have been computed from nothing — the thin-core trap of §2.3, and the reason detectors/bs.js prints core's marker count before setting it aside. The §6.4 fallback corpus is this bundle (1029 values, 99 formal / 0 informal) plus the sibling apps' FRONTEND bs.js: launchpad (1090, 110/0), opencatalogi (787, 88/0), openconnector (510, 70/1). The backend bs.json is excluded by §1, and that exclusion is load-bearing here because openbuild ships a Croatian catalogue under bs.json. Bosnian HAS a full T-V distinction and this register uses the V-form, so this is the hr case and not the mt/ga case: the deviation the gate looks for is a real `ti` slip, not an impossible form. The single informal hit is not in this app — it is openconnector's \"Još nemaš posredovanih vjerodajnica. Prvo napravi jednu u OpenRegisteru.\", the same defect class the sq pass found in the same sibling bundle (§11).", + "buttons": "Bare 2sg imperative for action labels, against formal 2pl prose — §7.3 pattern 2, as in Croatian. Attested: Sačuvaj, Obriši, Otkaži, Uredi, Zatvori, Kreiraj, Kopiraj, Ukloni, Ažuriraj, Osvježi, Omogući, Onemogući, Pokušaj ponovo, Nazad.", + "buttonsLexicalOverride": "The prompt override is LEXICALLY BOUNDED and the English verb decides it, which had to be measured rather than carried over. `Select …` takes the 2sg (Odaberi, 20 of 20, at 14 to 45 characters); `Choose …` takes the 2pl (Odaberite, 5 of 5, at 15 to 128 characters); `Enter …` goes with Choose (Unesite, 5 of 5). So this is neither sq's length grading nor mk's \"Select/Choose/Enter all take 2pl\" — the line runs BETWEEN Select and Choose. A `Please …` frame also forces the 2pl (Molimo odaberite), and any prompt embedded in prose follows the prose. Measure this per verb, per locale; three locales have now produced three different partitions of the same verb set.", + "capitalisationNote": "The bundle capitalises the app's FIRST-CLASS domain nouns mid-sentence and nothing else, one-sidedly, and this is a convention to follow rather than a defect to fix — the sr outcome, not mk's. Measured by word class over prose keys (English key >=6 words and not Title Case), which is the §8.10 conditioning: Objekat 26:0, Datoteka 21:0, Šema 13:0, Registar 15:0, Svojstvo 7:0 — against ordinary nouns one-sided the OTHER way, vrijednost 0:4 and korisnik 0:5. Zero counter-examples on either side of the line. The whole-bundle prose rate is 7.7% against a 2.3% sibling-frontend rate, and that gap IS this convention rather than drift, which is why the per-term split is what decides it and the aggregate is not. Aggregate by word class, not per surface form: casing.js keys terms by surface form, so it reports Objekat's cases separately (objekata 9:0, objekta 5:0, objekti 5:0, objekte 4:0) and understates how one-sided the lemma is.", + "orthographyNote": "Consistently ijekavian, which is correct for Bosnian and distinguishes it from ekavian sr: vrijednost 16:0, uspješ- 32:0, promjen- 3:0, sljedeć-, prije. Zero ekavian forms. Beware two false positives when checking this: `vremena`/`vremenu` are the correct ijekavian OBLIQUE forms (the root shortens outside the nominative in every BCS variant, so they are not ekavian `vreme`), and any such sweep must run over VALUES ONLY — over the raw .js file the English key `Delete` scores as an ekavian `del-` root, and `pre-` matches the prefix in pregled/pretraga/preuzimanje.", + "lexiconNote": "Bosnian, not Croatian, and measured rather than assumed: byte-identity with hr is a WEAK signal for BCS because the languages genuinely share most of their morphology and much of their lexicon, so `Revizijski trag #{id}` matching hr exactly is correct Bosnian and not the contamination §2.3 warns about. The frontend bs.js is a genuine Bosnian bundle: it splits from hr on lexicon (Sačuvaj/Spremi, Obriši/Izbriši, Otkaži/Odustani, Kreiraj/Stvori, Nazad/Natrag) while keeping ijekavian phonology where sr is ekavian (Osvježi, Sljedeće). The openbuild Croatian catalogue is under bs.JSON — the backend set, out of scope — not here. A sweep of the whole known bs/hr divergence set found exactly one Croatianism, `pružatelj` (see corrections).", + "verbFormationNote": "Internationalist verbs are conventionalised PER LEMMA and corroborated across the app family, so the -irati/-ovati split is not drift and must not be swept. analizirati is -ir- in openregister (9) and openconnector (6); vektorizovati, sinhronizovati, serijalizovati, inicijalizovati and konfigurisati are -ov-/-is- here and in opencatalogi and openconnector. Each lemma is internally consistent in two independent apps, which is stronger evidence than one bundle's self-agreement (§8.11 warns a uniform lemma is one decision copied — cross-app agreement is what upgrades it). Follow the established lemma; for a verb with no precedent, Bosnian standard prefers -irati.", + "pluralNote": "nplurals=3, and the header and @nextcloud/l10n AGREE on which index each count selects — no pluralOrder or pluralBoundary is needed. Form 0 = n%10==1 && n%100!=11 (1, 21, 31…), nominative singular. Form 1 = n%10 in 2..4 with n%100 outside 10..19 (2, 3, 4, 22…), the paucal, which takes the GENITIVE SINGULAR. Form 2 = everything else (0, 5..20, 11…), genitive plural. The paucal and the genitive plural are distinct for many nouns and identical for others, so both must be written out rather than copied: Objekat -> 1 Objekat / 2 Objekta / 5 Objekata; Datoteka -> 1 Datoteka / 2 Datoteke / 5 Datoteka; unos -> 1 unos / 2 unosa / 5 unosa. The verb is singular in form 0 and plural in forms 1 and 2 (`1 unos još nema heš` against `2 unosa još nemaju heš`).", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "Avatar": "Bosnian has adopted 'avatar' unchanged for a profile picture; the bundle's own 'Avatar je uklonjen' and 'Avatar je uspješno ažuriran' inflect it as an ordinary masculine noun, so the nominative singular is genuinely identical.", + "CSV": "File-format initialism, identical in Bosnian; every finished locale keeps it as-is.", + "Deck": "Proper name of the Nextcloud Deck app; product names are not translated. The surrounding prose declines it ('Integracija Decka nije dostupna').", + "DSAR": "Initialism for Data Subject Access Request. Bosnian data-protection practice has coined no equivalent acronym, and the expanded key 'Data-subject access request' is translated in full beside it as 'Zahtjev nosioca podataka za pristup'.", + "Excel (.xlsx)": "Product name plus a file extension; both are invariant in Bosnian.", + "Format": "Bosnian uses 'format' unchanged for a data or display format, and the bundle's own derived keys confirm it ('Format prikaza', 'Format izvoza').", + "Host *": "Bosnian networking usage keeps the English 'host' for a hostname field, and the trailing asterisk is the form's required-field marker rather than text. The bundle declines it elsewhere ('Host *' beside 'Putanja krajnje tačke*').", + "ID": "Initialism written identically in Bosnian; the lowercase variant key 'Id' is normalised TO this form rather than left alone.", + "ID:": "Same initialism with a colon. Bosnian, unlike French, puts no space before a colon, so the punctuation is genuinely unchanged.", + "ID: {id}": "Same initialism plus the placeholder; nothing in this string differs in Bosnian.", + "Interval": "Bosnian 'interval' is the standard term and is spelled identically.", + "Minimum": "Bosnian 'minimum' is the standard noun and is spelled identically; the derived keys are translated ('Minimalna dužina', 'Minimalan broj stavki', 'Minimalna vrijednost'), so only the bare noun coincides.", + "OpenDocument (.ods)": "Product name plus a file extension; both are invariant in Bosnian. Measured: no locale in the set carries a differing value, so this is untranslatable rather than merely cognate — it stays wrapped only because the sibling format labels are.", + "PDF": "File-format initialism, identical in Bosnian. Measured: no locale carries a differing value.", + "RBAC": "Initialism for role-based access control, used untranslated in Bosnian IT writing; the expanded prose keys around it are translated in full. Measured: no locale carries a differing value.", + "Slug": "Bosnian technical writing keeps the English 'slug' for a URL-friendly identifier and has coined no equivalent, the same call hr made. Six locales do translate it, so this is a real per-language decision rather than an untranslatable string — and the descriptive key beside it, 'Optional URL-friendly identifier', is translated in full as 'Opcionalni identifikator prilagođen URL-u'.", + "Status": "Bosnian 'status' is the standard term and is spelled identically; sr writes the same word in Cyrillic ('Статус'). The derived keys are translated ('Status kvaliteta', 'Statusni kod', 'Svi statusi'), so only the bare noun coincides.", + "URL": "Initialism written identically in Bosnian; the lowercase-variant key 'Url' is normalised TO this form, and the derived keys are translated ('URL vanjske OpenAnonymiser instance', 'URL Ollame'). Only is renders it as a native coinage ('Vefslóð').", + "UUID:": "Initialism with a colon. Bosnian, unlike French, puts no space before a colon, so the punctuation is genuinely unchanged — fr is the only locale in the set that differs here, and it differs only in that spacing.", + "Webhook": "Bosnian keeps 'webhook' and declines it across a hyphen, which is the BCS convention for Latin-script loanwords: the bundle's own values are 'webhook-a', 'webhook-ovi', 'webhook-ove'. The nominative singular is therefore identical to the English key while every oblique form is not. Four locales do coin a native term, so this is a per-language decision rather than an untranslatable string.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", + "Driver": "Database-driver term, used unchanged in Bosnian technical language.", + "Id": "Abbreviation of identifier, written the same way in Bosnian technical UI.", + "Model": "Same spelling and meaning in Bosnian.", + "myapp": "A literal example application id shown verbatim; not prose.", + "Ollama URL": "Product name plus the invariant abbreviation URL.", + "Test": "Same spelling and meaning in Bosnian.", + "Url": "Invariant abbreviation, written the same way in Bosnian.", + "{property} - {other}": "Two placeholders joined by a dash; there is no prose to translate.", + "Browser Web Push (VAPID)": "Browser and Web Push are the protocol's own names and VAPID is its acronym; the protocol name is not translated.", + "Bucket": "Object-storage term, used unchanged in this locale's technical language.", + "Golden record": "Master-data-management term with no established local equivalent; used unchanged in data-governance language.", + "Survivor": "Master-data-management term for the record that survives a merge; used unchanged in data-governance language.", + "objectType: object\\naction: created": "Literal CloudEvents payload example with an escaped newline; the field names are protocol tokens.", + "objectType: object\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens and are never translated." + }, + "siblingParentheticalNote": "The sibling '(s)' keys use SLASH, LOWERCASE: register(s) -> 'registar/i', schema(s) -> 'šema/e', configuration(s) -> 'konfiguracija/e'. This family is the one place the bundle does NOT capitalise its first-class nouns, and that was left alone rather than normalised — the keys are lowercase in the source, the existing values are real translations (§3.8), and a house style spanning three keys is a decision rather than a slip. The slash was a workaround for a single string having to serve every count; the five stats labels that once needed it are real n() arrays now and take the paucal and genitive plural properly ('objekat/objekta/objekata'), so the slash survives only on these sibling keys.", + "correctionCodes": "TERM-HR = a Croatian-standard form in a Bosnian bundle. ORTHOGRAPHY = a spelling against the bundle's own measured convention. CONSISTENCY = a capitalisation or casing decision aligned to capitalisationNote. SENSE = the value renders a different meaning than the call site asks for. SWAP = a converse pair written the wrong way round.", + "auditScope": "The pre-existing half was 1053 values. corediff is near-useless here (1 core catalogue, 4 shared keys, 1 disagreement) so the yield came from termdrift, spell, the Croatianism sweep and reading. The three mechanical reports plus the sweeps found 8 of the 10 corrections. NOTE ON RECALL: §6.9 prescribes a subagent fan-out for the read and this pass could not use one, so the read was sequential — on sk a second fan-out found 5 defects a sequential read had missed. Treat this locale's pre-existing half as audited-once, not audited-twice.", + "auditNoChangeDecisions": "Recorded because an unrecorded 'left alone' is indistinguishable from 'never looked' (§6.9). (1) AUDIT TRAIL renders two ways and both were KEPT: the genitive 'trag revizije' in 10 keys and the adjectival 'revizijski trag' in 4 ('Revizijski trag #{id}', 'pokrivenost pečatima revizijskog traga', 'čita cijeli revizijski trag', 'unos revizijskog traga'). Adjective-versus-genitive is Bosnian stylistic variation rather than terminology drift, and the adjectival form reads better in the oblique cases where it is used. Normalising would have been a change of taste. (2) Settings -> 'Postavke' DISAGREES with the one core bs catalogue's 'Podešavanje' and was kept: §3.5 gives the bundle the lexicon, the bundle is internally consistent at 14 of 15 keys, and 'Postavke' also keeps Settings distinct from Configuration -> 'Konfiguracija' as adjacent tab labels. (3) The -irati/-ovati verb split was kept — see verbFormationNote; it is conventionalised per lemma with cross-app corroboration, not drift.", + "corrections": { + "Bucket": "SENSE — written 'Grupa' earlier in this pass, which collided byte-for-byte with Group/Groups -> Grupa/Grupe. QualityIndex.vue renders it as a beside 'Count' over a score histogram, so it is a statistical bin: 'Opseg', which also stays distinct from Range -> 'Raspon'. A collision the user can see on one screen is the ca lesson (§6.9).", + "Chat Provider": "TERM-HR — 'pružatelj' is the Croatian agent noun; Bosnian standard forms this class in -lac ('pružalac', gen. 'pružaoca', instr. 'pružaocem'), as BiH legal usage does in 'pružalac usluga'. Seven pre-existing values carried the Croatian form and all seven are corrected together. This was the only Croatianism a sweep of the whole known bs/hr divergence set found, and the half written in this pass already used 'pružalac', so the bundle was split against itself.", + "Chat Provider (RAG)": "TERM-HR — same as 'Chat Provider'.", + "Chat provider connection successful!": "TERM-HR — same as 'Chat Provider'; the instrumental is 'pružaocem', not 'pružateljem'.", + "Embedding Provider": "TERM-HR — same as 'Chat Provider'.", + "Embedding provider connection successful!": "TERM-HR — same as 'Chat Provider'; instrumental 'pružaocem'.", + "LLM must be enabled with an embedding provider configured": "TERM-HR — same as 'Chat Provider'; instrumental 'pružaocem'.", + "Select provider": "TERM-HR — same as 'Chat Provider'; accusative 'pružaoca'.", + "Objects already stored under this schema may no longer match it. Save anyway?": "ORTHOGRAPHY + CONSISTENCY — the only value in the bundle spelling the noun 'shema' with the anglicised digraph against 39 occurrences of 'šem-', and the only one leaving it lowercase against the capitalisation convention. Corrected to 'Šemom' on both counts.", + "Uses": "SWAP — written 'Upotrebe' earlier in this pass, a noun meaning 'usages', where ObjectDetails.vue renders Uses and 'Used by' as ADJACENT tab titles over an object's outgoing and incoming relations. The pair needs the verb: 'Koristi' / 'Koriste ga', which is what hr ships. Nothing mechanical catches a converse pair written the wrong way round (§6.9).", + "Used by": "SWAP — written 'Koristi' earlier in this pass, which means 'uses' and so named the sibling tab rather than this one. Corrected to 'Koriste ga'. Note sr carries this exact defect ('Користи' for 'Used by' against 'Употребе' for 'Uses') and is a recorded locale — one for the re-audit stream.", + "Commit Message": "SENSE — 'Poruka potvrde' means 'confirmation message'. PublishConfiguration.vue and PublishRegister.vue render this over a GitHub publish form, so it is a git commit: 'Poruka commita'. The duplicate-key sibling 'Commit message' carried the same value and is corrected with it, so the pair still renders identically.", + "Commit message": "SENSE — same as 'Commit Message'; the two keys are a duplicate pair for one concept and must not diverge.", + "Code (short readable key, e.g. v-2026-001)": "CONSISTENCY — written 'Kod' in this pass against the pre-existing 'Code' -> 'Kôd'. The circumflex is the bundle's convention and it is load-bearing in Bosnian: it separates the noun kôd (code) from the preposition kod (at). Caught only by comparing this pass's half against HEAD.", + "Status code": "CONSISTENCY — written 'Statusni kod' in this pass while the duplicate-key sibling 'Status Code' already read 'Statusni kôd'. A duplicate pair for one concept that renders two ways is visible to the user and to nothing else.", + "Templates": "TERM-HR — 'predložak' is the Croatian standard term; Bosnian uses 'šablon', as sr does ('Шаблони'). Three pre-existing values carried the Croatian form against nine using 'šablon', so the bundle was split; the three are corrected to the Bosnian-standard majority. Same class as the 'pružatelj' set.", + "Failed to load templates": "TERM-HR — same as 'Templates'; genitive plural 'šablona'.", + "Manage document templates and themes": "TERM-HR — same as 'Templates'; instrumental plural 'šablonima'.", + "For chat and retrieval-augmented generation": "GARBLED-OR-FOREIGN — the final letter of 'prošireno' was a CYRILLIC small o, U+043E, inside an otherwise Latin word. Visually identical to Latin o and invisible to every gate: not empty, not identical to English, right arity, and it reads as finished work. Found by the homoglyph check added to script-coverage.js during this pass, which had no coverage for Latin-script locales at all.", + "General Issue": "TERM — 'opšti' is the Serbian form; Bosnian standard is 'opći', as Croatian is. One occurrence in the bundle, corrected to 'Opći problem'.", + "Loading organisations...": "ORTHOGRAPHY — the only one of 85 '...' values rendering the key's three dots as a single ellipsis character. The seven legitimate '…' values all mirror a key that itself uses '…'.", + "Log integrity": "CONSISTENCY — 'dnevnik' for log against the bundle's own 'zapis' in Logs -> 'Zapisi', 'log entries' -> 'unosa zapisa' and 'Loading log details' -> 'detalja zapisa'. The lone outlier is normalised to 'Integritet zapisa'.", + "Loading sources...": "CONSISTENCY — Izvor was capitalised mid-sentence in 5 pre-existing values and lowercase in 4, so unlike the five first-class nouns it was never one-sided and there was no convention to follow. Resolved DOWN, to the ordinary-noun side of the measured line where vrijednost (0:4) and korisnik (0:5) sit: BCS does not capitalise common nouns, the capitalised five are an explicit exception list, and a term the bundle never settled does not join it. Five values corrected.", + "Manage your data sources and their configurations": "CONSISTENCY — same as 'Loading sources...'.", + "No logs are available for this source.": "CONSISTENCY — same as 'Loading sources...'.", + "No sources found": "CONSISTENCY — same as 'Loading sources...'.", + "This source has no associated registers.": "CONSISTENCY — same as 'Loading sources...'.", + "Totals": "SENSE — 'iznos' is a monetary amount, and RegistersSideBar.vue renders this over systemTotals object counts and sizes. The bundle's own dominant pattern is 'Ukupno X' across 7 keys; these two were its only 'iznos' values. 'Zbirni podaci' keeps it distinct from Total -> 'Ukupno'.", + "Register Totals": "SENSE — same as 'Totals'.", + "📄 Object Serialization": "CONSISTENCY — the single lowercase 'objekata' in the bundle, against 29:0 in prose and 24:0 in labels once corrected. It was the lone counter-example in the capitalisation measurement.", + "Top Deleters": "SENSE — 'brisač' is a wiper or an eraser, an instrument, and has no reading as a person who deletes, so 'Najčešći brisači' named the wrong noun class. Rewritten as 'Korisnici s najviše brisanja' over the deletion-statistics panel.", + "{pct}% success rate": "CONSISTENCY — written 'stopa uspješnosti' in this pass against the pre-existing 'Success Rate' -> 'Stopa uspjeha'.", + "Retry upload": "CONSISTENCY — written 'Pokušaj ponovo učitati' in this pass; the bundle's pattern for this construction is 'Ponovo pokušaj X' ('Ponovo pokušaj neuspjele ekstrakcije').", + "_%n entry has no hash yet_::_%n entries have no hash yet_": "The catalogue header declared nplurals=2 while Bosnian uses the three-form Serbo-Croatian rule (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2) that Nextcloud core declares for hr and sr. With only two forms the paucal counts (2, 3, 4, 22 …) rendered the plural form meant for 5+. Header corrected and this array given its real third form; the existing wording is preserved as forms 0 and 2.", + "_%n declared in total_::_%n declared in total_": "The catalogue header declared nplurals=2 while Bosnian uses the three-form Serbo-Croatian rule (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2) that Nextcloud core declares for hr and sr. With only two forms the paucal counts (2, 3, 4, 22 …) rendered the plural form meant for 5+. Header corrected and this array given its real third form; the existing wording is preserved as forms 0 and 2.", + "_%n is out of date_::_%n are out of date_": "The catalogue header declared nplurals=2 while Bosnian uses the three-form Serbo-Croatian rule (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2) that Nextcloud core declares for hr and sr. With only two forms the paucal counts (2, 3, 4, 22 …) rendered the plural form meant for 5+. Header corrected and this array given its real third form; the existing wording is preserved as forms 0 and 2.", + "_%n register is missing_::_%n registers are missing_": "The catalogue header declared nplurals=2 while Bosnian uses the three-form Serbo-Croatian rule (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2) that Nextcloud core declares for hr and sr. With only two forms the paucal counts (2, 3, 4, 22 …) rendered the plural form meant for 5+. Header corrected and this array given its real third form; the existing wording is preserved as forms 0 and 2.", + "_Delete {count} object_::_Delete {count} objects_": "The catalogue header declared nplurals=2 while Bosnian uses the three-form Serbo-Croatian rule (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2) that Nextcloud core declares for hr and sr. With only two forms the paucal counts (2, 3, 4, 22 …) rendered the plural form meant for 5+. Header corrected and this array given its real third form; the existing wording is preserved as forms 0 and 2.", + "_file_::_files_": "The catalogue header declared nplurals=2 while Bosnian uses the three-form Serbo-Croatian rule (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2) that Nextcloud core declares for hr and sr. With only two forms the paucal counts (2, 3, 4, 22 …) rendered the plural form meant for 5+. Header corrected and this array given its real third form; the existing wording is preserved as forms 0 and 2.", + "_log_::_logs_": "The catalogue header declared nplurals=2 while Bosnian uses the three-form Serbo-Croatian rule (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2) that Nextcloud core declares for hr and sr. With only two forms the paucal counts (2, 3, 4, 22 …) rendered the plural form meant for 5+. Header corrected and this array given its real third form; the existing wording is preserved as forms 0 and 2.", + "_Object successfully deleted_::_Objects successfully deleted_": "The catalogue header declared nplurals=2 while Bosnian uses the three-form Serbo-Croatian rule (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2) that Nextcloud core declares for hr and sr. With only two forms the paucal counts (2, 3, 4, 22 …) rendered the plural form meant for 5+. Header corrected and this array given its real third form; the existing wording is preserved as forms 0 and 2.", + "_object_::_objects_": "The catalogue header declared nplurals=2 while Bosnian uses the three-form Serbo-Croatian rule (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2) that Nextcloud core declares for hr and sr. With only two forms the paucal counts (2, 3, 4, 22 …) rendered the plural form meant for 5+. Header corrected and this array given its real third form; the existing wording is preserved as forms 0 and 2.", + "_Purge {count} object from database_::_Purge {count} objects from database_": "The catalogue header declared nplurals=2 while Bosnian uses the three-form Serbo-Croatian rule (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2) that Nextcloud core declares for hr and sr. With only two forms the paucal counts (2, 3, 4, 22 …) rendered the plural form meant for 5+. Header corrected and this array given its real third form; the existing wording is preserved as forms 0 and 2.", + "_register_::_registers_": "The catalogue header declared nplurals=2 while Bosnian uses the three-form Serbo-Croatian rule (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2) that Nextcloud core declares for hr and sr. With only two forms the paucal counts (2, 3, 4, 22 …) rendered the plural form meant for 5+. Header corrected and this array given its real third form; the existing wording is preserved as forms 0 and 2.", + "_Restore {count} object_::_Restore {count} objects_": "The catalogue header declared nplurals=2 while Bosnian uses the three-form Serbo-Croatian rule (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2) that Nextcloud core declares for hr and sr. With only two forms the paucal counts (2, 3, 4, 22 …) rendered the plural form meant for 5+. Header corrected and this array given its real third form; the existing wording is preserved as forms 0 and 2.", + "_schema_::_schemas_": "The catalogue header declared nplurals=2 while Bosnian uses the three-form Serbo-Croatian rule (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2) that Nextcloud core declares for hr and sr. With only two forms the paucal counts (2, 3, 4, 22 …) rendered the plural form meant for 5+. Header corrected and this array given its real third form; the existing wording is preserved as forms 0 and 2.", + "_Successfully restored {count} object_::_Successfully restored {count} objects_": "The catalogue header declared nplurals=2 while Bosnian uses the three-form Serbo-Croatian rule (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2) that Nextcloud core declares for hr and sr. With only two forms the paucal counts (2, 3, 4, 22 …) rendered the plural form meant for 5+. Header corrected and this array given its real third form; the existing wording is preserved as forms 0 and 2.", + "_{count} email_::_{count} emails_": "The catalogue header declared nplurals=2 while Bosnian uses the three-form Serbo-Croatian rule (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2) that Nextcloud core declares for hr and sr. With only two forms the paucal counts (2, 3, 4, 22 …) rendered the plural form meant for 5+. Header corrected and this array given its real third form; the existing wording is preserved as forms 0 and 2.", + "_{count} schema_::_{count} schemas_": "The catalogue header declared nplurals=2 while Bosnian uses the three-form Serbo-Croatian rule (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2) that Nextcloud core declares for hr and sr. With only two forms the paucal counts (2, 3, 4, 22 …) rendered the plural form meant for 5+. Header corrected and this array given its real third form; the existing wording is preserved as forms 0 and 2." + } +} diff --git a/scripts/l10n/locales/ca.json b/scripts/l10n/locales/ca.json new file mode 100644 index 0000000000..375fa09f79 --- /dev/null +++ b/scripts/l10n/locales/ca.json @@ -0,0 +1,263 @@ +{ + "register": "formal", + "auditNote": "Corrections carry short CLASS CODES, not per-key prose (the volume makes prose unreadable — same convention as is.json/cs.json/sk.json). Codes used here: TERM-SETTINGS (Settings rendered with the Configuration entity's noun); TERM-TOKEN / TERM-REMOVE / TERM-RECORD (one code per normalised English term); COLLISION-LOG (two distinct English keys rendering byte-identically on one screen); DANGLING-PREP (a preposition left without its object in a column header); ELISION (a Catalan article that must contract before a vowel and did not); CONSISTENCY-* suffixed per convention (-LLM, -N8N, -TOAST, -VIEW, -CHOOSE) so the classes stay countable; PERMISSION-NOUN (an RBAC matrix column header written as an imperative instead of a verbal noun, §8.7); WORDFORM (an (s)-parenthetical that expands to a non-word); WORDORDER (a modifier attached to the wrong noun); SENSE (a real Catalan word carrying the wrong meaning); SENSE-FALSEFRIEND; FOREIGN-STEM (a Spanish calque); NONSENSE (ungrammatical as written); AGREEMENT; ELLIPSIS; REGISTER (address form mixed inside one value); IDIOM; PLEONASM.", + "registerEvidence": "formal, 491 vs 32 against core (recorded before this pass, not re-measured here — detector controls 21/21 still pass and patchcheck reported 17 formal / 0 informal markers across the 128 audited values).", + "buttons": "bare 2sg imperative regardless of the formal prose (Desa, Elimina, Afegeix, Crea, Cancel·la, Actualitza, Mostra, Copia, Importa, Suprimeix, Activa, Desactiva, Revoca, Restaura, Rebutja, Tanca, Obre, Prova, Verifica, Neteja). One of the four patterns in runbook §7.3. An infinitive or formal 2pl on an action label would be the defect here, not the imperative.", + "auditScope": "Whole bundle, 2052 values, audited 2026-08-19. Method: the three reports (core-diff, termdrift, spell) first, then an exact-value-collision scan, a dangling-preposition sweep and an elision sweep, then the whole listing read as a four-way subagent fan-out with central adjudication against core and the call site. 128 corrections, 6.2%. For comparison: is 22%, cs 5.5%, sk 3.0%.", + "auditAdjudicationNote": "The fan-out returned 85 candidates and roughly 20 died on core or the call site — the same ratio sk saw, and the reason the adjudication step is not optional. Killed by CORE: Refresh and Update both -> Actualitza (core ca collapses them exactly the same way, and both are in the core-diff AGREE list, so the collision is not a defect); Remove/Delete both -> the suprimir family (core ca renders Delete as Suprimeix x5/Suprimir x3 and Remove as Suprimir/Suprimeix, so core collapses them too — left as-is, following the sk precedent). Killed by the CALL SITE: Good/Fair/Poor -> Bo/Regular/Deficient looked like feminine-agreement failures against qualitat, but they are KPI card labels over integer bucket counts with no feminine antecedent (the exact false positive sk produced with Fair/Good/Poor over skóre); Successful/Failed -> Correcte/Fallit agree correctly with rastre (masculine), not with cerca; Fixed -> Fix has no determinable antecedent because its siblings Exponencial/Lineal are invariant; Access organisation dashboard and analytics -> Accedeix is correct because every sibling right-description in that table is also a bare imperative (Publica, Utilitza, Utilitza); Click Run compliance scan... quotes «Executa l'escaneig de compliment», which matches the Run compliance scan key exactly. Killed by the BUNDLE'S OWN VOCABULARY (§3.5): Ground -> Fonament is right because the bundle already renders Denial ground as Fonament de la denegació; Endpoints -> Punts finals was flagged as a poor calque but the whole endpoint family uses it and so does the sibling app openconnector; hash -> resum is the standard Catalan term; Display Name -> Nom visible was left alone because core's own rendering is Nom d'usuari, i.e. username, the §8.4 wrong-sense trap.", + "auditDecisionsEscalated": "Settings vs Configuration, 35 keys, put to the owner and answered 'normalise all 35'. The bundle rendered BOTH the app's first-class Configuration entity and the generic Settings sense as Configuració, which put two identically-labelled tabs in three dialogs (EditConfiguration.vue lines 33/128, EditWebhook.vue 23/215, EditOrganisation.vue 35/380). Core ca splits them — Settings -> Paràmetres x3, Configuration -> Configuració x1 — and so does the bundle's own single key containing both words (Manage your system configurations and settings -> configuracions i paràmetres del sistema), which is why this was a drift with a clear answer rather than the cs case where core pointed at the losing option. Configuració is now reserved for the Configuration entity and Paràmetres carries the Settings sense. Same three signals as the is skrá and cs konfigurace escalations: a first-class entity noun, 30+ keys, and a visible on-screen consequence.", + "auditCollisionNote": "Logs and Registers both rendered Registres, and ViewSource.vue builds its tab bar as tabs: ['Registers', 'Logs'] — two tabs with the same label — while its two empty states both read No s'ha trobat cap registre. Core ca ships no Log/Logs key at all, so core could not decide it; 13 of 16 sibling locales distinguish the two (cs Protokoly, de Protokolle, fr Journaux, nl Logboek, pl Dzienniki, ro Jurnale, ru Журналы, sk Protokoly, sl Dnevniki, hr Zapisnici, it Log, lt Žurnalai, lv Žurnāli) and only the three Ibero-Romance ones collapse them — es Registros, pt Registos — and both of those are still un-audited, so their agreement is not evidence of a considered choice. Registres is locked as the app's primary entity (manifest nav label), so the log side moved: Logs -> Registres d'activitat, keeping the bundle's own registre stem plus a qualifier, which is what its other 16 log keys already do (entrada de registre, registres del webhook, registres de cerca). The 16 qualified keys were left untouched. Re-running the collision scan after the patch confirms the direction of travel: 15 collisions on distinct English keys before, 12 after. Five went away (the three Settings/Configuration pairs and the two Logs/Registers pairs) and two are NEW and deliberate — Delete/Remove both -> Suprimeix, which core ca does too, and View API Docs / View API Documentation both -> Visualitza la documentació de l'API, which are duplicate ENGLISH keys for one action (§10) and are SUPPOSED to render identically. A future reviewer running the scan should expect 12, not 0.", + "auditOrthographyNote": "Catalan does NOT elide la before an unstressed initial i- or u-, so la informació, la identitat, la integració, la inicialització, la incrustació, la interfície and la UE are all correct and were not touched. The elision sweep produced 12 hits and exactly one real defect: falling back -> s'usa la alternativa, where alternativa begins with a stressed a and requires l'alternativa. One further real elision failure was found by reading: El LLM -> L'LLM, since LLM is read ela-ela-ema and so is vowel-initial. Two hits were artefacts of the interpunct — Instal·la OpenAnonymiser is the verb instal·la, not the article la — which is the same U+00B7 blind spot that was fixed in spell.js in the preceding commit.", + "auditArticleNote": "Two initialism-article splits were normalised in OPPOSITE directions, because both were normalised to their own majority rather than to a rule. LLM: de l'LLM plus L'LLM totalled 13 against 7 bare de LLM, so the seven moved to de l'LLM. n8n: de n8n led 9 to 3, so the three de l'n8n moved to de n8n. Neither bare form is a grammar error — Catalan allows a product name with no article, so de n8n means 'of n8n' and de l'n8n means 'of the n8n', and both are well formed; what was wrong was that one button's success and failure toasts disagreed with each other. Only El LLM was a genuine elision defect and it is recorded under ELISION, not here.", + "lexiconNote": "Left alone deliberately, and recorded so a zero is not mistaken for 'never looked'. (1) Delete/Remove both render the suprimir family, diverging from nothing since core ca collapses them too; the sk pass put the same question to the owner and the answer was to change nothing. The Remove family was however internally split 12 Suprimeix to 6 Elimina, and the six moved to Suprimeix, which is both the bundle majority and core's own rendering — Remove from favorites -> Suprimeix dels preferits is core verbatim. Removed alone was left as Eliminat rather than moved, because Suprimit would have created a NEW byte-identical collision with Deleted -> Suprimit, both of which are status labels. (2) delivery is split entrega (6) against lliurament (3); both are valid Catalan, lliurament is the Softcatalà preference and entrega the bundle majority, so there is no quality-neutral majority to normalise to and it was left. (3) The Nextcloud article is split 6 with (l'aplicació X del Nextcloud) against 5 without (de Nextcloud); both idiomatic, no majority, left. (4) The Select a … family (~40 keys) is split roughly half between bare 2sg imperative and formal 2pl for identical NcSelect placeholder/label roles; the same split affects the page-description family (Gestiona vs Gestioneu). Both are convention decisions across dozens of keys rather than grammar, so they were NOT changed — but note that one value mixed the two INSIDE itself (Manage and restore soft deleted items from your registers had bare Gestiona i restaura with formal vostres) and that one was fixed. (5) Are you sure … is split 4 Segur que voleu to 3 Esteu segur que voleu; both correct, no majority, left. (6) Daily/Weekly/Monthly are adverbs where the sibling Hourly is Cada hora; adverb and adjective both work as option labels and the call sites could not all be cheaply verified, so left.", + "siblingParentheticalNote": "The sibling '(s)' keys — ordinary translatable text, not the removed {plural} placeholder — were checked for MALFORMATION rather than style, and four expanded to non-words: dia(es) -> 'diaes' in three keys and problema(es) -> 'problemaes' in one, since both nouns change stem in the plural (dia/dies, problema/problemes). Those four took the slash instead, which is the shape is.json adopted for exactly this reason. The remaining parentheticals are well formed and were left: cas(os), tauler(s), giny(s), objecte(s), coincident(s), suprimit(s).", + "spellAllow": [ + "accept-language", + "backend", + "cloudevent", + "cloudevents", + "deck", + "dolphin", + "embedding", + "facetable", + "facetables", + "facetatge", + "filecollection", + "fireworks", + "github", + "gitlab", + "graphql", + "kvk", + "n8n", + "nextcloud", + "objectcollection", + "occ", + "ods", + "ollama", + "openai", + "openanonymiser", + "opendocument", + "openregister", + "appapi", + "exapp", + "autoriteit", + "persoonsgegevens", + "pgvector", + "prefragmentats", + "pregenerats", + "push", + "rebase", + "regex", + "slug", + "vectorització", + "vectoritzacions", + "webhook", + "webhooks", + "xlsx", + "multiinquilinatge" + ], + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "Agents": "Catalan plural of the Latinate 'agent' is 'agents' — identical by coincidence of shared morphology, not untranslated.", + "Articles": "Catalan plural of 'article' is 'articles' — identical by shared Latinate morphology.", + "Avatar": "'Avatar' in Catalan; Nextcloud core ca.json also renders it 'Avatar'.", + "Color": "'Color' is the Catalan word (cf. Spanish 'color'), spelled identically to English.", + "Complex": "'Complex' is the Catalan adjective, spelled identically. Used here as a search-complexity label (SearchTrailSideBar.vue:315).", + "CSV": "Format acronym, invariant in Catalan.", + "Deck": "Nextcloud app name; kept untranslated in all 17 finished locales.", + "DSAR": "Acronym; kept untranslated in all 17 finished locales.", + "Error": "'Error' is the Catalan word, spelled identically; core ca.json agrees.", + "Excel (.xlsx)": "Product name plus file extension.", + "Extensions": "Catalan plural of 'extensió' is 'extensions' — identical by shared Latinate morphology.", + "Facetable": "Catalan forms this adjective the same way English does, from 'facetar' + '-able', giving the identical spelling — cf. 'Immutable'. Consistent with 'facetatge' used for Faceting elsewhere in this batch.", + "Format": "'Format' is the Catalan noun, spelled identically.", + "ID": "Identifier acronym, invariant in Catalan.", + "Id": "Kept as 'Id' in all 17 finished locales; following that rather than diverging on casing alone.", + "ID:": "Identifier acronym plus a colon. Catalan, unlike French, puts no space before a colon.", + "ID: {id}": "Identifier acronym, colon and a placeholder — nothing translatable remains.", + "Immutable": "'Immutable' is the Catalan adjective, spelled identically.", + "Interval": "'Interval' is the Catalan noun, spelled identically.", + "Local": "'Local' is the Catalan adjective, spelled identically. Badge marking a locally-defined schema.", + "Mode:": "'Mode' is the Catalan noun, spelled identically, plus a colon.", + "Notes": "Catalan plural of 'nota' is 'notes' — identical to English by coincidence of shared Latinate morphology.", + "OpenDocument (.ods)": "Product name plus file extension.", + "PDF": "Format acronym, invariant in Catalan.", + "Quota": "'Quota' is the Catalan noun, spelled identically.", + "RBAC": "Access-control acronym, used untranslated in Catalan technical writing.", + "Reversible": "'Reversible' is the Catalan adjective, spelled identically.", + "{property} - {other}": "Two placeholders and an ASCII hyphen, which Catalan keeps. Stays wrapped in the source because ru localises the hyphen to an em dash.", + "No": "'No' is the Catalan negative, spelled identically to English; core ca.json also renders it 'No'.", + "Port": "'Port' is the Catalan noun for a network port, spelled identically; Nextcloud core ca.json renders it 'Port' too.", + "Secret": "'Secret' is the Catalan noun and adjective, spelled identically.", + "Simple": "'Simple' is the Catalan adjective, spelled identically. Used here as a query-complexity label alongside 'Medium' and 'Complex'.", + "Slug": "Kept in Catalan CMS/technical UI; kept untranslated in 8 of 10 sampled locales.", + "Total": "'Total' is the Catalan noun, spelled identically.", + "Total:": "'Total' plus a colon. Catalan, unlike French, puts no space before a colon.", + "Totals": "Catalan plural of 'total' is 'totals' — identical to English by shared Latinate morphology.", + "URL": "Invariant in Catalan; 'URL' is the standard form.", + "UUID:": "Identifier acronym plus a colon. Catalan puts no space before a colon, unlike fr which carries 'UUID :'.", + "Webhook": "Kept as 'Webhook' in Catalan; the app's existing ca glossary already renders the plural as 'Webhooks'.", + "Webhooks": "Plural of the loanword, already established in the app's existing ca translations.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens.", + "objectType: object\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens and are never translated.", + "Browser Web Push (VAPID)": "Browser and Web Push are the protocol's own names and VAPID is its acronym; the protocol name is not translated.", + "Bucket": "Object-storage term, used unchanged in this locale's technical language.", + "Driver": "Database-driver term, used unchanged in this locale's technical language.", + "Golden record": "Master-data-management term with no established local equivalent; used unchanged in data-governance language.", + "Host": "Networking term, used unchanged in this locale.", + "Host *": "Networking term, used unchanged in this locale, with the required-field marker.", + "Model": "Same spelling and meaning in this locale.", + "myapp": "A literal example application id shown verbatim; not prose.", + "Survivor": "Master-data-management term for the record that survives a merge; used unchanged in data-governance language.", + "Url": "Invariant abbreviation, written the same way in this locale.", + "Dates": "'Dates' is the Catalan plural of 'data' and is spelled exactly as the English word.", + "Documents": "Same spelling and meaning in Catalan.", + "IBANs": "International bank account number acronym with the plural s; invariant in Catalan.", + "Present": "Same spelling and meaning in Catalan." + }, + "corrections": { + "Settings": "TERM-SETTINGS", + "Save Settings": "TERM-SETTINGS", + "Settings updated successfully": "TERM-SETTINGS", + "Failed to save settings": "TERM-SETTINGS", + "Failed to save settings: {error}": "TERM-SETTINGS", + "Failed to load settings: {error}": "TERM-SETTINGS", + "Failed to save file settings: {error}": "TERM-SETTINGS", + "File settings saved successfully": "TERM-SETTINGS", + "Failed to save LLM settings: {error}": "TERM-SETTINGS", + "Failed to update LLM settings: {error}": "TERM-SETTINGS", + "LLM settings saved successfully": "TERM-SETTINGS", + "LLM settings updated successfully": "TERM-SETTINGS", + "Failed to update Multitenancy settings: {error}": "TERM-SETTINGS", + "Multitenancy settings updated successfully": "TERM-SETTINGS", + "Loading multitenancy settings...": "TERM-SETTINGS", + "Failed to update RBAC settings: {error}": "TERM-SETTINGS", + "RBAC settings updated successfully": "TERM-SETTINGS", + "Loading RBAC settings...": "TERM-SETTINGS", + "Failed to update Retention settings: {error}": "TERM-SETTINGS", + "Retention settings updated successfully": "TERM-SETTINGS", + "Loading retention settings...": "TERM-SETTINGS", + "Loading text extraction settings...": "TERM-SETTINGS", + "Organisation settings saved successfully": "TERM-SETTINGS", + "Synchronization Settings": "TERM-SETTINGS", + "🔢 Vectorization Settings": "TERM-SETTINGS", + "Fireworks AI Chat Settings": "TERM-SETTINGS", + "Ollama Chat Settings": "TERM-SETTINGS", + "OpenAI Chat Settings": "TERM-SETTINGS", + "Could not change browser notification settings.": "TERM-SETTINGS", + "Notification permission: denied. Re-enable notifications for this site in your browser settings.": "TERM-SETTINGS", + "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "TERM-SETTINGS", + "Manage your account settings, security, and personal data.": "TERM-SETTINGS", + "Any favorites and sharing settings for this view": "TERM-SETTINGS", + "They will be retained according to their schema's configured retention period and automatically permanently deleted when the retention period expires. The retention period is configurable per schema and can be found in the schema's settings.": "TERM-SETTINGS", + "Enable or disable n8n workflow integration. Configure connection settings below.": "TERM-SETTINGS + REGISTER (bare imperative Activa o desactiva switched to formal mid-value; its structural twin for LLM features is fully formal)", + "Logs": "COLLISION-LOG", + "No logs found": "COLLISION-LOG", + "Failed to save GitHub token": "TERM-TOKEN", + "Failed to save GitLab token": "TERM-TOKEN", + "GitHub token saved successfully": "TERM-TOKEN", + "GitLab token saved successfully": "TERM-TOKEN", + "Detected At": "DANGLING-PREP", + "Extracted At": "DANGLING-PREP", + "Extracted at": "DANGLING-PREP", + "Updated At": "DANGLING-PREP", + "Requested at": "DANGLING-PREP", + "Matched on": "DANGLING-PREP", + "Merged from": "DANGLING-PREP", + "From Date": "DANGLING-PREP", + "From date": "DANGLING-PREP", + "To Date": "DANGLING-PREP + SENSE (Fins a la data is the Catalan idiom for 'up to now', not a date-field label)", + "To date": "DANGLING-PREP + SENSE (as above)", + "LLM must be enabled with an embedding provider configured": "ELISION", + "falling back": "ELISION", + "LLM Configuration": "CONSISTENCY-LLM", + "LLM configuration saved successfully": "CONSISTENCY-LLM", + "Failed to load LLM configuration": "CONSISTENCY-LLM", + "LLM actions menu": "CONSISTENCY-LLM", + "LLM features enabled": "CONSISTENCY-LLM", + "LLM features disabled": "CONSISTENCY-LLM", + "n8n connection test failed: {message}": "CONSISTENCY-N8N", + "n8n initialization failed: {message}": "CONSISTENCY-N8N", + "Loading n8n configuration...": "CONSISTENCY-N8N", + "read": "PERMISSION-NOUN", + "create": "PERMISSION-NOUN", + "update": "PERMISSION-NOUN", + "delete": "PERMISSION-NOUN", + "manage": "PERMISSION-NOUN", + "Overdue by {days} day(s)": "WORDFORM", + "{days} day(s) left": "WORDFORM", + "{days} day(s) remaining": "WORDFORM", + "{count} compliance issue(s) detected": "WORDFORM", + "Choose the authoritative value for each conflicting attribute, then save.": "SENSE (autoritzat = authorised, not authoritative)", + "Why is this the authoritative value?": "SENSE (as above)", + "Required status is inconsistent": "SENSE-FALSEFRIEND (Catalan inconsistent = flimsy, not contradictory)", + "Serial Mode (Safer, slower)": "FOREIGN-STEM (serial is a broadcast serial in Catalan; the modal's own body already writes Sèrie)", + "User-friendly description shown in forms and help text": "FOREIGN-STEM (amigable describes a person's manner)", + "{pct}% success rate": "NONSENSE", + "Top Deleters": "SENSE (supressor is not a Catalan agent noun for a person)", + "Statutory citation": "SENSE (citació reads first as a judicial summons)", + "The move was rejected by the server.": "SENSE (desplaçament = scrolling/displacement, not moving an item)", + "Path in repository": "SENSE (its own hint key says the path is inside the repository, not to it)", + "Effective: {effective}": "SENSE (bare Efectiu also means 'cash'; the sibling key supplies Mètode)", + "Data stewardship review": "SENSE (governança is a distinct discipline from stewardship)", + "Aggregated across schemas": "SENSE (entre = between; the sibling renders across as en/de tots)", + "No data-subject-request cases are tracked for you yet.": "SENSE (the Catalan made the USER the thing tracked)", + "Reset Changes": "SENSE (the button discards pending edits; Restableix reads as restoring them)", + "Golden record": "TERM-RECORD", + "Post-merge golden record": "TERM-RECORD", + "No golden-record provenance": "TERM-RECORD", + "View golden record": "TERM-RECORD + CONSISTENCY-VIEW", + "Survivor": "TERM-RECORD", + "One-off (this record only)": "TERM-RECORD", + "Remove": "TERM-REMOVE", + "Remove filter": "TERM-REMOVE", + "Remove from favorites": "TERM-REMOVE (core ca verbatim: Suprimeix dels preferits)", + "Remove group": "TERM-REMOVE", + "Removed from favorites": "CONSISTENCY-TOAST (core ca verbatim: S'ha suprimit dels preferits)", + "Added to favorites": "CONSISTENCY-TOAST (core ca verbatim: S'ha afegit als preferits)", + "Copied": "CONSISTENCY-TOAST", + "Copied!": "CONSISTENCY-TOAST", + "View API Documentation": "CONSISTENCY-VIEW", + "View APIs": "CONSISTENCY-VIEW", + "View object": "CONSISTENCY-VIEW", + "View objects": "CONSISTENCY-VIEW", + "Choose a register and schema above to see its duplicate candidates.": "CONSISTENCY-CHOOSE", + "Choose a register and schema above to see its quality statistics.": "CONSISTENCY-CHOOSE", + "Choose a survivorship-enabled register and schema above to list its master entities.": "CONSISTENCY-CHOOSE", + "Optional event description template": "WORDORDER", + "Optional end date property": "WORDORDER", + "Optional location property": "WORDORDER", + "Please create an agent in the": "WORDORDER-LINK", + "menu or contact someone with permission to create agents.": "WORDORDER-LINK", + "Hide in collection view": "CONSISTENCY (Oculta 9 against Amaga 1 in the bundle)", + "Erasure preview": "CONSISTENCY (the Art-17 act is supressió everywhere else in the DSAR flow)", + "Erasure complete": "CONSISTENCY (as above)", + "Total events": "CONSISTENCY (the Total … stat-tile family is Total de/d'…)", + "Write an audit-trail entry for every step": "CONSISTENCY (the domain term is rastre d'auditoria, truncated here)", + "Sensitive PII": "CONSISTENCY (PII is expanded to dades personals in every other key)", + "Warning:": "CONSISTENCY (Avís everywhere else, and core ca)", + "Use filters to narrow down entities by type or category.": "CONSISTENCY (per acotar in the three sibling variants)", + "Set as Active": "AGREEMENT (the elided noun is organització, feminine — the sibling key confirms it)", + "Uses": "CONSISTENCY (an AppTab title; the 3sg verb Utilitza is indistinguishable from the imperative button form)", + "Loading organisations...": "ELLIPSIS (single U+2026 where the English key and 34 siblings use three dots)", + "Manage and restore soft deleted items from your registers": "REGISTER (bare imperative Gestiona i restaura mixed with the formal possessive vostres inside one value)", + "notify_push is installed but OpenRegister has not yet confirmed a successful push. Trigger an object save to activate, or check your notify_push configuration.": "IDIOM (you do not 'activate a save', and activar then repeated as the purpose clause)", + "Request account deactivation. This will notify administrators for review.": "PLEONASM (the clitic ho had no antecedent distinct from the subject Això)", + "View and manage authorization across registers and schemas": "CONSISTENCY-VIEW + SENSE (entre = between, where the sense is across all)" + } +} diff --git a/scripts/l10n/locales/cs.json b/scripts/l10n/locales/cs.json new file mode 100644 index 0000000000..0da045a8f7 --- /dev/null +++ b/scripts/l10n/locales/cs.json @@ -0,0 +1,186 @@ +{ + "register": "formal", + "registerEvidence": "828 formal (vy) markers against ZERO informal (ty) markers, over core cs's 32 catalogues / 5005 values, plus this bundle's own 2052 values which add 242 formal and 0 informal. §8.3 says a measurement landing on exactly zero deserves a second look, so the zero was re-checked by a RAW unguarded substring scan over the combined 6685-value corpus across 25 informal tokens (tvůj tvoj tvá tvé tvým tvých tebe tobě tebou můžeš musíš chceš víš vidíš máš potřebuješ budeš dostaneš používáš najdeš uvidíš znáš klikneš vybereš, plus bare jsi). Every one is absent. The raw scan did return 48 hits for the substring `tvá`, and they are the reason the guard matters rather than evidence of informality: all 48 are inside `vytvářet`/`vytváření` (\"to create\"/\"creating\"), one of the commonest verbs in this app. Czech is the ordinary Slavic T-V case, unlike the three locales done before it — it HAS a live vy/ty distinction in current use, and core uses vy throughout, so this is a plain measured choice with no structural story attached (contrast ga, which has no T-V distinction at all, mt, which has one and leaves it unused, and is, which had one and abandoned it in the 20th century).", + "buttons": "INFINITIVE — register-neutral, joining cs's existing entry in §7.3 alongside lt/lv/sk/rm/is, and now measured rather than assumed. Resolved 48 bare action keys against core cs: 27 come back as infinitives (Uložit, Smazat, Přidat, Upravit, Zkopírovat, Přesunout, Potvrdit, Povolit, Vypnout, Obnovit, Zobrazit, Hledat, Zavřít, Vytvořit, Aktualizovat, Přejmenovat, Nasdílet, Vybrat, Naimportovat, Odebrat, Odeslat, Pokračovat, Stáhnout, Nahrát, Použít, Odvolat, Přihlásit), ZERO are imperatives and ZERO are verbal nouns. The remaining handful are legitimately not verbs (Settings, Back = Zpět, Previous = Předchozí, Cancel = Storno, Reset = Vrátit na výchozí hodnoty) plus one reflexive (Log out = Odhlásit se). This bundle's own action keys agree unanimously and contain no imperative. Consequence for §6.5: the 2sg imperative CAN be counted as an informal marker here without flagging every button, the same position as sk and the opposite of ca/et/hr/sl/sr/ga/mt.", + "pluralNote": "nplurals=3, header plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2 — ABSOLUTE, not modular, and it AGREES with @nextcloud/l10n's own cs grouping, so no pluralOrder or pluralBoundary is needed and runtime-check passes clean. The shape to keep straight is the one in §7.1: because the boundaries are absolute, 22 selects form 2 (`22 objektů`, genitive plural), where a modular Slavic locale like hr would send 22 to form 1. An array copied from hr/ru/sr into cs is therefore wrong at every compound number, and vice versa. Form 0 is the counted singular (`{count} objekt`), form 1 the nominative/accusative plural for 2-4 (`{count} objekty`), form 2 the genitive plural for 0 and 5+ (`{count} objektů`). All 7 plural arrays in the bundle were checked against that and every one was already correct, including the tricky `_Successfully restored {count} object_` triple, which switches the participle's agreement per form (Obnoven / Obnoveny / Obnoveno) exactly as Czech requires: masculine singular, masculine inanimate plural, then the neuter singular that Czech uses after a genitive-plural quantifier.", + "siblingParentheticalNote": "The sibling '(s)' keys take the parenthetical suffix, which works for all of them because Czech pluralises these nouns by suffix alone with no stem change: `register(s)` -> `registr(y)`, `schema(s)` -> `schéma(ta)`. `schéma(ta)` is a real Czech plural (schéma -> schémata, a Greek-origin neuter) rather than the kind of non-word the is pass had to accept, so cs has no residual defect here. The five stats labels that used to sit in this family are real n() arrays now, and the log one takes the bundle's own `protokol` rather than the English `log` it previously carried.", + "orthographyNote": "NO DOMAIN-TERM CAPITALISATION MID-SENTENCE — a flat lowercase rule, the is shape rather than the sr/rm/mt list-of-capitalised-terms shape. Measured per term over all 2052 values with the (?<=.)(? `Průměrný počet členů/org.`) is correct rather than a defect. PROGRESSIVE STATES are the reflexive passive with the verb agreeing in number with its subject — `Načítá se aktivita...` for a singular and `Načítají se registry...` for a plural — and all 37 members of the Loading family get that agreement right, including `Loading organisations...` -> `Načítají se organizace...`, which the re-audit handoff flags as having been the outlier in ga, mt AND is. It is correct here. Czech has no letters outside its own alphabet anywhere in the bundle: a scan for the discriminating neighbours (sk ä ľ ĺ ŕ ô, pl ą ę ł ń ś ź ż ć, hr ć đ, hu ő ű, plus Baltic and Turkish diacritics) returned ZERO hits, so unlike sr and sl this bundle carries no cross-locale contamination. An English-function-word scan also returned zero, so there are no untranslated leftovers.", + "lexiconNote": "THE CONFIGURATION / SETTINGS SPLIT WAS THE OWNER'S CALL and is the largest correction set here. The bundle rendered the app's `Configuration` entity two ways in a near-perfect tie — 33 values with `nastavení` against 31 with `konfigurace` — and the split ran THROUGH individual features rather than between them, which is what made it a defect rather than a preference: the Configurations list screen was titled `Konfigurace` while its own buttons read `Nové nastavení` / `Upravit nastavení` / `Exportovat nastavení`; `LLM Configuration` was `Konfigurace LLM` but `Loading LLM configuration...` was `nastavení LLM`; and `Failed to save configuration: {error}` was byte-identical to `Failed to save settings: {error}`. Core cs renders the generic heading `Configuration` -> `Nastavení`, which pointed the other way, so the decision was put to the owner per §6.4 step 2 (~33 keys and a first-class entity noun of this app). THE OWNER CHOSE `konfigurace` for the noun `configuration` and `nastavení` for `settings`, so the split now follows the English word one-to-one. Core is knowingly diverged from, and the justification is that core has no Configuration ENTITY — its `Configuration` is a settings heading, so its rendering does not transfer to a domain where you create, edit, export, import and publish configurations. The bundle's own `Configurations` -> `Konfigurace` is the §3.5 evidence. Verbs were NOT touched: `Configure X` -> `Nastavte X` stays, because it is idiomatic and a verb cannot collide with either noun. OTHER TERMS NORMALISED ONTO THE BUNDLE'S OWN MAJORITY, each with its count: embedding -> `vnoření` (16 against 8 left as the raw loanword `embedding`); chunk -> `úsek` (20 against 4 `část`); audit trail -> `auditní záznam` (34 against 3 `auditní stopa`); confidence -> `spolehlivost` (7 against 2 `míra jistoty`); search trail -> `záznam vyhledávání` (15 against 4 `záznam hledání`); the View ENTITY -> `pohled` (27 against 6 `zobrazení`, of which only 3 were the entity — see below); Validate -> `kontrola` (6 against 3 `ověřit`), which also stops Validate and Verify sharing `ověřit`; Test -> `Otestovat` (3 against 3, broken by `Test connection` and `Test Connection` rendering differently from each other); `(optional)` -> `nepovinné` (17 against 5 `volitelné`, and core cs prefers `volitelné` but the file's own convention is followed per §3.5 since this is style, not register); `This action cannot be undone.` -> `vzít zpět` (8 against 4 `vrátit zpět`). REFRESH WAS DECIDED BY CORE, not by the bundle: `Refresh` and `Restore` both rendered `Obnovit`, and core cs distinguishes them exactly — Refresh -> `Znovu načíst`, Restore -> `Obnovit` — so all 7 Refresh values moved and Restore kept `Obnovit`. Erasure went the other way, to `výmaz` over `vymazání` at a 2:2 tie, because `právo na výmaz` is the established Czech rendering of GDPR Art 17 and this app's erasure feature is that article. FOUR COLLISIONS RESOLVED: Refresh/Restore (above); Poor/Low both `Nízká`, so Poor -> `Špatná`; Reachable/Available both `Dostupné`, so Reachable -> `Dosažitelné`, which the bundle's own `Unreachable` -> `Nedosažitelné` already corroborated; and Objects Analyzed / Objects being analyzed both `Analyzované objekty`, so the progressive one -> `Právě analyzované objekty`. COLLISIONS LEFT DELIBERATELY, the §8.5 unavoidable case: `Konfigurace` serves both Configuration and Configurations, `Organizace` both Organisation and Organisations, `Akce` both Action and Actions, and `Integrace` both Integration and Integrations — in every one of those the Czech feminine -e noun is genuinely syncretic between nominative singular and nominative plural, so there is nothing to disambiguate. `Název` serves Name, Title and the Dutch source key `Naam`, which is correct for each. `Bucket` -> `Rozsah` was left alone even though `Rozsah` renders Range in eight other keys: core cs leaves Bucket untranslated and there it means an S3 bucket (§8.4), so core is no authority for this histogram-bin sense, and inventing a term to break a soft cross-screen collision is the change of taste §3.8 protects. ESTABLISHED TERMS FOLLOWED RATHER THAN RE-COINED: object = objekt, schema = schéma (pl. schémata), register = registr, property = vlastnost, view = pohled, entity = entita, source = zdroj, log = protokol, audit ENTRY = záznam auditu (kept distinct from audit TRAIL = auditní záznam, which is a useful distinction the bundle already drew), webhook = webhook (declined: webhooku, webhooky, webhooků), organisation = organizace, dashboard = nástěnka, flow = tok, workflow = pracovní postup, facet = faseta, chunk = úsek, embedding = vnoření, payload = datová část, handler = řešitel, endpoint = koncový bod, soft delete = měkké smazání, hash = otisk, golden record = zlatý záznam, compliance = soulad. `Obchodní rejstřík` for `KvK Company Register` is the correct Czech term for a companies register and was NOT normalised onto `registr` despite being the only `rejstřík` in the bundle.", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "CSV": "File-format acronym, unchanged in Czech and used as-is in Czech technical writing. All 37 bundles carry exactly one distinct value.", + "Data": "`data` IS the Czech word — a neuter plural borrowed from Latin that declines fully, and the bundle uses it declined throughout (`Kopírovat data`, `Surová data změn`, `žádná data`, `Konfigurační data`). Note that sk chose `Údaje` and pl `Dane`, so this is a real per-language difference and not cs copying English: Czech `data` is standard where Slovak prefers `údaje`.", + "Deck": "The Nextcloud Deck app's own name, a product name that is not translated in any of the 37 bundles.", + "DSAR": "Data subject access request acronym, used untranslated in Czech privacy practice. The bundle spells the concept out where it has room (`Žádost subjektu údajů o přístup`) and keeps the acronym where it is a label.", + "Excel (.xlsx)": "Microsoft product name plus a file extension; neither part is translated in Czech. All 37 bundles agree.", + "ID": "Acronym, used as-is in Czech and declined only by the noun it qualifies (`ID objektu`, `ID registru`).", + "ID:": "Acronym plus a colon; Czech takes no space before a colon.", + "ID: {id}": "Acronym plus a colon plus a placeholder — nothing translatable.", + "Interval": "`interval` is the ordinary Czech word (masculine, declines normally), not an English borrowing left in place. ca, da and hr agree.", + "Maximum": "`maximum` is the standard Czech noun (neuter, genitive `maxima`). The bundle uses the Czech adjective `maximální` wherever the source is attributive, which is what shows the bare noun is a deliberate rendering.", + "Metadata": "`metadata` is the established Czech term (neuter plural, declines: metadat, metadatům). The bundle uses it declined in `Filtry metadat` and `metadata schématu a registru`.", + "Minimum": "`minimum` is the standard Czech noun, the sibling of Maximum above, with `minimální` used attributively.", + "OpenDocument (.ods)": "Format name plus a file extension, both proper names. Untranslated in all 37 bundles, exactly as the sibling Excel (.xlsx) key is.", + "PDF": "File-format acronym, unchanged in Czech. All 37 bundles carry exactly one distinct value.", + "Port": "`port` is the standard Czech term for a network port and declines normally. Kept by the majority of bundles; only el and es reach for a native word.", + "RBAC": "Role-based access control acronym; no Czech expansion is in use, and the bundles agree on the English acronym.", + "Slug": "Kept deliberately, and measured before deciding: twelve bundles (bg bs ca da de el es fi fr ga cs …) render `Slug` unchanged, and Czech web and CMS practice uses the English word with no competing native term. The field's own description key spells out what it means (`Optional URL-friendly identifier` -> `Identifikátor vhodný pro URL`), so the label is not carrying the explanation alone. Contrast is, which had to coin `Stuttheiti` — but only because there `Slug` collided with ID on the same screen, which it does not here.", + "URL": "Acronym, unchanged in Czech and declined only by its qualifier (`URL databáze`, `URL Ollama`). The lower-case-`l` variant `Url` was a source-side key that this pass normalised in the VALUE to `URL`, so it is no longer identical and is recorded under corrections rather than here.", + "UUID:": "Acronym plus a colon. Translatable rather than unwrappable, and measured so per §3.3: two distinct values exist across the bundles, `UUID:` and the French `UUID :` with its space before the colon. Czech takes no space, so the value equals the key.", + "Webhook": "Unanimous across every locale checked (cs da de fr nl pl sk all render `Webhook`), and integrated into Czech rather than merely copied — the bundle declines it throughout (`webhooku`, `webhooky`, `webhooků`, `Vefkrók`-style case marking is not needed but the genitive and plural are). There is no Czech alternative in use.", + "{property} - {other}": "Two placeholders joined by a hyphen. Nothing translatable, and measured per §3.3 rather than assumed.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", + "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens.", + "objectType: object\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens and are never translated.", + "Browser Web Push (VAPID)": "Browser and Web Push are the protocol's own names and VAPID is its acronym; the protocol name is not translated.", + "Bucket": "Object-storage term, used unchanged in this locale's technical language.", + "Driver": "Database-driver term, used unchanged in this locale's technical language.", + "Golden record": "Master-data-management term with no established local equivalent; used unchanged in data-governance language.", + "Host *": "Networking term, used unchanged in this locale, with the required-field marker.", + "Id": "Abbreviation of identifier, written the same way in this locale's technical UI.", + "Index": "Same spelling and meaning in this locale.", + "Model": "Same spelling and meaning in this locale.", + "myapp": "A literal example application id shown verbatim; not prose.", + "Survivor": "Master-data-management term for the record that survives a merge; used unchanged in data-governance language.", + "Test": "Same spelling and meaning in this locale.", + "Url": "Invariant abbreviation, written the same way in this locale.", + "Avatar": "International term, unchanged in Czech; Nextcloud core cs renders it identically.", + "DELETE COLLECTION": "A literal confirmation phrase the user must retype exactly; translating it would make the typed confirmation impossible.", + "Export": "Same spelling and meaning in Czech.", + "Import": "Same spelling and meaning in Czech.", + "Multitenancy": "Architecture term, used unchanged in Czech technical language." + }, + "auditNote": "THE WHOLE BUNDLE WAS GRAMMATICALLY AUDITED — all 2052 values, not a pre-existing half, because cs is one of the sixteen locales finished before any of this tooling existed (§9.2) and so had never had a register measurement, a detector, a cognate review or a grammar pass. 113 values were corrected. THE HEADLINE RESULT IS THAT cs IS IN GENUINELY GOOD SHAPE, and that is worth recording as a finding rather than buried: where the is pass found 235 defects in 1052 pre-existing values (22%), this pass found 113 in 2052 (5.5%), and the character of the defects is completely different. There are no garbled words, no foreign stems, no wrong-language contamination, no gender-agreement failures on quantifiers, and no wrong plural arrays. Czech is a mature, actively maintained Nextcloud locale with a real translator community, and the bundle reads like it. The defects that DO exist are overwhelmingly terminology drift and internal inconsistency — the same concept rendered two ways in sibling keys — plus a small number of genuine grammatical errors. DO NOT GENERALISE THE is DEFECT PROFILE ONTO THE TIER 1 LOCALES: the re-audit handoff reasonably expected them to be as bad or worse because they are doubly un-audited, and on this evidence the opposite is true for the mature ones. Budget the pass for terminology counting rather than for grammar repair. METHOD: mechanical checks first, then every value read. The mechanical checks found ALMOST NOTHING — the ellipsis-glyph, trailing-punctuation, capitalisation, number-agreement, whitespace and placeholder checks between them produced one real finding and a dozen false positives, and every single defect of substance came from reading. That is a stronger version of the §6.9 warning (on is the checks found 4 of 239; here they found ~0 of 113), and the reason is that this bundle's defects are semantic rather than morphological — no checker can see that `Zpráva potvrzení` reads `commit` as `confirm`. THE HIGHEST-YIELD CHECK BY FAR was counting competing renderings per English term, exactly as the handoff predicted: it produced about 70 of the 113 corrections and needs no grammar knowledge at all. Second was the exact-value-collision scan (22 collisions, 4 of them real defects). CORE cs OVERTURNED FOUR CANDIDATE FIXES and that check earned its keep — `Current password` -> `Dosavadní heslo` looked like a wrong sense and is core's exact wording; `Loading…` -> `Načítání…` looked like the outlier against 37 `Načítá se` siblings and is core's own form, so the one value I was about to 'correct' was the only one matching core; `Bucket` has no core authority in this sense; and core's `API key` expansion settled the word order. CHECK THE CALL SITE BEFORE CALLING A FORM WRONG: `folder` -> `složky` looks like a genitive where a nominative belongs and is correct — the key is a button in the middle of a split sentence whose preceding fragment ends `přejděte do`, which governs the genitive. That is the §6.9 case-governance trap in its exact form. CORRECTION CLASS CODES used in `corrections` below: TERM-CONFIG = the noun `configuration` normalised onto `konfigurace` per the owner's decision. TERM-EMBED / TERM-CHUNK / TERM-TRAIL / TERM-CONFIDENCE / TERM-SEARCHTRAIL / TERM-VIEW / TERM-VALIDATE / TERM-TEST / TERM-REFRESH / TERM-ERASURE / TERM-PURGE = normalised onto the term the rest of the bundle (or core, for REFRESH) uses. CONSISTENCY-OPTIONAL / CONSISTENCY-UNDONE / CONSISTENCY-SEARCH / CONSISTENCY-APIKEY / CONSISTENCY-OPERATIONS / CONSISTENCY-BUTTON = aligned with an established convention of this bundle. CASE = wrong case after a governing verb or preposition, or a missing preposition the verb requires. TYPO = a misspelling. SENSE = a real Czech word carrying the wrong meaning. COLLISION = two distinct English keys rendering byte-identically, resolved. NORMALISE = an acronym's casing brought to its standard form. COGNATE-FILLER = a value===key entry that the §9.2 review found to be filler rather than a genuine cognate, now translated. WORDFORM = a coined noun that is not idiomatic Czech. DANGLING-PREP = a preposition left without its object in a column header.", + "corrections": { + "API Token Configuration": "TERM-CONFIG", + "Application identifier for this configuration (optional)": "TERM-CONFIG (accusative `pro tuto konfiguraci` after `pro`)", + "Array Object Configuration:": "TERM-CONFIG", + "Calendar Provider Configuration": "TERM-CONFIG", + "Cannot edit: This register is managed by external configuration {title}": "TERM-CONFIG (instrumental `externí konfigurací` after the passive `spravován`)", + "Checking browser web push configuration …": "TERM-CONFIG", + "Configuration is up to date": "TERM-CONFIG", + "Configuration type (default, application, etc.)": "TERM-CONFIG", + "Edit Configuration": "TERM-CONFIG — a button on the screen the bundle titles `Konfigurace`, so it read `Upravit nastavení` under a `Konfigurace` heading", + "Enable or disable LLM features. Configure providers and models using the LLM configuration button above.": "TERM-CONFIG for the noun only; the verb `nastavte` is kept", + "Enter configuration description (optional)": "TERM-CONFIG", + "Enter configuration title": "TERM-CONFIG", + "Export Configuration": "TERM-CONFIG", + "Faceting Configuration:": "TERM-CONFIG — its sibling `Property Configuration:` already said `Konfigurace vlastnosti:`", + "Failed to save configuration: {error}": "TERM-CONFIG, and this is the COLLISION: the value was byte-identical to `Failed to save settings: {error}`", + "Failed to update schema configuration for {schema}: {error}": "TERM-CONFIG", + "Import Configuration": "TERM-CONFIG", + "Loading LLM configuration...": "TERM-CONFIG — disagreed with `Failed to load LLM configuration`, which already said `konfiguraci LLM`", + "Loading n8n configuration...": "TERM-CONFIG — disagreed with `n8n configuration saved successfully`, which already said `Konfigurace n8n`", + "New Configuration": "TERM-CONFIG (feminine agreement: `Nová konfigurace`)", + "No configuration selected": "TERM-CONFIG (feminine agreement: `Není vybrána žádná konfigurace`)", + "Object Configuration:": "TERM-CONFIG", + "Organisation Configuration": "TERM-CONFIG — it also read identically to the `Organisation settings` wording", + "Publish Configuration to GitHub": "TERM-CONFIG", + "Schema configuration updated successfully for {schema}": "TERM-CONFIG (feminine agreement: `Konfigurace ... byla upravena`)", + "Search configurations": "TERM-CONFIG", + "Select configuration type...": "TERM-CONFIG", + "Showing {showing} of {total} configurations": "TERM-CONFIG (genitive plural `konfigurací` after a quantifier)", + "This schema must use magic table configuration to sync": "TERM-CONFIG (accusative after `používat`)", + "To adjust chunk size or strategy, go to file configuration → processing limits.": "TERM-CONFIG (genitive after `do`) and TERM-CHUNK (`částí` -> `úseků`) in one value", + "To change the embedding provider or model, go to LLM configuration.": "TERM-CONFIG and TERM-EMBED (`embeddingů` -> `vnoření`) in one value", + "ℹ️ current configuration": "TERM-CONFIG (feminine agreement: `současná konfigurace`)", + "Configure large language model (LLM) providers for ai-powered features including semantic search, embeddings, and chat.": "TERM-EMBED", + "Embedding test failed: {error}": "TERM-EMBED — its siblings `Embedding Model` and `Embedding Provider` already said `vnoření`", + "Failed to clear embeddings: {error}": "TERM-EMBED — `Clear All Embeddings` already said `Vymazat všechna vnoření`", + "Successfully deleted {count} embeddings. Please re-vectorize your data.": "TERM-EMBED", + "This will permanently delete ALL embeddings (vectors) from the database. You will need to re-vectorize all objects and files. This action cannot be undone.\n\nAre you sure you want to continue?": "TERM-EMBED, with the quantifier re-agreed to the neuter plural `vnoření` (`VŠECHNY` -> `VŠECHNA`)", + "Chunks vectorized": "TERM-CHUNK — six sibling keys around it already said `úsek`", + "File vectorization completed! {vectorized} chunks vectorized from {files} files. {failed} failed.": "TERM-CHUNK (genitive plural `úseků`)", + "Text Chunk #{id}": "TERM-CHUNK", + "Processes objects in chunks with simulated parallelism.": "SENSE — `v úsecích` read the English `in chunks` as this app's text-chunk entity, which objects do not have; the parallel option keys and `Number of objects to process in each batch` show the object path is batched, so `v dávkách`", + "Audit trail #{id}": "TERM-TRAIL — `auditní stopa` against 34 uses of `auditní záznam`", + "Could not read the seal coverage of the audit trail.": "TERM-TRAIL (genitive `u auditního záznamu` after `u`)", + "Recomputes every hash and compares it with the one stored. This reads the whole audit trail, so it is run on request rather than each time this page opens.": "TERM-TRAIL", + "Confidence": "TERM-CONFIDENCE — `Míra jistoty` against 7 uses of `spolehlivost`, including its own siblings `Confidence Level` and `Confidence Score`", + "Confidence: {confidence}%": "TERM-CONFIDENCE; the space before `%` is the bundle's convention and is kept", + "Error deleting search trails: {error}": "TERM-SEARCHTRAIL — `záznamů hledání` against 15 uses of `záznamů vyhledávání`", + "Failed to clear search trails: {error}": "TERM-SEARCHTRAIL", + "Showing {showing} of {total} search trail entries": "TERM-SEARCHTRAIL, and the singular `záznamu` corrected to the plural `záznamů` the English requires", + "Successfully cleared {count} search trails": "TERM-SEARCHTRAIL", + "Type to search for objects...": "CONSISTENCY-SEARCH — a family of seven keys with identical English structure split 4:3 between `vyhledání` and `hledání`; normalised onto the majority", + "Type to search for organisations": "CONSISTENCY-SEARCH", + "Type to search...": "CONSISTENCY-SEARCH", + "Base URL (Optional)": "CONSISTENCY-OPTIONAL — `volitelné` against 17 uses of `nepovinné`", + "Enter description (optional)...": "CONSISTENCY-OPTIONAL", + "Optional webhook secret for signature verification": "CONSISTENCY-OPTIONAL, as the adjective agreeing with `klíč` (`Nepovinný`)", + "Organization ID (Optional)": "CONSISTENCY-OPTIONAL", + "Secret key for HMAC signature generation (optional)": "CONSISTENCY-OPTIONAL", + "Are you sure you want to delete the selected search trails? This action cannot be undone.": "CONSISTENCY-UNDONE (`vrátit zpět` -> `vzít zpět`, 8:4 majority)", + "Do you want to permanently delete all filtered audit trail entries? This action cannot be undone.": "CONSISTENCY-UNDONE", + "Do you want to permanently delete this audit trail entry? This action cannot be undone.": "CONSISTENCY-UNDONE", + "This action cannot be undone.": "CONSISTENCY-UNDONE — the bare key disagreed with its own sibling `This cannot be undone.`", + "Validate": "TERM-VALIDATE — `Ověřit` against 6 uses of `kontrola` for validation, and it also stopped Validate sharing `ověřit` with Verify", + "Validate Objects": "TERM-VALIDATE", + "Objects to be validated": "TERM-VALIDATE (`ke kontrole`)", + "Test": "TERM-TEST — normalised onto the perfective `Otestovat`; the split was exposed by `Test connection` -> `Testovat připojení` against `Test Connection` -> `Otestovat připojení`, the same two English words rendered differently", + "Test connection": "TERM-TEST", + "Test connection for {backend}": "TERM-TEST", + "Refresh": "TERM-REFRESH and COLLISION — `Obnovit` was byte-identical to `Restore`. Decided by core cs, which distinguishes them exactly: Refresh -> `Znovu načíst`, Restore -> `Obnovit`", + "Refresh dashboard": "TERM-REFRESH", + "Refresh Data": "TERM-REFRESH", + "Refresh database info": "TERM-REFRESH", + "Refresh Workflows": "TERM-REFRESH", + "Failed to refresh database information": "TERM-REFRESH", + "Database information refreshed": "TERM-REFRESH (feminine plural agreement: `Informace ... byly znovu načteny`)", + "API Key": "CONSISTENCY-APIKEY — the bundle treats API as a POSTPOSED attribute in seven other compounds (`Tokeny API`, `koncový bod API`, `specifikaci API`, `dokumentaci API`, `Kvóta požadavků API`, `Nastavení tokenů API`, `volání API`) and core cs's own expansion is likewise noun-first; the three `API klíč` values were the outliers against `Zadejte svůj klíč API`", + "Your Fireworks AI API key. Get one at": "CONSISTENCY-APIKEY", + "Your OpenAI API key. Get one at": "CONSISTENCY-APIKEY", + "Enable faceting": "TYPO — `fazetování` for `fasetování`; the bundle spells the stem `fas` in nine other values (`faseta`, `Popis fasety`, `Pořadí faset`, `Lze fasetovat`)", + "No facetable fields available. Select a register and schema to see available filters.": "TYPO — `fazetovatelná` for `fasetovatelná`, the second instance of the same misspelling", + "No register objects reference this file": "CASE — `odkazovat` governs `na` plus the accusative, and the preposition was missing entirely, leaving `Tento soubor neodkazují žádné objekty registru`, which reads as though the file were the subject's direct object and is ungrammatical. -> `Na tento soubor neodkazují...`", + "Search GitHub": "CASE — `na` governs the locative, so `na GitHubu`, which is what the bundle's own `Zveřejnit konfiguraci na GitHubu` already had", + "Search GitLab": "CASE — as Search GitHub", + "Queue / sync health": "CASE — `Stav frontu` used the genitive of the MASCULINE `front` (a front line); a queue is the feminine `fronta`, genitive `fronty`", + "Select event to listen to...": "CASE — `naslouchat` governs the DATIVE, and the value had the accusative `kterou`; the bare relative-plus-infinitive `kterou naslouchat` is also not a Czech construction, so a modal was supplied: `Vyberte událost, které chcete naslouchat...`", + "Select the event this webhook should listen to": "CASE — as above, dative `které` after `naslouchat`", + "Commit message": "SENSE — `Zpráva potvrzení` reads `commit` as `confirm`. This bundle is unambiguously about Git: it has `Branch` -> `Větev`, `Repository` -> `Repozitář`, `Path in repository` and publishing to GitHub/GitLab. -> `Zpráva commitu`", + "Commit Message": "SENSE — as Commit message", + "Complex": "SENSE — Czech `komplexní` means comprehensive or holistic, not complicated; the false friend for `complex` is `složitý`, which the bundle's own `Complex queries` -> `Složité dotazy` already used. The call site is the query-complexity filter, so the label now reads `Složitý` alongside `Jednoduchý` and `Střední`", + "Poor": "COLLISION and SENSE — `Nízká` was byte-identical to `Low`; a poor quality rating is `Špatná`", + "Reachable": "COLLISION — `Dostupné` was byte-identical to `Available`. The bundle's own `Unreachable` -> `Nedosažitelné` shows which stem belongs here, so Reachable -> `Dosažitelné` and the Available/Unavailable pair keeps `Dostupné`/`Nedostupné`", + "Uses": "SENSE — `Používá` is a 3sg present verb; the call site is an AppTab title in ViewObject.vue and ObjectDetails.vue, so it needs the noun `Použití`", + "falling back": "SENSE — the call site is a status badge shown while the effective backend differs from the active one, so the future `použije se záloha` (\"a backup will be used\") is wrong in both tense and sense; `záloha` is a backup COPY. -> `záložní režim`", + "Objects being analyzed": "COLLISION — byte-identical to `Objects Analyzed`, which is the completed count rather than the in-flight one. -> `Právě analyzované objekty`", + "Permanently Delete": "CONSISTENCY-BUTTON — the button read `Trvale smazat` while another value instructs the user to click a button it names `Smazat natrvalo` (`Použijte \"Smazat natrvalo\"`), and `Permanently Delete ({count})` also said `Smazat natrvalo`. The quoted name now matches the button it names", + "Id": "NORMALISE — the acronym's standard Czech form is `ID`; this also takes the key out of the identical set, so it is recorded here rather than under cognates", + "Url": "NORMALISE — `URL`, as for Id. This is the §10 Url/URL source defect showing up in a value", + "Driver": "COGNATE-FILLER — `Driver` was an unjustified identical value and the §9.2 review found it to be filler, not a cognate: almost every locale translates it (de `Treiber`, fr `Pilote`, es `Controlador`, hr `Upravljački program`, hu `Illesztőprogram`, fi `Ajuri`), only cs and da did not, and core cs itself writes `ovladače databází`. -> `Ovladač`", + "N/A": "COGNATE-FILLER — kept by cs, da and pl but translated by de (`Nicht verfügbar`), fr (`S.O.`), nl (`N.v.t.`) and, closest to cs, sk (`Neuvedené`). Czech has a natural equivalent for a missing field value, so the identical value was not forced. -> `Neuvedeno`", + "Required status is inconsistent": "WORDFORM — `vyžadovanost` is a nonce -ost formation that is not idiomatic Czech; the concept is `povinnost`, which the bundle's own `Required field` -> `Povinné pole` already uses. -> `Stav povinnosti je nekonzistentní`", + "Detected At": "DANGLING-PREP — `Detekováno v` leaves the preposition `v` without an object. Fixed onto the bundle's own column-header pattern, `Deleted Date` -> `Datum smazání`, giving `Datum detekce`; the alternative of simply dropping the preposition would have collided with other headers", + "Extracted at": "DANGLING-PREP — `Extrahováno v` -> `Datum extrakce`", + "Extracted At": "DANGLING-PREP — as `Extracted at`; the two keys differ only in casing", + "Updated At": "DANGLING-PREP — `Aktualizováno v` -> `Datum aktualizace`. Dropping the preposition here would have produced `Aktualizováno`, byte-identical to the existing `Updated`, so the `Datum X` pattern was used instead", + "Merge Operations": "CONSISTENCY-OPERATIONS — `Sloučení` against its sibling headers `Create Operations` -> `Operace vytvoření` and `Delete Operations` -> `Operace mazání`. -> `Operace sloučení`", + "_Purge {count} object from database_::_Purge {count} objects from database_": "TERM-PURGE — `Vyčistit` was the lone outlier against the three `Purge` values that render `vymaz` (`Purge`, `Purge Date`, `No purge date set`). The three plural forms keep the correct absolute-boundary agreement: `objekt` / `objekty` / `objektů`", + "Erasure preview": "TERM-ERASURE — the bundle was split 2:2 between `vymazání` and `výmaz` for GDPR erasure, and `výmaz` wins because `právo na výmaz` is the established Czech rendering of Art 17. -> `Náhled výmazu`, which also matches the existing `zobrazte náhled výmazu`", + "Erasure complete": "TERM-ERASURE -> `Výmaz dokončen`", + "Failed to load view: {error}": "TERM-VIEW — the View ENTITY is `pohled` in 27 values; three `Failed to ... view` keys used `zobrazení` while their own siblings `Failed to update view` and `An error occurred while deleting the view` already said `pohled`", + "Failed to save view: {error}": "TERM-VIEW", + "Failed to update view: {error}": "TERM-VIEW", + "No saved views yet. create one in the search tab!": "TERM-VIEW, and the number corrected: `Vytvořte je` (\"create them\") for the English `create one`. -> `Zatím žádné uložené pohledy. Vytvořte si jeden na kartě hledání!`", + "Tip: only enable views that need semantic search to minimize embedding costs. Simple lookup tables rarely need vectorization.": "TERM-VIEW and TERM-EMBED in one value" + }, + "undetectableNote": "Register deviations this locale's detector cannot see are enumerated in `detectors/cs.js` UNDETECTABLE. The load-bearing one is §8.2's: bare `ty` and `ti` are at once the informal pronoun and the plural demonstrative (`ty soubory` = \"those files\", `ti uživatelé` = \"those users\"), and unlike Slovak — where the acute splits dative `ti` from demonstrative `tí` — Czech spells them identically, so neither is matched. Measured over the combined corpus: `ty` occurs 6 times, every one the demonstrative, and `ti` occurs 0 times. Recall is recovered from the oblique forms, the possessive and the verbs. Bare `si` is also unmatched, but for a DIFFERENT reason from hr/sk/sl, and the difference is worth keeping straight: in those languages `si` is the 2sg of \"to be\" as well as the reflexive dative clitic, so it is an ambiguous marker; Czech's 2sg of `být` is `jsi`, so `si` is ONLY reflexive and carries no address information in either direction. Czech `prosím` (\"please\") is a 1sg present verb and inflects for the SPEAKER, so unlike Maltese `jekk jogħġbok` it gives no free signal (§8.1) — in `Počkejte prosím` the register is carried by `počkejte` alone." +} diff --git a/scripts/l10n/locales/et.json b/scripts/l10n/locales/et.json new file mode 100644 index 0000000000..45f045d1cb --- /dev/null +++ b/scripts/l10n/locales/et.json @@ -0,0 +1,259 @@ +{ + "register": "informal", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "CSV": "Format acronym, invariant in Estonian.", + "Deck": "Nextcloud app name; kept untranslated in all 18 finished locales.", + "DSAR": "Acronym (data subject access request); kept untranslated in all 18 finished locales, so following that.", + "Excel (.xlsx)": "Product name plus file extension.", + "ID": "Identifier acronym, invariant in Estonian.", + "Id": "Kept as 'Id' in all 18 finished locales; following that rather than diverging on casing alone.", + "ID:": "Identifier acronym plus a colon. Estonian, unlike French, puts no space before a colon.", + "ID: {id}": "Identifier acronym, colon and a placeholder — nothing translatable remains.", + "Min ms": "'Min' abbreviates 'miinimum' in Estonian exactly as it abbreviates 'minimum' in English, and 'ms' is the SI symbol; both are invariant.", + "Host *": "'Host' is the established Estonian loanword for a network host; Nextcloud core et_EE renders 'Host' too.", + "Ollama URL": "Product name plus an invariant acronym; Estonian would add no article or genitive here, so the label is unchanged.", + "PDF": "Format acronym, invariant in Estonian.", + "OpenDocument (.ods)": "Product name plus file extension.", + "Port": "'Port' is the established Estonian loanword for a network port; Nextcloud core et_EE renders 'Port' too.", + "RBAC": "Access-control acronym, used untranslated in Estonian technical writing.", + "Register": "'Register' is the Estonian noun, spelled identically; the app's existing et glossary already uses it (plural 'Registrid').", + "Register:": "The Estonian noun 'Register' plus a colon; Estonian, unlike French, puts no space before a colon.", + "URL": "Invariant in Estonian; 'URL' is the standard form.", + "UUID:": "Identifier acronym plus a colon. Estonian puts no space before a colon, unlike fr which carries 'UUID :'.", + "{property} - {other}": "Two placeholders and an ASCII hyphen, which Estonian keeps. Stays wrapped in the source because ru localises the hyphen to an em dash.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", + "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens.", + "objectType: object\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens and are never translated.", + "Browser Web Push (VAPID)": "Browser and Web Push are the protocol's own names and VAPID is its acronym; the protocol name is not translated.", + "Bucket": "Object-storage term, used unchanged in this locale's technical language.", + "Driver": "Database-driver term, used unchanged in this locale's technical language.", + "Golden record": "Master-data-management term with no established local equivalent; used unchanged in data-governance language.", + "Host": "Networking term, used unchanged in this locale.", + "myapp": "A literal example application id shown verbatim; not prose.", + "Survivor": "Master-data-management term for the record that survives a merge; used unchanged in data-governance language.", + "Avatar": "International term, unchanged in Estonian; Nextcloud core et renders it identically.", + "Link": "Same spelling and meaning in Estonian.", + "Register #{id}": "'Register' is identical in Estonian and the rest is a number sign and a placeholder." + }, + "corrections": { + "Add a contact from any of your address books to associate it with this object.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Add schema titles, descriptions, and register information to provide richer context for search": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Apply a role to all schemas in this register that do not have explicit authorization overrides.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Choose a register": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Choose a schema": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Choose how vector similarity calculations are performed for semantic search": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Choose which file types to include in the vectorization process. Only files with extracted text and chunks will be processed.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Choose which views to include in the vectorization process. Leave empty to process all views based on your configuration.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Chunk deletion not yet implemented. Use chunk-based endpoints.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Click Run compliance scan to find schemas where PII has been detected but no processing-activity annotation exists.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Click to subscribe to notifications": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Click to unsubscribe from notifications": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Configure Apache SOLR search engine for advanced search capabilities": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Configure API tokens for external service integrations": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Configure basic connection settings for your SOLR server including authentication and network options. Use the separate ConfigSet and Collection Management dialogs to manage cores and collections.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Configure basic connection settings for your SOLR server including authentication and network options. Use the separate configset and collection management dialogs to manage cores and collections.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Configure default organisation and organisation-related settings": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Configure how database objects are converted into vector embeddings for semantic search. Objects are directly vectorized without needing text extraction.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Configure how objects are converted to text before vectorization. These settings affect search quality and context.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Configure Large Language Model (LLM) providers for AI-powered features including semantic search, embeddings, and chat.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Configure large language model (LLM) providers for ai-powered features including semantic search, embeddings, and chat.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Configure multi-organization support and tenant isolation": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Configure parameters for file vectorization. This process will generate vector embeddings for all extracted file chunks.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Configure parameters for object vectorization. This process will generate vector embeddings for all objects matching your view filters.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Configure this schema to surface objects as events in the Nextcloud Calendar app.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Configure which types of data to search and how many sources to retrieve": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Control which object views should be vectorized to reduce API costs. Only vectorize views that benefit from semantic search.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Control which views and tools the AI can use in this conversation. By default, all agent capabilities are enabled.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Create a new event or link an existing one from any of your calendars.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Create or link a Deck card to track work on this object.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Create the first verwerkingsactiviteit to start tagging audit-trail rows with their AVG Art 30 attribution.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Create your first AI agent to get started.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Defaults to current user. Select a different user if needed.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Disable this to make the agent": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Download a copy of all your personal data stored in OpenRegister (GDPR Article 20).": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enable or disable LLM features. Configure providers and models using the LLM Configuration button above.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Enable or disable LLM features. Configure providers and models using the LLM configuration button above.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Enable or disable n8n workflow integration. Configure connection settings below.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Enable or disable SOLR search integration. Configure connection settings using the Connection Settings button above.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Enable or disable SOLR search integration. Configure connection settings using the connection settings button above.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Enable tools that allow the agent to interact with data through function calling.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter agent description (optional)": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter agent name": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter application description (optional)": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter application name": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter collection name": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter ConfigSet name": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter configuration description (optional)": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter configuration title": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter description (optional)...": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter new collection name": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter Nextcloud usernames to grant access to this private agent": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter object ID": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter organisation description (optional)": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter organisation name": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter search term": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter search terms or leave empty to browse all": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter system prompt for the agent": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter username and press Enter": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Enter view name...": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter webhook name": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter your API key": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter your object here...": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Enter your schema here...": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Export is rate limited. Please try again later.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Filter and analyze search trail entries": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Filter and manage audit trail entries": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Filter and manage soft deleted items": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Filter and search entities": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Filter webhook triggers by payload properties (one per line, format: key: value)": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Import the report-bundle.json template to get the `reports` register, then create your first dashboard via the standard object UI. Dashboards declare their widgets in JSON and the renderer feeds each widget live aggregation data.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "No active objects to soft-delete for schema {schema}. Use \"Permanently Delete\" to remove soft-deleted objects.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "No conversations yet. Create a new one to get started!": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "No entities detected in this file. Run text extraction first.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "No facetable fields available. Select a register and schema to see available filters.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "No groups found. Try a different search.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "No registers found. Create a register to configure permissions.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "No saved views yet. Create one in the Search tab!": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "No views found. Create views first before configuring vectorization.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "No workflows found in this project. Create workflows in the n8n editor.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Review the selected objects below. You can remove any objects you don't want to delete by clicking the remove button.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Review the selected objects below. You can remove any objects you don't want to include.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Review the selected objects below. You can remove any objects you don't want to permanently delete by clicking the remove button. This action cannot be undone.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Review the selected objects below. You can remove any objects you don't want to restore by clicking the remove button. Objects will be restored to their original location.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Select an organisation and user to add them as a member. Search for organisations by name.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Select or create labels, or select \"No label\" to add files": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Select which Nextcloud groups are available for this organisation. Users in these groups will have access to organisation resources.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Set limits for API usage and token consumption. Use 0 for unlimited resources.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "The requested dashboard could not be loaded. Verify the URL or pick another dashboard from the list.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "This action cannot be undone. Make sure no collections are using this ConfigSet.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "This action cannot be undone. Make sure no collections are using this configset.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "This endpoint is deprecated. Use chunk-based endpoints instead.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "This token will only be shown once. Copy it now.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Tools execute with the agent's default user permissions when no user session is active (e.g., cron jobs). Configure the default user in the Settings tab.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Try searching by UUID or with different keywords": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Use AI agents for processing and analysis": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Use external storage (e.g. MongoDB) instead of the internal Nextcloud storage.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Use filters to narrow down audit trail entries by register, schema, action type, user, date range, or object ID.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Use filters to narrow down deleted items by register, schema, deletion date, or user who deleted them.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Use filters to narrow down entities by type or category.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Use filters to narrow down search trail entries by register, schema, success status, user, date range, search terms, or performance metrics.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Use Large Language Model features": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Use SolrCloud with Zookeeper for distributed search": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Use SolrCloud with zookeeper for distributed search": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "Use VNG APIs instead of MongoDB.": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "View and analyze search trail logs with advanced filtering and analytics capabilities": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "View and analyze system audit trails with advanced filtering capabilities": "Formal 2nd-person-plural imperative replaced with the informal singular. et's measured register in this bundle is INFORMAL and detectors/et.js flags the -ge/-ke form; the rest of the bundle already uses the bare-stem imperative.", + "View and manage authorization across registers and schemas": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "View entity details and manage relations": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "View search analytics and manage search logs": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "View webhook delivery logs and filter by webhook": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Advanced filters are not available when using database source. Switch to Auto or SOLR Index for filtering options.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "and add the files there.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Archived conversations are hidden from your active list": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Avatar changes are not supported by your authentication provider.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Backend updated successfully. Please reload the application.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "ConfigSets define the schema and configuration for your SOLR collections. They contain field definitions, analyzers, and other search settings.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "File vectorization started. Check the statistics section for progress.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Filter data loaded automatically. Use the filters below to refine your search.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Locate every object referencing a data subject (Art 15 inzage), preview an erasure (Art 17 vergetelheid), or export their data (Art 20 portabiliteit).": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Make this installation discoverable within the federation network.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Manage and configure agents for automated tasks": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage and view detected entities from files and objects": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage processing activities, run data-subject access requests, and audit compliance under the EU GDPR / Dutch AVG.": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage SOLR Collections (data stores) and assign them for objects and files.": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage SOLR collections (data stores) and assign them for objects and files.": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Maximum number of files to process. Set to 0 to process all files.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Maximum number of objects to process. Set to 0 to process all objects.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "menu or contact someone with permission to create agents.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "No properties match your filters.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "No saved views yet. create one in the search tab!": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "No views match your search": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "notify_push is installed but OpenRegister has not yet confirmed a successful push. Trigger an object save to activate, or check your notify_push configuration.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Optional: Add query parameters to filter the referenced schema (e.g., status=active&type=public)": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "or pick a range": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Password changes are not supported by your authentication provider.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Please create an agent in the": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Please select a user": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Please select an agent to continue": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Please select an organisation": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Please select at least one item to import": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Please select registers or schemas first": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Please select which register and schema to use for the new object": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Please try again later.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Please wait while we fetch your agents.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Please wait while we fetch your applications.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Please wait while we fetch your configurations.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Please wait while we fetch your deleted items.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Please wait while we fetch your sources.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Please wait while we permanently delete the SOLR collection. This may take a few moments.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Save the organisation first to manage users.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Select a repository you have write access to": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Select an AI agent to begin chatting with your data.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Select an email to see linked objects": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Select registers and schemas to save a view": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "SOLR setup failed - check logs": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "SOLR setup failed. Please check the configuration and try again.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "There are no audit trail entries matching your current filters.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "There are no deleted items matching your current filters.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "There are no search trail entries matching your current filters.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "There are no webhook log entries matching your filters.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "This action will submit a deactivation request to your administrators.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "This will permanently delete ALL embeddings (vectors) from the database. You will need to re-vectorize all objects and files. This action cannot be undone.\n\nAre you sure you want to continue?": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "This will permanently delete ALL embeddings (vectors) from the database. You will need to re-vectorize all objects and files. This action cannot be undone.\\n\\nAre you sure you want to continue?": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Tip: Only enable views that need semantic search to minimize embedding costs. Simple lookup tables rarely need vectorization.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Tip: only enable views that need semantic search to minimize embedding costs. Simple lookup tables rarely need vectorization.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "To add files larger than or equal to 512MB, go to the": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "To adjust chunk size or strategy, go to File Configuration → Processing Limits.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "To adjust chunk size or strategy, go to file configuration → processing limits.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "To change the embedding provider or model, go to LLM Configuration.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "To change the embedding provider or model, go to LLM configuration.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Vectors will be stored in your existing object and file collections": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "You": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "You do not have access to this conversation": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "You do not have permission to delete this conversation": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "You do not have permission to modify this conversation": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "You do not have permission to restore this conversation": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "You do not have permission to view the permission matrix. Admin access is required.": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Your feedback has been recorded. Optionally, you can provide additional details here...": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Your feedback has been recorded. optionally, you can provide additional details here...": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Your Fireworks AI API key. Get one at": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Your OpenAI API key. Get one at": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "📦 ConfigSets contain the schema and configuration files for your search index...": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "🔄 Replication ensures your search index is available even if nodes fail...": "Formal address replaced with the informal. et's measured register in this bundle is INFORMAL: detectors/et.js flags both the -ge/-ke imperative and the formal pronoun 'teie/teil', where this bundle uses the bare-stem imperative and 'sinu/sul'.", + "Manage and monitor file text extraction status": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage and restore soft deleted items from your registers": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage document templates and themes": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage n8n workflows for OpenRegister automation. Workflows will be stored in the configured project.": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage SOLR ConfigSets (configuration templates) for your collections.": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage SOLR configsets (configuration templates) for your collections.": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage webhooks for event-driven integrations": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage your account settings, security, and personal data.": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage your applications and modules": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage your chat conversations": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage your data registers and their configurations": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage your data schemas and their properties": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage your data sources and their configurations": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage your organisations and switch between them": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage your saved search configurations": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Manage your system configurations and settings": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Press Enter to send, Shift+Enter for new line": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL.", + "Press enter to send, shift+enter for new line": "Formal imperative replaced with the informal; et's measured register in this bundle is INFORMAL." + } +} diff --git a/scripts/l10n/locales/ga.json b/scripts/l10n/locales/ga.json new file mode 100644 index 0000000000..ec98e08631 --- /dev/null +++ b/scripts/l10n/locales/ga.json @@ -0,0 +1,50 @@ +{ + "register": "informal", + "registerEvidence": "Measured against core: 33 ga catalogues, 5395 values. 434 second-person SINGULAR markers (213 tú/thú/tusa/thusa + 221 2sg prepositional pronouns) against ZERO second-person plural markers — not one occurrence of sibh, sibhse, bhur, agaibh, daoibh, libh, oraibh, uaibh, chugaibh, or an -aigí/-igí 2pl imperative, confirmed by raw grep as well as by the detector. The bundle's own 1036 pre-existing values agree: 15 2sg markers, 0 2pl. IRISH HAS NO T-V DISTINCTION — sibh is strictly plural in modern standard Irish and is never a polite singular, so 'informal' here names the ONLY register available rather than a choice between two. The verdict is recorded this way because it sets the gate's polarity correctly: patchcheck then refuses 2pl address, which for a single-user UI is always a defect — a Continental-politeness plural imported by analogy from de/fr/nl. That is a real failure mode for this locale and the only register defect it can have.", + "buttons": "Bare 2sg imperative — the same pattern as ca/et/hr/sl/sr, and the same form English uses. Measured from core's own short labels: Sábháil (Save, 5 catalogues), Scrios (Delete, 9), Cealaigh (Cancel, 14), Cruthaigh (Create, 5), Deimhnigh (Confirm, 5), Cóipeáil (Copy, 4), Athainmnigh (Rename, 3), Comhroinn (Share, 3), Cumasaigh / Díchumasaigh (Enable/Disable, 2 each), Roghnaigh (Select/Choose), Bain (Remove, 2), Bog (Move, 2), Dún (Close), Athnuaigh (Refresh), Athshocraigh (Reset, 2), Athchóirigh (Restore), Diúltaigh (Reject), Glac (Accept, 4). A nominal minority exists and is followed per key where core sets it: Update -> Nuashonrú (verbal noun, 3 catalogues) and Search -> Cuardach (noun, 3) — but THIS bundle already ships Update -> Nuashonraigh and Cuardaigh as the imperative, so the file wins on those two (§3.5). Because the imperative IS the label convention, the detector must not count it (§6.5 test 1).", + "pluralNote": "nplurals=5, plural=(n==1 ? 0 : n==2 ? 1 : n<7 ? 2 : n<11 ? 3 : 4). The header's five forms are real Irish morphology (aon/dhá lenite, trí–sé lenite, seacht–deich eclipse, 11+ differs again) but @nextcloud/l10n's getPlural returns only THREE indices: form 0 <- n=1, form 1 <- n=2, form 2 <- 0 and every n>=3. Forms 3 and 4 are unreachable. Measured over 1..120: the header and the library disagree at every n>=7, so form 2 must be correct for 0, 3-6, 7-10 and 11+ alike. NO pluralOrder entry is needed — there is no ordering disagreement, only a smaller reachable form count. Unlike rm, the collapse here IS harmless, and that is measured rather than assumed: across core ga's 101 fully-translated 5-form arrays, form2 != form3 in ZERO cases and form3 != form4 in ZERO cases. Core writes forms 2, 3 and 4 identically without exception. THE DECIDING RULE IS THE COUNTED-NOUN RULE, NOT THE FORM INDEX: Irish takes the SINGULAR noun after a numeral (An Caighdeán Oifigiúil: numerals 1-19 govern the singular), so a key whose value contains {count}/%n needs the same counted singular in every form, while a key with NO numeral needs a genuine singular/plural split. Core's own data separates cleanly on exactly that axis — of 73 numeral-bearing arrays 53 keep the singular in every form against 20 that pluralise (the calqued minority), while 28 of 28 arrays WITHOUT a numeral pluralise. So 5 of the 6 arrays written in this pass are all-forms-identical (legitimate, the hu/tr shape) and only _Object successfully deleted_::_Objects successfully deleted_, which carries no numeral, splits. Initial mutation is NOT applied after a digit, which is also core's measured practice rather than a guess: core writes form 0 as %n comhad, %n beart, %n carachtar, %n fógra, %n soicind — never chomhad/bheart/charachtar — even though aon lenites a spelled-out numeral phrase. The one pre-existing array in this bundle (_%n entry has no hash yet_) writes the 7-10 eclipsis at form 3 (%n n-iontráil); that form is unreachable so it renders nothing, and it is left untouched rather than normalised, but new arrays follow core and carry no mutation.", + "siblingParentheticalNote": "The bundle's pre-existing '(s)' siblings are parentheticals — register(s) -> clár(acha), schema(s) -> scéimre(í), configuration(s) -> cumraíocht(aí) — and diverge from the counted-singular rule the plural arrays follow. Left alone: they are real pre-existing translations under §3.8, and the consistency that matters is among the n() arrays, which all apply the counted-noun rule in pluralNote.", + "orthographyNote": "THERE IS NO INDEPENDENT DOMAIN-TERM CAPITALISATION CONVENTION HERE, and that is a measured result, not an omission. Unlike sr (six terms capitalised mid-sentence) and rm (three), ga's casing MIRRORS THE ENGLISH SOURCE exactly. Measured over 14 domain terms: where the English key is title-cased the ga value capitalises 76 times against 1, and where the English key is prose it capitalises 0 times against 193. The single apparent exception (Update register OAS: ... -> Nuashonraigh OAS cláir: ...) is not one — the English there has lowercase 'register', so the ga value mirrors it correctly and the title-case heuristic simply misfired on the OAS: token. So the rule is: follow the source's casing per key, which also explains why a naive per-term count reads as MIXED for every term (clár 3:5, scéimre 9:21, réad 11:19, comhad 6:11, amharc 3:22). Ellipsis likewise mirrors the source glyph: 41 of 42 keys carrying ... keep ..., and 2 of 2 keys carrying … keep …. Em dash — is used for the source's parenthetical dashes (8 uses); there is no en dash, no non-breaking space, no guillemets or curly quotes anywhere in the bundle, and % never appears outside a placeholder.", + "lexiconNote": "Terminology fixed by the bundle's own pre-existing half and kept: Register clár/cláir, Schema scéimre/scéimrí, Object réad/réada (gen sg réid, gen pl réad), Property airí (gen pl airíonna), File comhad/comhaid, View amharc/amhairc, Source foinse/foinsí, Audit trail rian iniúchta/rianta iniúchta, Flow sruth (gen an tsrutha), Workflow sreabhadh oibre/sreabháin oibre, Entity aonán/aonáin, Chunk smután/smutáin, Embedding leabú/leabuithe, Vectorization veicteoiriú, Endpoint críochphointe/críochphointí, Token comhartha, Hash hais, Chain slabhra, Seal séala, Dashboard deais, Repository stórlann, Relations gaolta, soft delete boigscriosadh/boigscriosta, Issue saincheist, Field réimse, Count líon, Coverage clúdach. FOUR DIVERGENCES FROM CORE, all deliberate: (1) Webhook stays the English loan, because this bundle already uses it unchanged in 28 places, where core's webhook_listeners ga renders Webhooks as Crúcaí gréasáin — the file wins on lexicon (§3.5), as hr kept lozinka over core's zaporka. (2) Flow is sruth and Workflow is sreabhadh oibre; core ga renders Flow as Sreabhadh, which would collapse the two concepts the app distinguishes. (3) Update is the imperative Nuashonraigh, not core's verbal noun Nuashonrú, matching this bundle's imperative label convention. (4) Revoke is Cúlghair, NOT core's Chúlghairm — that value carries a stray lenition on a citation form and is a typo of the kind §8.4 warns against taking (cf. core tr's Yenlle). Two §8.4 wrong-sense traps resolved against the call site rather than the harvest: Bucket is a HISTOGRAM BIN in QualityIndex.vue, so banda — never core's Buicéad, which is a literal pail — and it is kept distinct from Interval eatramh and Range raon; People is the PERSON entity type in EntitiesTab.vue, so core's Daoine is correct here.", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "CSV": "File-format acronym, unchanged in Irish as in every other locale here.", + "Deck": "Proper name of the Nextcloud Deck app, so it stays as the product name.", + "DSAR": "Acronym for a data-subject access request, kept as the term of art; the spelled-out key is translated (Iarratas rochtana ó ábhar sonraí).", + "Excel (.xlsx)": "Product name plus a file extension; both stay unchanged in Irish, as the sibling OpenDocument (.ods) entry does.", + "OpenDocument (.ods)": "Format proper name plus a file extension, unchanged in Irish.", + "PDF": "File-format acronym, unchanged in Irish as in every other locale here.", + "RBAC": "Acronym for role-based access control, kept as the term of art; the spelled-out sense is carried by Maitrís na gCeadanna and Cearta Speisialta.", + "URL": "Acronym borrowed unchanged in Irish; the bundle already writes Bun-URL, URL Ollama and URL Bunachair Sonraí, and all 37 locales render it identically.", + "UUID:": "Acronym plus colon, unchanged in Irish. Translatable in principle — fr writes UUID : with French spacing — so it is a cognate rather than an unwrap candidate (§3.3).", + "Port": "Network port. Irish uses port unchanged, and core ga confirms it in 3 catalogues; nine other locales do translate it, so this is a cognate and not untranslatable.", + "Slug": "URL slug. Irish has no settled term, and this bundle keeps unsettled technical loans unchanged (webhook, enum, regex, LLM, JSON, HMAC, RAG); 26 of 37 locales do the same. Translatable elsewhere (lt Trumpinys, sl Oznaka), hence a recorded cognate.", + "Webhook": "Kept as the English loan throughout this bundle — 28 pre-existing uses, lowercase in prose and Webhook in title-cased labels. Core's Crúcaí gréasáin is not adopted, per §3.5.", + "Webhooks": "Plural of the loan above, kept unchanged; the bundle already writes Ar Ais go Webhooks, Cuardaigh Webhooks and Níor aimsíodh aon webhooks.", + "{property} - {other}": "Two placeholders joined by a separator; Irish supplies no word to translate. The ASCII hyphen of the source is kept because this bundle uses no en dash anywhere (0 occurrences against 8 em dashes, which it reserves for the source's parenthetical dashes) — unlike ru, which substitutes an em dash here, and hr, an en dash.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "e.g., lib/Settings/config.json": "A literal example file path shown verbatim; not prose.", + "e.g., lib/Settings/register.json": "A literal example file path shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", + "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens.", + "objectType: object\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens and are never translated." + }, + "corrections": { + "Loading organisations...": "Was `Eagraíochtaí á luchtú…`, the only one of the bundle's 35 `Loading X...` keys not built as `Ag ...` — the other 34 are, and it also carried the … glyph where the English source has three ASCII dots, against the bundle's own 41-of-42 practice of mirroring the source glyph. Normalised to `Ag luchtú eagraíochtaí...` so the progressive construction and the ellipsis convention both hold without an exception to explain." + } +} diff --git a/scripts/l10n/locales/hr.json b/scripts/l10n/locales/hr.json new file mode 100644 index 0000000000..001890d6fd --- /dev/null +++ b/scripts/l10n/locales/hr.json @@ -0,0 +1,79 @@ +{ + "register": "formal", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "Avatar": "Croatian has adopted 'avatar' unchanged for a profile picture; core hr uses it too, so translating it would invent a term users do not know.", + "CSV": "File-format initialism, identical in Croatian; every other finished locale keeps it as-is.", + "Deck": "Proper name of the Nextcloud Deck app; product names are not translated.", + "DSAR": "Initialism for Data Subject Access Request. Croatian GDPR practice has coined no equivalent acronym, and the expanded key 'Data-subject access request' is translated in full alongside it.", + "Excel (.xlsx)": "Product name plus a file extension; both are invariant in Croatian.", + "Format": "Croatian uses 'format' unchanged for a data/display format; core hr has no competing term.", + "ID": "Initialism written identically in Croatian; the lowercase variant key 'Id' is normalised TO this form rather than left alone.", + "ID:": "Same initialism with a colon. Croatian, unlike French, puts no space before a colon, so the punctuation is genuinely unchanged.", + "ID: {id}": "Same initialism plus the placeholder; nothing in this string differs in Croatian.", + "Interval": "Croatian 'interval' is the standard term and is spelled identically.", + "Minimum": "Croatian 'minimum' is the standard term and is spelled identically; the derived 'Minimum length/value' keys are translated as 'Minimalna …'.", + "OpenDocument (.ods)": "Product name plus a file extension; both are invariant in Croatian.", + "PDF": "File-format initialism, identical in Croatian; every other finished locale keeps it as-is.", + "RBAC": "Initialism for role-based access control, used untranslated in Croatian IT writing; the expanded prose keys around it are translated in full.", + "Slug": "Croatian technical writing keeps the English 'slug' for a URL-friendly identifier; no Croatian equivalent is in use, and the descriptive key 'Optional URL-friendly identifier' is translated in full beside it.", + "Status": "Croatian 'status' is the standard term and is spelled identically; core hr confirms it.", + "URL": "Initialism written identically in Croatian; the lowercase-variant key 'Url' is normalised TO this form, and 'Database URL' etc. are translated as 'URL baze podataka'.", + "UUID:": "Initialism with a colon. Croatian, unlike French, puts no space before a colon, so the punctuation is genuinely unchanged.", + "Webhook": "Croatian keeps 'webhook' and declines it ('webhookovi', 'webhookove'); the nominative singular is therefore identical to the English key.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", + "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens.", + "objectType: object\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens and are never translated.", + "Driver": "Database-driver term, used unchanged in this locale's technical language.", + "Host *": "Networking term, used unchanged in this locale, with the required-field marker.", + "Id": "Abbreviation of identifier, written the same way in this locale's technical UI.", + "Model": "Same spelling and meaning in this locale.", + "myapp": "A literal example application id shown verbatim; not prose.", + "Ollama URL": "Product name plus the invariant abbreviation URL.", + "Test": "Same spelling and meaning in this locale.", + "Url": "Invariant abbreviation, written the same way in this locale.", + "{property} - {other}": "Two placeholders joined by a dash; there is no prose to translate.", + "Browser Web Push (VAPID)": "Browser and Web Push are the protocol's own names and VAPID is its acronym; the protocol name is not translated.", + "Bucket": "Object-storage term, used unchanged in this locale's technical language.", + "Golden record": "Master-data-management term with no established local equivalent; used unchanged in data-governance language.", + "Survivor": "Master-data-management term for the record that survives a merge; used unchanged in data-governance language." + }, + "corrections": { + "Categories of data subjects (one per line)": "batch 1 wrote the paraphrase \"osoba čiji se podaci obrađuju\"; \"ispitanik\" is the term used by the official Croatian text of the GDPR", + "create": "written as the imperative \"stvori\" before PermissionMatrix.vue showed this renders as a COLUMN HEADER in actions:[read,create,update,delete,manage], so it is a permission name — verbal noun \"stvaranje\"", + "delete": "same as \"create\": imperative \"izbriši\" corrected to \"brisanje\"", + "manage": "same as \"create\": imperative \"upravljaj\" corrected to \"upravljanje\"", + "_%n entry has no hash yet_::_%n entries have no hash yet_": "The catalogue header declared nplurals=2 while Nextcloud core declares nplurals=3 for hr (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2). With only two forms the paucal counts (2, 3, 4, 22 …) rendered the form meant for 5+. Header corrected to match core and this array given its real third form.", + "_%n declared in total_::_%n declared in total_": "The catalogue header declared nplurals=2 while Nextcloud core declares nplurals=3 for hr (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2). With only two forms the paucal counts (2, 3, 4, 22 …) rendered the form meant for 5+. Header corrected to match core and this array given its real third form.", + "_%n is out of date_::_%n are out of date_": "The catalogue header declared nplurals=2 while Nextcloud core declares nplurals=3 for hr (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2). With only two forms the paucal counts (2, 3, 4, 22 …) rendered the form meant for 5+. Header corrected to match core and this array given its real third form.", + "_%n register is missing_::_%n registers are missing_": "The catalogue header declared nplurals=2 while Nextcloud core declares nplurals=3 for hr (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2). With only two forms the paucal counts (2, 3, 4, 22 …) rendered the form meant for 5+. Header corrected to match core and this array given its real third form.", + "_Delete {count} object_::_Delete {count} objects_": "The catalogue header declared nplurals=2 while Nextcloud core declares nplurals=3 for hr (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2). With only two forms the paucal counts (2, 3, 4, 22 …) rendered the form meant for 5+. Header corrected to match core and this array given its real third form.", + "_file_::_files_": "The catalogue header declared nplurals=2 while Nextcloud core declares nplurals=3 for hr (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2). With only two forms the paucal counts (2, 3, 4, 22 …) rendered the form meant for 5+. Header corrected to match core and this array given its real third form.", + "_log_::_logs_": "The catalogue header declared nplurals=2 while Nextcloud core declares nplurals=3 for hr (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2). With only two forms the paucal counts (2, 3, 4, 22 …) rendered the form meant for 5+. Header corrected to match core and this array given its real third form.", + "_Object successfully deleted_::_Objects successfully deleted_": "The catalogue header declared nplurals=2 while Nextcloud core declares nplurals=3 for hr (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2). With only two forms the paucal counts (2, 3, 4, 22 …) rendered the form meant for 5+. Header corrected to match core and this array given its real third form.", + "_object_::_objects_": "The catalogue header declared nplurals=2 while Nextcloud core declares nplurals=3 for hr (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2). With only two forms the paucal counts (2, 3, 4, 22 …) rendered the form meant for 5+. Header corrected to match core and this array given its real third form.", + "_Purge {count} object from database_::_Purge {count} objects from database_": "The catalogue header declared nplurals=2 while Nextcloud core declares nplurals=3 for hr (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2). With only two forms the paucal counts (2, 3, 4, 22 …) rendered the form meant for 5+. Header corrected to match core and this array given its real third form.", + "_register_::_registers_": "The catalogue header declared nplurals=2 while Nextcloud core declares nplurals=3 for hr (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2). With only two forms the paucal counts (2, 3, 4, 22 …) rendered the form meant for 5+. Header corrected to match core and this array given its real third form.", + "_Restore {count} object_::_Restore {count} objects_": "The catalogue header declared nplurals=2 while Nextcloud core declares nplurals=3 for hr (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2). With only two forms the paucal counts (2, 3, 4, 22 …) rendered the form meant for 5+. Header corrected to match core and this array given its real third form.", + "_schema_::_schemas_": "The catalogue header declared nplurals=2 while Nextcloud core declares nplurals=3 for hr (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2). With only two forms the paucal counts (2, 3, 4, 22 …) rendered the form meant for 5+. Header corrected to match core and this array given its real third form.", + "_Successfully restored {count} object_::_Successfully restored {count} objects_": "The catalogue header declared nplurals=2 while Nextcloud core declares nplurals=3 for hr (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2). With only two forms the paucal counts (2, 3, 4, 22 …) rendered the form meant for 5+. Header corrected to match core and this array given its real third form.", + "_{count} email_::_{count} emails_": "The catalogue header declared nplurals=2 while Nextcloud core declares nplurals=3 for hr (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2). With only two forms the paucal counts (2, 3, 4, 22 …) rendered the form meant for 5+. Header corrected to match core and this array given its real third form.", + "_{count} schema_::_{count} schemas_": "The catalogue header declared nplurals=2 while Nextcloud core declares nplurals=3 for hr (n%10==1 -> 0, n%10 in 2..4 -> 1, else 2). With only two forms the paucal counts (2, 3, 4, 22 …) rendered the form meant for 5+. Header corrected to match core and this array given its real third form." + } +} diff --git a/scripts/l10n/locales/is.json b/scripts/l10n/locales/is.json new file mode 100644 index 0000000000..c620e25219 --- /dev/null +++ b/scripts/l10n/locales/is.json @@ -0,0 +1,331 @@ +{ + "register": "informal", + "pluralBoundary": "library", + "registerEvidence": "626 informal (2sg) markers against ZERO formal markers, over core is's 28 catalogues / 3610 values, plus this bundle's own 1054 pre-existing values which add 0 formal. The zero was re-checked by raw grep over the 28 catalogues and not only by the detector scan, because §8.3 says a measurement landing on exactly zero deserves a second look: yður, yðar, yðvar, yðr, þið, ykkur, ykkar, þéra and þérun are all literally absent, nine tokens at zero occurrences. THIS IS A THIRD DISTINCT SITUATION and must not be collapsed with the two locales done immediately before it. ga has no T-V distinction at all (structural absence, no choice exists). mt HAS one that is current — intom as a polite singular, Is-Sinjur with third-person agreement — and simply leaves it unused, so its verdict is an ordinary choice. is is neither: Icelandic HAD a T-V distinction (þérun: nominative þér plus a 2pl verb, possessive yðar) and ABANDONED it during the 20th century. The forms survive in legal, liturgical and deliberately archaic register, so they are not impossible the way Irish 2pl-as-polite is, but in a 2026 admin UI 'Hafið þér aðgang?' reads as parody rather than deference. So `informal` here records a live language state, not a preference and not an absence. THE GATE BUNDLES TWO DIFFERENT DEFECTS under one polarity and they are worth keeping distinct: (1) archaic deference — yður, yðar, or nominative þér with a 2pl verb; (2) plain WRONG NUMBER — þið, ykkur, ykkar, which are the ordinary modern 2nd person plural, perfectly correct Icelandic for several addressees and simply wrong in a single-user UI. The second is the likelier slip by far, because a translator importing a Continental politeness plural from de/fr/nl reaches for the plural that is actually current; reaching for yðar would take a knowledge of Icelandic philology. THE þér BIGRAM is the interesting part of detectors/is.js: þér is at once the 2sg DATIVE ('þér er ekki heimilt' — 'you are not permitted') and the archaic polite NOMINATIVE, and all 54 core occurrences are the dative, verified at their call sites. So bare þér counts as informal, and the polite reading is recovered without giving up the dative by matching the BIGRAM þér + finite 2pl verb, in both orders since a question inverts it. Neither token is decidable alone; the pair is. Icelandic 'please' (vinsamlegast) is an adverb and inflects for nothing, so unlike Maltese jekk jogħġbok it carries no address marker — §8.1 says to check for the politeness formula rather than assume, and here there is no free signal.", + "buttons": "INFINITIVE — register-neutral, joining cs/lt/lv/sk/rm. Measured from core is by resolving ~35 bare action keys: 29 of the 35 distinct values are infinitives (Vista, Eyða, Breyta, Afrita, Færa, Staðfesta, Virkja, Endurstilla, Endurheimta, Skoða, Leita, Loka, Búa til, Bæta við, Hætta við, Uppfæra, Endurnefna, Deila, Velja, Endurnýja, Flytja inn, Reyna aftur, Gera óvirkt, Fjarlægja, Senda inn, Halda áfram, Sækja), ZERO verbal nouns, and exactly ONE enclitic imperative — Choose = Veldu, against Select = Velja, so it is an outlier rather than a pattern. The remaining 5 are legitimately not verbs at all (Settings = Stillingar, Back = Til baka, Download = Niðurhal, plus two untranslated English). This bundle's own 21 pre-existing action keys agree unanimously and contain no imperative. Consequence for §6.5: test 1 comes out NO here, which is why the enclitic imperative CAN be counted as an informal marker without flagging every button — the opposite of ca/et/hr/sl/sr/ga/mt.", + "pluralNote": "nplurals=2, header plural=(n%10!=1 || n%100==11) — MODULAR, which is the shape to check, and it disagrees with the library. THIS IS A BOUNDARY DISAGREEMENT, NOT AN ORDERING ONE (§6.7), so pluralBoundary is set and pluralOrder deliberately is NOT: there is nothing to reorder. Form 0 is the singular and form 1 the plural under both readings; what differs is which counts reach form 0. The header is correct CLDR Icelandic — a numeral ending in 1 but not 11 governs the SINGULAR noun, so 1, 21, 31, 41 ... 191 all take '21 hlutur', not '21 hlutir'. But @nextcloud/l10n files is under its coarse `number === 1 ? 0 : 1` group, so form 0 is reachable ONLY at exactly n=1. Measured over counts 0..200: 17 counts disagree, every one of the form n≡1 (mod 10) with n>=21, and at each of those the library renders the PLURAL where Icelandic wants the singular. Note 11 is NOT among them — the header sends 11 to form 1 too, correctly. WHICH COUNTS STAY WRONG: 21, 31, 41, 51, 61, 71, 81, 91, 101, 111, 121, 131, 141, 151, 161, 171, 181, 191 render a plural noun after a numeral that governs the singular. This is accepted rather than worked around, and the reasoning matters because it is the OPPOSITE of the rm decision: form 1 here is correct for 0 and for 2-20, which is the overwhelming majority of counts a user of this app actually sees, so contorting it into a number-neutral shape would trade 17 wrong counts for roughly 180 unidiomatic ones. rm needed the contortion because its collapsed form 0 was wrong at EVERY count but 1 — total, not a 9% tail. Form 0 is still written as the true counted singular so the arrays match the header and stay correct for whatever Transifex regenerates and for any future library fix. mk carries the same modular header and disagrees in the OPPOSITE direction, at only 11 and 111, because the library implements the modular rule there but drops the n%100!=11 guard.", + "siblingParentheticalNote": "KNOWN REMAINING DEFECT in two pre-existing values. The bundle's sibling '(s)' keys use the parenthetical — `configuration(s)` -> `stilling(ar)`, `register(s)` -> `skrá(r)` — which is well formed where the plural is the stem plus -r. It is NOT well formed for `skema`: the plural is `skemu`, so `skemar` is not Icelandic. `schema(s)` -> `skema(r)` and `schema(s) selected` -> `skema(r) valin` therefore carry a non-word. Left alone as pre-existing values outside that pass's correction classes, and recorded here so the next audit finds them. Separately, plain `{count} X` phrases take the bare PLURAL — `{count} færslur`, `{count} leitir`, `{days} dagar eftir` — the hr/lv/ro 'form correct for the most counts' shape, consistent with the pluralBoundary decision above: right for 0 and 2-20, wrong only where the numeral ends in 1.", + "orthographyNote": "NO DOMAIN-TERM CAPITALISATION AT ALL, and this is a fourth outcome for §8.10. Measured per term over the pre-existing half with the (?<=.)(?...' — 22 of 23 Loading* values follow it exactly (Hleð skemu..., Hleð notendur..., Hleð yfirlit...) and Processing... is 'Vinnur...'. Icelandic uses á é í ó ú ý þ ð æ ö and does NOT use c q w z; the only values containing those are the 17 untranslated English Browser-Web-Push strings, so that check doubles as a leftover-English scan here. No Slavic or other cross-locale contamination is present, unlike sr/sl.", + "lexiconNote": "REGISTER vs FILE both rendered `skrá` in the pre-existing bundle, which is the one significant lexical defect here and the reason for the largest correction set. Icelandic skrá means both 'a file' and 'a register/list', so four pairs of distinct English keys rendered byte-identically — All registers / All Files -> 'Allar skrár', Register / File -> 'Skrá', Registers / Files -> 'Skrár', No registers found / No files found -> 'Engar skrár fundnar' — and one value came out incomprehensible, 'No register objects reference this file' -> 'Engin skrárhlutar vísa í þessa skrá', with both senses in one sentence as the same word. Core Nextcloud is locks skrá = file (Skrá, Skrár, Skráaforrit) and ships no Register/Record/List key at all, so file is the sense that cannot move. THE OWNER DECIDED (asked explicitly per §6.4 step 2, because this is the app's primary noun and the choice propagated through ~76 keys) to adopt `gagnaskrá` for Register, keeping plain skrá for File. That is not a coinage: the bundle already used gagnaskrá for exactly this sense in 'Manage your data registers and their configurations' -> 'Stjórna gagnaskránum þínum', so this is the §3.5 rule of following the file's own vocabulary. Two further collisions were resolved with core's own support: Slug shared `Auðkenni` with ID while both are field labels on the same EditSchemaProperty screen, so Slug becomes `Stuttheiti` (the shape core lt takes with Trumpinys and sl with Oznaka); and Refresh shared `Uppfæra` with Update, so Refresh becomes `Endurnýja`, which is exactly what core is uses for it. ONE COLLISION IS LEFT DELIBERATELY: Configurations and Settings both render `Stillingar`, which is the correct Icelandic word in each case and what core uses for Settings — the §8.5 'unavoidable, record rather than work around' case, like bg rendering both Cancel and Denial as Отказ. Other established terms, followed rather than re-coined: object = hlutur, schema = skema (plural skemu), property = eiginleiki, view = yfirlit, entity = eining, source = uppspretta, chunk = bútur, embedding/vectorization = vektórfelling / vektórfærsla, audit trail = endurskoðunarferlatal, search trail = leitarferlatal, flow = flæði but workflow = vinnuflæði, webhook = vefkrókur, dashboard = mælaborð, database = gagnagrunnur, organisation = skipulagsheild, soft deleted = mjúkt eyddur.", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "CSV": "File-format acronym, unchanged in Icelandic and used as-is in Icelandic technical writing. All 37 bundles carry exactly one distinct value.", + "PDF": "File-format acronym, unchanged in Icelandic. All 37 bundles carry exactly one distinct value.", + "RBAC": "Role-based access control acronym; no Icelandic expansion is in use, and all 37 bundles agree on the English acronym.", + "DSAR": "Data subject access request acronym, used untranslated in Icelandic privacy practice. All 37 bundles agree.", + "UUID:": "Acronym plus a colon. Translatable rather than unwrappable, and measured so per §3.3: two distinct values exist across the bundles, `UUID:` and the French `UUID :` with its space before the colon. Icelandic takes no space, so the value equals the key.", + "Deck": "The Nextcloud Deck app's own name, a product name that is not translated in any of the 37 bundles.", + "Excel (.xlsx)": "Microsoft product name plus a file extension; neither part is translated in Icelandic. All 37 bundles agree.", + "OpenDocument (.ods)": "Format name plus a file extension, both of which are proper names. Untranslated in all 37 bundles, exactly as the sibling Excel (.xlsx) key is.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose." + }, + "auditNote": "THE WHOLE PRE-EXISTING HALF WAS GRAMMATICALLY AUDITED, and that is a required step of a pass rather than an optional one — the first version of this pass scope-limited itself to 'a systematic error with a core-confirmed model, or a collision, and otherwise leave a real translation alone', and the owner rejected that line. 298 of the 1052 pre-existing values were corrected in total. Method: mechanical checks generated candidates (quantifier/adjective agreement against a per-noun gender lexicon, postposed possessive and participle agreement, case governance restricted to the forms that actually discriminate) and then EVERY pre-existing value was read by hand, because the mechanical checks only found 4 of the defects. Recall matters more than precision here: the lexicon-limited nominative-only checker cannot see a wrong compound, a garbled word, a foreign stem or a wrong sense, and those turned out to be the bulk of it. Two lessons for the next locale. (1) The agreement checker's first version produced 30-odd false positives because in the Icelandic strong declension the NEUTER PLURAL nominative is homographous with the FEMININE SINGULAR — `Öll skemu` and `Engin mál` are correct — so every bare feminine-singular form must license a neuter plural too, and the checker must not look across punctuation or at a supine after a modal. (2) Case-governance checking is worthless for feminine and neuter singulars, which are syncretic for nominative/accusative/dative in most declensions: `bæta við skrá` IS the dative. Restrict it to masculine singulars and to plurals, where the forms differ. CORRECTION CLASS CODES used in `corrections` below: AGREEMENT = gender/number disagreement between a quantifier/adjective/participle and its noun. AGREEMENT-OR-FORM = a wrong inflected form more generally (wrong conjugation, wrong agent noun, genitive where a nominative plural belongs). CASE = wrong case after a governing verb or preposition. NUMBER = singular where the English source is plural. COMPOUND = a malformed Icelandic compound, usually a missing or wrong linking morpheme. HYPHEN = an acronym or product name juxtaposed to a noun without the compounding hyphen Icelandic requires. TYPO = a misspelling. TYPO-FERSLA = the specific recurring misspelling `fersla` for `færsla`, which is not an Icelandic word. GARBLED-OR-FOREIGN = a nonsense word, broken syntax, or a stem from another language. SENSE = a real word carrying the wrong meaning. TERM-ORG / TERM-AUDIT / TERM-LOG / TERM-TOKEN / TERM-CONFIDENCE / TERM-FILTER = normalised onto the term the rest of the bundle uses. CONSISTENCY = aligned with an established convention of this bundle. CONSISTENCY-DECLENSION = normalised to the bundle's indeclinable treatment of `skema`.", + "correctionClassesFirstPass": "The first 63 corrections, recorded individually with prose reasons below, were in four classes. (1) REGISTER TERMINOLOGY, 34 values: skrá -> gagnaskrá per the owner's decision above, resolving the Register/File homonymy. (2) THE sía GENDER ERROR, 21 values: the bundle declined sía (filter) as a MASCULINE noun with plural `síar` and masculine plural adjectives — Virkir síar, Ítarlegir síar, Tiltækir síar, Engir virkir síar, Hreinsa alla síar. Icelandic sía is FEMININE, nom/acc plural `síur`. This is confirmed by core rather than by my own grammar: core is renders the key `Filters` as `Síur` and contains the exact phrase `Virkar síur`, with feminine agreement, against this bundle's `Virkir síar`; core's only sí- forms anywhere are síur (3) and síu (3), never síar. Both the noun form and every adjective were corrected. The 5 pre-existing dative-plural `síum` values were already correct and are untouched. (3) THE TWO REMAINING COLLISIONS, 6 values: Slug and Refresh, both settled by core. (4) FOUR INCIDENTAL DEFECTS found while editing the above, each fixed in a value already being touched for another reason: `eyðdi` -> `eyddi` (past of eyða), `eyðingardagsetning` -> accusative `eyðingardagsetningu` after `eftir`, `vefkrókurskráningarfærslur` -> `vefkrókaskráningarfærslur` (a compound must take the genitive plural vefkróka-, not the nominative singular vefkrókur-), and `Skrá ekki fundin` -> `Gagnaskrá fannst ekki` (core's construction, e.g. `Síðan fannst ekki`). SUPERSEDED: this first pass stopped there and explicitly declined a full grammatical audit, on the reasoning that §3.8 protects a real translation unless it is systematically wrong. The owner rejected that, and rightly — `Sérsniðin API endapunktur` (named here as an example of what was being left) was one of 235 further defects the audit then found. See `auditNote` above. §3.8 guards against changes of taste, not against fixing grammar.", + "corrections": { + "Active Filters": "sía is feminine: masculine `Virkir síar` -> `Virkar síur`, the exact form core is uses.", + "Active filters:": "sía gender/plural error, as Active Filters.", + "Advanced Filters": "sía gender/plural error: `Ítarlegir síar` -> `Ítarlegar síur`.", + "Available Filters": "sía gender/plural error: `Tiltækir síar` -> `Tiltækar síur`.", + "Clear all filters": "sía gender/plural error: `Hreinsa alla síar` -> `Hreinsa allar síur`.", + "Content Filters": "sía plural error inside a compound: `Efnissíar` -> `Efnissíur`.", + "Current Filters": "sía gender/plural error: `Núverandi síar` -> `Núverandi síur`.", + "Hide Filters": "sía plural error: `Fela síar` -> `Fela síur`.", + "Metadata Filters": "sía plural error inside a compound: `Lýsigagnasíar` -> `Lýsigagnasíur`.", + "No active filters": "sía gender/plural error: `Engir virkir síar` -> `Engar virkar síur`.", + "Property Filters": "sía plural error inside a compound: `Eiginleikasíar` -> `Eiginleikasíur`.", + "Reset Filters": "sía plural error: `Endurstilla síar` -> `Endurstilla síur`.", + "Show Filters": "sía plural error: `Sýna síar` -> `Sýna síur`.", + "Filter data loaded automatically. Use the filters below to refine your search.": "sía plural error in the definite form: `síarnar` -> `síurnar`.", + "No filters are currently active. This will delete ALL audit trail entries!": "sía gender/plural error: `Engir síar eru virkir` -> `Engar síur eru virkar`.", + "No properties match your filters.": "sía plural error in the definite form: `síarnar þínar` -> `síurnar þínar`.", + "There are no audit trail entries matching your current filters.": "sía plural error: `núverandi síar` -> `núverandi síur`.", + "There are no deleted items matching your current filters.": "sía plural error: `núverandi síar` -> `núverandi síur`.", + "There are no search trail entries matching your current filters.": "sía plural error: `núverandi síar` -> `núverandi síur`.", + "There are no webhook log entries matching your filters.": "sía plural error (`síarnar` -> `síurnar`), and in the same value the compound `vefkrókurskráningarfærslur` -> `vefkrókaskráningarfærslur`, since an Icelandic compound takes the genitive plural vefkróka- and not the nominative singular vefkrókur-.", + "Configure parameters for object vectorization. This process will generate vector embeddings for all objects matching your view filters.": "sía plural error inside a compound definite form: `yfirlitssíarnar` -> `yfirlitssíurnar`.", + "Add schema titles, descriptions, and register information to provide richer context for search": "Register terminology: also fixes `skráningarupplýsingum` (registration information) which was the wrong sense, -> `gagnaskrárupplýsingum`.", + "All registers": "Register terminology: `Allar skrár` was byte-identical to this bundle's value for `All Files`. -> `Allar gagnaskrár`.", + "Back to Registers": "Register terminology: skrár -> gagnaskrár.", + "Choose a register": "Register terminology: skrá -> gagnaskrá.", + "Edit Register": "Register terminology: skrá -> gagnaskrá.", + "Failed to load register data": "Register terminology: skrár- -> gagnaskrár-.", + "Include schema and register metadata": "Register terminology, and the original `Taka með skema og skráarlýsigögn` also misparsed the English as [schema] and [register metadata] rather than [schema and register] metadata. -> `Taka með lýsigögn skema og gagnaskrár`.", + "Loading registers...": "Register terminology: skrár -> gagnaskrár.", + "Manage and restore soft deleted items from your registers": "Register terminology: skránum -> gagnaskránum.", + "No facetable fields available. Select a register and schema to see available filters.": "Register terminology and the sía plural error in one value: skrá -> gagnaskrá, `tiltækar síar` -> `tiltækar síur`.", + "No register data available": "Register terminology: skrárgögn -> gagnaskrárgögn.", + "No register objects reference this file": "THE WORST CASE OF THE HOMONYMY: `Engin skrárhlutar vísa í þessa skrá` used skrá for both register and file inside one sentence, making it unreadable. Also fixes the agreement — hlutur is masculine, so `Engin` -> `Engir` and `skrárhlutar` -> `gagnaskrárhlutir`.", + "No registers found": "Register terminology: `Engar skrár fundnar` was byte-identical to this bundle's value for `No files found`. -> `Engar gagnaskrár fundnar`.", + "No registers found for this application.": "Register terminology: skrár -> gagnaskrár.", + "Objects by Register": "Register terminology: skrá -> gagnaskrá.", + "Path where the register OAS file will be saved in the repository": "Register terminology, and the original `Slóð þar sem skrár OAS skrá verður vistuð` had skrá twice with no genitive marking. -> `Slóð þar sem OAS skrá gagnaskrárinnar verður vistuð í geymslunni`.", + "Please select which register and schema to use for the new object": "Register terminology: skrá -> gagnaskrá.", + "Register": "Register terminology: `Skrá` was byte-identical to this bundle's value for `File`. -> `Gagnaskrá`.", + "Register ID": "Register terminology, and `Skrárauðkenni` becomes the clearer genitive phrase `Auðkenni gagnaskrár`.", + "Register not found": "Register terminology, and `Skrá ekki fundin` was not idiomatic — core is uses the `fannst ekki` construction (`Síðan fannst ekki`, `Dashboard not found` -> `... fannst ekki`). -> `Gagnaskrá fannst ekki`.", + "Register Statistics": "Register terminology, and `skráar` is not a valid form — the genitive singular of skrá is skrár. -> `Tölfræði gagnaskrár`.", + "Register Totals": "Register terminology, and the same invalid genitive `skráar`. -> `Heildarfjöldi gagnaskrár`.", + "register(s)": "Register terminology: skrá(r) -> gagnaskrá(r). The (s) parenthetical shape is the pre-existing house style for these sibling keys and is kept.", + "register(s) selected": "Register terminology only. The participle `valin` is left exactly as it was: a parenthetical cannot agree for both numbers, so changing it would not make it more correct (§3.8).", + "Register/Schema Usage": "Register terminology, and the invalid genitive `skráar`. -> `Notkun gagnaskrár/skema`.", + "Registers": "Register terminology: `Skrár` was byte-identical to this bundle's value for `Files`. -> `Gagnaskrár`.", + "Select Register and Schema": "Register terminology: skrá -> gagnaskrá.", + "Select registers and schemas to save a view": "Register terminology: skrár -> gagnaskrár.", + "This source has no associated registers.": "Register terminology: skrár -> gagnaskrár.", + "Unknown Register": "Register terminology: skrá -> gagnaskrá.", + "Update register OAS: ...": "Register terminology, and `Uppfæra skrár OAS` had no genitive marking. -> `Uppfæra OAS gagnaskrár: ...`.", + "Use filters to narrow down audit trail entries by register, schema, action type, user, date range, or object ID.": "Register terminology and the sía plural error in one value.", + "Use filters to narrow down deleted items by register, schema, deletion date, or user who deleted them.": "Register terminology and the sía plural error, plus two more defects in the same value: `eyðingardagsetning` -> accusative `eyðingardagsetningu` (governed by eftir), and the typo `eyðdi` -> `eyddi` (past tense of eyða).", + "Use filters to narrow down search trail entries by register, schema, success status, user, date range, search terms, or performance metrics.": "Register terminology and the sía plural error in one value.", + "Loading organisations...": "Two defects in one value, and it is the SAME key that was the outlier in the ga pass. It read 'Hleð inn skipulagsheildum…' where all 22 sibling Loading* values are 'Hleð ...': it alone used the 'Hleð inn' + dative construction, and it alone used the '…' glyph where its English source has three dots (the only glyph mismatch in 45 values). Normalised to 'Hleð skipulagsheildir...' on both counts.", + "Slug": "Rendered `Auðkenni`, identical to this bundle's value for ID, and the two are separate field labels on the same EditSchemaProperty screen, so the user saw one screen with two fields of the same name. Changed to `Stuttheiti`. §3.3 already records that Slug is translatable rather than a cognate (core lt renders it Trumpinys, sl Oznaka).", + "Refresh": "Rendered `Uppfæra`, identical to this bundle's value for Update; both are buttons and appear together on the dashboard and workflow screens. Changed to `Endurnýja`, which is what core is itself uses for Refresh.", + "Refresh dashboard": "Followed Refresh -> Endurnýja for consistency with the corrected button label.", + "Refresh Workflows": "Followed Refresh -> Endurnýja for consistency with the corrected button label.", + "Refresh Data": "Followed Refresh -> Endurnýja for consistency with the corrected button label.", + "Refresh database info": "Followed Refresh -> Endurnýja for consistency with the corrected button label.", + "Add Endpoint": "`Bæta við endapunkt` used the accusative where `bæta við` governs the DATIVE. -> `Bæta við endapunkti`, which is also what the sibling openconnector is bundle has for the same key.", + "(no title)": "AGREEMENT", + "All Categories": "AGREEMENT", + "All Webhooks": "AGREEMENT", + "No relations found": "AGREEMENT", + "Please wait while we fetch your deleted items.": "AGREEMENT", + "Audit trail data copied to clipboard": "AGREEMENT", + "Back to Webhooks": "CASE", + "Custom API endpoint if using a different region": "HYPHEN", + "Custom HTTP headers (one per line, format: Header-Name: value)": "HYPHEN", + "New users without specific organisation membership will be automatically added to this organisation": "TERM-ORG", + "Objects will be serialized as JSON text before vectorization": "HYPHEN", + "Exclusive Maximum": "SENSE", + "Exclusive Minimum": "SENSE", + "Base URL (Optional)": "GARBLED-OR-FOREIGN", + "Auto-retry failed vectorizations": "TYPO", + "This will permanently delete ALL embeddings (vectors) from the database. You will need to re-vectorize all objects and files. This action cannot be undone.\n\nAre you sure you want to continue?": "TYPO", + "Database information refreshed": "COMPOUND", + "Failed to refresh database information": "COMPOUND", + "Extraction Status": "COMPOUND", + "Failed to load extraction data": "COMPOUND", + "No extraction data available for this file": "COMPOUND", + "Chunks to Vectorize:": "GARBLED-OR-FOREIGN", + "Avg Execution Time": "SENSE", + "Anonymized": "SENSE", + "Data sources": "SENSE", + "Data type does not match observed values": "SENSE", + "Detected At": "CONSISTENCY", + "Extracted At": "CONSISTENCY", + "Extracted at": "CONSISTENCY", + "Failed": "GARBLED-OR-FOREIGN", + "Exponential": "SENSE", + "Export completed successfully": "CASE", + "Realtime push not available — the notify_push app is not installed": "SENSE", + "Create Operations": "SENSE", + "Enable auto-creation": "SENSE", + "Vectorize on object creation": "SENSE", + "Compliance": "SENSE", + "Confidence Level": "TERM-CONFIDENCE", + "Confidence Score": "TERM-CONFIDENCE", + "All Confidence Levels": "TERM-CONFIDENCE", + "Generate recommendations and confidence scores": "TERM-CONFIDENCE", + "Export": "SENSE", + "Import": "SENSE", + "Organisation": "TERM-ORG", + "Organization": "TERM-ORG", + "Organisations": "TERM-ORG", + "Active Organisations": "TERM-ORG", + "No Organisation": "TERM-ORG", + "Default Organisation": "TERM-ORG", + "Select default organisation": "TERM-ORG", + "Auto-create Default Organisation": "TERM-ORG", + "Automatically create a default organisation if none exists when the app is initialized": "TERM-ORG", + "Organisation ID": "TERM-ORG", + "Organization ID (Optional)": "TERM-ORG", + "Organisation Members": "TERM-ORG", + "Organisation settings saved successfully": "TERM-ORG", + "Organisation Statistics": "TERM-ORG", + "Manage Organisation Roles": "TERM-ORG", + "Total Organisations": "TERM-ORG", + "Avg Members/Org": "TERM-ORG", + "Failed to load organisations": "TERM-ORG", + "Select which Nextcloud groups are available for this organisation. Users in these groups will have access to organisation resources.": "TERM-ORG", + "Audit entries": "TERM-AUDIT", + "Could not read the seal coverage of the audit trail.": "TERM-AUDIT", + "Every audit entry carries a hash. That says the sweeper is keeping up — it does not by itself prove the stored hashes still agree with their rows. Verify the chain to establish that.": "TERM-AUDIT", + "Every audit entry is chained to the one before it with a SHA-256 hash, so altering or deleting history breaks the links either side of it. This is where you can see whether that chain is whole.": "TERM-AUDIT", + "Recomputes every hash and compares it with the one stored. This reads the whole audit trail, so it is run on request rather than each time this page opens.": "TERM-AUDIT", + "Write an audit-trail entry for every step": "TERM-AUDIT", + "Do you want to permanently delete all filtered audit trail entries? This action cannot be undone.": "TYPO-FERSLA", + "Do you want to permanently delete this audit trail entry? This action cannot be undone.": "TYPO-FERSLA", + "No audit trail entries found": "TYPO-FERSLA", + "No search trail entries found": "TYPO-FERSLA", + "Selected search trails deleted successfully": "CASE", + "This audit trail entry does not contain any change information.": "TYPO-FERSLA", + "Audit Trails": "NUMBER", + "Search Trails": "NUMBER", + "Cleanup Old Trails": "NUMBER", + "Clear Filtered Audit Trails": "NUMBER", + "Error loading audit trails": "NUMBER", + "Error loading search trails": "NUMBER", + "Filter Audit Trails": "NUMBER", + "Filter Search Trails": "NUMBER", + "Loading search trails...": "NUMBER", + "View and analyze system audit trails with advanced filtering capabilities": "NUMBER", + "Are you sure you want to cleanup old search trails? This will delete entries older than 30 days.": "NUMBER", + "Delete Audit Trail": "CASE", + "Audit trail successfully deleted": "CASE", + "Filter and analyze search trail entries": "COMPOUND", + "Filter and manage audit trail entries": "COMPOUND", + "Failed to save GitHub token": "TERM-TOKEN", + "Failed to save GitLab token": "TERM-TOKEN", + "GitHub token saved successfully": "TERM-TOKEN", + "GitLab token saved successfully": "TERM-TOKEN", + "API Key": "HYPHEN", + "Your Fireworks AI API key. Get one at": "HYPHEN", + "Your OpenAI API key. Get one at": "HYPHEN", + "Download API Spec": "HYPHEN", + "Failed to download API specification": "HYPHEN", + "View API Docs": "HYPHEN", + "Number of chunks to vectorize in one API call (1-100).": "HYPHEN", + "Number of chunks to vectorize in one API call. Higher = faster but more memory. Recommended: 10-50.": "HYPHEN", + "Number of objects to vectorize in one API call. Higher = faster but more memory. Recommended: 10-50.": "HYPHEN", + "Control which object views should be vectorized to reduce API costs. Only vectorize views that benefit from semantic search.": "HYPHEN", + "Failed to load LLM configuration": "HYPHEN", + "LLM actions menu": "HYPHEN", + "LLM Configuration": "HYPHEN", + "LLM configuration saved successfully": "HYPHEN", + "LLM features disabled": "HYPHEN", + "LLM features enabled": "HYPHEN", + "LLM settings updated successfully": "HYPHEN", + "Failed to load Nextcloud groups": "HYPHEN", + "Select a Nextcloud group to add": "HYPHEN", + "Open Nextcloud App Store": "HYPHEN", + "Install the notify_push app from the Nextcloud App Store to enable real-time updates.": "HYPHEN", + "Filters": "TERM-FILTER", + "Real-time push notification status via notify_push": "GARBLED-OR-FOREIGN", + "Realtime push active": "SENSE", + "Select a repository you have write access to": "GARBLED-OR-FOREIGN", + "From Date": "CASE", + "From date": "CASE", + "To Date": "CASE", + "To date": "CASE", + "Remove filter": "CASE", + "Retry Failed Extractions": "AGREEMENT", + "Retrying...": "AGREEMENT-OR-FORM", + "Risk Level": "CASE", + "Risk level": "CASE", + "Search property names...": "CASE", + "Search Term": "NUMBER", + "Search Views": "CASE", + "Search views...": "CASE", + "Search Webhooks": "CASE", + "Number Constraints": "AGREEMENT-OR-FORM", + "String Constraints": "AGREEMENT-OR-FORM", + "Webhook deleted": "CASE", + "User removed successfully": "AGREEMENT-OR-FORM", + "Using Pre-Generated Chunks": "AGREEMENT-OR-FORM", + "The URL where webhook events will be sent": "COMPOUND", + "No purge date set": "AGREEMENT", + "No request body available": "AGREEMENT", + "No response body available": "AGREEMENT", + "Optional webhook secret for signature verification": "COMPOUND", + "Secret key for HMAC signature generation (optional)": "COMPOUND", + "How to handle retries for failed webhook deliveries": "GARBLED-OR-FOREIGN", + "Maximum number of retry attempts for failed deliveries": "GARBLED-OR-FOREIGN", + "Filter webhook triggers by payload properties (one per line, format: key: value)": "COMPOUND", + "LLM must be enabled with an embedding provider configured": "GARBLED-OR-FOREIGN", + "Required status is inconsistent": "AGREEMENT-OR-FORM", + "Identify properties not in the schema": "CONSISTENCY-DECLENSION", + "Request timeout in seconds": "SENSE", + "Timeout (seconds)": "SENSE", + "High Confidence": "TERM-CONFIDENCE", + "Low Confidence": "TERM-CONFIDENCE", + "Medium Confidence": "TERM-CONFIDENCE", + "Max execution time (ms)": "COMPOUND", + "Min execution time (ms)": "COMPOUND", + "Max ms": "COMPOUND", + "Min ms": "COMPOUND", + "Max result count": "COMPOUND", + "Min result count": "COMPOUND", + "Max results": "COMPOUND", + "Min results": "COMPOUND", + "Max Retries": "COMPOUND", + "Maximum Nesting Depth": "COMPOUND", + "Max Files (0 = all)": "COMPOUND", + "Max Objects (0 = all)": "COMPOUND", + "Maximum value constraint is missing": "GARBLED-OR-FOREIGN", + "Minimum value constraint is missing": "GARBLED-OR-FOREIGN", + "Execution Time": "COMPOUND", + "Execution Time Range": "COMPOUND", + "Total Size": "COMPOUND", + "Total Storage": "COMPOUND", + "Totals": "COMPOUND", + "Total Audit Trails": "NUMBER", + "Update Operations": "COMPOUND", + "View Error": "COMPOUND", + "Pattern Issue": "GARBLED-OR-FOREIGN", + "Manage your system configurations and settings": "GARBLED-OR-FOREIGN", + "No activity data available": "COMPOUND", + "No configuration data": "COMPOUND", + "Configuration Data": "COMPOUND", + "No schema data available": "AGREEMENT", + "Processing Limits": "CONSISTENCY", + "Top Deleters": "AGREEMENT-OR-FORM", + "Re-vectorize on object update": "CONSISTENCY", + "Updated At": "CONSISTENCY", + "URL where Ollama is running": "CONSISTENCY", + "User Agent Statistics": "SENSE", + "Logs": "TERM-LOG", + "No logs found": "TERM-LOG", + "No logs are available for this configuration.": "TERM-LOG", + "No logs are available for this source.": "TERM-LOG", + "No log entries found": "TERM-LOG", + "Loading log details...": "TERM-LOG", + "View Logs": "TERM-LOG", + "View search analytics and manage search logs": "TERM-LOG", + "View webhook delivery logs and filter by webhook": "TERM-LOG", + "Webhook Logs": "TERM-LOG", + "Webhook Log Details": "TERM-LOG", + "Warmup Names Cache": "SENSE", + "Names cache warmup completed": "SENSE", + "Starting names cache warmup...": "SENSE", + "Private": "SENSE", + "Public": "SENSE", + "Generate vectors immediately when new objects are created": "CASE", + "Vectors on pgvector fast path": "TYPO", + "HTTP Method": "HYPHEN", + "HTTP method used to send webhook requests": "HYPHEN", + "Select HTTP method": "HYPHEN", + "Select an AI Agent": "HYPHEN", + "You need an AI agent to start a conversation.": "HYPHEN", + "✨ AI Features": "HYPHEN", + "Use AI agents for processing and analysis": "HYPHEN", + "Failed to save GitLab URL": "HYPHEN", + "GitLab URL saved successfully": "HYPHEN", + "Failed to save n8n configuration": "HYPHEN", + "n8n configuration saved successfully": "HYPHEN", + "n8n connection test successful": "HYPHEN", + "n8n integration disabled": "HYPHEN", + "n8n integration enabled": "HYPHEN", + "n8n project initialized successfully": "HYPHEN", + "notify_push configuration guide": "HYPHEN", + "Ollama URL": "HYPHEN", + "How deep to traverse nested object properties (1-20). Higher values capture more detail but increase vector size.": "AGREEMENT-OR-FORM", + "Objects already stored under this schema may no longer match it. Save anyway?": "AGREEMENT-OR-FORM", + "Processes objects in chunks with simulated parallelism.": "GARBLED-OR-FOREIGN", + "View and analyze search trail logs with advanced filtering and analytics capabilities": "GARBLED-OR-FOREIGN", + "Wrap webhook payload in CloudEvents format for better interoperability": "CASE" + } +} diff --git a/scripts/l10n/locales/lb.json b/scripts/l10n/locales/lb.json new file mode 100644 index 0000000000..640ffe7f73 --- /dev/null +++ b/scripts/l10n/locales/lb.json @@ -0,0 +1,172 @@ +{ + "register": "formal", + "registerEvidence": "FORMAL, 200 polite markers against 9 informal over 3321 translated values. Core is NOT a usable source for this locale and this is the dangerous shape rather than the obvious one: Nextcloud ships exactly ONE lb catalogue in the scanned roots (server/lib/l10n/lb.json, 72 values), so coreCatalogues('lb') does NOT throw the way it does for rm and mt — §5 step 2 appears to run and would report a verdict computed from zero markers. Measured: that catalogue contains 0 formal and 0 informal markers. So this is the §6.4 fallback, widened to the sibling apps' FRONTEND .js bundles as the mt pass did: openregister 1012 values (42 formal / 0 informal), opencatalogi 767 (62/0), openconnector 487 (26/2), launchpad 1055 (70/7). openregister's own bundle carries ZERO informal markers. All 4 informal values live in two OTHER apps and are real register slips there, not here: 3 in launchpad ('Du hues d'Limitt vun {limit} Dashboards erreecht' and two siblings) and 1 in openconnector ('Du hues nach keng vermëttelt Zougangsdaten'). Luxembourgish builds its V-form from the 2pl on the German Sie model — Dir / Iech / Ären — and it is live, current, ordinary usage: NOT archaic (contrast is, where yðar was abandoned in the 20th century) and NOT merely available-but-unused (contrast mt, where intom exists and is declined). A fifth distinct situation behind the same 'formal' label.", + "registerDetectorNote": "detectors/lb.js is the FIRST detector in the set whose fold() does not lowercase, and that is the whole design rather than an optimisation. Case is the only thing separating the two polarities here: lowercase `dir` is the informal 2sg DATIVE while capitalised `Dir` is the polite 2pl NOMINATIVE, and both are attested in this corpus (1 and 82 times respectively). A case-folding detector merges all 83 into one bucket. This is the da/nb De/Dem/Deres situation (§8.2) with a sharper edge, because there the collision is with a third-person pronoun rather than with the familiar form of the same paradigm. The residual hole is recorded in UNDETECTABLE: a value that OPENS with the informal dative takes a sentence-initial capital and is then indistinguishable. All 11 sentence-initial `Dir` in the corpus are polite, each followed by a 2pl verb, so the cost is currently theoretical.", + "registerTraps": "Two exclusions, both measured rather than reasoned. (1) THE MODALS SYNCRETISE 1sg/2sg/3sg, so bare `muss` and `weess` carry no address information whatever — 'du muss' and 'hie muss' are spelled identically. All 15 occurrences of bare `muss` in the corpus are 3sg ('De Slug muss…', 'D'Tabell muss…', 'LLM muss…', 'publishAt muss…'); not one is 2sg. Both are excluded while the regularly inflected 2sg of the SAME verbs (kanns, wëlls, sollst) is kept, which makes this a PARTIALLY-detectable paradigm in the bg/is sense — but split by LEXICAL class (the modals) rather than by conjugation class, which is a third way for that split to fall. (2) `-t` is the 2pl ending AND the 3sg present ending, so `kënnt` ('you can' / 'he comes', 3sg of kommen) and `braucht` ('you need' / 'he needs') are both excluded from the formal list; the 2pl forms kept are only those whose 3sg is spelled differently — hutt/huet, sidd/ass, musst/muss, gitt/gëtt, maacht/mécht. The 2sg IMPERATIVE is excluded wholesale, and note this is NOT forced by the label convention the way it is in ca/et/hr/sl/sr/ga/mt: §6.5 test 1 comes out NO here because labels are infinitives. It fails test 2 alone — the bare stem is also an ordinary noun for the productive verbs (`Späicher` = storage/loft, `Filter`, `Test`). Cheap to give up: every informal slip actually shipped in this app family is a 2sg INDICATIVE, never an imperative.", + "politenessFormulaNote": "Checked for the free signal mt's `jekk jogħġbok` provided, and it is NOT there. Luxembourgish `wann ech glift` / `w.e.g.` ('please') is literally 'if I please' and inflects for the SPEAKER, not the addressee, so it is register-neutral despite 21 occurrences. Same negative as is `vinsamlegast` (an adverb) and ga `le do thoil`. Three of the four locales checked so far come out empty — keep checking, since the payoff when it lands is large, but do not expect it.", + "buttons": "INFINITIVE, register-neutral — 64 infinitives against 0 imperatives (2sg or 2pl) and 0 verbal nouns, resolved over 59 bare action keys across all four app bundles plus core's single lb catalogue. Späicheren, Läschen, Ofbriechen, Beaarbechten, Erstellen, Aktualiséieren, Ewechhuelen, Kopéieren, Eroflueden, Eroplueden, Verëffentlechen, Deelen, Validéieren. Luxembourgish distinguishes the four candidate forms cleanly — infinitive -en, 2pl imperative -t, 2sg imperative bare stem, verbal noun -ung — so this is a real measurement and not an artefact of syncretism. The remainder are legitimately not verbs: adverbs (Zréck, Weider, Virdrun) and nouns (Test, Usiicht, Import, Export, Filter), the same shape as cs's Zpět/Storno. Joins cs lt lv sk rm is in the infinitive row (§7.3). Infinitive buttons must NOT be 'corrected' to imperatives.", + "pluralNote": "nplurals=2, plural=(n != 1). NO disagreement of any kind: runtime-check confirms the header and @nextcloud/l10n's getPlural agree on which index every count selects, and both declared forms are reachable. This is the plain `n != 1` shape §7.1 predicts to be safe, and it is — unlike is and mk, whose two-form headers are MODULAR and therefore disagree with the library's coarse `number === 1` grouping. The one pre-existing array is correct and instructive: '_%n entry has no hash yet_' -> ['%n Antrag huet nach kee Hash', '%n Anträg hunn nach kee Hash'] agrees in BOTH the noun (Antrag/Anträg, umlaut plural) and the verb (huet/hunn), so an array here cannot be built by swapping the noun alone.", + "orthographyNote": "A FIFTH §8.10 outcome, and the first where the answer is that there is no choice to make: Luxembourgish capitalises ALL nouns as a rule of orthography, exactly as German does, so domain-term capitalisation is GRAMMATICALLY FORCED rather than a house convention. Measured anyway and it is unanimous — Objet 49:0, Datei 27:0, Usiicht 24:0, Schema 25:1, Webhook 23:0, Register 20:1, Eegeschaft 15:0, Benotzer 12:0, Flux 11:0, Entitéit 9:0, Andréi 14:0. Per §8.10 the CONDITIONED measurement was run as well even though the unconditioned one already looked one-sided, and it changes nothing: under title-cased English keys the terms capitalise 39:1, under prose keys 172:2, so this is not ga's mirror-the-source convention wearing a disguise. The four apparent exceptions are all false positives, which is worth recording so nobody 'fixes' them: `{register}` and `{schema}` are PLACEHOLDERS, not words, and the 14 lowercase `filteren` are the VERB (the noun Filter is capitalised 25:0). So the outcomes now seen are: a list of capitalised terms (sr, rm, mt), mirror the source (ga), flat lowercase (is), and forced-by-orthography (lb). Flat uppercase remains unobserved.", + "typographyNote": "Heavy compound hyphenation is the dominant convention: 234 values carry an internal Letter-hyphen-Capital compound against 3 in the English source — Audit-Trail-Andréi, Sich-Trail-Detailer, Webhook-Liwwerungsprotokoller, Metadonnéeën-Filteren, Objet-Usiichten. Luxembourgish compounds like German but hyphenates where a loanword or proper noun is a member, which is most of this app's vocabulary. The elided article `d'` takes a STRAIGHT apostrophe (51 values, 0 typographic ’) — d'Objeten, d'Datei, d'Astellungen. Ellipsis follows the English source per key rather than a house rule (38 '...' against 1 '…', mirroring en's 77 and 2); note the separate Loading... / Loading… duplicate-key defect in §10. No en dash; 6 em dashes, all mirroring the source. No double quotes at all where en has 9.", + "eifelerNote": "THE EIFELER REGEL (n-deletion) is the single biggest grammatical hazard in writing this locale and no gate can see it. A word-final -n/-nn is DELETED before a consonant other than n, d, t, z, h, and KEPT before those five and before any vowel. It is genuinely in force in this bundle — Keng Usiichte fonnt, Eegeschafte filteren, Statistike filteren, Mëll geläscht Artikele filteren — so it must be applied to every value written. Measured against the bundle's OWN practice per lemma rather than asserted from the grammar (the §8.10 method applied to morphology), which is what makes it actionable: 16 lemmas keep the -n before a delete-consonant and 5 do it BOTH ways. Two exemption classes must be encoded or the check is pure noise: (a) stem-final -n is not inflectional, so the -ioun/-ion/-oun nouns keep it always — Aktioun kann, Konfiguratioun gespäichert, Applikatioun läschen and Restriktioun feelt are all CORRECT, and they were 25 of the first 95 raw hits; (b) non-integrated loans (Token, JSON, Login, Session) and the short function words (wann, dann, schonn, kann, sinn, hunn, ginn). The rule applies to the nominalised INFINITIVE exactly as it does to plural nouns, and getting that wrong cost this pass a whole round. MEASUREMENT TRAP, recorded because it is the reusable part: the first check compared each lemma ONLY AGAINST ITSELF, so `Lueden` (which only ever appears with the -n) and `Deele` (which only ever appears without it) each looked internally consistent, and the cross-lemma picture never surfaced. That artefact was then read as evidence of a grammatical exception for infinitives, and 26 values were left uncorrected on the strength of a '16:0 with no counter-example' count that was really one copy-pasted phrase. Measuring the WORD CLASS across all lemmas instead shows the family does both — 118 kept against 108 deleted — with directly parallel pairs: `Lueden vum` and `Späicheren feelgeschloen` keep it, while `Deele vun`, `Erofsetze vun`, `Migréiere vum`, `bäisetze wëllt`, `ausféiere kënnt` and `zesummeféiere wann` delete it. So a per-lemma consistency check is necessary but NOT sufficient: a lemma that occurs in only one environment carries no information, and a pile of such lemmas looks like a convention. Aggregate by word class before concluding anything. TRUE RESIDUE: 2 values, both pre-existing, both a bare `sinn`/`ginn` infinitive governed by a preceding modal ('muss aktivéiert sinn mat', 'kënne erëmgewonne ginn wann'), where reducing to `si`/`gi` collides with the pronouns and the family supplies no directly parallel example either way. §3.8 governs those two. Everything else was corrected: 75 violations in the pre-existing half, 44 more in the half written during this pass, 48 in the follow-up round that this note exists to explain.", + "lexiconNote": "Files -> Datei/Dateien, a DELIBERATE divergence from core, recorded because an unrecorded 'left alone' is indistinguishable from 'never looked' (§6.9). core-diff flags it: core lb renders Files as `Fichieren`, the French-derived form, in its single catalogue. The app family is unanimous the other way — 111 uses of Datei* across openregister (48), opencatalogi (36), launchpad (23) and openconnector (4), against 0 of Fichier* anywhere. §3.5 settles it decisively and no owner ask was needed: this is not the is `skrá` case, where the app's primary noun collided with itself and produced byte-identical renderings of distinct keys. The other core-diff disagreement goes the same way: Email -> `E-Mail` (bundle) against core's `Email`, and the hyphenated form is correct Luxembourgish orthography.", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "All": "Luxembourgish 'all' is spelled exactly like the English quantifier and is the form this bundle already uses in All Aktiounen / All Kategorien / All Dateien. 30 distinct values across the 37 locales, so translatable and merely identical here.", + "All Webhooks": "Both members are unchanged in Luxembourgish: 'all' is the native quantifier and 'Webhook' is the borrowed term this app family uses throughout. 34 distinct values across the locales.", + "Avatar": "Standard in Luxembourgish as in its neighbours. 14 distinct values across the locales, so translatable in principle — but §8.4 warns core et ships a two-word gloss unfit for a label, which is what a forced translation would produce here.", + "Code": "Luxembourgish 'Code' is the ordinary word, borrowed from French. 19 distinct values across the locales. Distinct from Status-Code, which the bundle compounds.", + "CSV": "A standardised format acronym, unchanged in Luxembourgish. This is the one entry whose §3.3 measurement returns a SINGLE distinct value across all 37 locales, which is normally the signal to UNWRAP rather than record — but §3.2 names CSV as the canonical example of a genuine cognate, and it renders as a user-facing format choice rather than a placeholder or product name, so it stays wrapped.", + "Deck": "The proper name of the Nextcloud Deck app, used as a section label. §3.3 returns a SINGLE distinct value across all 37 locales, i.e. nobody translates it — the §8.5 'product names keep the English' rule rather than a translation decision.", + "Driver": "Luxembourgish IT register borrows 'Driver'; German Treiber has no established Luxembourgish form and 'Dreiwer' would be a coinage nobody writes. Recorded with the tension visible: §3.3 finds 21 distinct values, and the cs pass classified its own identical 'Driver' as FILLER because core cs renders it ovladače databází. This is therefore the weakest entry in this list — challenge it if a native speaker disagrees.", + "DSAR": "An established acronym (Data Subject Access Request), kept as-is in Luxembourgish GDPR practice. §3.3 returns a single distinct value across all 37 locales.", + "Excel (.xlsx)": "A product name plus its file extension, offered as an export-format choice alongside CSV. Single distinct value across all 37 locales.", + "Status": "Identical in Luxembourgish. 18 distinct values across the locales, and the bundle compounds the same stem as Extraktiounsstatus, Erfollegsstatus, Qualitéitsstatus and pluralises it as 'All Statusen'.", + "Total": "Identical in Luxembourgish. 26 distinct values across the locales; the bundle already ships Total Objeten, Total Memberen, Total Gréisst and the plural Totaler.", + "Total:": "As 'Total', with the colon the source supplies. 27 distinct values across the locales.", + "URL": "A standardised acronym, unchanged in Luxembourgish. Only 2 distinct values across all 37 locales. The bundle compounds it as Datebank-URL, Basis-URL and GitLab-URL. Note the sibling key 'Url' is NOT a cognate — it is normalised to 'URL', the same casing defect the cs pass corrected.", + "UUID:": "One of the two strings §3.3 names as a trap for looking untranslatable while not being so — French inserts a space before the colon, so the key genuinely varies (2 distinct values across the locales). Luxembourgish follows German spacing and takes no space, hence identical.", + "Webhook": "The app family's borrowed term, unchanged in Luxembourgish. 12 distinct values across the locales, and this bundle already carries 23 occurrences in compounds such as Webhook-Liwwerung and Webhook-Protokoller.", + "Webhooks": "Plural of the borrowed 'Webhook', which Luxembourgish forms without a suffix here. 21 distinct values across the locales. Already shipped in 'Zréck zu de Webhooks' and 'Webhooks fir evenementgesteiert Integratioune verwalten'.", + "Workflows": "The app family's borrowed term. 28 of the 37 locales translate it, but the bundle already ships 'Verfügbar Workflows', 'Workflows aktualiséieren' and n8n-Workflow-Integratioun — §3.5, the file wins on lexicon.", + "{property} - {other}": "The second string §3.3 names as a trap: it looks like pure punctuation but genuinely varies, because ru substitutes an em dash and hr an en dash (3 distinct values across the locales). Luxembourgish keeps the plain hyphen the source has — the §8.10 typography measurement of this bundle found NO en dash anywhere and only source-mirroring em dashes.", + "OpenDocument (.ods)": "A product name plus its file extension, offered as an export-format choice. Single distinct value across all 37 locales.", + "Parallel:": "Luxembourgish 'parallel' is spelled identically; the colon is supplied by the source. 25 distinct values across the locales. The bundle already ships 'Parallelle Modus'.", + "PDF": "A standardised format acronym, unchanged in Luxembourgish. Single distinct value across all 37 locales, the same shape as CSV.", + "Port": "Luxembourgish keeps 'Port' for a network port, as German and French do. 13 distinct values across the locales.", + "RBAC": "An established acronym (role-based access control), kept as-is. Single distinct value across all 37 locales.", + "Register": "The app's primary noun, and genuinely identical in Luxembourgish. 24 of the 37 locales translate it, so this is a §3.5 lexicon decision resting on the bundle's own unanimous practice — Register appears capitalised mid-sentence 20:0 and compounds as Registerdonnéeën, Registerstatistiken, Dateregistere and Registeriwwerbléck. NOT the is `skrá` situation: nothing else in this bundle renders as Register, so no two distinct English keys collide.", + "Register / Schema": "Both members are cognates in Luxembourgish (see the separate Register and Schema entries); the slash is the source's. 27 distinct values across the locales.", + "Register:": "As 'Register', with the colon the source supplies. 21 distinct values across the locales.", + "Repository": "Luxembourgish IT register keeps 'Repository'. 28 distinct values across the locales, but the bundle already ships 'Pad am Repository' and 'E Repository auswielen', so this is the established term here.", + "Schema": "Identical in Luxembourgish, as in German. 19 of the 37 locales translate it; the bundle's own practice is unanimous — Schema capitalised mid-sentence 25:1 (the single exception being the {schema} PLACEHOLDER, not a word), compounding as Schemadonnéeë, Schemastatistiken, Schematitelen and Dateschemaen. Plural Schemaen.", + "Schema:": "As 'Schema', with the colon the source supplies. 17 distinct values across the locales.", + "Score": "Luxembourgish borrows 'Score' (masculine). 25 distinct values across the locales, and the bundle already compounds it as Vertrauensscore, Qualitéitsscore and 'Duerchschnëttleche Score'.", + "Host *": "Luxembourgish IT register keeps 'Host' for a server host, as German and French do. 20 distinct values across the locales, most of them transliterations rather than real translations. The trailing ' *' is the required-field marker the source supplies.", + "ID": "The standard abbreviation, unchanged in Luxembourgish. 9 distinct values across the locales, essentially ID / Id / a Cyrillic transliteration. Note the sibling key 'Id' is NOT a cognate here — it is normalised to 'ID', which is the casing defect the cs pass also found.", + "ID:": "As 'ID', with the colon the source supplies. 10 distinct values across the locales.", + "ID: {id}": "As 'ID', with the colon and placeholder the source supplies. 6 distinct values across the locales.", + "Linear": "Luxembourgish 'linear' is spelled identically; capitalised here because it is a standalone option label and Luxembourgish capitalises the nominalised form. The bundle already ships the adverb in 'D'Verzögerunge klamme linear'. 27 distinct values across the locales.", + "Maximum": "A Latin loan, identical in Luxembourgish. 15 distinct values across the locales. Corroborated by the bundle's own 'Exklusivt Maximum' and 'Maximalwäert', which inflect the same stem.", + "Method": "Luxembourgish 'Method' (feminine) is spelled identically to the English. Well evidenced inside this bundle rather than assumed: it already ships HTTP-Method, Sichmethod and 'Aktiv Method: {active}'. 24 distinct values across the locales.", + "Minimum": "A Latin loan, identical in Luxembourgish. 15 distinct values across the locales. Corroborated by the bundle's own 'Exklusivt Minimum' and 'Minimalwäert'.", + "Multitenancy": "The app family's term for the feature, and this bundle already compounds it as Multitenancy-Astellungen. 28 of the 37 locales translate it, so this is a §3.5 lexicon decision rather than an untranslatable string.", + "Format": "Luxembourgish 'Format' is the ordinary word, identical to the English. 16 distinct values across the locales, so translatable in principle and merely identical here. The bundle already compounds it as Exportformat and Uweisungsformat.", + "Dashboard": "Kept deliberately against the majority: 31 of the 37 locales translate this, but the whole app family's Luxembourgish vocabulary is built on the borrowed 'Dashboard' (launchpad's lb bundle uses it throughout, and openregister already ships Dashboard aktualiséieren). §3.5 — the file wins on lexicon.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", + "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens." + }, + "correctionCodes": "EIFELER-DEL = a word-final -n that must be DELETED before the following consonant and was not. EIFELER-KEEP = a word-final -n that must be KEPT (before a vowel, or n/d/t/z/h) and was wrongly dropped; the bundle got the rule wrong in BOTH directions, which is why there are two codes. CAPITAL-POLITE = the polite possessive Är-/Ären written lowercase, against the sibling bundles' 50:0. AGREEMENT = wrong gender agreement. TYPO. GERMANISM = a German form where this bundle's own vocabulary is Luxembourgish. TERM-AUDIT = Auditprotokoll for *audit trail*, against 24 uses of Audit-Trail. CONSISTENCY = breaks a pattern the bundle otherwise holds unanimously. GRAMMAR = a malformed clause. 77 corrections over 1011 pre-existing translated values (7.6%), which sits between cs (5.5%) and ca (6.2%) rather than near is (22%) — this is a healthy locale whose single systematic weakness is the Eifeler Regel.", + "corrections": { + "Configure parameters for object vectorization. This process will generate vector embeddings for all objects matching your view filters.": "CAPITAL-POLITE: ären -> Ären", + "There are no audit trail entries matching your current filters.": "CAPITAL-POLITE: ären -> Ären", + "There are no search trail entries matching your current filters.": "CAPITAL-POLITE: ären -> Ären", + "There are no deleted items matching your current filters.": "CAPITAL-POLITE: ären -> Ären", + "There are no webhook log entries matching your filters.": "CAPITAL-POLITE: äre -> Äre", + "Vectors will be stored in your existing object and file collections": "CAPITAL-POLITE: ären -> Ären", + "Manage and restore soft deleted items from your registers": "CAPITAL-POLITE: äre -> Äre", + "No properties match your filters.": "CAPITAL-POLITE äre -> Äre, and EIFELER-KEEP: Eegeschafte -> Eegeschaften before the vowel of entspriechen", + "Load advanced filters with live data from your search index": "CAPITAL-POLITE: ärem -> Ärem", + "Choose which views to include in the vectorization process. Leave empty to process all views based on your configuration.": "CAPITAL-POLITE: ärer -> Ärer", + "No views match your search": "CAPITAL-POLITE ärer -> Ärer, and EIFELER-KEEP: Usiichte -> Usiichten before the vowel of entspriechen", + "Custom HTTP headers (one per line, format: Header-Name: value)": "EIFELER-DEL: een -> ee before pro", + "Filter webhook triggers by payload properties (one per line, format: key: value)": "EIFELER-DEL: een -> ee before pro", + "menu or contact someone with permission to create agents.": "EIFELER-DEL: een -> ee before mat", + "Defaults for every flow on this instance. A flow can override each of these for itself; a flow that overrides none of them follows the values set here, including later changes.": "EIFELER-DEL: relative pronoun deen -> dee before keen", + "Use filters to narrow down deleted items by register, schema, deletion date, or user who deleted them.": "EIFELER-DEL: Filteren -> Filtere before fir, and relative pronoun deen -> dee before se", + "Failed to load register data": "EIFELER-DEL: den -> de before Registerdonnéeë; and in the second round Lueden -> Luede before vun", + "File vectorization started. Check the statistics section for progress.": "EIFELER-DEL: den -> de before Statistik-Beräich", + "Choose which file types to include in the vectorization process. Only files with extracted text and chunks will be processed.": "EIFELER-DEL: Dateien -> Dateie before mat, and finite ginn -> gi before verschafft", + "No filters are currently active. This will delete ALL audit trail entries!": "EIFELER-DEL: finite sinn -> si before keng", + "Objects will be soft-deleted (marked as deleted but kept in database). They can be recovered later if needed.": "EIFELER-DEL: finite ginn -> gi before mëll. The second ginn is a passive INFINITIVE after kënne and correctly keeps its -n", + "Text chunks are generated during file extraction and stored in the database. Vectorization reads these pre-chunked files and converts them to embeddings.": "EIFELER-DEL: finite ginn -> gi before während, and conjunction an -> a before wandelt", + "Counting objects...": "EIFELER-DEL: Objeten -> Objete and finite ginn -> gi before gezielt. Two adjacent violations in one three-word value", + "Configure how database objects are converted into vector embeddings for semantic search. Objects are directly vectorized without needing text extraction.": "EIFELER-DEL: preposition an -> a before Vektor-Embeddings, Objeten -> Objete, finite ginn -> gi before direkt", + "Cannot delete: objects are still attached": "EIFELER-DEL: Objeten -> Objete before sinn", + "Clear all filters": "EIFELER-DEL: Filteren -> Filtere before läschen", + "Configure how objects are converted to text before vectorization. These settings affect search quality and context.": "EIFELER-DEL: Objeten -> Objete before viru", + "Delete Objects": "EIFELER-DEL: Objeten -> Objete before läschen", + "Filter Objects": "EIFELER-DEL: Objeten -> Objete before filteren", + "Validate Objects": "EIFELER-DEL: Objeten -> Objete before validéieren", + "objects processed": "EIFELER-DEL: Objeten -> Objete before verschafft", + "Failed to load Nextcloud groups": "EIFELER-DEL: Gruppen -> Gruppe before feelgeschloen; and in the second round Lueden -> Luede before vun. den correctly keeps its -n before Nextcloud", + "Save changes": "EIFELER-DEL: Ännerungen -> Ännerunge before späicheren, matching the bundle's own Ännerunge kopéieren", + "Calculate Sizes": "EIFELER-DEL: Gréissten -> Gréisste before berechnen", + "Hide Filters": "EIFELER-DEL: Filteren -> Filtere before verstoppen", + "Hide in forms": "EIFELER-DEL: Formularen -> Formulare before verstoppen", + "Loading schemas...": "EIFELER-DEL: Schemaen -> Schemae before gi", + "Loading sources...": "EIFELER-DEL: Quellen -> Quelle before gi", + "Manage document templates and themes": "EIFELER-DEL: Themen -> Theme before verwalten", + "Members of selected groups can access this view": "EIFELER-DEL: Memberen -> Membere before vun", + "Generate recommendations and confidence scores": "EIFELER-DEL: Vertrauensscoren -> Vertrauensscore before generéieren", + "Use filters to narrow down audit trail entries by register, schema, action type, user, date range, or object ID.": "EIFELER-DEL: Filteren -> Filtere before fir", + "Use filters to narrow down search trail entries by register, schema, success status, user, date range, search terms, or performance metrics.": "EIFELER-DEL: Filteren -> Filtere before fir", + "Real-time push notifications are active. Connected clients receive instant updates when objects are created, updated, or deleted.": "EIFELER-DEL: Clienten -> Cliente before kréien", + "Failed to load entities": "EIFELER-DEL: Entitéiten -> Entitéite before feelgeschloen; and in the second round Lueden -> Luede before vun", + "Failed to load files": "EIFELER-DEL: Dateien -> Dateie before feelgeschloen; and in the second round Lueden -> Luede before vun", + "Auto-retry failed vectorizations": "EIFELER-DEL: participle Feelgeschloen -> Feelgeschloe before Vektoriséierungen", + "Automatically retry failed vectorization attempts (max 3 retries)": "EIFELER-DEL: participle Feelgeschloen -> Feelgeschloe before Vektoriséierungsversuche", + "Optional webhook secret for signature verification": "AGREEMENT: Geheimnis is neuter, so the attributive adjective takes -t; Optionalen -> Optionalt (which also resolves the EIFELER-DEL before Webhook)", + "Serial Mode (Safer, slower)": "EIFELER-DEL: Seriellen -> Serielle before Modus", + "Maximum number of retry attempts for failed deliveries": "EIFELER-DEL: participle feelgeschloen -> feelgeschloe before Liwwerungen", + "How to handle retries for failed webhook deliveries": "GRAMMAR: 'Wéi mat den Neiversuche ... fonctionéiert gëtt' is not a well-formed clause and fonctionéiert is the FRENCH spelling (GERMANISM's mirror image) where this bundle writes funktionéieren. Rewritten with verfuer gëtt, plus EIFELER-DEL feelgeschloen -> feelgeschloe", + "View and analyze search trail logs with advanced filtering and analytics capabilities": "EIFELER-DEL: erweiderten -> erweiderte before Filter-, matching the sibling key which already has erweiderte Filterfäegkeeten", + "Add schema titles, descriptions, and register information to provide richer context for search": "EIFELER-DEL: räicheren -> räichere before Kontext", + "Delays double with each attempt (2, 4, 8 minutes...)": "EIFELER-DEL: finite verduebelen -> verduebele before sech", + "How deep to traverse nested object properties (1-20). Higher values capture more detail but increase vector size.": "EIFELER-DEL: finite erfaassen -> erfaasse before méi", + "LLM settings updated successfully": "EIFELER-KEEP: Astellunge -> Astellungen before the vowel of erfollegräich", + "No entities have been detected yet": "EIFELER-KEEP: goufe -> goufen before nach (n is a keep-consonant) and Entitéite -> Entitéiten before the vowel of erkannt. The sibling key 'No files have been extracted yet' already writes goufen nach correctly", + "No views found. Create views first before configuring vectorization.": "EIFELER-KEEP: the second Usiichte -> Usiichten before ier. The first correctly deletes before fonnt", + "Select registers and schemas to save a view": "EIFELER-KEEP Schemae -> Schemaen before the vowel of auswielen; and in the second round EIFELER-DEL auswielen -> auswiele before fir", + "Update vectors when object data changes (recommended for accurate search)": "EIFELER-KEEP: Vektore -> Vektoren before the vowel of aktualiséiere", + "No webhooks have been configured yet": "EIFELER-KEEP: goufe -> goufen before nach", + "No request body available": "EIFELER-KEEP: Kee -> Keen before the vowel of Ufro-Kierper. The sibling key 'No response body available' already writes Keen Äntwert-Kierper correctly", + "Include related object references": "EIFELER-KEEP: Verbonne -> Verbonnen before the vowel of Objet-Referenzen", + "Write an audit-trail entry for every step": "EIFELER-KEEP E -> En before the vowel, and TERM-AUDIT: Auditprotokollantrag -> Audit-Trail-Andrag, the form used by 'This audit trail entry does not contain any change information'", + "Your OpenAI API key. Get one at": "EIFELER-KEEP: Äre -> Ären before the vowel of OpenAI. The Fireworks sibling key correctly writes Äre before a consonant", + "Generate vectors immediately when new objects are created": "EIFELER-DEL: Vektoren -> Vektore before wann", + "Estimated Duration:": "AGREEMENT: Dauer is feminine, so Geschätzten -> Geschätzte, matching the sibling Geschätzte Batchen / Geschätzte Käschten", + "Old Value": "AGREEMENT: Wäert is masculine, not neuter, so the neuter Aalt is wrong; Ale Wäert, matching the sibling key New Value -> Neie Wäert", + "Exclusive Maximum": "TYPO: Exklusiivt -> Exklusivt. The doubled i is not Luxembourgish orthography and the bundle writes Aktiv with one i", + "Exclusive Minimum": "TYPO: Exklusiivt -> Exklusivt", + "Most Active Objects": "TYPO: Aktiivst -> Aktivst, same doubled i, against the bundle's own Aktiv", + "This change breaks the existing data model:": "GERMANISM: bestehend is German; this bundle's word for 'existing' is existéierend (Existéierend Verbesserungen, existéierend an zukënfteg Usiichten, ären existéierenden Objet-Sammlunge)", + "Could not read the seal coverage of the audit trail.": "TERM-AUDIT: Auditprotokoll -> Audit-Trail", + "Recomputes every hash and compares it with the one stored. This reads the whole audit trail, so it is run on request rather than each time this page opens.": "TERM-AUDIT: Auditprotokoll -> Audit-Trail", + "An entry with no hash is one the chain cannot vouch for. A background job sweeps these every five minutes and seals the oldest first, so a backlog after heavy write activity is normal and drains on its own. A backlog that never shrinks is not — it means sealing is failing on both the write path and the sweep.": "EIFELER-DEL (second round): buergen -> buerge before kann, Schreiwen -> Schreiwe before wéi, Duerchgoen -> Duerchgoe before feelschléit", + "Automatically create a default organisation if none exists when the app is initialized": "EIFELER-DEL (second round): erstellen -> erstelle before wann", + "Error loading application": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Error loading audit trails": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Error loading entity": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Error loading search trails": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Failed to download API specification": "EIFELER-DEL (second round): Eroflueden -> Erofluede before vun", + "Failed to load entity": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Failed to load extraction data": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Failed to load LLM configuration": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Failed to load organisations": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Failed to load templates": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Failed to load webhooks": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Failed to load workflows": "EIFELER-DEL (second round): Lueden -> Luede before vun", + "Failed to test webhook": "EIFELER-DEL (second round): Testen -> Teste before vum", + "Select a Nextcloud group to add": "EIFELER-DEL (second round): auswielen -> auswiele before fir", + "This has two possible causes and they are not equally serious: the entry was altered after it was written, or it was sealed before the seal lock existed and chained onto the wrong predecessor. Read the entry before deciding. Repair is a deliberate operator action — it rewrites stored hashes, which is exactly the event this chain exists to make visible — so it is only available as a command:": "EIFELER-DEL (second round): Schreiwen -> Schreiwe before geännert", + "Starting...": "CONSISTENCY: the bare noun Start... against nine sibling progressives that all use 'Gëtt ...' (Gëtt gespäichert, Gëtt getest, Gëtt gelueden, Gëtt analyséiert, Gëtt erstallt, Gëtt geläscht, Gëtt verschafft, Gëtt verëffentlecht, Gëtt nei probéiert). Sole outlier of ten", + "Loading organisations...": "CONSISTENCY: used U+2026 … where the other 38 values in this bundle use three dots, mirroring the English source" + } +} diff --git a/scripts/l10n/locales/lt.json b/scripts/l10n/locales/lt.json new file mode 100644 index 0000000000..fe681850ca --- /dev/null +++ b/scripts/l10n/locales/lt.json @@ -0,0 +1,44 @@ +{ + "register": "formal", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "CSV": "File-format initialism, identical in Lithuanian; every other finished locale keeps it as-is.", + "Deck": "Proper name of the Nextcloud Deck app; product names are not translated.", + "DSAR": "Initialism for Data Subject Access Request. Lithuanian practice has coined no equivalent acronym, and the expanded key 'Data-subject access request' is translated in full beside it.", + "Excel (.xlsx)": "Product name plus a file extension; both are invariant in Lithuanian.", + "ID": "Initialism written identically in Lithuanian; the lowercase variant key 'Id' is normalised TO this form rather than left alone.", + "ID:": "Same initialism with a colon. Lithuanian, unlike French, puts no space before a colon, so the punctuation is genuinely unchanged.", + "ID: {id}": "Same initialism plus the placeholder; nothing in this string differs in Lithuanian.", + "OpenDocument (.ods)": "Product name plus a file extension; both are invariant in Lithuanian.", + "PDF": "File-format initialism, identical in Lithuanian; every other finished locale keeps it as-is.", + "RBAC": "Initialism for role-based access control, used untranslated in Lithuanian IT writing; the surrounding prose keys are translated in full.", + "Schema": "Lithuanian 'schema' is the standard term and its nominative singular is spelled identically; core lt confirms it (4 hits), and the declined forms used elsewhere in this bundle are 'schemos', 'schemą', 'schemų'.", + "Schema:": "Same word plus a colon. Lithuanian, unlike French, puts no space before a colon, so the punctuation is genuinely unchanged.", + "Ollama URL": "Lithuanian keeps the attributive order for these, so a product name followed by URL is already correct: this bundle's own values are 'GitLab URL', 'Bazinis URL' and 'Duomenų bazės URL'. (Croatian differs — it fronts the initialism, so hr translates this key to 'URL Ollama'.)", + "URL": "Initialism written identically in Lithuanian; the lowercase-variant key 'Url' is normalised TO this form, and the compounds are 'Bazinis URL' / 'Duomenų bazės URL'.", + "UUID:": "Initialism with a colon. Lithuanian, unlike French, puts no space before a colon, so the punctuation is genuinely unchanged.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", + "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens.", + "objectType: object\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens and are never translated." + }, + "corrections": { + "Apply redaction": "\"redagavimas\" is the same word this app uses for EDIT (\"Redaguoti\"), so a redaction control read as an edit control; corrected to \"užtemdymas\"", + "Choose a survivorship-enabled register and schema above to list its master entities.": "\"subjektas\" collided with \"duomenų subjektas\" (GDPR data subject); corrected to \"esybė\", the Lithuanian data-modelling term" + } +} diff --git a/scripts/l10n/locales/lv.json b/scripts/l10n/locales/lv.json new file mode 100644 index 0000000000..958ef3397f --- /dev/null +++ b/scripts/l10n/locales/lv.json @@ -0,0 +1,139 @@ +{ + "register": "informal", + "pluralOrder": "library", + "registerEvidence": "Measured, not assumed, and it CONTRADICTS this bundle's own 1053 pre-existing keys — which is why 'corrections' below is unusually long. Nextcloud core lv: 44 informal (tu) markers against 3 formal (jūs) across 890 values in 9 catalogues, and not one jūs-series pronoun anywhere in core. The informal usage is spread over core, lib, settings, oauth2 and files_trashbin rather than one translator's file, and it appears in UI prose ('Kuras datnes vēlies paturēt?', the oauth2 redirect-URL validation message), not only in notification email. The 3 formal hits are two imperatives: 'Skatiet dokumentāciju' (twice) and the tagline 'Turiet savus kolēģus un draugus vienuviet'. This bundle's own pre-existing keys measured the other way (85 formal / 1 informal), so following core meant correcting the 78 pre-existing formal values listed below — exactly what the et pass did, where the pre-existing file measured FORMAL (26/1) against core's INFORMAL and core won.", + "buttons": "Infinitive — register-neutral, the cs/lt pattern. Core lv uses it for every short label: 'Saglabāt', 'Atjaunināt', 'Apstiprināt', 'Turpināt', 'Atcelt', 'Dzēst', 'Aizvērt', 'Drukāt', and this bundle already agreed. Infinitive buttons must NOT be 'corrected' to imperatives, and because the infinitive ends in -āt/-at they must never be counted as 2pl-present formal markers — every one of the 12 distinct -at/-āt words in core lv is an infinitive or an adverb. See detectors/lv.js.", + "pluralNote": "THE ARRAY ORDER FOR lv IS NOT THE ORDER ITS OWN HEADER DESCRIBES. Measured with @nextcloud/l10n over counts 0-32 plus 100-1001: the library partitions Latvian as index 0 <- n==0, index 1 <- n%10==1&&n%100!=11 (1, 21, 31, 101), index 2 <- everything else. The file's plural= header carries the legacy gettext order instead ([one, other, zero]), and `register(app, bundle)` IGNORES it — so an array written to match the header renders the WRONG form for EVERY count. All seven arrays here are therefore [zero, one, other]: genitive plural after 0, singular after numerals ending in 1 except 11, plural otherwise. lv is the only locale in this app where library and header disagree on ORDER (tr, rm and ga disagree only on form COUNT, which is documented separately). Never copy an array from hr/ru/lt/cs/pl: their library order DOES match their header, and their form 2 is a 'many' form where Latvian's index 0 is zero-only.", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "CSV": "File-format initialism, written identically in Latvian; every finished locale keeps it as-is.", + "Deck": "Proper name of the Nextcloud Deck app; product names are not translated.", + "DSAR": "Initialism for Data Subject Access Request. Latvian has coined no equivalent acronym, and the expanded key 'Data-subject access request' is translated in full beside it as 'Datu subjekta piekļuves pieprasījums'.", + "Excel (.xlsx)": "Product name plus a file extension; both are invariant in Latvian.", + "ID": "Initialism written identically in Latvian; the lowercase variant key 'Id' is normalised TO this form rather than left alone.", + "ID:": "Same initialism with a colon. Latvian, unlike French, puts no space before a colon, so the punctuation is genuinely unchanged.", + "ID: {id}": "Same initialism plus the placeholder; nothing in this string differs in Latvian.", + "Ollama URL": "Latvian keeps the attributive order with URL last, so a product name followed by URL is already correct: this bundle's own values are 'GitLab URL', 'Bāzes URL' and 'Datu bāzes URL'. (Croatian differs — it fronts the initialism, so hr translates this key to 'URL Ollama'; Danish and German compound it as 'Ollama-URL'.)", + "PDF": "File-format initialism, identical in Latvian; no locale in this app carries a differing value for it.", + "RBAC": "Initialism for role-based access control, used untranslated in Latvian IT writing; the surrounding prose keys are translated in full and the matrix column headers are the Latvian verbal nouns 'lasīšana' / 'izveide' / 'atjaunināšana' / 'dzēšana' / 'pārvaldība'.", + "OpenDocument (.ods)": "Product name plus a file extension; both are invariant in Latvian, and no locale in this app translates it.", + "URL": "Initialism written identically in Latvian; this bundle's compounds are 'Bāzes URL' and 'Datu bāzes URL', and the lowercase-variant key 'Url' is normalised TO this form.", + "UUID:": "Initialism with a colon. Latvian, unlike French, puts no space before a colon, so the punctuation is genuinely unchanged.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", + "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens.", + "objectType: object\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens and are never translated." + }, + "corrections": { + "Add schema titles, descriptions, and register information to provide richer context for search": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Are you sure you want to cleanup old search trails? This will delete entries older than 30 days.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Are you sure you want to delete the selected search trails? This action cannot be undone.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Are you sure you want to permanently delete": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Choose a register": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Choose a schema": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Choose how vector similarity calculations are performed for semantic search": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Choose which file types to include in the vectorization process. Only files with extracted text and chunks will be processed.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Choose which views to include in the vectorization process. Leave empty to process all views based on your configuration.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Configure how database objects are converted into vector embeddings for semantic search. Objects are directly vectorized without needing text extraction.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Configure how objects are converted to text before vectorization. These settings affect search quality and context.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Configure parameters for file vectorization. This process will generate vector embeddings for all extracted file chunks.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Configure parameters for object vectorization. This process will generate vector embeddings for all objects matching your view filters.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Custom API endpoint if using a different region": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Do you want to permanently delete all filtered audit trail entries? This action cannot be undone.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Do you want to permanently delete this audit trail entry? This action cannot be undone.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Enable or disable n8n workflow integration. Configure connection settings below.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Enter description (optional)...": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Enter object ID": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Enter search term": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Enter view name...": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Enter webhook name": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "File vectorization started. Check the statistics section for progress.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Filter data loaded automatically. Use the filters below to refine your search.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Load advanced filters with live data from your search index": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Manage and view detected entities from files and objects": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "menu or contact someone with permission to create agents.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "No facetable fields available. Select a register and schema to see available filters.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "No properties match your filters.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "No views match your search": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "notify_push is installed but OpenRegister has not yet confirmed a successful push. Trigger an object save to activate, or check your notify_push configuration.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "or pick a range": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Please select which register and schema to use for the new object": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Please try again later.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Please wait while we fetch your configurations.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Please wait while we fetch your deleted items.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select a branch": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select a model or type a custom model name": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select a Nextcloud group to add": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select a repository": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select a repository you have write access to": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select an AI Agent": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select backend": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select chat model": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select default organisation": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select event to listen to...": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select File Types to Vectorize:": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select HTTP method": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select model": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select options...": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select period": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select property to send as payload": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select provider": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select registers and schemas to save a view": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select retry policy": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select the branch to publish to": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select the event this webhook should listen to": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select Views to Vectorize:": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select views to vectorize:": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select which Nextcloud groups are available for this organisation. Users in these groups will have access to organisation resources.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Select which property from the event should be used as the webhook payload data": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "There are no audit trail entries matching your current filters.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "There are no deleted items matching your current filters.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "There are no search trail entries matching your current filters.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "There are no webhook log entries matching your filters.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Use filters to narrow down audit trail entries by register, schema, action type, user, date range, or object ID.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Use filters to narrow down deleted items by register, schema, deletion date, or user who deleted them.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Use filters to narrow down search trail entries by register, schema, success status, user, date range, search terms, or performance metrics.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Vectors will be stored in your existing object and file collections": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "View and analyze search trail logs with advanced filtering and analytics capabilities": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "View and analyze system audit trails with advanced filtering capabilities": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "View entity details and manage relations": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "View search analytics and manage search logs": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "View webhook delivery logs and filter by webhook": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "You must be logged in to favorite views": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "You need an AI agent to start a conversation.": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Your Fireworks AI API key. Get one at": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Your OpenAI API key. Get one at": "Pre-existing formal (jūs) value; corrected to the informal register measured from core lv — see registerEvidence.", + "Custom display title for this facet": "Own-batch fix: this pass first wrote 'šķautne' for facet, splitting the bundle's own lexicon; corrected to the 'fasete' family already used at HEAD ('Iespējot fasetēšanu').", + "Date Faceting:": "Own-batch fix: this pass first wrote 'šķautne' for facet, splitting the bundle's own lexicon; corrected to the 'fasete' family already used at HEAD ('Iespējot fasetēšanu').", + "Facet Description": "Own-batch fix: this pass first wrote 'šķautne' for facet, splitting the bundle's own lexicon; corrected to the 'fasete' family already used at HEAD ('Iespējot fasetēšanu').", + "Facet Order": "Own-batch fix: this pass first wrote 'šķautne' for facet, splitting the bundle's own lexicon; corrected to the 'fasete' family already used at HEAD ('Iespējot fasetēšanu').", + "Facet Title": "Own-batch fix: this pass first wrote 'šķautne' for facet, splitting the bundle's own lexicon; corrected to the 'fasete' family already used at HEAD ('Iespējot fasetēšanu').", + "Facet Type": "Own-batch fix: this pass first wrote 'šķautne' for facet, splitting the bundle's own lexicon; corrected to the 'fasete' family already used at HEAD ('Iespējot fasetēšanu').", + "Facetable": "Own-batch fix: this pass first wrote 'šķautnojams' for facetable, splitting the bundle's own lexicon; corrected to 'Fasetējams', the form already used at HEAD.", + "Faceting Configuration:": "Own-batch fix: this pass first wrote 'šķautne' for facet, splitting the bundle's own lexicon; corrected to the 'fasete' family already used at HEAD ('Iespējot fasetēšanu').", + "When disabled, this facet will only show values from this schema and will include a schema filter when selected.": "Own-batch fix: this pass first wrote 'šķautne' for facet, splitting the bundle's own lexicon; corrected to the 'fasete' family already used at HEAD ('Iespējot fasetēšanu').", + "Use AI agents for processing and analysis": "Own-batch fix: this pass first wrote 'AI aģentus'; the bundle already renders AI agents as 'MI aģents' at HEAD (product names like 'Fireworks AI' keep AI), so corrected to 'MI aģentus'.", + "_%n entry has no hash yet_::_%n entries have no hash yet_": "Pre-existing array ordered to match the file's plural= header ([dat sg, dat pl, gen pl]), which the runtime ignores — so it rendered the dative singular for 0 and the dative plural for 1. Reordered to the library's actual [zero, one, other]; see pluralNote.", + "_Delete {count} object_::_Delete {count} objects_": "Reordered to the library's actual [zero, one, other] partition; written first to match the file's plural= header, which the runtime ignores. See pluralNote.", + "_Object successfully deleted_::_Objects successfully deleted_": "Reordered to the library's actual [zero, one, other] partition; written first to match the file's plural= header, which the runtime ignores. See pluralNote.", + "_Purge {count} object from database_::_Purge {count} objects from database_": "Reordered to the library's actual [zero, one, other] partition; written first to match the file's plural= header, which the runtime ignores. See pluralNote.", + "_Restore {count} object_::_Restore {count} objects_": "Reordered to the library's actual [zero, one, other] partition; written first to match the file's plural= header, which the runtime ignores. See pluralNote.", + "_Successfully restored {count} object_::_Successfully restored {count} objects_": "Reordered to the library's actual [zero, one, other] partition; written first to match the file's plural= header, which the runtime ignores. See pluralNote.", + "_{count} email_::_{count} emails_": "Reordered to the library's actual [zero, one, other] partition; written first to match the file's plural= header, which the runtime ignores. See pluralNote." + } +} diff --git a/scripts/l10n/locales/mk.json b/scripts/l10n/locales/mk.json new file mode 100644 index 0000000000..a0b2669cb6 --- /dev/null +++ b/scripts/l10n/locales/mk.json @@ -0,0 +1,175 @@ +{ + "register": "formal", + "registerEvidence": "FORMAL. §5 step 2 ran as written and core is usable here: 24 mk catalogues in the scanned roots, 3424 values, 565 formal markers against 59 informal — 9.6:1, past the 3:1 threshold. The bundle's own 1053 pre-existing values agree rather than contradicting, which is the ordinary case and not the et/lv one: 31 formal markers (23 ваш*, 5 Ве, 3 сте) against ZERO informal, and a raw unguarded scan for ти / тебе / те / твој* / си over those values returns nothing at all. So core and the file point the same way and there was nothing to overrule. SPLIT THE INFORMAL COUNT BEFORE READING IT (§7.2): the 59 are 25 твој* possessives, 14 ти/те/тебе pronouns and ~20 2sg present verbs, and they concentrate in ONE catalogue — settings 34, then files_sharing 10, dav 7, files 7, core 4 — reading as app-store blurbs and older notification text (`Сподели датотеки со други лица надвор од твојата организација`, `Листа на твоите датотеки и папки`). Split that way it is a legacy minority in two or three catalogues, not a second register. Macedonian's T-V distinction is live and ordinary — вие/Ваш on the Slavic model, in current use — so this is the plain measured choice cs is the baseline for, with no structural story of the ga / mt / is kind.", + "registerDetectorNote": "detectors/mk.js. fold() LOWERCASES, but it CONSUMES ONE TOKEN FIRST, which is new in this set — see registerTraps. The 2sg imperative is EXCLUDED and §6.5 test 1 alone forces it: the bare 2sg imperative is Macedonian's short-label convention, so counting it would flag every button in the app. Test 2 comes out the OTHER way and is recorded rather than inferred — Macedonian imperatives are not 3sg homographs the way Bulgarian's и-conjugation is (`избриши` against the 3sg aorist `избриша`, `зачувај` against `зачува`/`зачувува`; the forms stay distinct across the conjugations). So mk gives the paradigm up to convention rather than to ambiguity, which means the recall lost here is real, unlike in a locale where the form was unusable anyway. Two useful NEGATIVES, both measured: bare `ти` is usable, because Macedonian's demonstratives are тој/таа/тоа/тие and there is no demonstrative reading to collide with (all 6 corpus occurrences are 2sg address); and bare `те` is usable, which bg's equivalent is NOT — Bulgarian `те` is the 3pl pronoun *they*, Macedonian's is `тие`, so `те` is only ever the 2sg accusative clitic (6 occurrences, all of them that). Do not port bg's `те` exclusion across.", + "registerTraps": "`ВИ` IS THE MACEDONIAN ACRONYM FOR AI — вештачка интелигенција — and it is a homograph of the formal dative clitic `ви`. This bundle uses it: `ВИ-агент`, `ВИ-функции`, `Изберете ВИ-агент`. A detector that folds case scores the app's AI vocabulary as deferential address. Case is the whole of the discriminator — the acronym is always all-caps, the pronoun is `ви` or sentence-initial `Ви` — so fold() strips the all-caps form BEFORE lowercasing rather than lowercasing it into the pronoun's bucket. That is the lb `dir`/`Dir` situation reached from the other end: lb had to preserve case throughout, mk needs it for exactly one token and can consume it up front, which keeps the word lists readable. The hyphen belongs in the LEFT guard and NOT the right one: Macedonian attaches an acronym to the following noun with a hyphen (`ВИ-агент`, `API-клуч`, `LLM-дејства`, `push-известувања`), so the acronym must still match with a hyphen after it, while an acronym-final `ви` must not be readable as the pronoun. Albanian needed the same question answered for the same reason and Catalan the opposite way (§8.3).", + "registerExclusions": "Four, each measured. (1) `-те` is NOT a formal suffix rule: it is the 2pl ending, present and imperative alike, AND the DEFINITE PLURAL ARTICLE, one of the commonest morphemes in the language — `објектите`, `датотеките`, `филтрите`, `записите`, `промените` are all nouns, so a `-те` rule scores nearly every plural noun phrase in the app as formal prose. Same shape as bg. Closed lists of 2pl presents and 2pl imperatives instead. (2) `-ш` is NOT an informal suffix rule: `ваш` (your-FORMAL) ends in it, so the rule reads the formal possessive as informal and INVERTS THE POLARITY — the hr/sk/sl/bg trap. This bundle additionally carries the nouns `хеш` (hash, 7 values) and `кеш` (cache), so even off the possessive the ending is not verbal. (3) bare `си` is excluded as both the 2sg of `сум` and the reflexive dative clitic — but note it is ALSO absent outright, 0 of 6864 corpus values, so unlike the hr/sk/sl/bg equivalent the exclusion costs nothing at all. (4) `треба` is impersonal 3sg and carries no person: in `Ви треба ВИ-агент` the register is `Ви`, in `треба да се случува` there is no addressee. `молиме` is likewise 1pl — `Ве молиме` is formal because of `Ве`.", + "buttons": "SECOND LOCALE WITH THE sq LENGTH GRADIENT, and the crossover lands in the same place — which is what turns sq's finding from one locale's quirk into a shape worth checking for. Value-initial imperatives by value length, 2sg against 2pl: core mk 128:1 at 1-14 chars, 135:14 at 15-24, 101:21 at 25-39, 31:31 at 40-79, 6:12 at 80+; the sibling frontends 139:3, 143:12, 62:25, 12:49, 1:26; this bundle's own half 41:2, 65:12, 28:14, 20:16, 7:14. Crossover ~40 characters in core and this bundle, ~30 in the siblings. Read it the way sq's was read: the long end is not a label convention at all but ordinary formal prose that happens to open with a verb, so the register governs it and the button rule covers only the short end. Independently, resolving ~70 bare action keys against core gives thirty-odd bare 2sg imperatives and not one infinitive or verbal noun — Зачувај, Избриши, Додај, Откажи, Уреди, Отстрани, Креирај, Ажурирај, Затвори, Копирај, Преименувај, Преземи, Прикачи, Барај, Освежи, Сподели, Избери, Потврди, Примени, Извези, Увези, Овозможи, Оневозможи, Продолжи, Премести, Испрати, Инсталирај, Објави, Одземи, Одбиј, Прескокни, Заврши. Non-verb labels are plain nouns as elsewhere (Назад, Следно, Претходно, Поставки, Најава, Одјава).", + "buttonsLexicalOverride": "sq's Select/Choose override REPLICATES here, and measuring one more prompt family shows it is LEXICALLY BOUNDED rather than 'any prompt'. Select/Choose (`избери`/`избира`) takes 2pl at any length — this bundle 31:1 for 2pl, the siblings 34:24, core 13:27; the app family is decisive even where core is not, so §3.5 settles it, and `Изберете шема` is right at 13 characters. Enter/Type (`внесе`/`впише`/`напише`) goes the same way and harder: this bundle 9:0, the siblings 16:0, core 18:10. But Search (`барај`/`пребарај`) goes the OTHER way and is not close — core 61:1 for 2sg, this bundle 11:0 — so `Барај` and `Пребарај` stay 2sg however prompt-shaped they look. The distinction that predicts it is not length and not prompt-ness: a dropdown you pick from and a field you type into are addressed to the user, a toolbar search button is an action you press. Do not generalise the override to every label that reads like an instruction.", + "pluralBoundary": "library", + "pluralNote": "nplurals=2; plural=(n%10==1 && n%100!=11 ? 0 : 1) — MODULAR, not `n != 1`, which is exactly the shape §6.7 says to check, and it does disagree with the library. A BOUNDARY disagreement, not a permutation: runtime-check names n=11 and n=111, where the library selects form 0 and the header form 1. @nextcloud/l10n implements the modular rule but DROPS the `n%100 != 11` guard, so the residue is only 11 and 111 (and 211, 311 … outside the checked range), and it goes the opposite way from `is`: the library selects the SINGULAR where Macedonian takes the plural. Nothing can be reordered, so `pluralBoundary: \"library\"` and the counts that stay wrong are named here. WHICH COUNTS STAY WRONG: 11 and 111 render `11 запис` where Macedonian wants `11 записи`. Everything else is correct, including the 1/21/31/41 … singulars the header is there for, so form 0 is written as the plain counted singular and form 1 as the true plural — the opposite call from `is`, where form 0 was reachable only at exactly 1. NO COUNTED FORM: Macedonian has an избројана форма in `-а` for masculine non-person nouns, so a reader coming from the bg row will reach for `5 објекта`. Core does not: it writes `%n бајти`, `{count} известувања`, `%n датотеки`, and the app family writes `{count} објекти` and `{count} записи`. The `-а` form survives in core only for a closed set of measure nouns — `%n ден`/`%n дена`, `%n час`/`%n часа` — while `месец` takes the plain plural `месеци`. So the plain plural is what this bundle writes, and bg's productive count-form hazard does NOT transfer to Macedonian. The one pre-existing array is the model and is correct: `%n запис сè уште нема хеш` / `%n записи сè уште немаат хеш` agrees in the noun AND the verb (нема/немаат), so an array here cannot be built by swapping the noun alone.", + "siblingParentheticalNote": "The sibling `(s)` keys use the parenthetical, which Macedonian's plural allows for these nouns: `датотека(и)`, `објект(и)`, `регистар(и)`, `шема(и)`, `лог(ови)`. Note the last is not `-и`: the plural of `лог` is `логови`, so the parenthetical carries the whole ending rather than one letter. No slash form is needed — unlike `is`, no stem changes.", + "capitalisationNote": "FLAT LOWERCASE — the is outcome, but reached in a bundle that BREAKS the rule ~106 times rather than following it, and extended to headings, where is only had to answer for prose. Macedonian does not capitalise common nouns, mid-sentence or in a heading, and the corpus is unanimous. PROSE (English key >=6 words and not Title Case, and the guard must exclude a LATER sentence's first word, not merely the value's — see below): the sibling frontends run 1 capitalised against 273 lowercase and core 2 against ~480, essentially zero. This bundle's own half runs 41 against 146 — and the 41 sit in only four terms, `Датотека` 20:0, `Шема` 9:5, `Регистар` 7:6, `Извор` 3:0, `Својство` 2:5, while every other domain term in the same bundle is unanimously lowercase (`објект` 0:26, `приказ` 0:17, `трага` 0:20, `тек` 0:24, `веб-кукичка` 0:13, `филтер` 0:17, `конфигурација` 0:11, `лог` 0:6, `ентитет` 0:4, `корисник` 0:6). That is NOT the sr case: sr capitalised its six first-class concepts one-sidedly across the whole bundle, so there was a convention to follow. Here the app's single most central noun, `објект`, is 0:26 lowercase, and three of the four capitalised terms are themselves split — so there is no rule, only inconsistency, and the lb lesson applies exactly: `Датотека` at 20:0 looks like a convention per lemma and is one decision copied, because BY WORD CLASS the same bundle does both, 41 to 146. HEADINGS carry the same rule and it is the second half of the finding: under Title-Cased English keys, core mk capitalises 7 non-initial words against 122 and the siblings 40 against 518 — Macedonian sentence-cases a heading — while this bundle runs 65 against 507. So `Add Schema` is `Додај шема`, not `Додај Шема`. One rule for both populations, which is what makes it different from ga/sq (mirror the key's casing) and from sr (a list of capitalised terms).", + "capitalisationMeasurementNote": "THE GUARD `(?<=.)(?` in 20 values (`Се вчитуваат регистри...`, `Се тестира...`, `Се обработува...`); this one alone used a verbal noun, and alone used the single character … where its 19 siblings use three dots.", + "Reading seal coverage …": "PROGRESSIVE. Verbal noun where the bundle`s convention is `Се <3sg reflexive>`. The ellipsis character is kept — this key`s English source carries … while the `Loading` family carries ... , and the bundle mirrors the source per key.", + "Verifying …": "PROGRESSIVE. `Проверка` is the noun `a check`, not a progressive state. Core mk has `Потвордување …` here, also a noun, so corediff flagged the disagreement — but the bundle`s own 20-value convention decides it (§3.5) and neither core`s form nor the bundle`s old one follows it.", + "Log integrity": "CONSISTENCY. `дневник` for *log* against `лог`/`логови` in all 9 other log values in this bundle (`Логови`, `Логови на веб-кукичка`, `Не се пронајдени логови`, `Се вчитуваат детали за лог...`). Both words are valid Macedonian; splitting the app`s own term is what §3.5 settles against.", + "NO ACTION": "CONSISTENCY. `акција` for *action* against `дејство` in all 5 other action values (`Дејство`, `Дејства`, `Сите дејства`, `Мени за LLM-дејства`, `Ова дејство не може да се отповика`). The all-caps styling is kept: it mirrors the English source, which is a status enum rendered in caps." + }, + "correctionCodes": "Class codes used in `corrections`, documented once per §6.9. CAPS: a domain noun capitalised at a non-initial position, in prose or in a heading, where Macedonian sentence-cases — 118 occurrences over 105 values, the dominant class by an order of magnitude and the mk analogue of lb`s Eifeler Regel: one mechanical rule, obligatory, broken repeatedly, invisible to every gate. AGREEMENT: `просечно` (neuter singular) directly modifying a masculine or plural noun, 4 values, all in stat-tile labels. PROGRESSIVE: a verbal noun where the bundle`s own 20-value convention is `Се <3sg reflexive>`, 3 values. CONSISTENCY: a second word for a term the bundle otherwise renders one way, 2 values. GARBLED-OR-FOREIGN: 1 value, Serbo-Croatian." +} diff --git a/scripts/l10n/locales/mt.json b/scripts/l10n/locales/mt.json new file mode 100644 index 0000000000..fbf5b13150 --- /dev/null +++ b/scripts/l10n/locales/mt.json @@ -0,0 +1,79 @@ +{ + "register": "informal", + "registerEvidence": "CORE SHIPS NO mt CATALOGUES AT ALL — coreCatalogues('mt') throws, so this is the §6.4 fallback and the verdict rests on the app's own values rather than on core. Measured 128 second-person SINGULAR markers against ZERO plural and ZERO third-person deferential, over 3422 frontend values (this bundle's 1015 translated values plus the sibling apps' frontend mt.js). The markers split as 15 pronoun (int/inti), 75 possessive (tiegħek), 4 prepositional pronoun, and 35 `jekk jogħġbok` — the politeness formula, whose 2sg object suffix makes it a real address marker and the second commonest one here. Not one occurrence of intom, tagħkom, any -kom prepositional pronoun, `jogħġobkom`, or Sinjur/Sinjura. IMPORTANT — THIS IS NOT THE ga CASE. Maltese genuinely HAS a politeness system: intom serves as a polite singular the way French vous does, and Is-Sinjur/Is-Sinjura plus third-person agreement is the very formal register. Both are available and both are simply unused here, so 'informal' records a real measured choice, exactly as it does for nl/de/et/lv — unlike ga, where no T-V distinction exists in the language at all. The gate therefore refuses 2pl and deferential-third-person address as the deviation. Method note: the first probe run omitted the /i flag and so scored 0 for the pronoun, missing capitalised `Inti ċert li trid…`; the counts above are from the case-insensitive re-run.", + "buttons": "Bare 2sg imperative — the same pattern as ca/et/hr/sl/sr/ga. Measured from the bundle's own 20 unambiguous short labels, since there is no core to measure against: Issejvja (Save), Ħassar (Delete), Ikkanċella (Cancel), Editja (Edit), Neħħi (Remove), Agħlaq (Close), Ikkupja (Copy), Aqsam (Share), Oħloq (Create), Aġġorna (Update/Refresh), Attiva (Enable), Iddiżattiva (Disable), Importa (Import), Esporta (Export), Ippubblika (Publish), Valida (Validate), Ittestja (Test), Irrestawra (Restore), Aqra (Read), Purga (Purge). Longer labels follow the same shape (Ivverifika l-katina, Ittestja l-Konnessjoni, Agħżel Kollha, Uri l-Filtri, Aħbi l-Filtri, Neħħi l-filtri kollha). Because the imperative IS the label convention, the detector must not count it (§6.5 test 1), and test 2 agrees: the Maltese Form II imperative is spelled identically to the 3sg perfect, so Ħassar is both 'delete!' and 'he deleted'.", + "pluralNote": "nplurals=4, plural=(n==1 ? 0 : n==0 || (n%100>1 && n%100<11) ? 1 : (n%100>10 && n%100<20) ? 2 : 3). THE HEADER AND THE LIBRARY AGREE EXACTLY — verified index-by-index over counts 0-130, zero disagreements, and all four forms are reachable. That makes mt the first low-resource locale in this set with no plural surprise at all: no reordering as in lv, no collapsed form count as in rm/ga/tr. Form 0 <- n==1; form 1 <- 0 and n%100 in 2..10; form 2 <- n%100 in 11..19; form 3 <- everything else. THE FOUR FORMS ARE REAL MALTESE MORPHOLOGY, and the shape is Semitic rather than European: the noun is PLURAL only after 2-10, and SINGULAR after 1, after 11-19 (ħdax-il ktieb) and after 20+ (għoxrin ktieb). So forms 0, 2 and 3 normally carry the singular and only form 1 carries the plural — which looks like three duplicated forms and is not. §2.3 flagged this bundle's one pre-existing array as 'suspect, forms 2 and 3 fall back to the singular'; it was verified and it is CORRECT, so it is left untouched: _%n entry has no hash yet_ reads ['%n entrata għadha m'għandhiex hash', '%n entrati għadhom m'għandhomx hash', '%n entrata għadha m'għandhiex hash', '%n entrata għadha m'għandhiex hash']. Note it also carries VERB AGREEMENT across the whole predicate, not just the noun — għadha m'għandhiex (3sg f) against għadhom m'għandhomx (3pl) — so an array here cannot be built by swapping the noun alone.", + "orthographyNote": "DOMAIN-TERM CAPITALISATION IS A PARTIAL, PER-TERM LIST — the sr/rm shape, not ga's mirror-the-source. FOUR term families are capitalised mid-sentence and the rest are lowercase, measured over the whole bundle: Oġġett 56:2, Oġġetti 142:3, Reġistru 55:0, Reġistri 17:0, Proprjetà 21:0, Proprjetajiet 20:0, Fajl 18:0, Fajls 53:0 — against skema 4:17, iskema 6:49, skemi 0:8, veduta 3:26, vedute 5:17, entità 2:7, biċċiet 3:14, utent 4:14. The asymmetry worth noticing is that Reġistru is capitalised while skema is not, even though they are the app's two paired core concepts; carrying another locale's list across would get that backwards. THIS PASS BROKE IT AND THE CHECK CAUGHT IT: measuring the pre-existing half separately from the newly written half showed HEAD capitalising Fajl/Fajls 43:0 while the new values had lowercased it 24:4, so 21 values were normalised (via --allow-replace; no corrections entry, since those keys are absent at HEAD — §6.3). Comparing HEAD against new values per term is the check that found it, and no gate can see this class of defect. Diacritics are NOT folded by the detector. Maltese uses ċ ġ ħ ż plus the digraph għ and the apostrophe in ta' / ma' / tista', and għ is load-bearing inside the 2sg possessive tiegħek itself. JS \\b is ASCII-only and would split on ħ/ġ/ż/ċ, so every guard is (? 'Ma nstabu l-ebda X', 'Failed to X' -> 'Naqas milli X', progressives -> 'Qed ' + verb (Qed jitilgħa..., Qed jiġi ssejvjat...), 'This action cannot be undone.' -> 'Din l-azzjoni ma tistax tiġi annullata.', 'Please X' -> 'Jekk jogħġbok X'. NOTE AN OBSERVED SPLIT LEFT IN PLACE: the bundle renders 'entry/entries' as `entrata/entrati` 19 times and as `annotazzjoni/annotazzjonijiet` 18 times, for the same audit-trail and log rows. It is not random — it splits by SCREEN, with annotazzjoni on the audit-trail / search-trail / log listing screens and entrata on the sealing-and-hash screens. `entrata` is the accurate word (annotazzjoni is literally 'annotation'), but normalising 18 values is a larger intervention than this pass warrants and annotazzjoni may well be idiomatic for a log row in Maltese IT, so it is recorded rather than changed (§6.9: a mild inconsistency is not automatically a defect). New keys added by this pass follow the ADJACENT existing value on their own screen, so no screen was made self-inconsistent: annotazzjonijiet for the three log/search-trail listing keys, entrati for the cache-statistics, oneOf and sealing keys.", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "Avatar": "Kept as the loan, consistent with this bundle's English-IT-loan house style; Xbieha would be a coinage nothing else here uses.", + "CSV": "File-format acronym, unchanged in Maltese as in every other locale here.", + "Endpoints": "Kept as the loan, and the bundle confirms it in the pre-existing Żid Endpoint; the -s plural matches Fajls and Tokens.", + "Excel (.xlsx)": "Product name plus a file extension; both stay unchanged in Maltese, as the sibling OpenDocument (.ods) entry does.", + "Host *": "Network host. Kept as the loan, alongside Path and Port; the asterisk is the source's required-field marker.", + "IBANs": "Acronym plural — IBAN is the same in Maltese, and the -s plural is how this bundle already writes borrowed plurals (Fajls, Tokens, Endpoints).", + "ID: {id}": "The only translatable token is the acronym ID, which is unchanged in Maltese; the rest is a placeholder.", + "Data": "Real Maltese noun. Note it doubles as 'date' in Maltese (the bundle writes Data tal-Purga for Purge Date), but that ambiguity is the language's, not a translation defect.", + "Deck": "Proper name of the Nextcloud Deck app, so it stays as the product name.", + "Driver": "Database driver. Kept as the loan; the Maltese xufier means a vehicle driver and would be wrong here.", + "DSAR": "Acronym for a data-subject access request, kept as the term of art; the spelled-out key is translated (Talba għal aċċess mis-suġġett tad-data).", + "Dashboard": "Kept as the English loan; the sibling launchpad mt bundle uses Dashboard unchanged in 5 places.", + "Email": "Maltese IT keeps the English loan; the formal alternative posta elettronika is a two-word phrase unfit for a compact label.", + "Emails": "Plural of the loan above, kept unchanged for the same reason.", + "Format": "Real Maltese loan, and the bundle already uses it inline — Detected Format: renders Format Skopert:.", + "Headers": "HTTP headers. Kept as the loan, as the sibling mt bundle does; the calque intestaturi is not used anywhere here.", + "ID": "Acronym, unchanged in Maltese.", + "ID:": "Same acronym with a colon.", + "Multitenancy": "Kept as the loan; Maltese has no settled term for multi-tenant isolation and this bundle keeps unsettled technical loans unchanged.", + "OpenDocument (.ods)": "Format proper name plus a file extension, unchanged in Maltese.", + "PDF": "File-format acronym, unchanged in Maltese as in every other locale here.", + "RBAC": "Acronym for role-based access control, kept as the term of art; the spelled-out sense is carried by Matriċi tal-Permessi.", + "Logs": "Kept as the English loan in four independent places (Logs tal-Webhook, Ma nstabu l-ebda logs, Ara l-Logs). The sibling apps' Reġistri is NOT available here: it is already this bundle's plural of Register.", + "Parallel:": "Execution mode. Maltese uses parallel unchanged, and the bundle already writes Mod Parallel.", + "Password": "Kept as the loan, as the sibling mt bundle does in 2 places; the bundle also writes s-settings and t-token the same way.", + "Port": "Network port, unchanged in Maltese. Nine other locales translate it, so this is a cognate and not untranslatable.", + "Repository": "Kept as the loan, and the bundle confirms it inline with Path fir-repository. Siblings use Repożitorju; the file wins on lexicon per §3.5.", + "Slug": "URL slug. Maltese has no settled term and this bundle keeps unsettled technical loans unchanged; 26 of 37 locales do the same.", + "Status": "Real Maltese loan, and the sibling mt bundles use it unchanged in 5 places.", + "Timestamp": "Kept as the loan, as the sibling mt bundle does; the calque marka tal-ħin is not used anywhere here.", + "Total": "Real Maltese noun borrowed from Italian totale. The bundle already uses it in ten compound labels (Total Membri, Total Oġġetti, Total Riżultati).", + "Total:": "Same word with a colon.", + "URL": "Acronym, unchanged in Maltese as in all 37 locales.", + "UUID:": "Acronym plus colon. Translatable in principle — fr writes UUID : — hence a recorded cognate rather than an unwrap candidate (§3.3).", + "Webhook": "Kept as the English loan throughout, as in the pre-existing Logs tal-Webhook and Proprjetà tal-Avveniment għall-Payload.", + "Webhooks": "Plural of the loan above, kept unchanged.", + "Workflows": "Kept as the loan, and the bundle confirms it three times over in the pre-existing Workflows Disponibbli, Aġġorna l-Workflows and l-workflows.", + "{property} - {other}": "Two placeholders joined by a separator; Maltese supplies no word to translate. The ASCII hyphen of the source is kept because this bundle uses no en dash anywhere — unlike ru, which substitutes an em dash here, and hr, an en dash.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", + "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens.", + "objectType: object\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens and are never translated." + }, + "corrections": { + "Audit trail #{id}": "Was `Audit Trail #{id}` — untranslated English, merely re-cased, and the only audit-trail value in the bundle left that way against 24 that use awditjar. Now `Traċċa tal-Awditjar #{id}`, matching Traċċi tal-Awditjar, Dettalji tat-Traċċa tal-Awditjar and the rest.", + "Audit entries": "Was `Entrati tal-verifika`. One of six values whose English key says AUDIT but whose Maltese said verifika (verification) — a different concept in this app, and one that appears on the same screen (Verifika tal-katina, Ivverifika l-katina). 24 values use awditjar for audit against these 6, so the 6 are normalised. Now `Entrati tal-Awditjar`.", + "Could not read the seal coverage of the audit trail.": "Same verifika-for-audit defect; `tat-traċċa tal-verifika` -> `tat-traċċa tal-awditjar`.", + "Every audit entry carries a hash. That says the sweeper is keeping up — it does not by itself prove the stored hashes still agree with their rows. Verify the chain to establish that.": "Same verifika-for-audit defect; `Kull entrata tal-verifika` -> `Kull entrata tal-awditjar`. Note this value also contains a legitimate Ivverifika (verify the chain), which is left alone — the two concepts are distinct and now read as distinct.", + "Every audit entry is chained to the one before it with a SHA-256 hash, so altering or deleting history breaks the links either side of it. This is where you can see whether that chain is whole.": "Same verifika-for-audit defect; `Kull entrata tal-verifika` -> `Kull entrata tal-awditjar`.", + "Recomputes every hash and compares it with the one stored. This reads the whole audit trail, so it is run on request rather than each time this page opens.": "Same verifika-for-audit defect; `t-traċċa tal-verifika kollha` -> `t-traċċa tal-awditjar kollha`.", + "Write an audit-trail entry for every step": "Same verifika-for-audit defect; `entrata tat-traċċa tal-verifika` -> `entrata tat-traċċa tal-awditjar`.", + "Log integrity": "Was `Integrità tar-reġistru`, which reads 'integrity of the REGISTER' — reġistru is this bundle's word for Register, so on a screen listing Reġistri it names the wrong thing. The bundle keeps Logs as a loan in four other places, so this is a 1-against-4 outlier. Now `Integrità tal-Logs`.", + "Loading organisations...": "Was `Qed jitgħabbew l-organizzazzjonijiet…` — the only one of the bundle's 19 `Loading X...` values not built on the verb `jitilgħu`/`titilgħa` (the other 18 are), and it also carried the … glyph where the English source has three ASCII dots. Now `Qed jitilgħu l-organizzazzjonijiet...`. Worth noting that this exact key was the 1-of-N outlier in the ga bundle too, on both the same counts." + } +} diff --git a/scripts/l10n/locales/nl.json b/scripts/l10n/locales/nl.json new file mode 100644 index 0000000000..aedd772716 --- /dev/null +++ b/scripts/l10n/locales/nl.json @@ -0,0 +1,120 @@ +{ + "registerNotMeasured": "This file is a cognate record created for the n() plural conversion, not an nl pass. The register verdict in docs/l10n-workflow.md §7.2 lists nl as informal, but nothing was measured here and there is no detectors/nl.js, so selfcheck must not report a register result for nl. Measuring it belongs to the Tier 1 re-audit.", + "scopeNote": "COGNATE RECORD ONLY — this is not a finished nl pass. It was created because the n() plural conversion needed '_register_::_registers_', whose Dutch value is spelled exactly as the English, and apply.js gate 3 refuses an identical value without a recorded reason. Creating the file opts nl into cognate enforcement (COGNATES_ENFORCED keys on this file existing), so every one of the bundle's identical values had to be justified here at the same time or test:l10n:parity would fail on them. What is recorded below is therefore the identical-value review and nothing else: no register measurement, no detector, no grammatical audit of the ~1050 pre-existing Transifex values. nl remains on the Tier 1 re-audit list. Two entries carry a caveat rather than a clean justification — read them before treating this file as a finished review.", + "cognates": { + "Avatar": "International term, unchanged in Dutch; Nextcloud core nl renders it identically.", + "Code": "Same spelling and meaning in Dutch.", + "Complex": "Same spelling and meaning in Dutch as an adjective.", + "CSV": "File-format acronym, invariant in Dutch.", + "Dashboard": "Established English loan in Dutch UI language; core nl keeps it untranslated.", + "Deadline": "Established English loan; the native alternatives read as legalese in a UI.", + "Deck": "Nextcloud app name. Kept untranslated in every finished locale.", + "DSAR": "Acronym (Data Subject Access Request), kept untranslated in Dutch privacy practice.", + "Excel (.xlsx)": "Product name plus a file extension; nothing translatable.", + "Filters": "Same spelling in Dutch, and the Dutch plural is also 'filters'.", + "Flows": "The app's own feature name, deliberately kept in English in nl, de and da.", + "Host *": "'Host' is the Dutch technical term too; the asterisk is a required-field marker.", + "ID": "Acronym, invariant in Dutch.", + "Id": "The same acronym in the casing the source uses.", + "ID:": "Acronym plus a colon; nothing translatable.", + "ID: {id}": "Acronym, colon and placeholder only.", + "in {register}": "'in' is the same preposition in Dutch; the remainder is a placeholder.", + "Interval": "Same spelling and meaning in Dutch.", + "Label": "Established loan, standard in Dutch UI language.", + "Labels": "Same loan; the Dutch plural is also 'labels'.", + "Links": "Established loan; the Dutch plural is also 'links'.", + "Maximum": "Latin loan, identical in Dutch.", + "Minimum": "Latin loan, identical in Dutch.", + "Multitenancy": "Technical term with no established Dutch equivalent.", + "Object": "Dutch uses 'object' for this concept, spelled exactly as the English.", + "object": "The same Dutch noun, in the lowercase form the source uses.", + "Object #{id}": "Dutch noun plus a number sign and a placeholder.", + "Object A": "Dutch noun plus a bare letter label.", + "Object B": "Dutch noun plus a bare letter label.", + "Object:": "Dutch noun plus a colon.", + "OpenDocument (.ods)": "Format name plus a file extension; nothing translatable.", + "Parallel:": "Same spelling in Dutch, plus a colon.", + "PDF": "File-format acronym, invariant in Dutch.", + "pgvector ANN sidecar": "Component and product name; nothing translatable.", + "RBAC": "Acronym (Role-Based Access Control), kept untranslated.", + "Register / Schema": "Both nouns are spelled the same in Dutch; the rest is a separator.", + "register(s)": "Dutch noun 'register'. CAVEAT: the source's '(s)' happens to read correctly here because the Dutch plural is 'registers', unlike the 'schema(s)' entry below.", + "Register:": "Dutch noun plus a colon.", + "Registers": "The Dutch plural of 'register' is also 'registers'.", + "Schema": "Dutch noun 'schema', spelled exactly as the English.", + "Schema:": "Dutch noun plus a colon.", + "Score": "Established loan, identical in Dutch.", + "Slug": "Technical term kept in English in nl; core lt translates it, core nl does not.", + "Status": "Latin loan, identical in Dutch.", + "Trigger": "Dutch uses the English loan for the event that starts a flow; 'aanleiding' is prose, not the technical term, and nothing else in this bundle coins a native word for it.", + "Type": "Same spelling and meaning in Dutch.", + "URL": "Acronym, invariant in Dutch.", + "UUID:": "Acronym plus a colon.", + "Webhook": "Technical loan, standard in Dutch.", + "Webhooks": "Same loan; the Dutch plural is also 'webhooks'.", + "Workflows": "Established loan; the Dutch plural is also 'workflows'.", + "{count} widget(s)": "'widget' is an established loan; the rest is a placeholder and a parenthetical.", + "{property} - {other}": "Placeholders and a dash only.", + "{title} in {register} / {schema}": "Placeholders plus the Dutch preposition 'in'.", + "_register_::_registers_": "Dutch spells both forms exactly as the English: singular 'register', plural 'registers'. The bundle's own 'Registers' and 'register(s)' keys already carry the same word, so this is the established term rather than a new choice.", + "_object_::_objects_": "Form 0 only: 'object' is the Dutch noun and is spelled as the English singular. The plural form is 'objecten' and differs.", + "_schema_::_schemas_": "Form 0 only: 'schema' is the Dutch noun and is spelled as the English singular. The plural form is \"schema's\" and differs.", + "_{count} schema_::_{count} schemas_": "Form 0 only: '{count} schema' matches the English because 'schema' is the Dutch noun. The plural form uses \"schema's\" and differs.", + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "N/A": "Abbreviation used unchanged in Dutch UI language; core nl keeps it.", + "AVG / Verwerkingsregister": "The source string is already Dutch: AVG is the Dutch name for the GDPR and Verwerkingsregister the statutory register. Translating it would produce the same text.", + "Beschrijving": "The source string is already Dutch (Description).", + "Bewaartermijn": "The source string is already Dutch (retention period); the statutory term.", + "Categorieën betrokkenen (één per regel)": "The source string is already Dutch.", + "Categorieën persoonsgegevens (één per regel)": "The source string is already Dutch.", + "Doelbinding *": "The source string is already Dutch; doelbinding is the statutory AVG term for purpose limitation.", + "Naam": "The source string is already Dutch (Name).", + "Naam *": "The source string is already Dutch (Name), with the required-field marker.", + "Organisatorische maatregelen": "The source string is already Dutch; the statutory AVG term.", + "Rechtsgrond": "The source string is already Dutch; the statutory AVG term for legal basis.", + "Technische maatregelen": "The source string is already Dutch; the statutory AVG term.", + "Verantwoording": "The source string is already Dutch (accountability); the statutory AVG term.", + "App": "Established English loan in Dutch; core nl renders it identically.", + "Account": "Established English loan in Dutch UI language; core nl keeps it.", + "OpenCorporates": "Proper noun, a third-party service name.", + "OpenRegister": "Proper noun, this application's own name.", + "OpenRegister Settings": "Proper noun plus Settings, which core nl also leaves as the app name; the app name is not translated.", + "RAG": "Acronym (retrieval-augmented generation), invariant in Dutch technical usage.", + "ConfigSet": "SOLR product term; Apache SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world) plus index, which is identical in Dutch.", + "Keyword GIN index": "PostgreSQL index-type name; GIN is a product term and index is identical in Dutch.", + "Bucket": "Object-storage term, used unchanged in Dutch technical language.", + "Branch": "Version-control term, used unchanged in Dutch developer language.", + "Driver": "Database-driver term, used unchanged in Dutch technical language.", + "Host": "Networking term, used unchanged in Dutch.", + "Index": "Same spelling and meaning in Dutch.", + "Metadata": "Same spelling and meaning in Dutch.", + "Model": "Same spelling and meaning in Dutch.", + "Register": "Same spelling and meaning in Dutch; the domain term this app is named for.", + "Repository": "Used unchanged in Dutch developer language.", + "Shards": "SOLR/search partitioning term, used unchanged in Dutch.", + "Polls": "The Nextcloud Polls app name; core nl does not translate the app name.", + "chunk": "Retrieval/embedding term, used unchanged in Dutch AI language.", + "chunks": "Retrieval/embedding term, used unchanged in Dutch AI language.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "e.g., lib/Settings/config.json": "A literal example file path shown verbatim; not prose.", + "e.g., lib/Settings/register.json": "A literal example file path shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example shown verbatim; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline, shown verbatim; header names are protocol tokens.", + "objectType: object\\naction: created": "Literal CloudEvents payload example shown verbatim; the field names are protocol tokens.", + "Browser Web Push (VAPID)": "Browser and Web Push are the protocol's own names and VAPID is its acronym (voluntary application server identification); Dutch keeps the protocol name.", + "Golden record": "Master-data-management term, used unchanged in Dutch data-governance language; there is no established Dutch equivalent.", + "Register #{id}": "The noun 'register' is identical in Dutch and the rest is a number sign and a placeholder, so the rendered label is the same.", + "Survivor": "Master-data-management term for the record that survives a merge; used unchanged in Dutch data-governance language.", + "Uptime:": "Established English loan in Dutch technical language, with its label colon.", + "_log_::_logs_": "Both forms are spelled as the English: 'log' is the Dutch noun and its plural is 'logs'. The bundle's own 'Logs' key already carries the same word." + } +} diff --git a/scripts/l10n/locales/rm.json b/scripts/l10n/locales/rm.json new file mode 100644 index 0000000000..737d523927 --- /dev/null +++ b/scripts/l10n/locales/rm.json @@ -0,0 +1,116 @@ +{ + "register": "formal", + "registerEvidence": "FORMAL, 81 markers against 0 informal across the 995 translated values this bundle already carried. UNLIKE EVERY OTHER LOCALE IN THIS SET, THE VERDICT DOES NOT REST ON NEXTCLOUD CORE: core ships ZERO rm catalogues — none in core/l10n, none in lib/l10n, none in any bundled app — so coreCatalogues('rm') throws rather than computing a verdict from nothing, and §5 step 2 cannot be run as written. This is the §6.4 fallback: measured from the bundle's own pre-existing values. It is one-sided enough to settle the question on its own. Romansh has a real T-V distinction, so the polite 2pl Vus/voss IS a register marker (the German Sie model), not neutral address as Russian вы is. Cross-checked for recall against the three sibling apps' rm bundles (2316 further values, not authoritative — same project, same pipeline): 248 formal against 4 informal, and all 4 informal are genuine slips in launchpad and openconnector, none in this bundle. Zero false positives across all 3311 values scanned.", + "buttons": "INFINITIVE — register-neutral, the cs/lt/lv/sk pattern (§7.3). Measured from this bundle's own short action labels, which are unanimous and carry no imperative at all: Memorisar (Save), Stizzar (Delete), Annullar (Cancel), Modifitgar (Edit), Serrar (Close), Allontanar (Remove), Crear (Create), Actualisar (Update/Refresh), Exportar, Importar, Copiar, Validar, Restituir (Restore), Reemprovar (Retry), Leger (Read), Tscherner (Select), Vesair (View), Agiuntar (Add), Publitgar, Purgar. The infinitive also resolves the dual-role Create/Read/Update/Delete keys that forced ro onto verbal nouns, because it reads correctly both as a column header and as a button — so ro's problem does not arise here. Progressive states take the bare infinitive plus an ellipsis: Analyzing... -> Analisar..., Loading... -> Chargiar..., Saving... -> Memorisar....", + "buttonsWhyImperativeIsExcluded": "The bare 2sg imperative is undetectable and must not be counted, for §6.5 test 2: for every -ar verb it is spelled exactly like the 3sg present indicative, and also like the feminine singular past participle. `stizza` is at once 'delete!', 'it deletes' and 'deleted-f.sg', and the 3sg reading is live in this bundle's own prose — 'Quai stizza las endataziuns', 'Ferma mintga flux', 'Elavurescha ils chunks', 'Recalculescha mintga hash' are all real values. rm is the MIRROR IMAGE of sk rather than a family difference: both label buttons with the infinitive, so test 1 comes out the same for both, and they diverge only on test 2 (`ulož` != `uloží` in Slovak, but `stizza` == `stizza` here). Same button convention, opposite detector decision. Unlike bg the split is not by conjugation class — every Romansh -ar and -escha verb collides this way, so the paradigm is excluded wholesale.", + "pluralNote": "nplurals=2 in the header, but @nextcloud/l10n's getPlural returns index 0 for EVERY count in rm — verified over 0,1,2,3,5,11,21,100,101 — so form 1 is unreachable and form 0 renders at every count. THIS IS NOT THE HARMLESS CASE THE RUNBOOK RECORDS FOR tr/rm/ga IN §6.7. It is harmless for tr because Turkish does not pluralise after a numeral, so one form is correct Turkish. Romansh DOES pluralise (endataziun/endataziuns, datoteca/datotecas), so a bare singular in form 0 renders '5 datoteca', which is wrong at every count but 1. Form 0 therefore carries the (s) parenthetical that this bundle already uses for the sibling (s) keys — '{count} object(s)' — which is never grammatically wrong at any count. The one plural key with no numeral takes a number-neutral phrasing instead, because a parenthetical cannot be spread across three agreeing words. Form 1 is still written as the true plural even though it is dead, so that if the library ever gains an rm entry the array becomes correct rather than garbage. pluralOrder is NOT set: there is no ordering disagreement here, only a smaller form count, and runtime-check passes with a NOTE.", + "siblingParentheticalNote": "The bundle's own house style for the sibling '(s)' keys is the parenthetical: configuration(s) -> 'configuraziun(s)', register(s) -> 'Register(s)', schema(s) -> 'Schema(s)', 'register(s) selected' -> 'Register(s) tschernì(s)'. Romansh forms the plural regularly with +s, so it reads naturally. This is also why form 0 of every plural array here carries a parenthetical (see pluralNote) — and why those forms are CAPITALISED, since a lowercase 'schema(s)' would collide with the key of the same name; see corrections.", + "capitalisationNote": "Measured per term, case-sensitively, mid-sentence only (§8.10). This bundle capitalises EXACTLY THREE domain terms like proper nouns and lowercases every other one: Schema 34:1, Register 30:0, Datoteca 43:0. Everything else is lowercase and unambiguously so — object 0:63, tschertga 0:46, colliaziun 0:45, vista 0:42, webhook 0:39, endataziun 0:33, caracteristica 0:24, utilisader 0:17, organisaziun 0:17, chunk 0:15, flux 0:13, entitad 0:12, gruppa 0:11, funtauna 0:6, roll 0:4, token 0:4. So 'Stizzar tut ils objects en quest Schema' and 'Tut las Datotecas', but 'Stizzar la vista'. Within a single value both conventions apply at once: 'naginas relaziuns cun objects u Datotecas'. Note this is a DIFFERENT SET from sr, which capitalises six terms including Object — the practice of capitalising domain terms is per-locale and so is the list, so it has to be measured rather than carried over. The single Schema outlier is corrected below. No gate can see a wrongly-cased value.", + "orthographyNote": "The polite pronoun and possessive are ALWAYS capitalised mid-sentence, on the German Sie/Ihr model: Vus 13:0 and Voss/Vossa/Vossas 21:0, with no lowercase instance anywhere in the bundle. (opencatalogi's rm bundle writes lowercase 'voss' 13 times, so do not normalise toward it when harvesting.) Ellipsis is three dots '...' not U+2026, mirroring whatever the key itself carries; the apostrophe is the straight ASCII ' in the d'/l' elisions, never U+2019; the dash used for parenthetical asides is the em dash —, matching the English source. No space before an ellipsis or a colon — Romansh follows German/Italian spacing here, not French, so 'UUID:' keeps its colon tight (contrast fr 'UUID :').", + "lexiconNote": "Terms established by the pre-existing half of the bundle and kept per §3.5, since there is no core rm to compare against: audit trail -> 'colliaziun d'audit' (25 uses; literally 'audit link', which is a loose rendering of 'trail', but it is the file's own consistent term and splitting the app's vocabulary is worse than a loose word — the three 'tratga da revisiun' outliers are normalised to it below), search trail -> 'colliaziun da tschertga', view -> 'vista', property -> 'caracteristica', source -> 'funtauna', file -> 'Datoteca', object -> 'object', field -> 'chomp', settings -> 'configuraziuns', workflow -> 'process da lavur', flow -> 'flux' (pl. 'fluxs'), log -> 'protocol', clipboard -> 'archivet provisoric', dashboard -> 'panel', owner -> 'possessur', password -> 'pled-clav', count -> 'dumber', size -> 'grondezza', soft delete -> 'stizzar mollamain', lock -> 'serradira'. Technical terms are borrowed from English throughout — webhook, chunk, embedding, payload, token, hash, batch, trigger, backend, timeout, endpoint, Slug, Branch — which is why Slug is recorded as a cognate below rather than coined, even though core lt and sl do translate it. Failure messages are 'Betg reussì da/d' '; success toasts are '
è vegnì/vegnida cun success', with the vegnir passive carrying full gender and number agreement. AI is 'IA' (agent IA, Funcziuns IA), but product names keep the English (Fireworks AI, OpenAI). Three near-synonyms are kept deliberately apart because the app uses all three on adjacent surfaces (§8.5): the pre-existing 'revocar' is UNDO ('Questa acziun na po betg vegnir revocada', 5 pre-existing values), 'Inversar' is reversing a completed merge (ro uses 'Inversare' likewise; 'Annullar' was unavailable because it is already Cancel), and 'Revocar' is revoking an API token, which is what ca, es, fr, it and ro all use. The last two therefore share a stem, which is an unavoidable overlap rather than a slip — a token row action and a delete-confirmation dialog never appear together, and coining a third word against five locales' consensus would have made one of them wrong. The reversible-merge family is consistently 'fusiun inversabla' / 'fanestra d'inversaziun'.", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "Agents": "Romansh 'agents' is spelled identically; the bundle already writes 'crear agents' and 'in agent IA' in prose", + "Avatar": "identical in all seven Romance and Germanic locales checked (ca de es fr it pt ro), and the borrowed form is the only one in use in Romansh", + "Branch": "the git branch selector in PublishRegister/PublishConfiguration. Romansh 'rama' is a tree branch; the version-control sense is borrowed, consistent with this bundle borrowing every other technical term", + "Chunk": "kept English as a technical term; the bundle uses 'chunk' unchanged in 15 prose values ('chunks da text', 'chunks elavurads')", + "chunk": "lowercase inline form of the same borrowed technical term", + "Chunks": "Romansh forms the plural with +s, so the borrowed plural is identical too", + "chunks": "lowercase inline form of the borrowed plural", + "Code": "Romansh 'code' is spelled identically; the bundle already writes 'Code da status'", + "CSV": "file-format acronym, not translated in any language", + "Deck": "the Nextcloud Deck app, a product name", + "Driver": "database driver; Romansh borrows 'driver' for the software sense, as this bundle borrows every other technical term", + "DSAR": "acronym for Data Subject Access Request, used untranslated as a term of art", + "Endpoints": "Romansh borrows 'endpoint' and pluralises with +s; the bundle already writes 'Agiuntar endpoint' and 'Endpoint API'", + "Excel (.xlsx)": "a product name plus a file extension, neither of which is translated", + "Contacts": "Romansh 'contact' pluralises to 'contacts'; the bundle already writes 'Agiuntar contact'", + "Filters": "Romansh 'filter' pluralises to 'filters'; the bundle already writes 'Filters activs' and 'ils filters' throughout", + "Format": "Romansh 'format' is spelled identically; the bundle already writes 'Format identifitgà'", + "Headers": "HTTP headers in EditWebhook; kept English as a protocol term, as the bundle does in 'Headers HTTP persunalisads'", + "Host *": "networking term borrowed unchanged, plus the source's own required-field asterisk", + "IBANs": "the IBAN entity type; an international standard acronym, pluralised with +s as Romansh does anyway", + "ID": "acronym, identical in Romansh; the bundle writes 'ID da l'object', 'ID dal Register'", + "ID:": "the same acronym with a colon, and Romansh keeps the colon tight as German does", + "ID: {id}": "the same acronym and colon followed by a placeholder, so nothing in the string is translatable", + "Links": "the URL entity type in the Mail sidebar — links extracted from a message body. Romansh borrows 'link' for the URL sense, which also keeps it distinct from 'Colliaziuns' (Connections)", + "Linear": "Romansh 'linear' is spelled identically; it is the retry-backoff strategy, and the bundle writes 'creschan linearmain'", + "Local": "Romansh 'local' is spelled identically in the masculine; the bundle writes 'Versiun locala' in the feminine", + "Max ms": "abbreviation plus the SI unit symbol, neither of which is translated", + "Maximum": "Romansh 'maximum' is the noun, spelled identically; the adjective 'maximal' is used where the source has one ('Dumber maximal')", + "Min ms": "abbreviation plus the SI unit symbol, neither of which is translated", + "Minimum": "Romansh 'minimum' is the noun, spelled identically", + "object": "lowercase inline form of the same word, which is this bundle's capitalisation convention for it", + "Object": "Romansh 'object' is spelled identically and is this bundle's own term, lowercase in 63 prose values", + "Object A": "the same word plus a bare disambiguating letter, used with Object B in the merge wizard", + "Object B": "the same word plus a bare disambiguating letter, used with Object A in the merge wizard", + "Object:": "the same word with a colon", + "OpenDocument (.ods)": "a format name plus a file extension, neither of which is translated", + "PDF": "file-format acronym, not translated in any language", + "Quota": "Romansh 'quota' is the Romance form, spelled identically; ca and fr keep it unchanged too", + "RBAC": "acronym for Role-Based Access Control, used untranslated as a term of art", + "Objects": "Romansh pluralises with +s, so 'objects' is identical", + "objects": "lowercase inline form, which is this bundle's capitalisation convention for this term", + "Parallel:": "Romansh 'parallel' is spelled identically; the bundle writes 'Modus parallel'", + "Port": "Romansh 'port' is spelled identically in the networking sense", + "Public": "Romansh 'public' is spelled identically in the masculine; it is a permission-matrix and a view badge, and the bundle writes 'Vista publica' in the feminine", + "Register": "Romansh 'register' is spelled identically and is this bundle's own term, capitalised in 30 prose values", + "Registers": "Romansh pluralises with +s, so 'registers' is identical", + "Schema": "Romansh 'schema' is spelled identically and is this bundle's own term, capitalised in 34 prose values", + "Schemas": "Romansh pluralises with +s, so 'schemas' is identical", + "Secret": "Romansh 'secret' is the noun, spelled identically; openconnector's rm bundle writes 'Il secret na vegn mai en OpenConnector'", + "Serial:": "Romansh 'serial' is spelled identically; the bundle writes 'Modus serial' and it pairs with 'Parallel:'", + "Slug": "the URL-slug field. Core lt renders it 'Trumpinys' and sl 'Oznaka', so it IS translatable and cannot be unwrapped, but Romansh has no established equivalent and this bundle borrows English for every other URL-layer and technical term, so coining one here would split the vocabulary", + "Status": "Romansh 'status' is spelled identically; the bundle writes 'Status da l'extracziun'", + "Success": "Romansh 'success' is spelled identically and the bundle uses it in every success toast ('cun success')", + "Total": "Romansh 'total' is spelled identically; the bundle writes 'Total da resultats'", + "Total:": "the same word with a colon", + "Totals": "Romansh pluralises with +s, so 'totals' is identical", + "URL": "acronym, identical in Romansh; the bundle writes 'URL da basa', 'URL da Ollama'", + "UUID:": "acronym with a colon. fr writes 'UUID :' with French spacing, but Romansh follows German/Italian spacing and keeps the colon tight", + "Webhook": "kept English as a technical term; the bundle uses 'webhook' unchanged in 39 values", + "{count} widget(s)": "a placeholder plus the borrowed 'widget' — Romansh keeps it, as the bundle does in 'Tip da widget nunenconuschent' — plus the source's own (s) plural hack, which Romansh also forms with +s", + "{property} - {other}": "two placeholders around a separator, so only the dash is translatable. Three distinct values exist across the bundles (plain hyphen, en dash in ro, em dash), which is why this cannot be unwrapped; Romansh keeps the plain hyphen as ca, de, fr and it do", + "Webhooks": "Romansh pluralises with +s, so the borrowed plural is identical", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", + "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens.", + "objectType: object\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens and are never translated." + }, + "corrections": { + "_schema_::_schemas_": "Capitalised to 'Schema(s)' after first being written lowercase. translatePlural hands the form it selects back to translate(), which resolves it against the bundle again — so lowercase 'schema(s)' matched this bundle's own 'schema(s)' KEY and rendered that key's value, 'Schema(s)'. Same characters, produced by an unrelated entry. The capital makes it this array's own value, and matches how the bundle writes the noun everywhere else. check-l10n-parity now fails on the collision class.", + "_register_::_registers_": "Same collision as '_schema_::_schemas_': lowercase 'register(s)' was resolving through this bundle's 'register(s)' key. Capitalised so the array renders its own value.", + "_file_::_files_": "Capitalised for consistency with the two forms above and with the bundle's own 'Datoteca' / 'Datotecas'. No collision on this one; changed so the five labels are not split between two casings.", + "_log_::_logs_": "Capitalised for consistency with the sibling plural labels; the bundle writes 'Protocol' / 'Protocols' capitalised throughout.", + "_object_::_objects_": "Capitalised for consistency with the sibling plural labels; the bundle writes 'Object' / 'Objects' capitalised throughout.", + "_{count} schema_::_{count} schemas_": "Capitalised with the bare '_schema_::_schemas_' key it mirrors, and for the same collision reason.", + "Could not read the seal coverage of the audit trail.": "rendered 'audit trail' as 'tratga da revisiun', one of three outliers against this bundle's own 'colliaziun d'audit' (25 uses). Normalised so the feature has one name", + "Recomputes every hash and compares it with the one stored. This reads the whole audit trail, so it is run on request rather than each time this page opens.": "same 'tratga da revisiun' outlier, normalised to 'colliaziun d'audit'", + "Write an audit-trail entry for every step": "same 'tratga da revisiun' outlier, normalised to 'colliaziun d'audit'", + "Audit entries": "rendered 'audit' as 'revisiun' here but as 'audit' in the 25 audit-trail values, so the same English word had two Romansh renderings across one feature. Normalised to the 'colliaziun d'audit' family", + "Every audit entry carries a hash. That says the sweeper is keeping up — it does not by itself prove the stored hashes still agree with their rows. Verify the chain to establish that.": "same 'endataziun da revisiun' vs 'endataziun da colliaziun d'audit' split, normalised", + "Every audit entry is chained to the one before it with a SHA-256 hash, so altering or deleting history breaks the links either side of it. This is where you can see whether that chain is whole.": "same 'endataziun da revisiun' vs 'endataziun da colliaziun d'audit' split, normalised", + "Defaults for every flow on this instance. A flow can override each of these for itself; a flow that overrides none of them follows the values set here, including later changes.": "carried 'siwa' for 'follows' — the only non-loanword w in the bundle, and w is not a letter of Romansh. Corrected to 'suonda', the 3sg of suandar", + "File extraction queued": "carried 'en la lingia d'spetga': d' elides only before a vowel, and this was the only d'+consonant in the bundle. Corrected to 'da spetga'", + "Text chunks are generated during file extraction and stored in the database. Vectorization reads these pre-chunked files and converts them to embeddings.": "carried 'La vectorisaziun lescha questas Datotecas' — 'lescha' is the noun 'law'; the 3sg present of leger is 'leja', which the bundle uses correctly elsewhere", + "{sealed} of {total} entries sealed": "carried 'endataziuns sigillads' with a masculine participle on the feminine noun 'endataziun'. Corrected to 'sigilladas'", + "Objects already stored under this schema may no longer match it. Save anyway?": "lowercase 'quest schema', the only genuine outlier against Schema capitalised 34:0 mid-sentence (see capitalisationNote)", + "_%n entry has no hash yet_::_%n entries have no hash yet_": "form 0 carried a bare singular noun AND a singular verb ('%n endataziun n'ha anc nagin hash'), and since the library selects form 0 at every count for rm (see pluralNote) it rendered '5 endataziun n'ha' — wrong in both the noun and the verb at every count but 1. Rewritten verblessly as '%n endataziun(s) anc senza hash', which is correct at every count; form 1 keeps the true plural even though it is unreachable", + "Loading organisations...": "carried 'Chargiar organisaziuns…' with a U+2026 ellipsis and no article, against 20 sibling Loading... values that all use '...' and the definite article. Normalised to 'Chargiar las organisaziuns...'" + } +} diff --git a/scripts/l10n/locales/ro.json b/scripts/l10n/locales/ro.json new file mode 100644 index 0000000000..2ce7473c0b --- /dev/null +++ b/scripts/l10n/locales/ro.json @@ -0,0 +1,143 @@ +{ + "register": "formal", + "registerEvidence": "PROSE-ONLY measurement, taken before detectors/ro.js gained its label-position rule: core ro scores 124 formal (dumneavoastră / vă / 2pl verbs) against 66 informal (tu-series) over 1078 values in 14 catalogues — verdict MIXED, the first locale in this app where core gives no clear answer. The informal strings are concentrated in core/ro.json and read as newer work ('Te rugăm să alegi un fișier', 'Conectează-te la contul tău'), while the formal ones dominate the email templates and the rest of the catalogues. Because core was inconclusive, the tiebreaker was this bundle's own 1053 pre-existing keys, which are decisive: 25 formal pronouns and 59 formal 2pl verb forms against ZERO informal pronouns. That is the opposite situation to lv and et, where core WAS one-sided and overruled the file. Confirmed independently by the project owner on a native speaker's advice that Romanian users expect formal address in a web UI. Re-running the detector's core scan now reports a higher informal count (134) because core's own 2sg button labels register as deviations from the convention chosen below — do not re-derive the prose verdict from that number.", + "buttons": "NO FAMILIAR ADDRESS ANYWHERE — a project decision by the owner on a native speaker's advice that Romanian users expect formal address throughout a web UI. It knowingly diverges from core ro, which uses the bare 2sg imperative for 15 of its 16 short labels ('Salvează', 'Adaugă', 'Șterge'). A bare 2sg imperative in label position is therefore a DEVIATION for this locale and detectors/ro.js counts it as informal — the opposite of how ca/et/hr treat theirs. It splits by string ROLE, which is the bundle's own pre-existing pattern (54 formal-2pl values in prose and placeholders, verbal nouns on the bare action keys): (a) ACTION LABELS — buttons, menu items, dialog titles — take a verbal noun phrase: 'Salvare', 'Ștergere', 'Anulare', 'Adăugare', 'Adăugare endpoint', 'Editare endpoint'. Core ro and the sibling apps produce the same shape ('Adăugare punct final', 'Editare punct final'). (b) ANYTHING THAT ADDRESSES THE USER — instructions, dropdown placeholders, confirmations, descriptions, errors — takes formal 2pl: 'Selectați un registru', 'Introduceți ID-ul obiectului', 'Sigur doriți să ștergeți...'. Both are formal; neither is familiar.", + "buttonsWhyNotAllImperative": "Perfect 2pl-imperative uniformity on buttons is NOT AVAILABLE without a source-side fix, and this was measured rather than assumed. 'Create', 'Read', 'Update' and 'Delete' are single keys used in BOTH roles: they render as column headers and audit-action filter labels (AvgIndex.vue around line 203, AuditTrailSideBar.vue actionOptions) AND as buttons (DeleteApplication.vue, EditActivityDialog.vue). A column header reading 'Ștergeți' — 'delete!' — above a count column is wrong, so those four are forced to the verbal noun, and the remaining bare action keys follow them for a consistent button voice. Normalising this needs separate keys in src/, which is the same open defect class as 'Url' vs 'URL' in CLAUDE.md. If the source ever splits them, the pure-button keys can move to 2pl imperatives ('Salvați', 'Ștergeți') in one pass.", + "orthographyNote": "Use the COMMA-BELOW codepoints ș U+0219 and ț U+021B, never the legacy Turkish cedilla ş U+015F / ţ U+0163. This bundle is already consistent (419 values comma-form, 0 cedilla) but core ro is not: 362 of its values use the comma form and 5 legacy strings use the cedilla ('contactaţi', 'fişiere'). detectors/ro.js normalises cedilla to comma in fold() so its closed lists still match those legacy strings; translations written here must use the comma form so the bundle stays consistent.", + "pluralNote": "nplurals=3 with the boundaries in an order no other locale in this app uses: form 0 is n==1 ONLY, form 1 is 0 and 2-19 (zero joins the teens branch), form 2 is 20+. So the SECOND form carries zero, unlike lv where zero has its own form, and unlike hr/ru/lt where form 1 is a small-number form. Verified against @nextcloud/l10n: library and header agree exactly for ro, so the arrays are in header order and no pluralOrder acknowledgement is needed. Romanian takes 'de' before the noun from 20 upward ('20 de obiecte'), which is why form 2 exists at all.", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "Avatar": "Romanian uses 'avatar' unchanged for a profile image; unlike lv, which renders it 'Profila attēls', Romanian has no native equivalent in general use.", + "Calendar": "Romanian for calendar IS 'calendar' — identical spelling, and core ro uses it for the Calendar app.", + "Complex": "Romanian adjective 'complex' is spelled identically; it labels the highest tier of the query-complexity scale, beside 'Simple' and 'Medium' which do differ ('Simplu', 'Mediu').", + "CSV": "File-format initialism, written identically in Romanian; every finished locale keeps it as-is.", + "Deck": "Proper name of the Nextcloud Deck app; product names are not translated.", + "Driver": "Romanian IT usage borrows 'driver' unchanged for a database driver; the native 'pilot' is not used in this sense.", + "DSAR": "Initialism for Data Subject Access Request. Romanian has coined no equivalent acronym, and the expanded key 'Data-subject access request' is translated in full beside it as 'Cerere de acces al persoanei vizate'.", + "Excel (.xlsx)": "Product name plus a file extension; both are invariant in Romanian.", + "Format": "Romanian noun 'format' is spelled identically; the compounds in this bundle are inflected ('Format de afișare', 'Format de export').", + "ID": "Initialism written identically in Romanian; the lowercase variant key 'Id' is normalised TO this form, and the inflected uses take a hyphen ('ID-ul resursei țintă').", + "ID:": "Same initialism with a colon. Romanian puts no space before a colon, so the punctuation is genuinely unchanged.", + "ID: {id}": "Same initialism plus the placeholder; nothing in this string differs in Romanian.", + "Interval": "Romanian noun 'interval' is spelled identically. It is also why the histogram key 'Bucket' had to become 'Segment' rather than 'Interval' — the two would otherwise collide.", + "Local": "Romanian adjective 'local' is spelled identically in the masculine singular, which is the form this label uses.", + "OpenDocument (.ods)": "Product name plus a file extension; both are invariant in Romanian.", + "PDF": "File-format initialism, identical in Romanian; no locale in this app carries a differing value for it.", + "Port": "Romanian noun 'port' is spelled identically. The definite form 'portul' that core ro uses for this key was NOT taken: this is a bare field label beside 'Gazdă', where Romanian uses the indefinite.", + "Public": "Romanian adjective 'public' is spelled identically in the masculine singular, which is the form this visibility label uses.", + "RBAC": "Initialism for role-based access control, used untranslated in Romanian IT writing; the surrounding prose keys are translated in full and the matrix column headers are the Romanian verbal nouns 'citire' / 'creare' / 'actualizare' / 'ștergere' / 'gestionare'.", + "Secret": "Romanian noun 'secret' is spelled identically; it labels the client-secret field beside 'Cheie'.", + "Total": "Romanian noun 'total' is spelled identically; the compound is inflected ('Total evenimente').", + "Total:": "Same noun with a colon. Romanian puts no space before a colon, so the punctuation is genuinely unchanged.", + "URL": "Initialism written identically in Romanian; the lowercase-variant key 'Url' is normalised TO this form, and the inflected uses take a hyphen ('Verificați URL-ul').", + "UUID:": "Initialism with a colon. Romanian puts no space before a colon, so the punctuation is genuinely unchanged.", + "Webhook": "This bundle keeps 'webhook' untranslated — the pre-existing values are 'Webhook' and 'Webhook-uri' — unlike lv and lt, which translate it. Romanian IT usage borrows the term.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "e.g., lib/Settings/config.json": "A literal example file path shown verbatim; not prose.", + "e.g., lib/Settings/register.json": "A literal example file path shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", + "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens.", + "objectType: object\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens and are never translated.", + "Browser Web Push (VAPID)": "Browser and Web Push are the protocol's own names and VAPID is its acronym; the protocol name is not translated.", + "Bucket": "Object-storage term, used unchanged in this locale's technical language.", + "Golden record": "Master-data-management term with no established local equivalent; used unchanged in data-governance language.", + "Host *": "Networking term, used unchanged in this locale, with the required-field marker.", + "Id": "Abbreviation of identifier, written the same way in this locale's technical UI.", + "Index": "Same spelling and meaning in this locale.", + "Model": "Same spelling and meaning in this locale.", + "myapp": "A literal example application id shown verbatim; not prose.", + "Survivor": "Master-data-management term for the record that survives a merge; used unchanged in data-governance language.", + "Url": "Invariant abbreviation, written the same way in this locale.", + "{property} - {other}": "Two placeholders joined by a dash; there is no prose to translate.", + "e.g., lib/settings/config.json": "A literal example file path shown verbatim; not prose.", + "e.g., lib/settings/register.json": "A literal example file path shown verbatim; not prose.", + "Multitenancy": "Architecture term, used unchanged in Romanian technical language.", + "public": "Same spelling and meaning in Romanian.", + "Slug": "URL-slug term, used unchanged in Romanian developer language." + }, + "corrections": { + "Add Application": "Informal imperative 'Adaugă aplicație' replaced with the formal 'Adăugați aplicația'. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the 2nd-person-singular imperative; every other imperative here already uses the -ați/-eți form, so this was inconsistent with its own bundle.", + "Save anyway": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Save flow settings": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Verify chain": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Enable": "Informal imperative 'Activează' replaced with the formal 'Activați'. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the 2nd-person-singular imperative; every other imperative here already uses the -ați/-eți form, so this was inconsistent with its own bundle.", + "Enable auto-creation": "Informal imperative 'Activează crearea automată' replaced with the formal 'Activați crearea automată'. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the 2nd-person-singular imperative; every other imperative here already uses the -ați/-eți form, so this was inconsistent with its own bundle.", + "Enable automatic synchronization": "Informal imperative 'Activează sincronizarea automată' replaced with the formal 'Activați sincronizarea automată'. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the 2nd-person-singular imperative; every other imperative here already uses the -ați/-eți form, so this was inconsistent with its own bundle.", + "Enable calendar provider": "Informal imperative 'Activează furnizorul de calendar' replaced with the formal 'Activați furnizorul de calendar'. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the 2nd-person-singular imperative; every other imperative here already uses the -ați/-eți form, so this was inconsistent with its own bundle.", + "Are you sure you want to clear all data from collection \"{name}\"?\n\nThis will:\n• Delete all indexed documents\n• Keep the collection structure intact\n• This action cannot be undone": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Are you sure you want to DELETE collection \"{name}\"?\n\nThis will:\n• Permanently delete the collection and all its data\n• Remove all indexed documents\n• This action cannot be undone": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Enable automatic file vectorization": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Enable automatic object vectorization": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Enable detailed logging for SOLR operations (recommended for debugging)": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Enable faceting": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Enable or disable LLM features. Configure providers and models using the LLM Configuration button above.": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Enable or disable LLM features. Configure providers and models using the LLM configuration button above.": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Enable or disable n8n workflow integration. Configure connection settings below.": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Enable or disable SOLR search integration. Configure connection settings using the Connection Settings button above.": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Enable or disable SOLR search integration. Configure connection settings using the connection settings button above.": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Enable or disable this webhook": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Enable RAG": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Enable tools that allow the agent to interact with data through function calling.": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Enable vectorization for all existing and future views (may increase costs)": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Enable write-back to target objects": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Export": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Export \"{schema}\" objects from \"{register}\"": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Export my data": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Export Objects": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Filter Audit Trails": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Filter by object ID": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Filter by search term": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Filter by type": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Filter by webhook": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Filter Deleted Items": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Filter fields...": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Filter Objects": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Filter Properties": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Filter Search Trails": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Filter Statistics": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Filter webhook triggers by payload properties (one per line, format: key: value)": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Generate recommendations and confidence scores": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Generate report": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Generate the verantwoordingsdocument": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Generate vectors immediately when new objects are created": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Import": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Import Data into Register": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Run oversight checks before each step": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Soft delete all objects for this schema ({active} active, {deleted} already deleted)": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Start a conversation": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Start Conversation": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Start Vectorization": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Stops every flow mid-run, at its next step. Use this when flows are misbehaving and disabling them one at a time is too slow.": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Unlink email": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Unlink event": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "Upload new avatar": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "View API Docs": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "View API Documentation": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "View APIs": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "View Changes": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "View Details": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "View Error": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "View File": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "View Full Details": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "View Logs": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "View Object": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "View Parameters": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form.", + "View {count} more": "Informal 2nd-person-singular imperative replaced with the formal -ați/-eți form. The measured register for ro in this bundle is FORMAL and detectors/ro.js flags the singular imperative; the rest of the bundle already uses the formal form." + }, + "correctionsScopeNote": "Only these FOUR values needed correcting, not the eleven that a string-initial 2sg-imperative scan reports. The other seven are long toggle DESCRIPTIONS where the same verb form is the third person singular, not an imperative: 'Creează automat o organizație implicită dacă nu există niciuna' is 'automatically creates', 'Generează automat embeddings vectoriale...' is 'automatically generates', 'Oprește fiecare flux în timpul execuției... Folosiți când...' is 'stops every flow... use this when...' — third-person description followed by a formal 2pl instruction. This is exactly the homograph the detector's 40-character label-position bound exists to separate, and it separated them correctly here." +} diff --git a/scripts/l10n/locales/sk.json b/scripts/l10n/locales/sk.json new file mode 100644 index 0000000000..ddafa260c2 --- /dev/null +++ b/scripts/l10n/locales/sk.json @@ -0,0 +1,131 @@ +{ + "register": "formal", + "registerEvidence": "Core sk is the least ambiguous of any locale measured for this app: 1001 formal (vy / vás / vám / váš / 2pl verb) markers against 1 informal over 4991 values in 31 catalogues — verdict FORMAL. The single informal hit is 'Zruš prihlasovanie' in core/sk.json, a bare 2sg imperative that deviates from core's own convention everywhere else, so it is a slip in core rather than evidence of a second register. This bundle's own 1053 pre-existing keys agree without exception: 126 formal markers in 102 values and ZERO informal, so no correction was needed on register grounds. No decision had to be escalated for sk — unlike ro, where core came out MIXED at 124/66 and the bundle plus the owner's native-speaker advice had to break the tie.", + "buttons": "INFINITIVE, and it is measured, not inherited. Thirty short action keys resolved against core sk give 27 infinitives ('Uložiť', 'Zmazať', 'Pridať', 'Vytvoriť', 'Zrušiť', 'Vybrať', 'Zdieľať', 'Potvrdiť') and ZERO imperatives; the two non-matches are 'Skúsiť znova' (also an infinitive, just a two-word phrase) and 'Import' (a bare noun). This bundle already followed that: 39 of its 733 short values end in -ť. So sk pairs the same infinitive labels as lv with the formal 2pl prose of ro — the fourth distinct combination in this app. The split is by string ROLE: (a) ACTION LABELS — buttons, menu items, dialog titles, bare field captions — take the infinitive ('Uložiť', 'Pridať koncový bod', 'Vybrať register a schému'); (b) ANYTHING THAT ADDRESSES THE USER — instructions, dropdown prompts, confirmations, descriptions, errors — takes formal 2pl ('Vyberte register', 'Zadajte popis', 'Naozaj chcete odstrániť…'). The English source itself marks the boundary reliably here: 'Select backend' is a field label and takes 'Vybrať backend', while 'Select a branch' is a prompt and takes 'Vyberte vetvu'.", + "buttonsWhyDetectable": "detectors/sk.js counts a bare 2sg imperative as informal, which is the OPPOSITE of how ca/et/hr treat theirs, and that is sound for Slovak specifically rather than a policy choice. In those languages the 2sg imperative IS the button convention and is also a homograph of the 3sg present indicative, so counting it flags every button and every 'automatically creates' description. Slovak has neither problem: the label convention is the infinitive (measured above), the 2sg imperative is not a homograph of the 3sg present ('ulož' vs 'uloží', 'pridaj' vs 'pridá'), and the infinitive's -ť ending cannot be mistaken for one. So 'Ulož' in this bundle is familiar address and nothing else, and no label-position bound is needed — ro.js needs one only because Romanian's imperative IS a 3sg homograph.", + "orthographyNote": "detectors/sk.js does NOT fold diacritics, and three of its distinctions depend on that: 'ti' (dative of 'ty', informal) vs 'tí' (masculine animate nominative plural of 'ten'); 'vyber' (2sg imperative) vs 'výber' ('selection', which this bundle uses in five keys); 'uprav' (2sg imperative) vs 'úprav' (genitive plural). Stripping the acute would break the detector in both directions, so fold() only lowercases. Slovak also takes a non-breaking space before the percent sign in careful typography, but this bundle follows core sk and writes a normal space ('Spoľahlivosť: {confidence} %').", + "pluralNote": "nplurals=3 with form 0 = n==1, form 1 = 2-4, form 2 = everything else INCLUDING ZERO. That last part is the trap: '0 záznamov' takes form 2, the same form as 5 and 100, so an array that treats form 2 as the large-number form still has to read correctly for zero. Verified against @nextcloud/l10n: library and header agree exactly for sk, so the arrays are in header order and no pluralOrder acknowledgement is needed — unlike lv, whose header and library disagree on ORDER.", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "Avatar": "Slovak uses 'avatar' unchanged for a profile image, and core sk keeps it too (server/apps/settings). Unlike lv, which renders it 'Profila attēls', Slovak has no native equivalent in general use.", + "CSV": "File-format initialism, written identically in Slovak; every finished locale keeps it as-is.", + "Deck": "Proper name of the Nextcloud Deck app; product names are not translated.", + "DSAR": "Initialism for Data Subject Access Request. Slovak has coined no equivalent acronym, and the expanded key 'Data-subject access request' is translated in full beside it as 'Žiadosť dotknutej osoby o prístup'.", + "Excel (.xlsx)": "Product name plus a file extension; both are invariant in Slovak.", + "ID": "Initialism written identically in Slovak; the lowercase variant key 'Id' is normalised TO this form, and the inflected uses in this bundle decline the following noun instead ('ID objektu', 'ID registra').", + "ID:": "Same initialism with a colon. Slovak puts no space before a colon, so the punctuation is genuinely unchanged.", + "ID: {id}": "Same initialism plus the placeholder; nothing in this string differs in Slovak.", + "Interval": "Slovak noun 'interval' is spelled identically. It is also why the histogram key 'Bucket' became 'Pásmo' rather than 'Interval' — the two would otherwise collide, and 'Interval' is the separate date-facet granularity field in EditSchemaProperty.vue.", + "Maximum": "Slovak noun 'maximum' is spelled identically; the compounds in this bundle are inflected ('Maximálna dĺžka', 'Maximálny počet súborov').", + "Minimum": "Slovak noun 'minimum' is spelled identically, matching 'Maximum'; the compounds are inflected ('Minimálna hodnota').", + "OpenDocument (.ods)": "Product name plus a file extension; both are invariant in Slovak.", + "PDF": "File-format initialism, identical in Slovak; no locale in this app carries a differing value for it.", + "Port": "Slovak noun 'port' is spelled identically. It is a bare field label in EditSource.vue beside 'Host *' ('Hostiteľ *'), so the indefinite nominative is the right form; core sk's definite 'portu' would be wrong here.", + "RBAC": "Initialism for role-based access control, used untranslated in Slovak IT writing; the surrounding prose keys are translated in full and the matrix column headers are the Slovak verbal nouns 'čítanie' / 'vytváranie' / 'aktualizácia' / 'odstraňovanie' / 'správa'.", + "Register": "Slovak for a register IS 'register' — identical spelling and the same Latin root. The declined forms elsewhere in this bundle prove it is a real Slovak word rather than an untranslated one ('ID registra', 'Všetky registre', 'Upraviť register').", + "Register:": "Same Slovak noun as 'Register' with a colon. Slovak puts no space before a colon, so the punctuation is genuinely unchanged too.", + "Slug": "Slovak IT usage borrows 'slug' unchanged for a URL-safe identifier; no native equivalent is in circulation, and openbuild's attempt at one ('slung') is a typo rather than a term.", + "URL": "Initialism written identically in Slovak; the inflected uses decline the following noun instead ('URL databázy', 'Základná URL').", + "UUID:": "Initialism with a colon. Slovak puts no space before a colon, so the punctuation is genuinely unchanged.", + "Webhook": "This bundle keeps 'webhook' untranslated — the pre-existing values are 'Webhook' and 'Webhooky' — matching ro and unlike lv and lt, which translate it. Slovak IT usage borrows the term.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", + "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens.", + "objectType: object\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens and are never translated.", + "Browser Web Push (VAPID)": "Browser and Web Push are the protocol's own names and VAPID is its acronym; the protocol name is not translated.", + "Bucket": "Object-storage term, used unchanged in this locale's technical language.", + "Driver": "Database-driver term, used unchanged in this locale's technical language.", + "Golden record": "Master-data-management term with no established local equivalent; used unchanged in data-governance language.", + "Host *": "Networking term, used unchanged in this locale, with the required-field marker.", + "Id": "Abbreviation of identifier, written the same way in this locale's technical UI.", + "Index": "Same spelling and meaning in this locale.", + "Model": "Same spelling and meaning in this locale.", + "myapp": "A literal example application id shown verbatim; not prose.", + "Survivor": "Master-data-management term for the record that survives a merge; used unchanged in data-governance language.", + "{property} - {other}": "Two placeholders joined by a dash; there is no prose to translate.", + "Register #{id}": "The noun 'register' is identical in Slovak and the rest is a number sign and a placeholder." + }, + "auditNote": "THE WHOLE BUNDLE WAS GRAMMATICALLY AUDITED (runbook §6.9), all 2052 values, not only the ~1000 this project translated. 57 values were corrected. sk is the second Tier 2 locale opened and the first data point on whether a locale with a 0 corrections count is clean or merely unverified: the answer is 'mostly clean' — 57 of 2052 is 2.8%, below cs's 5.5% and far below is's 22%, which confirms the cs finding that the defect rate tracks how healthy the locale is upstream rather than how long it went un-audited. sk is a well-maintained locale and the bundle reads like one. METHOD, in the order that paid: (1) term counting over short keys — the audit-trail term was the whole story and nothing else drifted; register/schema/file/source/object/configuration/settings/dashboard/log all came out 100% uniform, and the Configuration-vs-Settings split that cs had to escalate is already clean here (Konfigurácia 38/38, Nastavenia 24/24). (2) the byte-identical collision scan — 18 collisions, of which 11 were English synonym pairs that SHOULD render identically (Choose/Select, no title/unnamed, Organisation/Organization, API Docs/API Documentation, day(s) left/remaining) and 7 needed a verdict. (3) reading every value — this found the typo, the two reversals and the dangling prepositions, i.e. everything a checker cannot see. Mechanical morphology checks were NOT written: the term counting showed no morphological drift to chase, and on is they found 4 of 239 while on cs they found ~0 of 113, so the runbook's advice to skip them held. CHECKING CORE BEFORE 'FIXING' AN OUTLIER OVERTURNED FIVE CANDIDATE CORRECTIONS, which is a higher rate than cs's four and makes this the single most valuable step: Refresh and Restore both render Obnoviť and core sk collapses them the same way, so the collision is core's, not the bundle's; First/Last/Previous → Prvé/Posledné/Predchádzajúce is core sk verbatim, not a gender error against Stránka; bare Search → Hľadať is core sk verbatim (3 catalogues) despite every compound key using Vyhľadať. CHECKING THE CALL SITE OVERTURNED FIVE MORE: Handler → Riešiteľ is correct because c.handler is a person (an AVG case worker), not a code handler; Fair/Good/Poor → Uspokojivé/Dobré/Slabé are neuter because they are KPI labels over skóre (neuter), not over kvalita, so they do not have to match the feminine Vysoká/Stredná/Nízka confidence family; Filter Statistics → Filtrovať štatistiky is right because it is an h3 heading OVER filter controls, so the infinitive action-label rule applies rather than the noun rule; Requested at → Požiadané and Expires → Vyprší are not dangling because the template supplies the colon ({{ t(...) }}:) and a date follows; Current → Aktuálna is feminine because it labels the current organizácia. CLASSES USED: TERM-AUDITTRAIL (the head-noun re-coining, 36); CONSISTENCY-AUDITTRAIL (an 'audit-trail entry' key that followed the bare 'audit entry' pattern instead, 1); DANGLING-PREP (a preposition left without its object, 7); SENSE (a real Slovak word in the wrong meaning, 4); SENSE-SWAPPED (two keys carrying each other's meaning, 2); SENSE-REVERSED (subject and object exchanged, 1); TYPO (1); CONSISTENCY-LOADING (1); CONSISTENCY-FACET (1); CONSISTENCY-API (2); BUTTON (an action label that took the noun instead of the infinitive, 1).", + "auditDecisionsEscalated": "Two decisions met the runbook's three-signal test for the owner's call (a first-class term, ~30+ keys, and core disagreeing with the bundle's own vocabulary) and were put to the owner before the batch was written. (1) DELETE/REMOVE — the bundle renders both English Delete and Remove as Odstrániť across 122 values (88 on Delete keys, 41 on Remove keys). Core sk splits them cleanly: Delete → Zmazať (6) or Vymazať (3) and NEVER Odstrániť, Remove → Odstrániť (1) or Odobrať (1), Deleted → Zmazané. Moving Delete to Zmazať was the only coherent split available, because the bundle has already spent Vymazať on Clear (13 keys) and on the GDPR Erase/vymazanie family (6 keys, where vymazanie is the standard Slovak term for the Art 17 right and cannot move). THE OWNER CHOSE TO LEAVE IT: both words are valid Slovak for delete, the collision is soft because Remove-a-schema-from-a-register and Delete-the-schema rarely appear in the same view, and §3.8 protects an existing real translation from a change of taste. Recorded here as a DELIBERATE divergence from core sk so the next reviewer does not re-litigate it, and so the counts are on record if they ever want to. (2) AUDIT TRAIL — the head noun. The bundle rendered it audítny záznam ('audit record') in 28 keys, which forced 'audit trail entry' to come out as záznam audítneho záznamu, 'record of the audit record', in 8 of them, and left two stray renderings beside it (Auditná stopa ×1, audítorská stopa ×2). THE OWNER CHOSE TO RE-COIN AS auditná stopa: 'trail' becomes stopa, so 'audit trail entry' is záznam auditnej stopy and the stutter dissolves by construction rather than being patched key by key, and záznam is freed to mean only what it means everywhere else in this bundle — one entry. A pleasant consequence is that the single pre-existing outlier, 'Audit trail #{id}' → 'Auditná stopa #{id}', turned out to be the ONLY key already written the chosen way and needed no change at all: the outlier was the model. This is the cs 'Loading…' lesson in a new shape — a minority reading can be the correct one.", + "auditOrthographyNote": "The adjective was misspelled throughout and the fix rode along with the audit-trail batch, so it has no separate class code. The bundle wrote audítny / audítneho / audítnych with a LONG í in 35 keys and auditný with a short i in none. Slovak forms relational adjectives from consonant-final Latin borrowings by adding -ný to the stem with no vowel change (kredit → kreditný, limit → limitný, deficit → deficitný, depozit → depozitný), and there is no process in the language that would lengthen the stem vowel — the rhythmic law only ever shortens. The long í belongs to a DIFFERENT lexeme, audítor (from Latin audītor, where the ī is long), whose adjective is audítorský; audítny was a blend of the two, taking audítor's vowel and audit's suffix. The bundle's own forms corroborate this rather than my grammar: it writes audit, auditu, auditovanie and auditujte with a short i everywhere the base is audit-, matching core sk's 'Auditovanie / zaznamenávanie udalostí', and lengthens only in Audítori and audítorská — so it already had the distinction and applied it correctly to the noun and the verb while getting the adjective wrong. Audítori is preserved unchanged in the AVG Art 30 §4 value for exactly that reason. No external authority could be cited: openregister is the only app in the workspace carrying these forms, and its own backend l10n/sk.json (out of scope, different consumer) mixes both spellings, which is evidence of the same uncertainty rather than of a convention.", + "corrections": { + "An error occurred while clearing audit trails": "TERM-AUDITTRAIL", + "An error occurred while deleting the audit trail": "TERM-AUDITTRAIL", + "Archive this verwerkingsactiviteit? Audit-trail rows will keep referring to it.": "TERM-AUDITTRAIL", + "Audit Trail Changes": "TERM-AUDITTRAIL", + "Audit trail data copied to clipboard": "TERM-AUDITTRAIL", + "Audit Trail Details": "TERM-AUDITTRAIL", + "Audit Trail Management": "TERM-AUDITTRAIL", + "Audit trail of recent reversible merges. Reverse an operation while it is still within its reversal window.": "TERM-AUDITTRAIL", + "Audit Trail Statistics": "TERM-AUDITTRAIL", + "Audit trail successfully deleted": "TERM-AUDITTRAIL — also a gender change, záznam (m) → stopa (f), so the participle became bola úspešne odstránená", + "Audit Trails": "TERM-AUDITTRAIL", + "AVG Art 30 §4: this report joins each processing activity with the lifetime audit-trail counts attributed to it. Auditors and the Autoriteit Persoonsgegevens use this for supervisory review.": "TERM-AUDITTRAIL — Audítori left untouched, it is the separate audítor lexeme and correctly carries the long í", + "Changes for Audit Trail #{id}": "TERM-AUDITTRAIL — accusative after pre", + "Clear Filtered Audit Trails": "TERM-AUDITTRAIL", + "Could not read the seal coverage of the audit trail.": "TERM-AUDITTRAIL — was audítorská stopa, one of the two strays; head noun was already right, the adjective was not", + "Create the first verwerkingsactiviteit to start tagging audit-trail rows with their AVG Art 30 attribution.": "TERM-AUDITTRAIL", + "Delete Audit Trail": "TERM-AUDITTRAIL", + "Do you want to permanently delete all filtered audit trail entries? This action cannot be undone.": "TERM-AUDITTRAIL — one of the 8 stutter keys, záznamy audítneho záznamu → záznamy auditnej stopy", + "Do you want to permanently delete this audit trail entry? This action cannot be undone.": "TERM-AUDITTRAIL — stutter key", + "Erase {count} object(s) for this subject? This action is logged in the audit trail.": "TERM-AUDITTRAIL — genitive after do", + "Error loading audit trails": "TERM-AUDITTRAIL", + "Export, view, or delete audit trails": "TERM-AUDITTRAIL", + "Failed to clear audit trails: {error}": "TERM-AUDITTRAIL", + "Filter and manage audit trail entries": "TERM-AUDITTRAIL — stutter key", + "Filter Audit Trails": "TERM-AUDITTRAIL", + "No audit trail entries found": "TERM-AUDITTRAIL — stutter key", + "No filters are currently active. This will delete ALL audit trail entries!": "TERM-AUDITTRAIL — stutter key", + "Recomputes every hash and compares it with the one stored. This reads the whole audit trail, so it is run on request rather than each time this page opens.": "TERM-AUDITTRAIL — was audítorská stopa, the second stray", + "Record a field-level redaction on this case. The redaction and its ground are logged in the case audit trail.": "TERM-AUDITTRAIL", + "Successfully cleared {count} audit trails": "TERM-AUDITTRAIL — genitive plural auditných stôp, with the ô the noun takes in that form", + "There are no audit trail entries matching your current filters.": "TERM-AUDITTRAIL — stutter key", + "This audit trail entry does not contain any change information.": "TERM-AUDITTRAIL — stutter key", + "Total Audit Trails": "TERM-AUDITTRAIL", + "Use filters to narrow down audit trail entries by register, schema, action type, user, date range, or object ID.": "TERM-AUDITTRAIL — stutter key", + "View and analyze system audit trails with advanced filtering capabilities": "TERM-AUDITTRAIL", + "{count} audit trails cleared successfully": "TERM-AUDITTRAIL", + "Write an audit-trail entry for every step": "CONSISTENCY-AUDITTRAIL — the English is 'audit-trail entry', so it takes záznam auditnej stopy; it had been given záznam auditu, which is the bundle's (correct) rendering of the DIFFERENT key 'Audit entries'. The two English terms stay distinguished.", + "To adjust chunk size or strategy, go to file configuration → processing limits.": "TYPO — strategie → stratégie. The bare vowel is the Czech spelling; Slovak has the acute in every form of stratégia.", + "Uses": "SENSE-SWAPPED — was Používa sa ('it is used'), which is the meaning of the OTHER tab. This is an AppTab title over objectItem.relations (what this object references), so it needs a noun: Použitia. Matches the corrected cs Použití, de Verwendungen, fr Utilisations. Also resolves the byte-identical collision with 'In use', whose Používa sa is correct as a card badge.", + "Used by": "SENSE-SWAPPED — was Používa ('it uses'), the other tab's meaning. Over objectStore.relations (what references this object) → Používané v, matching cs Používáno v, de Verwendet von, pl Używane przez. Every locale checked marks the direction; sk was the only one that had the pair inverted.", + "No register objects reference this file": "SENSE-REVERSED — Tento súbor neodkazuje žiadne objekty registra makes the FILE the subject, i.e. 'this file references no register objects', the opposite of the English. Fixed to Na tento súbor neodkazujú žiadne objekty registra, which also supplies the na that odkazovať governs.", + "Detected At": "DANGLING-PREP — Zistené o left o with no object, and o + locative would mean 'at (o'clock)' anyway. Fixed onto the bundle's own 'Deleted Date' → 'Dátum odstránenia' pattern. All seven of these are column headers.", + "Extracted At": "DANGLING-PREP", + "Extracted at": "DANGLING-PREP", + "Updated At": "DANGLING-PREP", + "Merged at": "DANGLING-PREP", + "Merged from": "DANGLING-PREP — Zlúčené z; the column shows the source records, so Zdroj zlúčenia", + "Matched on": "DANGLING-PREP — Zhoda podľa left podľa without its object; the column shows which attribute matched, so Kritérium zhody", + "Loading organisations...": "CONSISTENCY-LOADING — wrong on the same two counts this key is wrong on in ga, mt and is: a verbal noun (Načítavanie) where all 37 other members of the family use the reflexive passive, and a Unicode ellipsis where the English key has ASCII dots. The rest of the family is otherwise flawless, including the reflexive-passive number agreement cs also gets right (Načítava sa aktivita... singular vs Načítavajú sa registre... plural).", + "Breaking change": "SENSE — Rozbíjajúca zmena is a literal calque; rozbíjať is physical smashing and does not convey loss of compatibility. 17 of 20 locales render this as 'incompatible change', including the sibling cs Nekompatibilní změna, hr Nekompatibilna promjena and de Inkompatible Änderung. The prose key 'This change breaks the existing data model' keeps its verb rozbíja — the English uses a different word there too, so it is not the same term.", + "Commit Message": "SENSE — Správa potvrdenia reads 'confirmation message'; potvrdenie is a confirmation or receipt, not a git commit. Both keys sit in PublishRegister.vue / PublishConfiguration.vue, dialogs that publish to GitHub, so the sense is unambiguous. Matches the corrected cs Zpráva commitu. Noted honestly: about seven locales use the 'confirmation' reading (ca, es, fi, lt, bs, is), so this was a defensible pre-existing choice rather than a garbling — the git context and the cs precedent decided it.", + "Commit message": "SENSE — as 'Commit Message'", + "Preview erasure": "BUTTON — an NcButton in AvgIndex.vue, so it takes the infinitive per the measured convention, while the h3 heading 'Erasure preview' keeps the noun Náhľad vymazania. The two had collided byte-identically; giving the button its infinitive separates them without recoining anything.", + "Facet Order": "CONSISTENCY-FACET — Poradie fasiet is a genitive PLURAL, 'order of facets', in a family whose other three members are all singular (Popis/Názov/Typ fasety). The label sits on a numeric input for THIS facet's position, so Poradie fasety.", + "Download API Spec": "CONSISTENCY-API — 'API specification' was rendered two ways across three keys: API špecifikáciu ×2 attributive, špecifikáciu API ×1 postposed. Normalised all three to postposed, matching the bundle's own 'View API Docs'/'View API Documentation' → dokumentáciu API (consistent already) and Tokeny API / Kvóta požiadaviek API. The tight compound API kľúč is left attributive on purpose — it is lexicalised in Slovak IT usage, and this normalisation is about one term, not about the acronym's position in general.", + "Failed to download API specification": "CONSISTENCY-API — as 'Download API Spec'", + "View {count} more": "SENSE — Zobraziť o {count} viac reads 'show more BY {count}', the comparative-of-degree construction, not 'show {count} further items'. Fixed to Zobraziť ďalších {count}.", + "Array Object Configuration:": "CASE — objektu polia used the nominative PLURAL of pole where the genitive singular poľa belongs, so it read 'configuration of the object of the fields'. The bundle already declines this noun correctly elsewhere (na úrovni poľa, vyprázdnenie poľa, and the nominative plural in fasetovateľné polia), so the form was available and simply not used. FOUND BY THE SECOND-PASS FAN-OUT, not by the first read — see auditSecondPassNote.", + "Draft denial": "BUTTON — Návrh zamietnutia is a noun where the measured convention takes the infinitive: this is the primary NcButton of DenialComposerDialog.vue (it carries the saving spinner), and its own sibling button 'Compose denial' is already the infinitive Zostaviť zamietnutie. Second-pass fan-out finding.", + "Analyze existing properties for improvement opportunities": "CONSISTENCY-STEPLIST — item 5 of the 'Analysis steps:' list in ExploreSchema.vue, whose other five items are all infinitives (Získať, Extrahovať, Zistiť, Identifikovať, Porovnať); this one alone switched to formal 2pl Analyzujte. Also 'pre možnosti zlepšenia' → 'z hľadiska možností zlepšenia', which is what Slovak uses for 'for … opportunities'. Second-pass fan-out finding.", + "Property name of inversed relation": "TERM-RELATION — obrátenej relácie uses the bundle's word for SESSION (Priemer vyhľadávaní/reláciu) to mean RELATION, which the bundle otherwise renders vzťah without exception (Relations → Vzťahy, žiadne vzťahy, spravujte vzťahy). 4 against 1. Second-pass fan-out finding.", + "Control which object views should be vectorized to reduce API costs. Only vectorize views that benefit from semantic search.": "SENSE — ovládať means to operate a device or master a skill, not to decide or govern which items are selected. Určite carries the intended sense. The weakest of the five second-pass findings and the one most open to disagreement; recorded so it can be reverted cheaply." + }, + "auditSecondPassNote": "FIVE FURTHER CORRECTIONS came from a SECOND PASS over the same bundle, run as a parallel fan-out on a fresh context rather than by re-reading. The first pass — one reader, sequentially, 57 corrections — MISSED ALL FIVE, and four of them are not marginal: a nominative-plural-for-genitive-singular case error (Array Object Configuration), a noun where the button convention takes an infinitive (Draft denial), a formal-2pl item in a six-item list of infinitives (Analyze existing properties), and the SESSION word used for RELATION (Property name of inversed relation). Each was confirmed at its call site before being accepted; the fifth (Control which…) is a softer sense call and is flagged as such in its own entry. THE LESSON IS ABOUT METHOD, NOT ABOUT THIS LOCALE: a single sequential reader has a recall ceiling that no amount of care removes, because the defects that survive are precisely the ones a tired eye normalises. sk's true count is 62, not 57, and the first-pass claim of 'zero wrong case' was simply wrong. Run the read-through as a fan-out over independent chunks with a central adjudicator, and expect a second pass to find things a first pass cannot — do NOT read a locale's completed audit as proof it is now clean." +} diff --git a/scripts/l10n/locales/sl.json b/scripts/l10n/locales/sl.json new file mode 100644 index 0000000000..d726f129f4 --- /dev/null +++ b/scripts/l10n/locales/sl.json @@ -0,0 +1,68 @@ +{ + "register": "formal", + "registerEvidence": "Core sl scores 304 formal (vi / vas / vam / vaš / 2pl verb) markers against ZERO informal over 3523 values in 27 catalogues — verdict FORMAL, and one-sided in the same way lt and sk were. This bundle's own 1053 pre-existing keys agree: 122 formal markers in 93 values and zero informal, so no value needed correcting on register grounds. The two recorded corrections below are lexical, not register.", + "buttons": "BARE 2sg IMPERATIVE, whatever the prose does — the ca/et/hr pattern, and pointedly NOT the infinitive that its neighbour sk uses. Measured: of 26 short action keys resolved against core sl, 23 are bare 2sg imperatives ('Shrani', 'Izbriši', 'Dodaj', 'Ustvari', 'Uredi', 'Prekliči', 'Omogoči', 'Potrdi') and ZERO are 2pl or infinitive; the remaining three are nouns ('Souporaba' for Share, 'Izbor' for Select) or the adverb 'Nazaj'. This bundle already followed that convention throughout. The split is by string ROLE, and the bundle's own adjacent keys show it cleanly: 'Select All' -> 'Izberi vse' and 'Select a branch' -> 'Izberi vejo' (2sg, labels and dropdowns) against 'Select a repository you have write access to' -> 'Izberite repozitorij, do katerega imate dostop za pisanje' and 'Select File Types to Vectorize:' -> 'Izberite vrste datotek ...' (2pl, instructions).", + "buttonsWhyImperativeIsExcluded": "detectors/sl.js does NOT count the bare 2sg imperative, and for Slovenian there are two independent reasons rather than one. First, it is the correct label convention (above), so counting it would flag every button in the app. Second, for the whole -iti verb class the 2sg imperative is spelled exactly like the 3sg present indicative: 'uredi' is both 'edit!' and 'he edits', and so are 'shrani', 'osveži', 'obnovi', 'posodobi' and 'preveri' — all six are button labels in this bundle. The -ati and -irati classes do differ ('dodaj' vs 'doda', 'kopiraj' vs 'kopira'), but the collision class is far too large to carve out. This is the OPPOSITE of sk, where the imperative is detectable precisely because it is neither the convention nor a homograph. sl looks like sk on the map and behaves like hr in the data.", + "orthographyNote": "This bundle puts a SPACE before an ellipsis, in both spellings: 'Poteka nalaganje ...', 'Vnesite opis (neobvezno) ...', 'Preverjanje …', 'Branje pokritosti s pečati …'. It is consistent across all pre-existing values and matches nb rather than ru, so new values follow it. Progressive states take the impersonal 'Poteka X ...' construction ('Poteka analiza ...', 'Poteka shranjevanje ...') rather than a present participle. Ranges use an en dash ('1–20', '10–50'); long prose uses an em dash with spaces ('Zasilni izklop — takoj ustavi ...').", + "pluralNote": "nplurals=4 — the ONLY four-form locale among the 25 finished here, and the only one with a DUAL. Boundaries are modular on n%100: form 0 is n%100==1, form 1 is n%100==2 (the dual), form 2 is n%100==3 or 4, form 3 is everything else including ZERO. The dual is not decoration: it governs noun case, adjective agreement AND verb number, so '2 vnosa še nimata' takes 'nimata' where the plural takes 'nimajo' — the pre-existing _%n entry has no hash yet_ array already got this right and is the model to follow. For an accusative context the four forms are 'objekt' / 'objekta' / 'objekte' / 'objektov'; nominative gives 'objekt' / 'objekta' / 'objekti' / 'objektov'. Verified against @nextcloud/l10n: library and header agree exactly, so the arrays are in header order and no pluralOrder acknowledgement is needed.", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "CSV": "File-format initialism, written identically in Slovenian; every finished locale keeps it as-is.", + "Deck": "Proper name of the Nextcloud Deck app; product names are not translated.", + "DSAR": "Initialism for Data Subject Access Request. Slovenian has coined no equivalent acronym, and the expanded key 'Data-subject access request' is translated in full beside it as 'Zahteva posameznika za dostop do podatkov'.", + "Excel (.xlsx)": "Product name plus a file extension; both are invariant in Slovenian.", + "ID": "Initialism written identically in Slovenian; the lowercase variant key 'Id' is normalised TO this form, and the inflected uses decline the following noun instead ('ID objekta', 'ID registra').", + "ID:": "Same initialism with a colon. Slovenian puts no space before a colon, so the punctuation is genuinely unchanged.", + "ID: {id}": "Same initialism plus the placeholder; nothing in this string differs in Slovenian.", + "Interval": "Slovenian noun 'interval' is spelled identically. It is also why the histogram key 'Bucket' became 'Razred' rather than 'Interval' — the two would otherwise collide, since 'Interval' is the separate date-facet granularity field in EditSchemaProperty.vue.", + "Minimum": "Slovenian noun 'minimum' is spelled identically. Note the asymmetry with 'Maximum', which is NOT a cognate here: Slovenian spells that one 'maksimum' with a k, and the bundle's pre-existing value already did. The compounds are inflected either way ('Najmanjša dolžina', 'Največja vrednost').", + "OpenDocument (.ods)": "Product name plus a file extension; both are invariant in Slovenian.", + "PDF": "File-format initialism, identical in Slovenian; no locale in this app carries a differing value for it.", + "RBAC": "Initialism for role-based access control, used untranslated in Slovenian IT writing; the surrounding prose keys are translated in full and the matrix column headers are the Slovenian verbal nouns 'branje' / 'ustvarjanje' / 'posodabljanje' / 'brisanje' / 'upravljanje'.", + "Register": "Slovenian for a register IS 'register' — identical spelling and the same Latin root. The declined forms elsewhere in this bundle prove it is a real Slovenian word rather than an untranslated one ('ID registra', 'Vsi registri', 'Uredi register').", + "Register:": "Same Slovenian noun with a colon. Slovenian puts no space before a colon, so the punctuation is genuinely unchanged too.", + "URL": "Initialism written identically in Slovenian. Core sl expands it to 'Naslov URL', but this bundle's pre-existing values keep the bare form ('Osnovni URL', 'URL podatkovne baze'), and bundle-internal consistency outranks core on lexicon.", + "UUID:": "Initialism with a colon. Slovenian puts no space before a colon, so the punctuation is genuinely unchanged.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", + "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens.", + "objectType: object\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens and are never translated.", + "Browser Web Push (VAPID)": "Browser and Web Push are the protocol's own names and VAPID is its acronym; the protocol name is not translated.", + "Bucket": "Object-storage term, used unchanged in this locale's technical language.", + "Driver": "Database-driver term, used unchanged in this locale's technical language.", + "Golden record": "Master-data-management term with no established local equivalent; used unchanged in data-governance language.", + "Host *": "Networking term, used unchanged in this locale, with the required-field marker.", + "Id": "Abbreviation of identifier, written the same way in this locale's technical UI.", + "Model": "Same spelling and meaning in this locale.", + "myapp": "A literal example application id shown verbatim; not prose.", + "Ollama URL": "Product name plus the invariant abbreviation URL.", + "Survivor": "Master-data-management term for the record that survives a merge; used unchanged in data-governance language.", + "Test": "Same spelling and meaning in this locale.", + "Url": "Invariant abbreviation, written the same way in this locale.", + "{property} - {other}": "Two placeholders joined by a dash; there is no prose to translate.", + "Avatar": "International term, unchanged in Slovenian; Nextcloud core sl renders it identically.", + "Format": "Same spelling and meaning in Slovenian.", + "Register #{id}": "The noun 'register' is identical in Slovenian and the rest is a number sign and a placeholder.", + "Slug": "URL-slug term, used unchanged in Slovenian developer language.", + "Webhook": "Protocol term, used unchanged in Slovenian technical language." + }, + "corrections": { + "Audit trail #{id}": "Pre-existing value was 'Revizijski trag #{id}'. 'Trag' is CROATIAN/Serbian for a trace; the Slovenian word is 'sled', which this bundle itself uses in all 14 of its other audit-trail keys ('Revizijske sledi', 'Podrobnosti revizijske sledi'). The gender agreement was wrong as a consequence too, since 'sled' is feminine. Corrected to 'Revizijska sled #{id}'. Worth noting the neighbourhood: openbuild ships a Croatian catalogue under sl.json, so a Croatian word appearing in a Slovenian bundle is not a coincidence to shrug at.", + "Objects already stored under this schema may no longer match it. Save anyway?": "Pre-existing value opened with 'Predmeti', the only occurrence of that word in the bundle, against 74 values using 'objekt' for the same concept. 'Predmet' means an item or a school subject, so it also collides with the sense of the 'Subject' key. Corrected to 'Objekti'; the rest of the sentence, including the idiomatic 1sg 'Vseeno shranim?', is unchanged." + }, + "lexiconNote": "sl translates far more aggressively than sk, and the pre-existing bundle decides these, not core: 'spletni kljuk' for webhook, 'koristni tovor' for payload, 'Vrata' for Port, 'Oznaka' for Slug, 'žeton' for token, 'del' for chunk, 'vložitev' for embedding, 'nadzorna plošča' for dashboard, 'zgoščena vrednost' for hash, 'potek dela' for workflow but 'tok' for flow, 'revizijska sled' for audit trail and 'revizijski vnos' for an audit entry. Most consequential: AI is rendered 'UI' (umetna inteligenca) — 'Funkcije UI', 'agenta UI' — so generic AI strings take UI while product names keep the English ('Fireworks AI', 'OpenAI', 'Dolphin AI'), exactly as the pre-existing values do. Two deliberate divergences from the harvest: 'Revoke' is 'Odvzemi' rather than core's 'Prekliči', because this bundle already uses 'Prekliči' for Cancel and the two appear on the same account screen; and 'Right' is 'Pravica' (the RBAC sense) rather than core's 'Desno' (right-aligned). 'Label' and the pre-existing 'Slug' both render 'Oznaka' — accepted rather than churned, since they never appear in the same control." +} diff --git a/scripts/l10n/locales/sq.json b/scripts/l10n/locales/sq.json new file mode 100644 index 0000000000..d0dfe218a2 --- /dev/null +++ b/scripts/l10n/locales/sq.json @@ -0,0 +1,369 @@ +{ + "register": "formal", + "registerEvidence": "FORMAL, and the least ambiguous verdict of any locale in this set. §5 step 2 ran as written: core ships five sq catalogues in the scanned roots (lib 146, encryption 51, settings 227, twofactor_backupcodes 12, user_ldap 167 = 603 values) and scores 218 polite markers against 1. Corroborated over the four sibling frontends (3377 translated values): 556 formal against 2. Albanian's V-form is the 2pl `Ju` on the French/Russian model — live and ordinary, neither archaic (contrast is) nor available-but-unused (contrast mt). The 2sg pronoun `ti` does not occur ONCE in the whole 3980-value corpus. The three informal values that do exist are all outside this bundle: core/settings \"Vendos fjalëkalimin tënd\" (2sg possessive) and openconnector's \"Nuk ke ende kredenciale të ndërmjetësuara\" plus \"...në vend që të ruash...\" (2sg indicative and 2sg subjunctive). openregister's own 1019 pre-existing values carry zero.", + "registerDetectorNote": "detectors/sq.js. fold() LOWERCASES, unlike lb.js — checked, not assumed: Albanian capitalises the polite `Ju` by convention but lowercase `ju` is the same 2pl pronoun and equally polite (core \"Nuk ju lejohet ta ndani %s\", \"ju lutemi, riprovoni\"), so there is no dir/Dir split to preserve and folding buys recall on sentence-initial forms. Formal markers: the 2pl pronoun in all cases (ju/juve/jush/jua), the juaj/tuaj possessive family written out in full, and a closed list of ~85 2pl finite forms. Informal markers: ti, the 2sg possessives (yt/jote/tënd/tënde), the three irregular 2sg presents whose 3sg differs (ke/ka, je/është, mundesh/mundet), the 2sg imperfect (ishe/kishe/doje), a closed 2sg-subjunctive list, and `të lutem`. 86 controls, all passing.", + "registerTraps": "THE LEFT GUARD CARRIES A HYPHEN and that is new in this set. Albanian attaches the definite/case ending to acronyms and loanwords after a hyphen — UUID-je, URL-je, Token-i, PHP-ja, DN-ja, email-it — so the `(?=6 words that are not Title Case, openregister's own half is 0 capitalised against 177 lowercase, core 0/17, the siblings 3/304. (b) SHORT LABELS AND HEADINGS — the English key's own Title Case is MIRRORED: openregister 270 follow against 11 flatten, core 39/1, opencatalogi 100/0, launchpad 65/5 (openconnector is the lone dissenter at 9/96). A naive scan that does not condition on the source's casing reports openregister's own half as capitalising domain terms 25-35% of the time against a family rate near zero, which reads as a ~110-value defect class. It is entirely an artefact of the Title-Cased heading keys. So: mirror the key for a label, lowercase inside a sentence.", + "progressiveNote": "In-flight states take `Duke ` in this bundle, measured 30 against 3 over English `-ing` keys, with core leaning the same way (4 against 2, and core renders `Verifying …` as `Duke verifikuar ...`). The family is split by app — launchpad prefers `Po ` 26:0 and opencatalogi 14:0 — so this is a house convention of openregister's own bundle per §3.5, not a language-wide rule.", + "politenessFormulaNote": "`ju lutem` / `ju lutemi` IS register-bearing here, which makes Albanian only the second locale checked where the politeness formula pays. Albanian's \"please\" inflects for the ADDRESSEE — `ju lutem` is literally \"I beg you(pl)\" and `të lutem` \"I beg you(sg)\" — so unlike lb `wann ech glift`, is `vinsamlegast` and ga `le do thoil`, which all inflect for the speaker and are register-neutral, the choice of object clitic is itself the T-V choice. Measured 83 occurrences of `ju lutem` against 0 of `të lutem`. It needs no separate formal pattern (the bare `ju` already matches it) but `të lutem` earns its own informal one.", + "lexiconNote": "Rejected cognates, recorded here rather than in `cognates` per §3.2: `Slug` is translated `Emër i shkurtër`, following the short-name pattern the three most recent passes used (lb Kuerznumm, is Stuttheiti, et Nälk) — the bundle had no prior slug term to defer to, and §3.3 shows nine locales translate it. `Total`/`Total:` become `Totali`/`Totali:`, matching the bundle's own `Totalet e Regjistrit`. `Format`, `Minimum`, `Status`, `Version` take the definite form (`Formati`, `Minimumi`, `Statusi`, `Versioni`), which both follows the bundle's own field labels and removes them from the identical set. `Test` is a webhook-delivery BUTTON at src/views/webhooks/WebhooksIndex.vue:167, not the noun — `Testo`, matching the bundle's own `Testo Lidhjen`/`Testo Chat` (§8.4). `Admin` is a filter option value, so `Administrator`. `Port` follows core's `Portë` because the bundle's own `Port` was an unjustified identical rather than a lexical choice. Kept from the bundle per §3.5: `webhook` stays untranslated in all 40 of its keys, including the plural (`Të gjitha Webhooks`, `ngarkimi i webhooks`).", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "OpenDocument (.ods)": "product name plus a literal file extension; nothing here is prose", + "PDF": "the file-format initialism is used as-is in Albanian", + "RBAC": "the access-control acronym is used untranslated in Albanian technical writing; the expansion is translated where it appears in prose", + "ID: {id}": "same as ID and ID:; the initialism is used as-is in Albanian and the rest of the value is a placeholder", + "Interval": "`interval` is the ordinary Albanian noun for a span, spelled identically — and it is the word this bundle already uses in `Intervali i Datave`, `Intervali Numerik` and `Intervali i Gjatësisë`", + "CSV": "the file-format initialism is used as-is in Albanian; every locale that differs does so only by expanding it", + "DSAR": "the GDPR acronym for a data-subject access request, used untranslated in Albanian privacy practice — the expanded phrase is translated separately under `Data-subject access request`", + "Deck": "the Nextcloud app's proper name", + "Excel (.xlsx)": "product name plus a literal file extension; nothing here is prose", + "Avatar": "`avatar` is the ordinary Albanian noun for this, spelled identically and inflected natively (avatari, avatarit) in the surrounding values", + "{property} - {other}": "pure punctuation: Albanian separates two enumerated names with a spaced ASCII hyphen, as sr does. The locales that differ do so only in the dash character (ru em dash, hr en dash, is em dash), which is what makes this translatable-but-identical rather than unwrappable (§3.3)", + "Email": "Albanian uses the loanword; core sq inflects it as a native noun (email-it, Adresa juaj e email-it) and the bundle's own `Emails` already renders as `Email`", + "ID": "the initialism is used as-is in Albanian; only ga, is, hu, tr, fi, el and the Cyrillic locales expand it", + "ID:": "same as ID; Albanian puts no space before a colon, unlike fr", + "Linear": "`linear` is an ordinary Albanian adjective spelled identically to the English; it labels a webhook delivery mode at src/modals/webhook/EditWebhook.vue:540", + "Person": "`person` is an ordinary Albanian noun spelled identically; it labels an entity type in the sidebar", + "Serial:": "`serial` is an ordinary Albanian adjective spelled identically, and the bundle's own `Serial Mode` already renders as `Modaliteti Serial`", + "URL": "the initialism is used as-is in Albanian and inflected with a hyphen (URL-në, URL-je); only is translates it", + "UUID:": "the initialism is used as-is in Albanian; only fr differs, and only by adding a space before the colon", + "Webhook": "kept untranslated throughout the bundle's 40 webhook keys per §3.5 — Webhook u krijua me sukses, Krijo Webhook, emrin e webhook", + "Webhooks": "same, and Albanian pluralises this loanword by leaving it alone in the bundle's own values (Të gjitha Webhooks, Kërko Webhooks, ngarkimi i webhooks)", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose." + }, + "spellAllowNote": "Three classes: product and app names, literal code/example tokens that survive the tokeniser, and this locale's domain coinages (the fasetë and vektoriz- families, Shumëqiramarrje, Transferueshmëri, Vendmbajtës, rebazim, renderoh-). Tokeniser artefacts (`et`, `eve`, `it`, `t`, `S`, `Gjeneruara`) are left OUT on purpose: they are splits of `p.sh.`, `t'u` and `S'aplikohet`, and allowlisting them would hide a real word that happens to split the same way.", + "spellAllow": [ + "AppAPI", + "CloudEvent", + "CloudEvents", + "Deck", + "Dolphin", + "Excel", + "Fireworks", + "GitHub", + "GitLab", + "GraphQL", + "KvK", + "Mail", + "Nextcloud", + "Ollama", + "OpenAI", + "OpenAnonymiser", + "OpenDocument", + "OpenRegister", + "Persoonsgegevens", + "Autoriteit", + "ExApp", + "Sidecar", + "pgvector", + "Accept", + "Language", + "active", + "array", + "backend", + "blob", + "cache", + "chat", + "commit", + "days", + "email", + "embedding", + "embeddings", + "enum", + "filecollection", + "lib", + "link", + "local", + "loopback", + "objectcollection", + "occ", + "ods", + "payload", + "public", + "ref", + "regex", + "settings", + "type", + "web", + "webhook", + "webhooks", + "xlsx", + "year", + "maks", + "Org", + "Serial", + "avatar", + "avatari", + "avatarin", + "avatarit", + "fasetë", + "Fasetës", + "Fasetave", + "Fasetimi", + "Fasetimit", + "fasetimin", + "fasetueshëm", + "fasetueshme", + "vektorizim", + "vektorizimi", + "vektorizimin", + "vektorizimit", + "vektorizimet", + "vektorizohen", + "vektorizuan", + "vektorizuar", + "vektorizuara", + "Vektorizo", + "Vektorizoni", + "Rivektorizo", + "rivektorizoni", + "Shumëqiramarrje", + "shumëqiramarrjes", + "transferueshmëri", + "Vendmbajtësat", + "vendmbajtëse", + "rebazimit", + "renderohen", + "renderohet", + "renderuesi", + "rikryer", + "Faqeruajtës", + "fjalëkyçesh", + "linearisht", + "sekuencialisht", + "kaskadë", + "jurisdiksion", + "autoritative", + "atribuimin", + "auditoni", + "Auditim", + "autentikimit", + "opsional", + "opsionale", + "përkthyeshme", + "padisponueshëm", + "pakërkuar", + "sinkronizime", + "Histogrami", + "Inicializo", + "Instalojeni", + "Kopjojeni", + "Aktivizojeni", + "aktivizuesit", + "Anonimizuar", + "Eksponencial", + "Optimizimi", + "Serializimi", + "serializohen", + "Inicializimi", + "inicializohet", + "inicializua", + "Analitika", + "analitikën", + "metrikave", + "performancës", + "Kompleksitetit", + "kontribuara", + "klasit", + "validimi", + "validimin", + "Valido", + "validuar", + "riprova", + "riprovës", + "rifreskua", + "përditësua", + "përditësuan", + "përditësimet", + "Menuja", + "menusë", + "auditimi", + "auditimit" + ], + "corrections": { + "Export completed successfully": "CONSISTENCY", + "Load advanced filters with live data from your search index": "LEFT-IN-ENGLISH", + "Are you sure you want to cleanup old search trails? This will delete entries older than 30 days.": "TERM-ENTRY", + "Auto-retry failed vectorizations": "ORTH-VEKTOR", + "Automatically retry failed vectorization attempts (max 3 retries)": "ORTH-VEKTOR", + "Before object vectorization can work:": "ORTH-VEKTOR", + "Choose which file types to include in the vectorization process. Only files with extracted text and chunks will be processed.": "ORTH-VEKTOR", + "Choose which views to include in the vectorization process. Leave empty to process all views based on your configuration.": "ORTH-VEKTOR", + "Chunks to Vectorize:": "ORTH-VEKTOR", + "Clear Entries": "TERM-ENTRY", + "Configure how database objects are converted into vector embeddings for semantic search. Objects are directly vectorized without needing text extraction.": "ORTH-VEKTOR", + "Configure how objects are converted to text before vectorization. These settings affect search quality and context.": "ORTH-VEKTOR", + "Configure parameters for file vectorization. This process will generate vector embeddings for all extracted file chunks.": "ORTH-VEKTOR", + "Configure parameters for object vectorization. This process will generate vector embeddings for all objects matching your view filters.": "ORTH-VEKTOR", + "Control which object views should be vectorized to reduce API costs. Only vectorize views that benefit from semantic search.": "ORTH-VEKTOR", + "Do you want to permanently delete all filtered audit trail entries? This action cannot be undone.": "TERM-ENTRY", + "Do you want to permanently delete this audit trail entry? This action cannot be undone.": "TERM-ENTRY", + "Enable automatic file vectorization": "ORTH-VEKTOR", + "Enable automatic object vectorization": "ORTH-VEKTOR", + "Enable vectorization for all existing and future views (may increase costs)": "ORTH-VEKTOR", + "Entries to be deleted:": "TERM-ENTRY", + "File chunk vectorization is not yet implemented. The chunks are ready and stored, but the vectorization service is under development.": "ORTH-VEKTOR", + "File Vectorization": "ORTH-VEKTOR", + "File vectorization configuration saved successfully": "ORTH-VEKTOR", + "File vectorization started. Check the statistics section for progress.": "ORTH-VEKTOR", + "Filter and analyze search trail entries": "TERM-ENTRY", + "Filter and manage audit trail entries": "TERM-ENTRY", + "Loading log details...": "COLLISION-LOG", + "Log integrity": "COLLISION-LOG", + "Logs": "COLLISION-LOG", + "No audit trail entries found": "TERM-ENTRY", + "No chunks to vectorize": "ORTH-VEKTOR", + "No filters are currently active. This will delete ALL audit trail entries!": "TERM-ENTRY + AGREEMENT", + "No log entries found": "TERM-ENTRY", + "No logs are available for this configuration.": "COLLISION-LOG", + "No logs are available for this source.": "COLLISION-LOG", + "No logs found": "COLLISION-LOG", + "No search trail entries found": "TERM-ENTRY", + "No views found. Create views first before configuring vectorization.": "ORTH-VEKTOR", + "Number of chunks to vectorize in one API call (1-100).": "ORTH-VEKTOR", + "Number of chunks to vectorize in one API call. Higher = faster but more memory. Recommended: 10-50.": "ORTH-VEKTOR + LEFT-IN-ENGLISH", + "Number of objects to vectorize in one API call. Higher = faster but more memory. Recommended: 10-50.": "ORTH-VEKTOR + LEFT-IN-ENGLISH", + "Object Vectorization": "ORTH-VEKTOR", + "Object vectorization configuration saved successfully": "ORTH-VEKTOR", + "Objects will be serialized as JSON text before vectorization": "ORTH-VEKTOR", + "Select File Types to Vectorize:": "ORTH-VEKTOR", + "Select Views to Vectorize:": "ORTH-VEKTOR", + "Select views to vectorize:": "ORTH-VEKTOR", + "Show only entries with changes": "TERM-ENTRY", + "Start Vectorization": "ORTH-VEKTOR + BUTTON-2SG", + "Text chunks are generated during file extraction and stored in the database. Vectorization reads these pre-chunked files and converts them to embeddings.": "ORTH-VEKTOR", + "There are no audit trail entries matching your current filters.": "TERM-ENTRY", + "There are no search trail entries matching your current filters.": "TERM-ENTRY", + "There are no webhook log entries matching your filters.": "TERM-ENTRY", + "This audit trail entry does not contain any change information.": "TERM-ENTRY", + "Use filters to narrow down audit trail entries by register, schema, action type, user, date range, or object ID.": "TERM-ENTRY", + "Use filters to narrow down search trail entries by register, schema, success status, user, date range, search terms, or performance metrics.": "TERM-ENTRY", + "Vectorization Triggers": "ORTH-VEKTOR", + "Vectorize all file types": "ORTH-VEKTOR + BUTTON-2SG", + "Vectorize All Files": "ORTH-VEKTOR + BUTTON-2SG", + "Vectorize All Objects": "ORTH-VEKTOR + BUTTON-2SG", + "Vectorize all views": "ORTH-VEKTOR + BUTTON-2SG", + "Vectorize on object creation": "ORTH-VEKTOR + BUTTON-2SG", + "vectorized": "ORTH-VEKTOR", + "View and analyze search trail logs with advanced filtering and analytics capabilities": "COLLISION-LOG", + "View Logs": "COLLISION-LOG + BUTTON-2SG", + "View search analytics and manage search logs": "COLLISION-LOG", + "View webhook delivery logs and filter by webhook": "COLLISION-LOG", + "Webhook Log Details": "COLLISION-LOG", + "Webhook Logs": "COLLISION-LOG", + "🔢 Vectorization Settings": "ORTH-VEKTOR", + "Purge": "COLLISION-PURGE", + "Purge Date": "COLLISION-PURGE", + "No purge date set": "COLLISION-PURGE", + "Analyze Objects": "BUTTON-2SG", + "Analyze Properties": "BUTTON-2SG", + "Calculate Sizes": "BUTTON-2SG", + "Continue to Properties": "BUTTON-2SG", + "Discover Files": "BUTTON-2SG", + "Extend Schema": "BUTTON-2SG", + "Filter Audit Trails": "BUTTON-2SG", + "Filter Deleted Items": "BUTTON-2SG", + "Filter Objects": "BUTTON-2SG", + "Filter Properties": "BUTTON-2SG", + "Filter Search Trails": "BUTTON-2SG", + "Filter Statistics": "BUTTON-2SG", + "Initialize Project": "BUTTON-2SG", + "Manage Organisation Roles": "BUTTON-2SG", + "Open n8n Editor": "BUTTON-2SG", + "Open Nextcloud App Store": "BUTTON-2SG", + "Reset Changes": "BUTTON-2SG", + "Reset Filters": "BUTTON-2SG", + "Start Conversation": "BUTTON-2SG", + "Toggle search sidebar": "BUTTON-2SG", + "View API Docs": "BUTTON-2SG", + "View Changes": "BUTTON-2SG", + "View Details": "BUTTON-2SG", + "View File": "BUTTON-2SG", + "View Full Details": "BUTTON-2SG", + "View Object": "BUTTON-2SG", + "Wait for Response": "BUTTON-2SG", + "Warmup Names Cache": "BUTTON-2SG", + "Re-vectorize on object update": "BUTTON-2SG", + "Rerun Search": "SENSE", + "Active Filters": "AGREEMENT", + "Active filters:": "AGREEMENT", + "No active filters": "AGREEMENT", + "All Files": "AGREEMENT", + "Total Deleted Items": "AGREEMENT", + "Popular Search Terms": "AGREEMENT", + "This source has no associated registers.": "AGREEMENT", + "Registers": "NUMBER", + "No registers found": "NUMBER", + "No registers found for this application.": "NUMBER", + "Run oversight checks before each step": "TYPO", + "Bookmarks": "TYPO", + "Exclusive Maximum": "TYPO", + "Exclusive Minimum": "TYPO", + "Wrap webhook payload in CloudEvents format for better interoperability": "TYPO", + "Delays increase linearly (5, 10, 15 minutes...)": "TYPO", + "Required status is inconsistent": "TYPO", + "Search by file name or path": "TYPO", + "Select registers and schemas to save a view": "TYPO", + "All clear": "SENSE", + "Count": "SENSE", + "Headers": "SENSE", + "Custom HTTP headers (one per line, format: Header-Name: value)": "SENSE", + "or pick a range": "SENSE", + "Failed to update favorite status": "SENSE", + "View Error": "SENSE", + "For chat and retrieval-augmented generation": "SENSE", + "Auto-create Default Organisation": "TERM-DEFAULT", + "Default Organisation": "TERM-DEFAULT", + "Automatically create a default organisation if none exists when the app is initialized": "TERM-DEFAULT", + "Select default organisation": "TERM-DEFAULT", + "Loading organisations...": "CONSISTENCY-PROGRESSIVE", + "Reading seal coverage …": "CONSISTENCY-PROGRESSIVE", + "Verifying …": "CONSISTENCY-PROGRESSIVE", + "Enable faceting": "TERM-FACET", + "No facetable fields available. Select a register and schema to see available filters.": "TERM-FACET", + "Audit trail data copied to clipboard": "LEFT-IN-ENGLISH", + "Changes copied to clipboard": "LEFT-IN-ENGLISH", + "Full data copied to clipboard": "LEFT-IN-ENGLISH", + "Failed to copy data to clipboard": "LEFT-IN-ENGLISH", + "Public views can be accessed by anyone in the system": "TERM-ACCESS", + "Erasure preview": "TERM-PREVIEW", + "Add Endpoint": "TERM-ENDPOINT", + "Endpoints": "TERM-ENDPOINT", + "Custom API endpoint if using a different region": "TERM-ENDPOINT", + "High": "ARTICLE", + "Low": "ARTICLE", + "LLM features disabled": "AGREEMENT-PARTICIPLE", + "LLM features enabled": "AGREEMENT-PARTICIPLE", + "n8n integration disabled": "AGREEMENT-PARTICIPLE", + "n8n integration enabled": "AGREEMENT-PARTICIPLE", + "Off by default: one entry per step per run is a lot of writes, and the run history already records what each step did. Turn this on where a compliance record of every step is required.": "AGREEMENT-PARTICIPLE", + "On by default. Oversight checks — contributed by apps — can stop a running flow. Turning this off applies to every flow that has not chosen for itself.": "AGREEMENT-PARTICIPLE", + "Managed": "ARTICLE", + "Visible to users": "ARTICLE", + "Read": "PERMISSION-NOUN", + "How deep to traverse nested object properties (1-20). Higher values capture more detail but increase vector size.": "GARBLED", + "An entry with no hash is one the chain cannot vouch for. A background job sweeps these every five minutes and seals the oldest first, so a backlog after heavy write activity is normal and drains on its own. A backlog that never shrinks is not — it means sealing is failing on both the write path and the sweep.": "GARBLED" + }, + "correctionCodes": "Class codes used in `corrections`, documented once per §6.9. ORTH-VEKTOR: the stem is spelled `vektor` in Albanian (c is /ts/, not /k/), and the bundle already wrote `vektoriale`/`vektorë`/`Rivektorizoni` 15 times — one value even carried both spellings (\"parametrat për vectorizimin ... embeddings vektoriale\"), which is what settles it. BUTTON-2SG: a 2pl imperative on a short label, against the measured length gradient in `buttons`. TERM-ENTRY: `hyrje` used for a log/audit ENTRY, where core sq uses that word for ACCESS (\"të kenë hyrje te instanca\") and renders \"directory entries\" as `zëra`; this bundle's own plural array and its chain-verification prose already use `zë`/`zëra`. COLLISION-LOG: `Logs` and `Registers` both rendered `Regjistra` and are ADJACENT TABS in src/modals/source/ViewSource.vue:246 — every finished locale distinguishes them, so `ditar` takes the log sense. COLLISION-PURGE: `Purge` and `Clear` both rendered `Pastro`; `Purge` is a button at src/modals/deleted/PurgeMultiple.vue:112, so it takes `Spastro`. AGREEMENT: adjective or participle not agreeing with its noun. AGREEMENT-PARTICIPLE: a bare participle where the sentence needs a finite aorist (`u çaktivizuan`, not `çaktivizuara`). ARTICLE: a predicative adjective missing its preposed article, where the sibling values in the same control (`Shumë i lartë`, `Mesatar`, `I thjeshtë`) show the convention. NUMBER: singular where the English is plural. TYPO. SENSE: right word, wrong meaning. TERM-*: a term inconsistent with the bundle's own or the family's. LEFT-IN-ENGLISH: an English word left in an otherwise translated value. CONSISTENCY-PROGRESSIVE: `Po ` where this bundle uses `Duke ` 30:3. PERMISSION-NOUN: a §8.7 permission-matrix rendered as an imperative. GARBLED: a value whose Albanian does not parse." +} diff --git a/scripts/l10n/locales/sr.json b/scripts/l10n/locales/sr.json new file mode 100644 index 0000000000..90acd6c9c6 --- /dev/null +++ b/scripts/l10n/locales/sr.json @@ -0,0 +1,437 @@ +{ + "register": "formal", + "registerEvidence": "Core sr scores 911 formal (Ви / вас / вам / ваш / 2pl verb) markers against ZERO informal over 4631 values in 32 catalogues — verdict FORMAL, and one-sided in the same way lt, sk and sl were. That zero is what makes the two pre-existing deviations below unambiguous rather than a judgement call: core never produces a 2sg present at all. This bundle's own 1053 pre-existing keys agree, with the polite pronoun written LOWERCASE mid-sentence ('Управљајте вашим Регистрима података', 'Нема уноса ревизорског трага који одговарају вашим тренутним филтерима') rather than the capitalised 'Ваш' that careful Serbian prose uses; the detector folds case, and new values follow the file.", + "script": "CYRILLIC. Serbian ships in both alphabets in the wild, so this is a deliberate finding rather than an assumption: 945 of the 1055 pre-existing values are Cyrillic-only, 90 mix Cyrillic with Latin technical tokens (API, URL, HTTP, LLM, n8n, ms), and the 20 Latin-only values are the 18 untranslated English strings plus exactly two defects — 'Revizijski trag #{id}' and 'NEMA RADNJE' — both corrected in this pass. Recorded in SCRIPTS in scripts/l10n/script-coverage.js so the sweep can run.", + "buttons": "BARE 2sg IMPERATIVE, whatever the prose does — the ca/et/hr/sl pattern. The pre-existing bundle follows it throughout: 'Сачувај', 'Обриши', 'Додај', 'Креирај', 'Уреди', 'Откажи', 'Освежи', 'Врати', 'Уклони', 'Изабери', 'Прикажи', 'Претражи', 'Тестирај', 'Валидирај', 'Векторизуј', 'Објави', 'Синхронизуј', 'Загреј'. The split is by string ROLE and the bundle's own adjacent keys show it: 'Select All' -> 'Изабери све' and 'Select a branch' -> 'Изабери грану' (2sg, labels and dropdown placeholders) against 'Select which property from the event should be used…' -> 'Изаберите које Својство…' and 'Please select which register and schema to use…' -> 'Изаберите који Регистар и Шему…' (2pl, instruction sentences).", + "buttonsWhyImperativeIsExcluded": "detectors/sr.js does NOT count the bare 2sg imperative, for the same two independent reasons as sl. First, it is the correct label convention, so counting it would flag every button in the app. Second, for the -ити verb class the 2sg imperative is spelled exactly like the 3sg present indicative — 'уреди' is both 'edit!' and 'he edits', and so are 'врати', 'провери', 'потврди'. The -ати/-овати classes do differ ('додај' vs 'додаје', 'копирај' vs 'копира'), but the collision class is too large to carve out. Consequence worth stating: an informal-looking pre-existing value has to be inspected for WHAT makes it informal before it is called a slip — 'Изабери модел или унеси прилагођени назив модела' looks like a deviation and is not, because 'унеси' is a 2sg IMPERATIVE and therefore the label convention, while its two siblings carry 2sg PRESENTS and are real slips. See corrections.", + "capitalisationNote": "THE CONVENTION MOST LIKELY TO BE BROKEN BY ACCIDENT, and it is measured rather than inferred. This bundle capitalises the six first-class register concepts mid-sentence, like proper nouns: Шема (33 capitalised vs 1 lowercase), Регистар (30 vs 0), Објекат (62 vs 0), Својство (25 vs 0), Датотека (43 vs 0), Извор (5 vs 1). Everything else is lowercase, including the terms one might expect to join them: приказ (0 capitalised vs 41 lowercase), ентитет (0 vs 13), ток (0 vs 25 — the two apparent capitals are sentence-initial). So 'Обриши све Објекте у овој Шеми' but 'Обриши приказ'. The two lowercase outliers were normalised in this pass; see corrections. No other locale in this project does this, and nothing automated checks it.", + "pluralNote": "nplurals=3 with the same expression as hr and ru — modular on n%10/n%100: form 0 is n%10==1 && n%100!=11, form 1 is n%10 in 2..4 excluding n%100 in 10..19, form 2 is everything else including ZERO. The three forms are Serbian nominative singular / genitive singular (the paucal) / genitive plural: 'Објекат' / 'Објекта' / 'Објеката'. Two consequences worth spelling out. First, forms 1 and 2 are HOMOGRAPHS for some nouns and genuinely so — 'унос' gives 'уноса' for both, which is why the pre-existing _%n entry has no hash yet_ array repeats itself and is correct; 'е-порука' collides the other way round, forms 0 and 2 both reading 'е-порука'. Second, a key with no numeral in it does not use the paucal at all: '_Object successfully deleted_' takes the plain plural 'Објекти су успешно обрисани' for both forms 1 and 2, while '_Delete {count} object_' needs 'Објекта' / 'Објеката'. Participles agree with the count too: 'Успешно враћен 1 Објекат' / 'Успешно враћена 2 Објекта' / 'Успешно враћено 5 Објеката'. Verified against @nextcloud/l10n: library and header agree, so no pluralOrder acknowledgement is needed. NOTE the boundary differs from sk/cs, which bound absolutely — 22 selects form 1 here and form 2 in Slovak.", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "CSV": "File-format initialism. This bundle keeps Latin-script computing initialisms unchanged rather than transliterating them into Cyrillic — pre-existing practice for 'API кључ', 'HTTP метода', 'LLM конфигурација', 'JSON', 'OAS', 'SHA-256' and 'HMAC' — so this is a genuine cognate and not an untranslated leftover. Note the bundle does NOT do this uniformly: 'ID' is transliterated to 'ИД' and 'Slug' to 'Слаг', so each initialism is a separate decision rather than a blanket rule.", + "URL": "Initialism written identically in Serbian, in Latin script. Note this bundle does NOT transliterate it the way it transliterates 'ID' -> 'ИД' and 'Slug' -> 'Слаг': the pre-existing values keep the bare Latin form and inflect the surrounding noun or attach a Cyrillic case ending with a hyphen ('URL базе података', 'Претражи по називу или URL-у'), which is standard Serbian practice for Latin initialisms.", + "UUID:": "Initialism with a colon. Serbian puts no space before a colon, so the punctuation is genuinely unchanged too. Kept in Latin script for the same reason as URL.", + "{property} - {other}": "Two placeholders joined by a hyphen — a format hint for a calendar event title, with no translatable word in it. It is NOT untranslatable in general (three distinct values exist across the 37 bundles, since some locales substitute an en or em dash), but Serbian joins such a pair with the same plain hyphen, and this bundle keeps plain hyphens everywhere the source has one. So the value is genuinely unchanged rather than unconsidered.", + "PDF": "File-format initialism, identical in Serbian and kept in Latin script; no locale in this app carries a differing value for it.", + "RBAC": "Initialism for role-based access control, used untranslated in Serbian IT writing. The surrounding prose keys are translated in full ('RBAC подешавања су успешно ажурирана') and the matrix column headers are the Serbian verbal nouns 'читање' / 'креирање' / 'ажурирање' / 'брисање' / 'управљање'.", + "Ollama URL": "Product name plus an initialism, both invariant. This value is PRE-EXISTING at HEAD and is a deliberate contrast with the bundle's own 'Database URL' -> 'URL базе података' and 'URL where Ollama is running' -> 'URL на коме се покреће Ollama': where the source gives a bare product-plus-initialism label there is nothing to translate, and where it gives a phrase the phrase is translated.", + "OpenDocument (.ods)": "Product name plus a file extension; both are invariant in Serbian and stay in Latin script.", + "Excel (.xlsx)": "Product name plus a file extension; both are invariant in Serbian and stay in Latin script, like every other file-format token in this bundle.", + "Deck": "Proper name of the Nextcloud Deck app; product names are not translated. The surrounding prose is translated in full and inflects around it ('Deck интеграција није доступна', 'Deck картицу').", + "DSAR": "Initialism for Data Subject Access Request. Serbian has coined no Cyrillic equivalent acronym, and the expanded key 'Data-subject access request' is translated in full beside it as 'Захтев субјекта података за приступ'. Every finished locale keeps DSAR untranslated.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", + "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens.", + "objectType: object\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens and are never translated.", + "Driver": "Database-driver term, used unchanged in this locale's technical language.", + "Host *": "Networking term, used unchanged in this locale, with the required-field marker.", + "Id": "Abbreviation of identifier, written the same way in this locale's technical UI.", + "Model": "Same spelling and meaning in this locale.", + "myapp": "A literal example application id shown verbatim; not prose.", + "Test": "Same spelling and meaning in this locale.", + "Url": "Invariant abbreviation, written the same way in this locale.", + "Browser Web Push (VAPID)": "Browser and Web Push are the protocol's own names and VAPID is its acronym; the protocol name is not translated.", + "Bucket": "Object-storage term, used unchanged in this locale's technical language.", + "Golden record": "Master-data-management term with no established local equivalent; used unchanged in data-governance language.", + "Survivor": "Master-data-management term for the record that survives a merge; used unchanged in data-governance language.", + "ID": "Latin acronym kept as-is in Serbian technical UI, as Nextcloud core does.", + "ID:": "Latin acronym with its label colon; see ID.", + "ID: {id}": "Latin acronym plus a placeholder; there is no prose to translate.", + "Slug": "URL-slug term, used unchanged in Serbian developer language.", + "Webhook": "Protocol term, used unchanged in Serbian technical language.", + "Status": "In Serbian Latin the word is spelled exactly as the English 'Status'; the Cyrillic 'Статус' transliterates to it directly.", + "Interval": "In Serbian Latin the word is spelled exactly as the English 'Interval'.", + "Minimum": "In Serbian Latin the word is spelled exactly as the English 'Minimum'.", + "Avatar": "In Serbian Latin this word is spelled exactly as the English 'Avatar'; the Cyrillic form transliterates to it directly.", + "Format": "In Serbian Latin this word is spelled exactly as the English 'Format'; the Cyrillic form transliterates to it directly." + }, + "corrections": { + "Audit trail #{id}": "Pre-existing value was 'Revizijski trag #{id}' — wrong on THREE counts at once, which is why it is worth spelling out. It was in LATIN script inside a Cyrillic bundle; 'trag' with that adjective form is CROATIAN rather than Serbian; and 'ревизијски' contradicted the 'ревизорски' this bundle uses in its 24 other audit-trail values. Corrected to 'Ревизорски траг #{id}'. Worth noting the neighbourhood: openbuild ships one Croatian catalogue under sr.json as well as bs/cs/hr/mk/sk/sl (harvest drops it automatically, §6.6), and the identical defect appeared in the sl bundle as 'Revizijski trag' — so a Croatian string in a Serbian bundle is a known contamination path, not a coincidence.", + "NO ACTION": "Pre-existing value was 'NEMA RADNJE' — correct Serbian, but written in LATIN script in a bundle that is otherwise entirely Cyrillic. Corrected to 'НЕМА РАДЊЕ'. Together with the entry above, these were the only two non-cognate Latin-only values in the bundle, and npm run l10n:script isolated both of them mechanically.", + "Could not read the seal coverage of the audit trail.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Recomputes every hash and compares it with the one stored. This reads the whole audit trail, so it is run on request rather than each time this page opens.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Write an audit-trail entry for every step": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Add Source": "Pre-existing value was 'Додај извор' with a lowercase 'извор', against 5 capitalised uses of the term elsewhere. Corrected to 'Додај Извор' for the capitalisation convention in capitalisationNote — a 1-of-6 outlier was not worth leaving in place while adding 999 values that follow the rule.", + "Objects already stored under this schema may no longer match it. Save anyway?": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Select a repository you have write access to": "Pre-existing value was 'Изабери репозиторијум за који имаш приступ за писање' — a 2sg PRESENT ('имаш'), so informal address, in a bundle whose prose is formal 2pl throughout and against a core that produces zero informal markers in 4631 values. Corrected to full 2pl. This is not a retreat from the 2sg label convention: the bundle itself already draws the line at instruction sentences, rendering 'Please select which register and schema to use…' and 'Select which property from the event…' as 'Изаберите…', so the correction makes the Select family MORE internally consistent, not less. Same split point sl uses for this very string.", + "Select registers and schemas to save a view": "Same 2sg-present slip as above ('сачуваш'), corrected to 'Изаберите Регистре и Шеме да сачувате приказ'. These two were the only detectable informal values in the bundle; a third, 'Изабери модел или унеси прилагођени назив модела', LOOKS like the same defect and was deliberately left alone because 'унеси' is a 2sg imperative rather than a present — see UNDETECTABLE in detectors/sr.js.", + "_%n entry has no hash yet_::_%n entries have no hash yet_": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "_%n declared in total_::_%n declared in total_": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "_%n is out of date_::_%n are out of date_": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "_%n register is missing_::_%n registers are missing_": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "_Delete {count} object_::_Delete {count} objects_": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "_file_::_files_": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "_log_::_logs_": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "_Object successfully deleted_::_Objects successfully deleted_": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "_object_::_objects_": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "_Purge {count} object from database_::_Purge {count} objects from database_": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "_register_::_registers_": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "_Restore {count} object_::_Restore {count} objects_": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "_schema_::_schemas_": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "_Successfully restored {count} object_::_Successfully restored {count} objects_": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "_{count} email_::_{count} emails_": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "_{count} schema_::_{count} schemas_": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "%n are out of date": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "%n declared in total": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "%n entries have no hash yet": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "%n entry has no hash yet": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "%n is out of date": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "%n register is missing": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "%n registers are missing": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Access (Art 15)": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Access results": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Accountability": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "across {count} registers": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Active method: {active}": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Add a connection": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Add another connection": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Add Application": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "All handlers": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "All statuses": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Allow private/loopback targets": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Allow writes (create, update and delete push through to the external database)": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "An entry with no hash is one the chain cannot vouch for. A background job sweeps these every five minutes and seals the oldest first, so a backlog after heavy write activity is normal and drains on its own. A backlog that never shrinks is not — it means sealing is failing on both the write path and the sweep.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "App": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "AppAPI is not installed. Install AppAPI and the OpenAnonymiser ExApp to enable Dutch-focused PII detection.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Apply redaction": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Apps ship their registers as descriptors that are imported when the app is installed or upgraded. Once an app's version stops changing that import never runs again, and a failed one is only written to the log — so a register can be missing on a instance that otherwise looks healthy. This is where you can see which ones landed.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Archive this processing activity? Audit-trail rows will keep referring to it.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Attribute": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Audit entries": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Audit trail of recent reversible merges. Reverse an operation while it is still within its reversal window.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Available": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Avatar": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Average score": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Awaiting a seal": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Back to cases": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Back to editing": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Base map layer": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Breaking change": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Browser notification client is still loading — please try again in a moment.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Browser notifications": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Browser web push is configured": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Browser web push is not configured": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Calls the installed OpenAnonymiser ExApp directly through AppAPI — no endpoint needed.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Candidate duplicate pairs detected for a register and schema. Launch the merge wizard on a pair to review and execute a reversible merge.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Cases": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Chain broken": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Chain intact": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Chain verification": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Change date range": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Checking browser web push configuration …": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Choose a register and schema above to see its duplicate candidates.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Choose a register and schema above to see its quality statistics.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Choose a survivorship-enabled register and schema above to list its master entities.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Choose the authoritative value for each conflicting attribute, then save.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Chunks vectorized": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Collect evidence": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Compose denial": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Confidence": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Configure a webhook to see its delivery health here.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Confirm merge": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Confirmed duplicate": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Connect": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Connect this email to a case, lead, invoice and more.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Connect to {name}": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Connected to {name}": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Connecting…": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Connection failed: {error}": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Connection removed": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Connection test failed": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Connections": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Copied": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Could not change browser notification settings.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Could not read the descriptor inventory: {reason}": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Could not read this email": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Coverage": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Create & connect": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Create the first processing activity to start tagging audit-trail rows with their AVG Art 30 attribution.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Create virtual register": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Creating {name}…": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Creating…": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Credential (UUID from the credential store)": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Data Quality": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Data stewardship review": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Data-subject request": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Database file path *": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Database name *": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Days remaining": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Deadline": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Deadline tracking": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Defaults for every flow on this instance. A flow can override each of these for itself; a flow that overrides none of them follows the values set here, including later changes.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Delivered": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Denial": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Denial ground": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Denial letter template": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Detection backends": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Disable the SSRF guard for this webhook so it can deliver to private, loopback or link-local addresses (e.g. http://localhost:8000). Only enable this for local testing.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Draft denial": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Duplicate Candidates": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Effective deadline": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Effective: {effective}": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Enable browser notifications": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Enabled, but has no owner — it will not start": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Escalate to regulator": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Escalation": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Escalation tier": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Every attribute across the linked sources already agrees, or has only one source.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Every audit entry carries a hash. That says the sweeper is keeping up — it does not by itself prove the stored hashes still agree with their rows. Verify the chain to establish that.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Every audit entry is chained to the one before it with a SHA-256 hash, so altering or deleting history breaks the links either side of it. This is where you can see whether that chain is whole.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Every declared register is present and current.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Every entry is sealed": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Evidence": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Export & escalation": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Failed to add attachment": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Failed to connect": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Failed to create {name} from email": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Failed to remove connection": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Failed to update flow settings: {error}": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Fair": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "falling back": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Field to redact *": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "File content is required": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "File name is required": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "File name is required (use \"name\" or \"filename\")": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "File not available for anonymous access": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Files folder not found": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Filter by handler": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Filter by status": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Finalising a denial is blocked until a regulator reference is recorded on the case.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Flow settings updated": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Flows": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Forbidden": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Format": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "GDPR / AVG processing register": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Generate export bundle": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Generate the accountability document": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Good": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Ground": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Handler": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Hybrid search readiness": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Identity verification: {status}": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Import failed: {reason}": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Install OpenAnonymiser": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Interval": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Keep run history for (days)": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Keyword GIN index": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Kill switch — stop all flow execution now": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "KvK Company Register": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Legal basis": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Lifecycle": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Links": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Loading connections...": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Loading organisations...": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Loading…": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Locate every object referencing a data subject (Art 15), preview an erasure (Art 17), or export their data (Art 20).": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Log integrity": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Lowest-quality objects": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Manual bulk cleanup": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Manual override ({actor})": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Map with {count} object locations": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Master entities": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Matched on": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Merge": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Merge objects": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Merge Operations": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Merge reason": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Merged at": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Merged from": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Merges executed from the Duplicate Candidates view will show up here.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Migration cleanup": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Minimum": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Missing": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "New flow": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "New {name}": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "New {name} from this email": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No candidate pairs were detected for this register and schema.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No cases": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No cases match the current filters. Clear the filters to see the full authorised set.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No conflicts to resolve": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No connections yet": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No data-subject-request cases are tracked for you yet.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No duplicate candidates found": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No evidence collected yet.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No golden-record provenance": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No master entities found": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No master entity selected": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No merge operations found": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No objects to show": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No objects with a location to display": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No recent failures": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No redactions applied.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No scored objects for this schema": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No transitions are available from the current state.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No VAPID keypair is configured yet. Generate one with the following occ command to enable browser notifications:": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "No webhooks configured": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Not configured": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Notes": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Notification permission: denied. Re-enable notifications for this site in your browser settings.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Notification permission: granted": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Notification permission: not yet requested. Enabling the toggle will ask for permission.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "object": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Object A": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Object B": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Off by default: one entry per step per run is a lot of writes, and the run history already records what each step did. Turn this on where a compliance record of every step is required.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "On by default. Oversight checks — contributed by apps — can stop a running flow. Turning this off applies to every flow that has not chosen for itself.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "One-off (this record only)": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Open": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Open connected item": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Open connections sidebar": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "OpenAnonymiser API endpoint": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "OpenAnonymiser source": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Out of date": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Overdue": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Overdue by {days} day(s)": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Overdue only": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Pending retries": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "People": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Per-webhook health": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Persistent (trust rule)": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "pgvector ANN sidecar": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Poor": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Portability (Art 20)": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Post-merge golden record": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Present": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Preview not available for unpublished files": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Quality score": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Quality score (best first)": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Quality score (worst first)": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Quality status": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Queue / sync health": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Rationale (optional)": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Re-import": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Reachable": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Read-only view of candidate duplicate pairs detected for a register and schema. Merge execution is out of scope here.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Read-only webhook delivery telemetry: per-webhook delivered / failed / pending-retry counts and recent failures.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Reading descriptors …": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Reading seal coverage …": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Receive OpenRegister notifications as native browser notifications, even when the tab is closed.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Recent failures": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Recommended": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Record a field-level redaction on this case. The redaction and its ground are logged in the case audit trail.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Record a proposed denial ground for this request. Drafting does not require a regulator reference; finalising the denial does.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Record a regulator reference": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Recorded ground": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Redaction ground": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Redactions": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Register descriptors": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Regulator escalation: {status}": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Regulator reference": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Remove connection": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Remove connection to {name}": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Remove the connection between this email and {name}?": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Replacement value (leave empty to blank the field)": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Resolve conflicts": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Retention period": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Reverse": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Reversed / final": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Reversible": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Review score distribution and the lowest-quality objects for a register and schema.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Review the details below. The new {name} will be connected to this email.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Run oversight checks before each step": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Runs and their per-step history are deleted once they exceed this age. A flow may keep less than this, or more.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Save anyway": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Save flow settings": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Save reference": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Saving…": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Schedule": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Score": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Score histogram": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Sealed": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Search recording mode": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Searches": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Select a reason": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Select a register and schema": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Select an email to see its connections": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Shares": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "ships v{shipped}": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Showing {from}-{to} of {total}": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Showing {shown} of {total} case(s)": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Sort by": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Source object not found": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "State": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Status": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Status code": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Statutory citation": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Stops every flow mid-run, at its next step. Use this when flows are misbehaving and disabling them one at a time is too slow.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Subject": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Survivorship-enabled objects for a register and schema, with their materialised quality columns.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Target object ID is required": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Target object not found": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Templates are coming soon": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Test connection": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Test connection for {backend}": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Text searches only": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "The action failed": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "The active policy pack supplies no denial-letter template reference for this jurisdiction.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "The conflict resolution could not be saved.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "The database password is never stored on the source; reference a credential from the credential store instead.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "The kill switch is on. No flow step will run on this instance until it is turned off.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "The letter body is rendered from this template reference (a document leaf); it is not stored in this form.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "The merge could not be completed.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "The merge preview could not be generated.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "The move was rejected by the server.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "The OpenAnonymiser ExApp is installed but disabled. Enable it to use it for entity recognition.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "The OpenAnonymiser ExApp is not installed. Install it to enable Dutch-focused PII detection.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "The templates feature is not available yet. This page is a placeholder and will list templates once the feature ships.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "There is nothing set up to connect this email to yet.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "This browser does not support web push notifications.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "This cannot be undone.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "This change breaks the existing data model:": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "This has two possible causes and they are not equally serious: the entry was altered after it was written, or it was sealed before the seal lock existed and chained onto the wrong predecessor. Read the entry before deciding. Repair is a deliberate operator action — it rewrites stored hashes, which is exactly the event this chain exists to make visible — so it is only available as a command:": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "This is a preview only — no object or merge operation is written until you confirm.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "This merge can be reversed until {date}.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "This object has no materialised attribute-provenance map.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "This schema has no objects with a materialised quality score yet.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "This schema has no objects yet.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Trigger": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Unassigned": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Unavailable": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "unknown": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Unreachable": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "URL to an external OpenAnonymiser instance (Dutch-focused PII detection)": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Use an external OpenAnonymiser endpoint": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Use the built-in OpenAnonymiser (recommended)": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "VAPID public key": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Vectors on pgvector fast path": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Verification could not be completed.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Verification stopped at entry {id} after {count} entries matched. From that entry on, the recomputed hash disagrees with the one stored.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Verified {count} entries against their stored hashes with no mismatch. The history has not been rewritten.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Verify chain": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Verify identity": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Verifying …": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "View golden record": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "View objects": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Virtual register created: {created} new and {updated} updated schemas": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "v{installed} → ships v{shipped}": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Warning: writes are executed directly on the external database. Use a database user with least-privilege grants. Re-run \"Create virtual register\" to unlock existing schemas.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Why is this the authoritative value?": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Winning source": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Winning source for {attribute}": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "Winning value": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "You do not have access to this object": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "{count} locations": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "{days} day(s) left": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "{days} day(s) remaining": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "{pct}% success rate": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "{register} was imported from {app}.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.", + "{sealed} of {total} entries sealed": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one." + } +} diff --git a/scripts/l10n/locales/tr.json b/scripts/l10n/locales/tr.json new file mode 100644 index 0000000000..91cb4090b4 --- /dev/null +++ b/scripts/l10n/locales/tr.json @@ -0,0 +1,55 @@ +{ + "register": "formal", + "cognates": { + "3": "Numeric literal; identical in every locale.", + "30": "Numeric literal; identical in every locale.", + "Avatar": "Turkish uses \"Avatar\"; Nextcloud core tr.json renders it that way too.", + "CSV": "Format acronym, invariant in Turkish.", + "Deck": "Nextcloud app name. Kept untranslated in all 16 finished locales.", + "DSAR": "Acronym; kept untranslated in all 16 finished locales.", + "Excel (.xlsx)": "Product name plus file extension.", + "Id": "Kept as \"Id\" in all 16 finished locales; following that.", + "Minimum": "Turkish spelling of minimum is \"minimum\". Note the app renders Maximum as \"Maksimum\" — that one DOES change; this one has no such change.", + "OpenDocument (.ods)": "Product name plus file extension.", + "PDF": "Format acronym, invariant in Turkish.", + "RBAC": "Access-control acronym, used untranslated in Turkish technical writing.", + "Slug": "Kept in Turkish CMS/technical UI; kept in 8 of 10 sampled locales.", + "URL": "Invariant in Turkish; \"URL\" is the standard form.", + "UUID:": "Acronym plus colon. Turkish, unlike French, puts no space before a colon — fr carries \"UUID :\", which is why this key stays wrapped rather than being deleted.", + "Webhook": "Kept as \"Webhook\" in Turkish; the app already renders the plural as \"Webhook'lar\".", + "{property} - {other}": "Two placeholders and an ASCII hyphen, which Turkish keeps. Stays wrapped because ru localises the hyphen to an em dash.", + "N/A": "Abbreviation shown as-is; not translated in this UI.", + "RAG": "Acronym (retrieval-augmented generation); invariant technical term.", + "ConfigSet": "Apache SOLR product term; SOLR does not localise it and the admin UI names the same object.", + "ConfigSet:": "Apache SOLR product term with its label colon; see ConfigSet.", + "HNSW index": "Algorithm name (hierarchical navigable small world); an invariant technical term.", + "OpenCorporates": "Proper noun — a third-party service name.", + "OpenRegister": "Proper noun — this application's own name.", + "http://localhost:11434": "A literal example URL shown verbatim; not prose.", + "https://api.fireworks.ai/inference/v1": "A literal example URL shown verbatim; not prose.", + "https://api.your-dolphin-instance.com": "A literal example URL shown verbatim; not prose.", + "https://example.com/webhook": "A literal example URL shown verbatim; not prose.", + "fw_...": "A literal API-key prefix example shown verbatim; not prose.", + "sk-...": "A literal API-key prefix example shown verbatim; not prose.", + "org-...": "A literal organisation-id prefix example shown verbatim; not prose.", + "X-Custom-Header: value\nAuthorization: Bearer token": "Literal HTTP header example; header names are protocol tokens and are never translated.", + "X-Custom-Header: value\\nAuthorization: Bearer token": "Literal HTTP header example with an escaped newline; header names are protocol tokens.", + "objectType: object\\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens.", + "objectType: object\naction: created": "Literal CloudEvents payload example; the field names are protocol tokens and are never translated.", + "Browser Web Push (VAPID)": "Browser and Web Push are the protocol's own names and VAPID is its acronym; the protocol name is not translated.", + "Bucket": "Object-storage term, used unchanged in this locale's technical language.", + "Driver": "Database-driver term, used unchanged in this locale's technical language.", + "Golden record": "Master-data-management term with no established local equivalent; used unchanged in data-governance language.", + "Host *": "Networking term, used unchanged in this locale, with the required-field marker.", + "Model": "Same spelling and meaning in this locale.", + "myapp": "A literal example application id shown verbatim; not prose.", + "Ollama URL": "Product name plus the invariant abbreviation URL.", + "Survivor": "Master-data-management term for the record that survives a merge; used unchanged in data-governance language.", + "Test": "Same spelling and meaning in this locale.", + "Url": "Invariant abbreviation, written the same way in this locale.", + "Min ms": "Two abbreviations (minimum, milliseconds) shown as a compact column label; both are invariant in Turkish.", + "Port": "Networking term, used unchanged in Turkish.", + "Zookeeper Port": "Apache ZooKeeper product name plus the invariant term Port." + }, + "corrections": {} +} diff --git a/scripts/l10n/patchcheck.js b/scripts/l10n/patchcheck.js new file mode 100644 index 0000000000..dc6e76585d --- /dev/null +++ b/scripts/l10n/patchcheck.js @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/* eslint-disable n/no-process-exit */ +/* eslint-disable no-console */ +/* eslint-disable n/shebang */ +/** + * Run a locale's register detector over a patch's VALUES before it is applied, so + * a register slip is caught while it is still cheap to fix rather than after 200 + * keys have landed. + * + * node scripts/l10n/patchcheck.js + * + * The gate is on the DEVIATION from the locale's measured register, which is why + * `locales/.json` records `"register"`. For a formal locale the deviation is + * an informal marker; for an informal locale it is a formal one. Getting that + * polarity backwards makes the check pass on exactly the strings it should catch. + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +'use strict' + +const fs = require('fs') +const path = require('path') +const { loadDetector, loadLocaleConfig } = require('./lib.js') + +const [loc, patchFile] = process.argv.slice(2) +if (!loc || !patchFile) { + console.error('usage: patchcheck.js ') + process.exit(2) +} + +const detector = loadDetector(loc) +if (!detector) { + console.error(`no detector at scripts/l10n/detectors/${loc}.js — build one before translating.`) + console.error('See docs/l10n-ui-translation.md: closed word lists, never suffix patterns,') + console.error('validated on must-fire / must-not-fire controls including the homograph traps.') + process.exit(2) +} +const cfg = loadLocaleConfig(loc) +if (!cfg.register) { + console.error(`locales/${loc}.json does not record a measured register — measure it against core first.`) + process.exit(2) +} + +const patch = JSON.parse(fs.readFileSync(path.resolve(patchFile), 'utf8')) +const deviation = cfg.register === 'formal' ? 'informal' : 'formal' + +let formal = 0 +let informal = 0 +const hits = [] +for (const [k, v] of Object.entries(patch)) { + for (const x of [].concat(v)) { + const s = detector.score(x) + formal += s.f + informal += s.i + const bad = deviation === 'informal' ? s.i > 0 : s.f > 0 + if (bad) { + hits.push([k, x]) + } + } +} + +// The controls guard the detector itself. A detector whose own must-fire / +// must-not-fire cases fail cannot be trusted to judge a patch. +const ctl = detector.runControls() +console.log(`detector controls: ${ctl.total - ctl.fail}/${ctl.total}`) +if (ctl.fail) { + console.error('detector controls FAIL — fix the detector before trusting this patch') + process.exit(1) +} + +console.log(`patch values: ${Object.keys(patch).length} keys`) +console.log(` formal markers: ${formal}`) +console.log(` informal markers: ${informal}`) +console.log(` measured register: ${cfg.register} -> gating on ${deviation} markers`) + +if (hits.length) { + console.error(` ${deviation.toUpperCase()} HITS — must be zero for ${loc}:`) + for (const [k, x] of hits) { + console.error(` ${JSON.stringify(k)}\n -> ${JSON.stringify(x)}`) + } + process.exit(1) +} +console.log(` OK — no ${deviation} marker in any value`) diff --git a/scripts/l10n/runtime-check.mjs b/scripts/l10n/runtime-check.mjs new file mode 100644 index 0000000000..aff26169ef --- /dev/null +++ b/scripts/l10n/runtime-check.mjs @@ -0,0 +1,268 @@ +#!/usr/bin/env node +/* eslint-disable n/no-process-exit */ +/* eslint-disable no-console */ +/* eslint-disable n/shebang */ +/** + * Drive the REAL @nextcloud/l10n against a real locale bundle. + * + * node scripts/l10n/runtime-check.mjs + * + * ## Why this exists on top of the static gates + * + * Every assertion here has caught a defect that the file looked fine with: + * + * - Plurals were stored under the bare singular in all 37 bundles. That renders + * correctly for count === 1 and falls back to ENGLISH for every other count, + * and it passed every static gate for months. + * - Catalan `schema{plural}` rendered "esquemas": masculine nouns in -a + * pluralise in -es, so the hardcoded English "s" produced a non-word. + * - A two-form array in a three-form language renders BLANK at the counts that + * select the missing form. + * + * ## The one thing to know about the runtime + * + * `register(app, bundle)` IGNORES a plural function passed as a third argument and + * installs the library's own per-language `getPlural`, which reads `getLanguage()`. + * So the file's `plural=` expression governs the arity gate, while the LIBRARY + * governs which element renders. A harness that assumes otherwise silently reads + * the wrong form — which happened, and briefly made three correct Slavic arrays + * look wrong. Hence `unregister()` + `setLanguage(loc)` before every check. + * + * Assertions are structural and locale-agnostic on purpose, so this runs for any + * locale without per-language expectations to maintain. + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +import { readFileSync } from 'fs' +import path from 'path' +import vm from 'vm' +import { createRequire } from 'module' + +const require = createRequire(import.meta.url) +const { + APP_ROOT, npluralsOf, MORPHOLOGY_PLACEHOLDER, loadLocaleConfig, configuredLocales, +} = require('./lib.js') + +const loc = process.argv[2] +if (!loc) { + console.error('usage: runtime-check.mjs ') + process.exit(2) +} + +const l10n = await import(path.join(APP_ROOT, 'node_modules/@nextcloud/l10n/dist/index.mjs')) +const { register, unregister, translate: t, translatePlural: n, setLanguage, getPlural } = l10n + +const locFile = path.join(APP_ROOT, 'l10n', `${loc}.js`) +let translations = null +let pluralForm = null +const sandbox = { OC: { L10N: { register: (app, tr, pf) => { translations = tr; pluralForm = pf } } } } +vm.createContext(sandbox) +vm.runInContext(readFileSync(locFile, 'utf8'), sandbox, { filename: locFile }) + +let fails = 0 +const ok = (cond, label, got) => { + console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${got !== undefined ? ' -> ' + JSON.stringify(got) : ''}`) + if (!cond) fails++ +} + +// "This rendered exactly the English source" is the symptom the plural-key bug +// produced in all 37 bundles — and it is ALSO what a legitimate cognate looks like: +// Dutch `register`/`registers` really is the English word, and Italian `email` is +// invariable, so `1 email` is correct Italian. +// +// Nothing at runtime can tell those apart, so the justification record decides. A +// locale with scripts/l10n/locales/.json is held to it and an unrecorded +// English rendering FAILS; a locale that predates the cognate rule gets a NOTE for +// a human instead of a false accusation. +const cognates = loadLocaleConfig(loc).cognates +const enforced = configuredLocales().includes(loc) +const notEnglish = (cond, key, label, got) => { + if (cond || Object.prototype.hasOwnProperty.call(cognates, key)) { + ok(true, label, got) + return + } + if (enforced) { + ok(false, `${label} (and no cognate is recorded for it)`, got) + } else { + console.log(`NOTE ${label} — ${loc} has no justification record, so this needs a human` + + `${got !== undefined ? ': ' + JSON.stringify(got) : ''}`) + } +} + +ok(translations !== null, `${loc}.js calls OC.L10N.register`, translations && Object.keys(translations).length) +if (!translations) { + process.exit(1) +} + +unregister() +setLanguage(loc) +register('openregister', translations) + +const declared = npluralsOf(pluralForm) + +// 1. The library must never index past any plural array. This is the direction +// that renders blank, and the only l10n defect invisible to a human reader. +const usedForms = new Set() +for (let i = 0; i <= 1000; i++) { + usedForms.add(getPlural(i, loc)) +} +const libForms = Math.max(...usedForms) + 1 +const arrays = Object.entries(translations).filter(([, v]) => Array.isArray(v)) +const short = arrays.filter(([, v]) => v.length < libForms) +ok(short.length === 0, `no plural array shorter than the ${libForms} form(s) the library uses`, + short.length ? short.map(([k]) => k).slice(0, 3) : undefined) +console.log(` declared nplurals=${declared}, library uses ${libForms}` + + (libForms !== declared ? ' (extra declared forms are never selected — harmless)' : '')) + +// 1b. The library and the file's own `plural=` header must agree on WHICH index +// each count selects — not merely on how many forms there are. +// +// This is the defect that ordering an array "correctly" produces. Latvian ships +// the legacy gettext order in its header ([one, other, zero]) while the library +// partitions it as [zero, one, other]. Both say nplurals=3, every array has 3 +// forms, arity passes, nothing renders blank, nothing renders English — and every +// single count renders the WRONG form. Assertion 1 cannot see it and neither can a +// reader, because the file's own header is the misleading part. +// +// A mismatch is only tolerated where the library uses FEWER forms than declared +// (tr and rm select 1, ga selects 3 of 5): those extra forms are unreachable, so +// no ordering of them is wrong. +// +// There are TWO kinds of mismatch and they need opposite remedies, so the check +// classifies rather than assuming Latvian's shape: +// +// • PERMUTATION — the two partition the counts into the same groups and merely +// label them differently. Reordering the arrays makes the locale fully +// correct, and `pluralOrder: "library"` records that it was done. This is lv. +// +// • BOUNDARY — the two draw the lines in different PLACES, so no permutation +// of the arrays can agree with the header everywhere. `is` and `mk` are both +// this, in opposite directions: the library sends 21/31/41… to Icelandic's +// plural where the language takes the singular, and sends 11/111 to +// Macedonian's singular where the language takes the plural. Telling someone +// to "reorder the arrays" here is wrong advice — the only real choice is +// which counts to be correct for, and the acknowledgement (`pluralBoundary`) +// asserts that the residue is known and written down rather than reordered +// away. +const headerExpr = /plural\s*=\s*([^;]+)/.exec(String(pluralForm)) +if (!headerExpr) { + ok(false, 'the bundle header declares a plural= expression', String(pluralForm).slice(0, 60)) +} else { + let headerIdx = null + try { + headerIdx = new Function('n', `return Number(${headerExpr[1]})`) + } catch { + ok(false, `the header plural= expression parses: ${headerExpr[1]}`) + } + if (headerIdx) { + const disagree = [] + for (let i = 0; i <= 200; i++) { + if (getPlural(i, loc) !== headerIdx(i)) disagree.push(i) + } + // Is the disagreement a pure relabelling? It is exactly when each library + // index maps to one and only one header index across every count, and the + // mapping is injective — that is precisely the condition under which + // permuting the arrays can satisfy the header everywhere. + const idxMap = new Map() + let isPermutation = true + for (let i = 0; i <= 200 && isPermutation; i++) { + const l = getPlural(i, loc) + const h = headerIdx(i) + if (!idxMap.has(l)) idxMap.set(l, h) + else if (idxMap.get(l) !== h) isPermutation = false + } + if (isPermutation && new Set(idxMap.values()).size !== idxMap.size) isPermutation = false + + const cfg = loadLocaleConfig(loc) + const detail = disagree.slice(0, 6) + .map(i => `n=${i}: lib ${getPlural(i, loc)} vs header ${headerIdx(i)}`) + + if (libForms < declared) { + console.log(`NOTE library selects only ${libForms} of the ${declared} declared form(s) for ${loc},` + + ' so the unreachable ones cannot be mis-ordered' + + (disagree.length ? ` (${disagree.length} count(s) map differently)` : '')) + } else if (disagree.length === 0) { + ok(true, 'library and header agree on which index each count selects') + } else if (isPermutation) { + // The mismatch is a property of the locale, not a defect to fix here: the + // header comes from Transifex. Acknowledging it in locales/.json is + // what keeps it from being silently "corrected" back to the header order. + ok(cfg.pluralOrder === 'library', + `${loc} header and library disagree on form ORDER at ${disagree.length} count(s) — a pure ` + + 'relabelling, so order the ARRAYS by the library (it renders) and record ' + + 'pluralOrder:"library" in locales/' + loc + '.json', + cfg.pluralOrder === 'library' ? undefined : detail) + if (cfg.pluralOrder === 'library') { + console.log(` ${disagree.length} count(s) relabelled; arrays are ordered by the library. See its pluralNote.`) + } + } else { + // No permutation can fix this one, so the acknowledgement is a different + // claim: that the residual wrong counts are known and recorded. + ok(cfg.pluralBoundary === 'library', + `${loc} header and library disagree on where the plural BOUNDARIES fall, at ` + + `${disagree.length} count(s) — NOT a relabelling, so reordering the arrays cannot fix it. ` + + 'Write each form to be correct across the counts the library actually routes to it, say ' + + 'which counts stay wrong in pluralNote, and record pluralBoundary:"library" in ' + + 'locales/' + loc + '.json', + cfg.pluralBoundary === 'library' ? undefined : detail) + if (cfg.pluralBoundary === 'library') { + console.log(` ${disagree.length} count(s) render a form the header would not choose: ` + + `${detail.join(', ')}${disagree.length > 6 ? ', …' : ''}. Acknowledged; see its pluralNote.`) + } + } + } +} + +// 2. Every form the library can select must actually be reachable and render +// something that is not the English source. +for (const [key] of arrays) { + const m = /^_([\s\S]*)_::_([\s\S]*)_$/.exec(key) + if (!m) { + ok(false, `plural key is stored under the identifier the runtime looks up: ${JSON.stringify(key)}`) + continue + } + const [, singular, plural] = m + // one representative count per form index the library can produce + const perForm = new Map() + for (let i = 0; i <= 200 && perForm.size < libForms; i++) { + const f = getPlural(i, loc) + if (!perForm.has(f)) perForm.set(f, i) + } + for (const [form, count] of [...perForm].sort((a, b) => a[0] - b[0])) { + const got = n('openregister', singular, plural, count, { count }) + ok(!!String(got).trim(), `n(${JSON.stringify(singular.slice(0, 40))}, ${count}) renders form ${form} non-empty`, got) + const asEnglish = [singular, plural].some(s => got === s.replace(/\{count\}/g, count).replace(/%n/g, count)) + notEnglish(!asEnglish, key, + `n(${JSON.stringify(singular.slice(0, 40))}, ${count}) renders form ${form} translated`, got) + } +} + +// 3. No `{plural}` reaches the user, in any key or any value. +// +// This used to be the section that VERIFIED the hack: five keys interpolated a +// literal "s" or "", and the check branched on whether a locale had kept the +// placeholder (`es` kept all five, `ca` four) or dropped it. Both branches are +// gone with the hack itself — those five keys are real n() calls now, and every +// locale carries a proper form array instead of `bestand(en)` or `súbor(y)`. +// +// What is left is the runtime end of the ban. The static gates +// (check-l10n.js, check-l10n-parity.js, apply.js, selfcheck.js) read the files; +// this reads what the LIBRARY actually renders, which is the only place a +// residue would be visible to a user. A key here is a defect regardless of +// which form it sits in, so plural arrays are flattened rather than sampled. +const pluralResidue = [] +for (const [key, value] of Object.entries(translations)) { + const forms = Array.isArray(value) ? value : [value] + if (MORPHOLOGY_PLACEHOLDER.test(key) || forms.some((f) => MORPHOLOGY_PLACEHOLDER.test(String(f)))) { + pluralResidue.push(key) + } +} +ok(pluralResidue.length === 0, + '{plural} appears in no key and no value — English morphology is not assembled ' + + 'from a placeholder; use a real n() plural key', + pluralResidue.slice(0, 5)) + +console.log(fails === 0 ? '\nALL RUNTIME CHECKS PASS' : `\n${fails} RUNTIME CHECK(S) FAILED`) +process.exitCode = fails ? 1 : 0 diff --git a/scripts/l10n/script-coverage.js b/scripts/l10n/script-coverage.js new file mode 100644 index 0000000000..557df40d5b --- /dev/null +++ b/scripts/l10n/script-coverage.js @@ -0,0 +1,198 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ +/** + * Script-coverage sweep for a non-Latin locale — bg, sr, mk, be. + * + * This is the replacement for the English-leftover word scan in + * docs/l10n-workflow.md §5 step 8. That scan looks for English function words + * ("the", "and", "with"), which works for a Latin-script target and is useless + * for a Cyrillic one: an untranslated English value and a correct Bulgarian one + * are both just words, and the interesting signal is the SCRIPT rather than the + * vocabulary. + * + * Two questions, and neither can be answered mechanically: + * + * 1. Which values carry no target-script character at all? Those are + * candidates for untranslated English — but a legitimate cognate ("CSV", + * "PDF", "Deck") looks exactly the same, which is why this prints them + * rather than failing on them. Cross-check against the `cognates` block in + * locales/.json: every no-script value should be either recorded there + * or a normalisation of one ("Url" -> "URL"). + * 2. Which Latin-letter runs appear INSIDE otherwise-translated values? Almost + * all are placeholders ({count}, {error}), product names (Nextcloud, + * OpenAnonymiser), acronyms (HTTP, RBAC) or literal example values + * (localhost, config.json) — and each has to be eyeballed once to confirm + * it is one of those and not a fragment nobody translated. + * + * So the exit code is 0 whatever it finds. It is a reading aid, not a gate; the + * gates are selfcheck.js and check-l10n-parity.js. + * + * Usage: node scripts/l10n/script-coverage.js + */ + +const fs = require('fs') +const path = require('path') +const { loadJsTranslations, loadLocaleConfig } = require('./lib.js') + +const APP_ROOT = path.resolve(__dirname, '..', '..') + +// Per-locale expected script. Keyed rather than inferred so an unlisted locale +// errors instead of being silently checked against the wrong alphabet — sr also +// ships in Latin in the wild, so "Cyrillic" is a claim about THIS bundle that +// has to be made deliberately. Verify with the locale's own pre-existing values +// before adding a row. +const SCRIPTS = { + bg: 'Cyrillic', + mk: 'Cyrillic', + be: 'Cyrillic', + // sr: measured, not assumed — 945 of this bundle's 1055 pre-existing values are + // Cyrillic-only and the Latin-only ones were two defects. See locales/sr.json. + sr: 'Cyrillic', + el: 'Greek', + uk: 'Cyrillic', + ru: 'Cyrillic', + // A LATIN row buys only the foreign-script check below, not the two lists, + // which are meaningless for a Latin target. Add one per locale as it is + // audited rather than defaulting unlisted locales to Latin — an unlisted + // locale must keep erroring, because a silent wrong-alphabet check is the + // failure this map exists to prevent. + bs: 'Latin', +} + +const loc = process.argv[2] +if (!loc) { + console.error('usage: node scripts/l10n/script-coverage.js ') + process.exit(2) +} +const script = SCRIPTS[loc] +if (!script) { + console.error(`no expected script recorded for "${loc}" — add it to SCRIPTS in this file, ` + + 'after checking which alphabet that bundle actually uses') + process.exit(2) +} + +const file = path.join(APP_ROOT, 'l10n', `${loc}.js`) +if (!fs.existsSync(file)) { + console.error(`no such bundle: ${file}`) + process.exit(2) +} + +const { translations } = loadJsTranslations(file) +const inScript = new RegExp(`\\p{Script=${script}}`, 'u') +// Three or more Latin letters: two-letter runs are almost all placeholder or +// unit fragments and drown the useful hits. +const LATIN_RUN = /[A-Za-z][A-Za-z.'-]{2,}/g + +// HOMOGLYPHS: a single WORD mixing two scripts. +// +// WHY THIS EXISTS, and why it runs for EVERY locale including Latin ones. A +// Cyrillic small o (U+043E) is visually identical to a Latin o, so one inside an +// otherwise-correct Latin word is invisible to every gate and to the eye: the +// value is not empty, not identical to English, not wrong-arity, and reads as +// finished work. It was found in `bs`, in "proširenо dohvatom". The two lists +// below cannot see it — they ask whether the EXPECTED script is PRESENT, and it +// is — so the homoglyph class needs a different question, and all 30 +// Latin-script locales had no coverage of it whatever. +// +// The test is per unbroken LETTER RUN rather than per value or per word, and the +// difference is what keeps it usable. Tolerating Latin anywhere in a value would +// be wrong for a Cyrillic bundle, because the inverse defect — a stray Latin o +// inside a Cyrillic word — is just as invisible; flagging any Latin in a Cyrillic +// value would flag every placeholder and product name, which is the second +// list's business. +// +// A HYPHEN IS A MORPHEME BOUNDARY AND MUST BREAK THE RUN. Macedonian, Serbian, +// Bosnian and Albanian all attach case endings to a Latin-script acronym across a +// hyphen — `API-клуч`, `MIME-типови`, `VAPID-пар`, `webhook-a`, `UUID-je` — which +// is correct morphology, not a homoglyph. Testing whole words instead reported +// 105 hits on `mk` and 19 on `uk`, essentially all of them that construction. A +// script change WITHIN one unbroken run is the real signal, because no language +// changes alphabet in the middle of a morpheme. +const WORD = /\p{L}[\p{L}\p{M}]*/gu +const isCyr = /\p{Script=Cyrillic}/u +const isLat = /\p{Script=Latin}/u +const isGrk = /\p{Script=Greek}/u + +/** + * @param {string} w A single word. + * @return {string[]|null} The scripts it mixes, or null if it is single-script. + */ +function mixedScripts(w) { + const found = [] + if (isLat.test(w)) found.push('Latin') + if (isCyr.test(w)) found.push('Cyrillic') + if (isGrk.test(w)) found.push('Greek') + return found.length > 1 ? found : null +} + +// Runs this locale records as legitimately mixed-script (see homoglyphAllow in +// lib.js). Case-folded, because the same run recurs sentence-initially. +const allow = new Set( + (loadLocaleConfig(loc).homoglyphAllow || []).flatMap((w) => [w, String(w).toLowerCase()]), +) +let allowed = 0 + +const noScript = [] +const runs = new Map() +const foreign = [] +for (const [key, value] of Object.entries(translations)) { + for (const s of Array.isArray(value) ? value : [value]) { + if (typeof s !== 'string') continue + if (!inScript.test(s)) noScript.push([key, s]) + for (const w of s.match(WORD) || []) { + const mixes = mixedScripts(w) + if (!mixes) continue + if (allow.has(w) || allow.has(w.toLowerCase())) { allowed++; continue } + // Name the minority characters: those are the homoglyphs, and printing + // their code points is the only way to see which "o" is which. + const majority = mixes.includes(script) ? script : mixes[0] + const odd = [...new Set([...w].filter((c) => /\p{L}/u.test(c) + && !new RegExp(`\\p{Script=${majority}}`, 'u').test(c)))] + .map((c) => `${c} U+${c.codePointAt(0).toString(16).toUpperCase()}`) + foreign.push([key, w, `${mixes.join(' + ')} — odd char(s): ${odd.join(', ')}`]) + } + for (const m of s.match(LATIN_RUN) || []) { + if (!runs.has(m)) runs.set(m, []) + runs.get(m).push(key) + } + } +} + +console.log(`${loc}: ${Object.keys(translations).length} keys, expected script ${script}\n`) + +// First, because it is the only section here that reports something that is +// always a defect rather than something needing a human read. +console.log(`=== HOMOGLYPHS — single words mixing two scripts: ${foreign.length}`) +console.log(' a Cyrillic o inside a Latin word, or a Latin o inside a Cyrillic one, is') +console.log(' invisible to every gate and to the eye. Real text does not change alphabet') +console.log(' mid-word, so any hit here is a defect.') +for (const [key, w, why] of foreign) { + console.log(` ${JSON.stringify(w)} ${why}`) + console.log(` in ${JSON.stringify(key)}`) +} +if (!foreign.length) console.log(' (none)') +if (allowed) { + console.log(` (${allowed} occurrence(s) suppressed by homoglyphAllow in locales/${loc}.json`) + console.log(' — a run belongs there only if this locale\'s own core catalogues use it too)') +} + +if (script === 'Latin') { + console.log('\nExpected script is Latin, so the two lists below are skipped: "values with no') + console.log('Latin character" and "Latin runs inside values" carry no signal for a Latin') + console.log('target. Use the English-leftover word scan of §5 step 8 instead.') + console.log('\nThis script never fails a build.') + process.exit(0) +} + +console.log(`\n=== values with NO ${script} character at all: ${noScript.length}`) +console.log(' each of these must be a recorded cognate or a normalisation — check locales/' + + `${loc}.json`) +for (const [key, s] of noScript) console.log(` ${JSON.stringify(key)} -> ${JSON.stringify(s)}`) + +console.log(`\n=== distinct Latin runs inside translated values: ${runs.size}`) +console.log(' expect placeholders, product names, acronyms and literal example values only') +for (const [m, keys] of [...runs].sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))) { + console.log(` ${String(keys.length).padStart(3)} ${m}`) +} + +console.log('\nBoth lists are for reading. This script never fails a build.') diff --git a/scripts/l10n/selfcheck.js b/scripts/l10n/selfcheck.js new file mode 100644 index 0000000000..cac5ca34db --- /dev/null +++ b/scripts/l10n/selfcheck.js @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/* eslint-disable n/no-process-exit */ +/* eslint-disable no-console */ +/* eslint-disable n/shebang */ +/** + * Full verification for one locale pass. Every assertion is measured against the + * files; nothing is assumed. + * + * node scripts/l10n/selfcheck.js + * + * Run this before committing a locale. It is deliberately stricter than + * test:l10n:parity, which is the CI gate and has to stay fast and dependency-free: + * this one also diffs against HEAD to prove no pre-existing translation was lost + * or silently altered, and round-trips the serializer. + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +'use strict' + +const fs = require('fs') +const path = require('path') +const vm = require('vm') +const { + loadJsTranslations, serializeJs, APP_ROOT, isIdentical, hasIdenticalForm, placeholders, npluralsOf, + MORPHOLOGY_PLACEHOLDER, loadLocaleConfig, loadDetector, bundleAtHead, +} = require('./lib.js') + +const loc = process.argv[2] +if (!loc) { + console.error('usage: selfcheck.js ') + process.exit(2) +} + +const locFile = path.join(APP_ROOT, 'l10n', `${loc}.js`) +const en = loadJsTranslations(path.join(APP_ROOT, 'l10n', 'en.js')) +const cur = loadJsTranslations(locFile) +const cfg = loadLocaleConfig(loc) +const detector = loadDetector(loc) +const head = bundleAtHead(loc) + +const NP = npluralsOf(cur.pluralForm) +const enKeys = new Set(Object.keys(en.translations)) +const keys = Object.keys(cur.translations) + +let fails = 0 +function check(name, ok, detail) { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`) + if (!ok) { + fails++ + } +} + +// Has this locale been through the recorded-justification workflow at all? Sixteen +// locales were finished before it existed. For those, "no cognate record" and "no +// detector" are the KNOWN GAP, not a regression in the bundle — reporting them as +// FAIL would say the locale is broken when it is merely unreviewed. Everything that +// does not depend on a record is still checked in full. +const recorded = Boolean(cfg.register) || Object.keys(cfg.cognates).length > 0 +function checkRecorded(name, ok, detail) { + if (recorded) { + check(name, ok, detail) + } else { + console.log(`SKIP ${name} — ${loc} predates the cognate rule and has no ` + + `scripts/l10n/locales/${loc}.json${detail ? ' (' + detail + ')' : ''}`) + } +} + +// 1. key-for-key parity, both directions +const absent = [...enKeys].filter(k => !(k in cur.translations)) +const extra = keys.filter(k => !enKeys.has(k)) +check('key-for-key parity with en.js', absent.length === 0 && extra.length === 0, + `${absent.length} absent, ${extra.length} extra`) + +// 2. no empty values, no edge or doubled HORIZONTAL whitespace ("\n\n" is a +// legitimate paragraph break the English source carries too) +const empties = [] +const ws = [] +for (const [k, v] of Object.entries(cur.translations)) { + for (const x of Array.isArray(v) ? v : [v]) { + if (!String(x).trim()) { + empties.push(k) + } + if (/^\s|\s$|[ \t]{2,}/.test(String(x))) { + ws.push([k, x]) + } + } +} +check('no empty values', empties.length === 0, `${empties.length}`) +check('no edge/doubled horizontal whitespace', ws.length === 0, ws.length ? JSON.stringify(ws.slice(0, 3)) : '') + +// 3. plural arity against this locale's OWN declared nplurals +const arity = Object.entries(cur.translations).filter(([, v]) => Array.isArray(v) && v.length !== NP) +check(`every plural array has ${NP} forms`, arity.length === 0, `${arity.length} wrong`) + +// 4. value===key only for recorded cognates, and no cognate recorded for a key +// that is not actually identical (that would be a stale permission slip) +const identical = keys.filter(k => isIdentical(k, cur.translations[k])) +const undocumented = identical.filter(k => !(k in cfg.cognates)) +checkRecorded('no undocumented value === key', undocumented.length === 0, + undocumented.length ? JSON.stringify(undocumented.slice(0, 5)) : `${identical.length} recorded cognates`) +// A record is stale only when NOTHING is left for it to excuse. For a plural key +// that means no form renders the English source — requiring every form to match +// (isIdentical) would call Romanian's email array stale while runtime-check.mjs +// still needs its record to excuse the singular. See hasIdenticalForm in lib.js. +const staleCognates = Object.keys(cfg.cognates) + .filter(k => !identical.includes(k) + && !(k in cur.translations && hasIdenticalForm(k, cur.translations[k]))) +checkRecorded('no stale cognate record', staleCognates.length === 0, + staleCognates.length ? JSON.stringify(staleCognates.slice(0, 5)) : `${Object.keys(cfg.cognates).length} all in use`) +const thin = Object.entries(cfg.cognates).filter(([, r]) => String(r).trim().length < 15).map(([k]) => k) +check('every cognate reason is a real justification', thin.length === 0, + thin.length ? JSON.stringify(thin) : '') + +// 5. placeholders preserved in BOTH directions; only the {plural} source hack may drop +const drift = [] +for (const k of keys) { + const enPh = new Set() + for (const f of Array.isArray(en.translations[k]) ? en.translations[k] : [en.translations[k]]) { + for (const p of placeholders(f)) { + enPh.add(p) + } + } + const lPh = new Set() + for (const f of Array.isArray(cur.translations[k]) ? cur.translations[k] : [cur.translations[k]]) { + for (const p of placeholders(f)) { + lPh.add(p) + } + } + const dropped = [...enPh].filter(x => !lPh.has(x)) + const added = [...lPh].filter(x => !enPh.has(x)) + if (dropped.length || added.length) { + drift.push([k, [...enPh], [...lPh]]) + } +} +check('placeholders preserved both ways', drift.length === 0, + drift.length ? JSON.stringify(drift.slice(0, 3)) : '') + +// `{plural}` is BANNED outright — it is English morphology assembled from a +// placeholder, and the five source keys that forced locales to carry it are real +// n() calls now. This is the fast local mirror of the same ban in +// tests/l10n/check-l10n-parity.js; the drift check above would also catch a +// `{plural}` that en.js does not have, but only as an anonymous "added +// placeholder", which does not tell the reader what to do about it. +const strayPlural = keys.filter(k => [].concat(cur.translations[k]) + .some(x => MORPHOLOGY_PLACEHOLDER.test(String(x)))) +check('no {plural} in any value (use a real n() plural key)', strayPlural.length === 0, + strayPlural.length ? JSON.stringify(strayPlural) : '') + +// 6. register: gate on the DEVIATION from the measured register, never on a +// carried-over assumption. Five consecutive locales measured differently. +if (!cfg.register && String(cfg.registerNotMeasured || '').trim().length >= 15) { + // A config that exists for a narrower reason than a full pass — see + // registerNotMeasured in lib.js. SKIP, never PASS: nothing has been verified. + console.log(`SKIP register not measured for ${loc} — ${cfg.registerNotMeasured}`) +} else if (!detector || !cfg.register) { + checkRecorded('register detector present and register measured', false, + `detector=${detector ? 'yes' : 'MISSING'}, register=${cfg.register || 'NOT MEASURED'}`) +} else { + const ctl = detector.runControls() + check('register detector controls', ctl.fail === 0, `${ctl.total - ctl.fail}/${ctl.total}`) + const deviation = cfg.register === 'formal' ? 'informal' : 'formal' + const dev = [] + for (const [k, v] of Object.entries(cur.translations)) { + for (const x of Array.isArray(v) ? v : [v]) { + const s = detector.score(String(x)) + if (deviation === 'informal' ? s.i > 0 : s.f > 0) { + dev.push([k, x]) + } + } + } + check(`zero ${deviation} forms — ${loc} prose is ${cfg.register}`, dev.length === 0, + dev.length ? JSON.stringify(dev.slice(0, 5)) : '') +} + +// 7. no pre-existing translation lost or silently altered. A value that was +// value===key at HEAD was a placeholder, so replacing it is the point of the pass. +if (head === null) { + check('HEAD baseline available', false, `git show HEAD:l10n/${loc}.js failed`) +} else { + const lost = [] + const altered = [] + for (const [k, v] of Object.entries(head)) { + const wasPlaceholder = typeof v === 'string' && v === k + if (!(k in cur.translations)) { + // A key en.js no longer carries was DELETED from the catalogue, not lost + // from this bundle. Without this the check fires on every locale at once + // for a single deliberate removal — which is exactly what the `{plural}` + // conversion did to five keys across all 37 files — and 36 identical + // failures read as a broken pass rather than one intended change. A key + // still in en.js and missing here is the real defect and still fires. + if (k in en.translations) lost.push(k) + continue + } + const now = cur.translations[k] + if (JSON.stringify(now) !== JSON.stringify(v) && !wasPlaceholder && !(k in cfg.corrections)) { + altered.push([k, v, now]) + } + } + check('no pre-existing translation lost', lost.length === 0, lost.length ? JSON.stringify(lost.slice(0, 5)) : '') + check('no pre-existing real value altered without a recorded reason', altered.length === 0, + altered.length ? JSON.stringify(altered.slice(0, 3)) : `${Object.keys(cfg.corrections).length} audited correction(s)`) +} + +// 8. loads under OC.L10N.register +let registered = null +const ctx = { OC: { L10N: { register: (app, t, p) => { registered = { app, t, p } } } } } +vm.createContext(ctx) +try { + vm.runInContext(fs.readFileSync(locFile, 'utf8'), ctx) + check('loads under OC.L10N.register', + registered !== null && Object.keys(registered.t).length === keys.length, + `app=${registered && registered.app}, ${registered ? Object.keys(registered.t).length : 0} keys`) +} catch (e) { + check('loads under OC.L10N.register', false, e.message) +} + +// 9. serializer round-trip byte-exact, so a Transifex regeneration is a no-op +const round = serializeJs({ app: cur.app, translations: cur.translations, pluralForm: cur.pluralForm }) +check('serializer round-trip byte-exact', round === fs.readFileSync(locFile, 'utf8')) + +// 10. plural arrays must not be N copies of one form where the language +// distinguishes them. Legitimate exceptions exist — Hungarian does not pluralise +// after a numeral, Swedish "objekt" is invariant, Italian "email" is invariant, and +// a key with no {count} reads the same for several counts — so this reports rather +// than fails, and a human confirms. +const plurals = Object.entries(cur.translations).filter(([, v]) => Array.isArray(v)) +const flat = plurals.filter(([, v]) => new Set(v).size < 2) +console.log(`${flat.length === 0 ? 'PASS' : 'NOTE'} plural arrays with every form identical — confirm each is genuine` + + ` — ${flat.length} of ${plurals.length}`) +for (const [k, v] of flat.slice(0, 5)) { + console.log(` ${JSON.stringify(k).slice(0, 70)} -> ${JSON.stringify(v[0])}`) +} + +console.log(`\n${loc}: ${keys.length} keys (en ${enKeys.size}), ${identical.length} recorded cognates,` + + ` register ${cfg.register || '?'}, nplurals=${NP}`) +console.log(fails === 0 ? '\nALL CHECKS PASS' : `\n${fails} CHECK(S) FAILED`) +process.exitCode = fails ? 1 : 0 diff --git a/scripts/l10n/spell.js b/scripts/l10n/spell.js new file mode 100644 index 0000000000..3f77328f5e --- /dev/null +++ b/scripts/l10n/spell.js @@ -0,0 +1,179 @@ +#!/usr/bin/env node +/* eslint-disable n/no-process-exit */ +/* eslint-disable no-console */ +/* eslint-disable n/shebang */ +/** + * Spell-sweep one bundle against a hunspell dictionary. + * + * node scripts/l10n/spell.js [--suggest] [--all] + * + * Needs scripts/l10n/dicts/.{aff,dic} — run fetch-dicts.js first. + * + * WHY. "Not a word in the language at all" was 14 of `is`'s 235 defects and typos + * another 8 — 22 between them, the single largest mechanically-findable class in + * that pass, and every one of them found by reading. `is` carried `Stav` (Slavic), + * `skrivaðgang` (a Danish/Norwegian stem where Icelandic needs `skrifaðgang`), + * `levranir` and `Misheppnaðst`. On `sk` this class was one key — `strategie`, the + * Czech spelling of `stratégie` — which this tool finds and offers the right + * correction for. Wrong-language contamination inside a committed bundle is the + * thing it is really for (§6.9), and neighbouring-language stems are exactly what + * a dictionary catches and a reader's eye slides over. + * + * WHAT IT WILL NOT DO. It will not adjudicate derived or technical vocabulary. + * Measured on `sk`: the dictionary rejects BOTH `auditný` and `audítny`, so the + * 35-key adjective misspelling that dominated that pass is invisible here — that + * argument had to be made from Slovak morphology and the bundle's own forms. + * + * NOISE, and why the allowlist is per locale. A raw sweep of `sk` flags 199 of + * 2593 distinct words. Almost all are (a) English identifiers and code samples, + * (b) product names, or (c) real domain vocabulary no general dictionary carries + * (`webhook`, `token`, `vektorizácia`, `faseta`, `úsek`, `nástenka`). (a) is + * stripped mechanically below; (b) and (c) go in `spellAllow` in + * scripts/l10n/locales/.json, ONCE, and then the report stays short for + * every later pass. Build that list on the first run and the tool becomes cheap. + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +'use strict' + +const fs = require('fs') +const path = require('path') +const { execFileSync } = require('child_process') +const { loadJsTranslations, APP_ROOT, loadLocaleConfig } = require('./lib.js') + +const loc = process.argv[2] +if (!loc) { + console.error('usage: spell.js [--suggest] [--all]') + process.exit(2) +} +const suggest = process.argv.includes('--suggest') +const showAll = process.argv.includes('--all') + +const dict = path.join(APP_ROOT, 'scripts', 'l10n', 'dicts', loc) +if (!fs.existsSync(`${dict}.dic`) || !fs.existsSync(`${dict}.aff`)) { + console.error(`spell ${loc}: no dictionary at scripts/l10n/dicts/${loc}.{aff,dic}`) + console.error(`run: node scripts/l10n/fetch-dicts.js ${loc}`) + console.error('(fi ga lb mk mt rm have no hunspell dictionary published — see fetch-dicts.js)') + process.exit(3) +} + +const { translations } = loadJsTranslations(path.join(APP_ROOT, 'l10n', `${loc}.js`)) +const cfg = loadLocaleConfig(loc) +const allow = new Set((cfg.spellAllow || []).map((w) => w.toLowerCase())) + +// ------------------------------------------------------------------ tokenising + +/** + * Strip everything that is not prose in the target language: placeholders, code + * spans, URLs, file paths, and bare ALLCAPS initialisms. + * + * @param {string} s Value. + * @return {string} Prose only. + */ +function prose(s) { + return String(s) + .replace(/\{[^}]*\}/g, ' ') + .replace(/`[^`]*`/g, ' ') + .replace(/https?:\/\/\S+/g, ' ') + .replace(/\b[\w.-]+\.(json|js|vue|ods|xlsx|php|md|ya?ml)\b/gi, ' ') + // snake_case identifiers. The classes either side of the underscore are + // DISJOINT on purpose: the previous `[a-z_]+(?:_[a-z_]+)+` let both + // halves consume underscores, so a run of them ("a__________b") gave the + // engine an exponential number of ways to split the same text — CodeQL + // js/redos. `_+` here still absorbs repeated underscores, so `foo__bar` + // matches exactly as before, but there is only one way to match it. + .replace(/\b[a-z0-9]+(?:_+[a-z0-9]+)+\b/gi, ' ') + .replace(/\b[a-z]+[A-Z]\w*/g, ' ') // camelCase identifiers + .replace(/(? first key it appears in +for (const [key, value] of Object.entries(translations)) { + for (const v of Array.isArray(value) ? value : [value]) { + for (const w of prose(v).match(/\p{L}[\p{L}'’·-]*/gu) || []) { + if (w.length < 3) continue + if (allow.has(w.toLowerCase())) continue + if (!words.has(w)) words.set(w, key) + } + } +} + +// ------------------------------------------------------------------- hunspell + +const list = [...words.keys()] +if (!list.length) { + console.log(`spell ${loc}: nothing to check`) + process.exit(0) +} + +let unknown = [] +try { + const out = execFileSync('hunspell', ['-d', dict, '-l'], { + input: list.join('\n'), + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }) + unknown = [...new Set(out.split('\n').filter(Boolean))] +} catch (e) { + console.error(`spell ${loc}: hunspell failed — ${e.message}`) + process.exit(3) +} + +let sugg = new Map() +if (suggest && unknown.length) { + try { + const out = execFileSync('hunspell', ['-d', dict, '-a'], { + input: unknown.join('\n'), + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }) + let i = 0 + for (const line of out.split('\n').slice(1)) { + if (!line.trim()) continue + const m = line.match(/^&\s+(\S+)\s+\d+\s+\d+:\s*(.*)$/) + if (m) sugg.set(m[1], m[2].split(', ').slice(0, 4)) + else if (line.startsWith('#')) sugg.set(line.split(/\s+/)[1], []) + i++ + } + void i + } catch { sugg = new Map() } +} + +console.log(`spell ${loc}: ${list.length} distinct prose words, ${unknown.length} not in the ` + + `dictionary (${allow.size} allowlisted)`) +console.log() +console.log('Read this as a worklist. A general dictionary does not carry this app\'s domain') +console.log('vocabulary; put the legitimate ones in "spellAllow" in locales/' + loc + '.json so') +console.log('the next pass sees a short report. What you are hunting is a NEIGHBOURING LANGUAGE\'S') +console.log('stem and an outright typo — that is what no reader reliably catches.') +console.log() + +// EVERY unknown word is printed. An earlier version suppressed the ones hunspell +// had no suggestion for, which is exactly backwards: a word so mangled that the +// dictionary cannot even propose a neighbour is the MOST likely real defect — +// `is`'s `Misheppnaðst` and `levranir` are that shape. Suggestions are a bonus +// column, never a filter. +for (const w of unknown.sort((a, b) => a.localeCompare(b))) { + const s = sugg.get(w) + const tail = s && s.length ? ` -> ${s.join(', ')}` : '' + // hunspell splits on hyphens and apostrophes, so `-l` can name a SUB-token of + // a word this script sent, which is then absent from `words`. An earlier + // version indexed that blindly and threw, killing the report after its header + // — the crash looked exactly like "nothing found", which is the worst possible + // failure for a reading aid. Never let a missing origin key be fatal. + const where = words.has(w) ? JSON.stringify(words.get(w)).slice(0, 46) : '(sub-token)' + console.log(` ${w.padEnd(26)} ${where}${tail}`) +} +void showAll + +console.log() +console.log(`spell ${loc}: a reading aid; never fails. Suggestions come from hunspell, not from`) +console.log('this tool — verify each against core and the call site before changing a value.') diff --git a/scripts/l10n/termdrift.js b/scripts/l10n/termdrift.js new file mode 100644 index 0000000000..1c5da8434b --- /dev/null +++ b/scripts/l10n/termdrift.js @@ -0,0 +1,173 @@ +#!/usr/bin/env node +/* eslint-disable n/no-process-exit */ +/* eslint-disable no-console */ +/* eslint-disable n/shebang */ +/** + * Terminology drift within one bundle: English words rendered two different ways. + * + * node scripts/l10n/termdrift.js [--min-keys=4] [--max-minority=0.34] [--top=40] + * + * WHY THIS IS THE FIRST TOOL TO RUN. §6.9 says to count competing renderings per + * English term before anything else, because it is the highest-yield check and + * needs no knowledge of the language. It produced ~70 of `cs`'s 113 corrections + * and 41 of `is`'s. On `sk` it was decisive: 40 of the 57 corrections were + * terminology drift, and 37 of those were a SINGLE term (`audit trail`). + * + * But on all three passes the counting was done BY HAND, against a list of terms + * someone guessed — `register`, `schema`, `file`, `audit`, `log`… That is the + * weak link: the term that had actually drifted on `sk` was only found because + * `audit` happened to be on the guessed list. This counts EVERY English content + * word instead, so the guess is removed from the method. + * + * HOW. Index English content word -> the keys containing it. For each word in + * enough keys, fold every target value to stems and cluster. Where one stem + * dominates and a minority of keys carry none of the dominant cluster, that + * minority is reported: the bundle is saying the same English thing two ways. + * + * WHAT IT DOES *NOT* CATCH, measured on `sk` rather than guessed: + * · Inflection-level splits. `Poradie fasiet` vs `Poradie fasety` share the + * stem `faset`, so a stem clusterer cannot see them. + * · Word-ORDER splits. `špecifikáciu API` vs `API špecifikáciu` have identical + * stems. Both of those `sk` corrections needed reading. + * Measured recall on `sk` is 37 of 57 (65%); the remaining 20 need the regex + * sweeps, the spell pass and reading. This is a worklist, not a gate. + * + * WHICH SIDE OF A SPLIT IS RIGHT IS NOT THIS TOOL'S CALL, and the majority is + * not automatically correct. On `sk` the single minority rendering of + * `audit trail` was the one the owner chose, and it became the convention for + * the other 36 keys. Check core and the call site (§6.9) before picking a side. + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +'use strict' + +const path = require('path') +const { loadJsTranslations, APP_ROOT } = require('./lib.js') + +const loc = process.argv[2] +if (!loc) { + console.error('usage: termdrift.js [--min-keys=4] [--max-minority=0.34] [--top=40]') + process.exit(2) +} +const num = (flag, dflt) => { + const a = process.argv.find((x) => x.startsWith(`--${flag}=`)) + return a ? Number(a.slice(flag.length + 3)) : dflt +} +const MIN_KEYS = num('min-keys', 4) +const MAX_MINORITY = num('max-minority', 0.34) +const TOP = num('top', 40) +const STEM = 5 + +// English function words carry no terminology and would swamp the index. +const STOP = new Set(('a an the this that these those and or but if then else not no of to in on ' + + 'for with by from at as is are was were be been being do does did have has had will would ' + + 'can could may might must shall should it its their there here you your we our they them ' + + 'all any each every some more most other another new old first last next previous up down ' + + 'out off over under again once only just also very too so than when while where which who ' + + 'what how why whether into onto upon per via yet still now already about after before' +).split(' ')) + +const fold = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase() + +/** @param {string} s Target value. @return {Set} Folded word stems. */ +function stems(s) { + const cleaned = String(s) + .replace(/\{[^}]*\}/g, ' ') + .replace(/`[^`]*`/g, ' ') + .replace(/https?:\/\/\S+/g, ' ') + const out = new Set() + for (const w of cleaned.match(/\p{L}[\p{L}-]*/gu) || []) { + const f = fold(w) + if (f.length < 4) continue + out.add(f.slice(0, STEM)) + } + return out +} + +const { translations } = loadJsTranslations(path.join(APP_ROOT, 'l10n', `${loc}.js`)) + +// --------------------------------------------------- English word -> keys index + +const byWord = new Map() +for (const [key, value] of Object.entries(translations)) { + if (Array.isArray(value) || !String(value).trim()) continue + // Short keys only: in a 30-word sentence the head word cannot be isolated, + // and every content word would claim the whole sentence's stems. + const words = key.match(/\S+/g) || [] + if (words.length > 6) continue + // A word that occurs ONLY inside a {placeholder} is substituted at runtime, so + // the value is not expected to carry any rendering of it. Counting those made + // "Converting {schema} to blob storage..." look like schema-drift, and that + // class alone dominated the first run's output. + const bare = key.replace(/\{[^}]*\}/g, ' ') + const seen = new Set() + for (const w of bare.match(/[A-Za-z][A-Za-z-]*/g) || []) { + const lw = w.toLowerCase() + if (lw.length < 3 || STOP.has(lw)) continue + if (seen.has(lw)) continue + seen.add(lw) + if (!byWord.has(lw)) byWord.set(lw, []) + byWord.get(lw).push(key) + } +} + +// ------------------------------------------------------------------- clustering + +const findings = [] +for (const [word, keys] of byWord) { + if (keys.length < MIN_KEYS) continue + + const counts = new Map() + const keyStems = new Map() + for (const k of keys) { + const s = stems(translations[k]) + keyStems.set(k, s) + for (const g of s) counts.set(g, (counts.get(g) || 0) + 1) + } + + // The dominant rendering must actually dominate, or there is no convention to + // deviate from — a word whose top stem covers half the keys is just a common + // word, not an established term. + const ranked = [...counts].sort((a, b) => b[1] - a[1]) + if (!ranked.length) continue + const [domStem, domCount] = ranked[0] + if (domCount / keys.length < 0.6) continue + if (domCount < 3) continue + + const missing = keys.filter((k) => !keyStems.get(k).has(domStem)) + if (!missing.length) continue + if (missing.length / keys.length > MAX_MINORITY) continue + + findings.push({ + word, + keys: keys.length, + domStem, + domCount, + missing, + // A big established majority broken by one key is the strongest claim. + score: domCount * (1 - missing.length / keys.length), + }) +} + +findings.sort((a, b) => b.score - a.score) + +console.log(`termdrift ${loc}: ${findings.length} English word(s) rendered inconsistently`) +console.log(`(words in >=${MIN_KEYS} short keys; dominant stem must cover >=60%; ` + + `minority <=${MAX_MINORITY * 100}%)`) +console.log() + +for (const f of findings.slice(0, TOP)) { + console.log(` "${f.word}" — ${f.domCount}/${f.keys} keys use "${f.domStem}-", ` + + `${f.missing.length} do not:`) + for (const k of f.missing.slice(0, 6)) { + console.log(` ${JSON.stringify(k).padEnd(46)} ${JSON.stringify(translations[k])}`) + } + if (f.missing.length > 6) console.log(` … +${f.missing.length - 6} more`) + console.log() +} + +if (findings.length > TOP) console.log(`… +${findings.length - TOP} more below the top ${TOP}`) +console.log(`termdrift ${loc}: the majority is NOT automatically right (§6.9). ` + + 'A reading aid; never fails.') diff --git a/src/entities/application/application.ts b/src/entities/application/application.ts index 5114c210ec..6dd10c3785 100644 --- a/src/entities/application/application.ts +++ b/src/entities/application/application.ts @@ -1,4 +1,4 @@ -import type { SafeParseReturnType } from 'zod' +import type { ZodSafeParseResult } from 'zod' import type { TApplication } from './application.types' import { z } from 'zod' @@ -77,7 +77,7 @@ export class Application implements TApplication { this.updated = application.updated || '' } - public validate(): SafeParseReturnType { + public validate(): ZodSafeParseResult { const schema = z.object({ id: z.number().optional(), uuid: z.string().optional(), diff --git a/src/entities/auditTrail/auditTrail.ts b/src/entities/auditTrail/auditTrail.ts index 8b6a5f0537..095a38f468 100644 --- a/src/entities/auditTrail/auditTrail.ts +++ b/src/entities/auditTrail/auditTrail.ts @@ -1,4 +1,4 @@ -import type { SafeParseReturnType } from 'zod' +import type { ZodSafeParseResult } from 'zod' import type { TAuditTrail } from './auditTrail.types' import { z } from 'zod' @@ -62,7 +62,7 @@ export class AuditTrail implements TAuditTrail { this.size = auditTrail.size || 0 } - public validate(): SafeParseReturnType { + public validate(): ZodSafeParseResult { const schema = z.object({ id: z.number(), uuid: z.string().uuid(), diff --git a/src/entities/configuration/configuration.ts b/src/entities/configuration/configuration.ts index dc275b2f81..d4034c47e4 100644 --- a/src/entities/configuration/configuration.ts +++ b/src/entities/configuration/configuration.ts @@ -1,4 +1,4 @@ -import type { SafeParseReturnType } from 'zod' +import type { ZodSafeParseResult } from 'zod' import type { TConfiguration } from './configuration.types' import { z } from 'zod' @@ -69,7 +69,7 @@ export class ConfigurationEntity implements TConfiguration { /** * Validates the configuration against a schema */ - public validate(): SafeParseReturnType { + public validate(): ZodSafeParseResult { const schema = z.object({ id: z.string().min(1), title: z.string().min(1), diff --git a/src/entities/database/database.ts b/src/entities/database/database.ts index b9bac12a85..3f2c5ac1a6 100644 --- a/src/entities/database/database.ts +++ b/src/entities/database/database.ts @@ -1,4 +1,4 @@ -import type { SafeParseReturnType } from 'zod' +import type { ZodSafeParseResult } from 'zod' import type { TDatabase } from './database.types' import { z } from 'zod' @@ -20,7 +20,7 @@ export class Database implements TDatabase { this.tablePrefix = database.tablePrefix || '' } - public validate(): SafeParseReturnType { + public validate(): ZodSafeParseResult { const schema = z.object({ id: z.string().min(1), name: z.string().min(1), diff --git a/src/entities/object/object.spec.ts b/src/entities/object/object.spec.ts index 8df643d27a..688b6da341 100644 --- a/src/entities/object/object.spec.ts +++ b/src/entities/object/object.spec.ts @@ -103,10 +103,16 @@ describe('Object Entity', () => { expect(validation.success).toBe(false) if (!validation.success) { + // zod 4 reworded the built-in `too_small` message: zod 3 emitted + // "String must contain at least 1 character(s)". The constraint + // itself (`z.string().min(1)` on `@self.id`) is unchanged, so the + // issue is additionally pinned by `code` to keep this assertion + // anchored on the rule rather than on zod's wording. expect(validation.error.issues).toContainEqual( expect.objectContaining({ path: ['@self', 'id'], - message: 'String must contain at least 1 character(s)', + code: 'too_small', + message: 'Too small: expected string to have >=1 characters', }), ) } diff --git a/src/entities/object/object.ts b/src/entities/object/object.ts index fbabfde9a0..e6fcf0d68a 100644 --- a/src/entities/object/object.ts +++ b/src/entities/object/object.ts @@ -1,4 +1,4 @@ -import type { SafeParseReturnType } from 'zod' +import type { ZodSafeParseResult } from 'zod' import type { TObject } from './object.types' import { z } from 'zod' @@ -83,7 +83,7 @@ export class ObjectEntity implements TObject { /** * Validates the object against a schema */ - public validate(): SafeParseReturnType { + public validate(): ZodSafeParseResult { const schema = z .object({ '@self': z.object({ diff --git a/src/entities/organisation/organisation.ts b/src/entities/organisation/organisation.ts index d5c9ad38c1..72c271a826 100644 --- a/src/entities/organisation/organisation.ts +++ b/src/entities/organisation/organisation.ts @@ -1,4 +1,4 @@ -import type { SafeParseReturnType } from 'zod' +import type { ZodSafeParseResult } from 'zod' import type { TOrganisation } from './organisation.types' import { z } from 'zod' @@ -84,7 +84,7 @@ export class Organisation implements TOrganisation { this.updated = organisation.updated || '' } - public validate(): SafeParseReturnType { + public validate(): ZodSafeParseResult { const crudSchema = z.object({ create: z.array(z.string()).optional(), read: z.array(z.string()).optional(), diff --git a/src/entities/register/register.ts b/src/entities/register/register.ts index 214cb3ea75..78838934b6 100644 --- a/src/entities/register/register.ts +++ b/src/entities/register/register.ts @@ -1,4 +1,4 @@ -import type { SafeParseReturnType } from 'zod' +import type { ZodSafeParseResult } from 'zod' import type { TRegister } from './register.types' import { z } from 'zod' @@ -54,7 +54,7 @@ export class Register implements TRegister { this.stats = register.stats } - public validate(): SafeParseReturnType { + public validate(): ZodSafeParseResult { const schema = z.object({ id: z.string().min(1), title: z.string().min(1), diff --git a/src/entities/schema/schema.ts b/src/entities/schema/schema.ts index 92f08d5d68..738bc6a83e 100644 --- a/src/entities/schema/schema.ts +++ b/src/entities/schema/schema.ts @@ -1,4 +1,4 @@ -import type { SafeParseReturnType } from 'zod' +import type { ZodSafeParseResult } from 'zod' import type { TSchema } from './schema.types' import { z } from 'zod' @@ -74,7 +74,7 @@ export class Schema implements TSchema { this.stats = schema.stats } - public validate(): SafeParseReturnType { + public validate(): ZodSafeParseResult { const schema = z.object({ id: z.string().min(1), title: z.string().min(1), @@ -97,7 +97,7 @@ export class Schema implements TSchema { .optional(), hardValidation: z.boolean(), maxDepth: z.number().int().min(0), - authorization: z.record(z.array(z.string())).optional(), + authorization: z.record(z.string(), z.array(z.string())).optional(), }) return schema.safeParse(this) diff --git a/src/entities/source/source.ts b/src/entities/source/source.ts index 8006efa043..d58f04fe1a 100644 --- a/src/entities/source/source.ts +++ b/src/entities/source/source.ts @@ -1,4 +1,4 @@ -import type { SafeParseReturnType } from 'zod' +import type { ZodSafeParseResult } from 'zod' import type { TSource } from './source.types' import { z } from 'zod' @@ -28,7 +28,7 @@ export class Source implements TSource { this.created = source.created || '' } - public validate(): SafeParseReturnType { + public validate(): ZodSafeParseResult { const schema = z.object({ id: z.union([z.string(), z.number()]), title: z.string().min(1), diff --git a/src/entities/view/view.ts b/src/entities/view/view.ts index 03faafa9dd..778b6f5884 100644 --- a/src/entities/view/view.ts +++ b/src/entities/view/view.ts @@ -8,7 +8,7 @@ * @version 1.0.0 */ -import type { SafeParseReturnType } from 'zod' +import type { ZodSafeParseResult } from 'zod' import type { TView } from './view.types' import { z } from 'zod' @@ -60,7 +60,7 @@ export class View implements TView { this.updated = view.updated || '' } - public validate(): SafeParseReturnType { + public validate(): ZodSafeParseResult { const schema = z.object({ id: z.number().optional(), uuid: z.string().optional(), @@ -69,7 +69,7 @@ export class View implements TView { owner: z.string().optional(), isPublic: z.boolean().optional(), isDefault: z.boolean().optional(), - query: z.record(z.any()).optional(), + query: z.record(z.string(), z.any()).optional(), favoredBy: z.array(z.string()).optional(), quota: z .object({ diff --git a/src/manifest.json b/src/manifest.json index 4694e9e9da..e9e94809e1 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,6 +1,31 @@ { "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json", - "version": "1.0.0", + "setup": { + "version": 1, + "completionConfigKey": "setup_completed_version", + "steps": [ + { + "id": "welcome", + "type": "info", + "title": "Welcome", + "body": "A short setup to get this app ready. Nothing here is required; you can close it and come back later." + }, + { + "id": "demo-data", + "type": "run-action", + "action": "install-demo-data", + "title": "Demo data (optional)", + "required": false, + "body": "Load a small example dataset so the lists, detail pages and dashboards show a working product straight away. The data is obviously sample data, it is safe to run more than once, and it can be removed afterwards. Skip this on a production install." + }, + { + "id": "done", + "type": "summary", + "title": "All set" + } + ] + }, + "version": "1.1.0", "nav": { "includePersonalSettings": false }, @@ -795,6 +820,24 @@ "route": "tables" } }, + { + "id": "see-flows", + "sinceVersion": "1.1.0", + "placement": "right", + "optional": true, + "allowManualNext": true, + "title": "Where the automation lives", + "body": "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.", + "task": "Open Flows in the menu", + "target": { + "kind": "nav-item", + "ref": "flows" + }, + "advanceOn": { + "type": "route-match", + "route": "flows" + } + }, { "id": "done", "sinceVersion": "1.1.5", diff --git a/src/modals/configuration/EditConfiguration.vue b/src/modals/configuration/EditConfiguration.vue index e73e70ccaf..65ecdf6fc3 100644 --- a/src/modals/configuration/EditConfiguration.vue +++ b/src/modals/configuration/EditConfiguration.vue @@ -1746,7 +1746,7 @@ export default { color: var(--color-text-maxcontrast); max-width: 100%; white-space: normal; - word-break: break-word; + overflow-wrap: break-word; } diff --git a/src/modals/configuration/ViewConfiguration.vue b/src/modals/configuration/ViewConfiguration.vue index dcc6091e56..b4b3b88cd3 100644 --- a/src/modals/configuration/ViewConfiguration.vue +++ b/src/modals/configuration/ViewConfiguration.vue @@ -326,7 +326,7 @@ export default { .jsonViewer pre { margin: 0; white-space: pre-wrap; - word-wrap: break-word; + overflow-wrap: break-word; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.875rem; line-height: 1.4; diff --git a/src/modals/file/UploadFiles.vue b/src/modals/file/UploadFiles.vue index 2d68fa7d97..bbbc645242 100644 --- a/src/modals/file/UploadFiles.vue +++ b/src/modals/file/UploadFiles.vue @@ -969,7 +969,6 @@ div[class='modal-container']:has(.TestMappingMainModal) { justify-content: space-between; text-align: unset; align-items: center; - -webkit-box-align: end; box-sizing: border-box; } diff --git a/src/modals/logs/AuditTrailChanges.vue b/src/modals/logs/AuditTrailChanges.vue index b619d02e1c..60f6397e17 100644 --- a/src/modals/logs/AuditTrailChanges.vue +++ b/src/modals/logs/AuditTrailChanges.vue @@ -526,7 +526,7 @@ export default { .old-value, .new-value { max-width: 200px; - word-break: break-word; + overflow-wrap: break-word; } .old-value pre, @@ -590,7 +590,7 @@ export default { font-family: 'Courier New', monospace; font-size: 0.85rem; white-space: pre-wrap; - word-break: break-word; + overflow-wrap: break-word; color: var(--color-main-text); max-height: 400px; overflow-y: auto; diff --git a/src/modals/logs/AuditTrailDetails.vue b/src/modals/logs/AuditTrailDetails.vue index 163bc4cd72..5842768635 100644 --- a/src/modals/logs/AuditTrailDetails.vue +++ b/src/modals/logs/AuditTrailDetails.vue @@ -495,7 +495,7 @@ export default { font-family: 'Courier New', monospace; font-size: 0.85rem; white-space: pre-wrap; - word-break: break-word; + overflow-wrap: break-word; color: var(--color-main-text); } diff --git a/src/modals/settings/MassValidateModal.vue b/src/modals/settings/MassValidateModal.vue index e835fcca2a..3c8320ac65 100644 --- a/src/modals/settings/MassValidateModal.vue +++ b/src/modals/settings/MassValidateModal.vue @@ -1049,7 +1049,7 @@ export default { font-size: 0.9rem; line-height: 1.4; white-space: pre-wrap; - word-break: break-word; + overflow-wrap: break-word; max-height: 200px; overflow-y: auto; } @@ -1082,7 +1082,7 @@ export default { font-size: 0.85rem; line-height: 1.4; white-space: pre-wrap; - word-break: break-word; + overflow-wrap: break-word; max-height: 300px; overflow-y: auto; } @@ -1282,7 +1282,7 @@ export default { color: var(--color-text); font-size: 0.9rem; line-height: 1.4; - word-break: break-word; + overflow-wrap: break-word; } .error-overflow { diff --git a/src/modals/webhook/EditWebhook.vue b/src/modals/webhook/EditWebhook.vue index 67bd24e0a3..6afec71782 100644 --- a/src/modals/webhook/EditWebhook.vue +++ b/src/modals/webhook/EditWebhook.vue @@ -1143,7 +1143,7 @@ export default { color: var(--color-text-maxcontrast); max-width: 100%; white-space: normal; - word-break: break-word; + overflow-wrap: break-word; } .option-meta { diff --git a/src/modals/webhook/ViewWebhookLog.vue b/src/modals/webhook/ViewWebhookLog.vue index ed1aa8f67c..aedbccfac7 100644 --- a/src/modals/webhook/ViewWebhookLog.vue +++ b/src/modals/webhook/ViewWebhookLog.vue @@ -402,7 +402,7 @@ export default { .logValue { color: var(--color-main-text); - word-break: break-word; + overflow-wrap: break-word; } .event-class { @@ -445,7 +445,7 @@ export default { margin: 0; color: var(--color-main-text); white-space: pre-wrap; - word-break: break-word; + overflow-wrap: break-word; } .codeBlock { @@ -474,7 +474,7 @@ export default { line-height: 1.5; color: var(--color-main-text); white-space: pre; - word-wrap: normal; + overflow-wrap: normal; } .loadingContainer { diff --git a/src/sidebars/search/SearchSideBar.vue b/src/sidebars/search/SearchSideBar.vue index d6140cd29c..294af6c3b1 100644 --- a/src/sidebars/search/SearchSideBar.vue +++ b/src/sidebars/search/SearchSideBar.vue @@ -2402,7 +2402,7 @@ export default { color: var(--color-text-maxcontrast); max-width: 100%; white-space: normal; - word-break: break-word; + overflow-wrap: break-word; } .search-input-container { diff --git a/src/views/avg/AvgIndex.vue b/src/views/avg/AvgIndex.vue index 8422fbfce4..70fa60febb 100644 --- a/src/views/avg/AvgIndex.vue +++ b/src/views/avg/AvgIndex.vue @@ -2211,7 +2211,7 @@ export default { .dsarObjectPayload { white-space: pre-wrap; - word-break: break-word; + overflow-wrap: break-word; background: var(--color-main-background); padding: 8px; border-radius: var(--border-radius); diff --git a/src/views/object/ObjectDetails.vue b/src/views/object/ObjectDetails.vue index 57edf9acad..be5a88d9cf 100644 --- a/src/views/object/ObjectDetails.vue +++ b/src/views/object/ObjectDetails.vue @@ -1112,7 +1112,7 @@ h4 { .grid { display: grid; - grid-gap: 24px; + gap: 24px; grid-template-columns: 1fr 1fr; margin-block-start: var(--OR-margin-50); margin-block-end: var(--OR-margin-50); diff --git a/src/views/settings/sections/RegisterDescriptors.vue b/src/views/settings/sections/RegisterDescriptors.vue index df9caaa0db..85ddfa41a2 100644 --- a/src/views/settings/sections/RegisterDescriptors.vue +++ b/src/views/settings/sections/RegisterDescriptors.vue @@ -429,7 +429,7 @@ export default { width: 1px; height: 1px; overflow: hidden; - clip: rect(0 0 0 0); + clip-path: inset(50%); white-space: nowrap; } diff --git a/stylelint.config.js b/stylelint.config.js index 1c98c96003..672bbc8fd6 100644 --- a/stylelint.config.js +++ b/stylelint.config.js @@ -7,18 +7,14 @@ module.exports = { ignorePseudoElements: ['v-deep'], }, ], - // Indentation is prettier's now — the same handover eslint-config-prettier + // Indentation is prettier's — the same handover eslint-config-prettier // performs for eslint, and for the same reason: two formatters with - // overlapping jurisdiction make a repo unfixable. They genuinely disagree, - // on multi-line selectors: prettier continues a wrapped selector list at two - // tabs, this rule demands one. + // overlapping jurisdiction make a repo unfixable. Prettier's glob is + // `**/*.{js,ts,vue,css,scss}` (the `format` script) and covers both `src/**` + // and `css/main.css`, so nothing is lost by stylelint not indenting. // - // Handing it over LOSES nothing and GAINS coverage. This rule only ever saw - // what the `stylelint` script's glob passes it — `src/**` — so it reported - // clean on `development` while `css/main.css`, outside that glob, sat at 522 - // space-indented lines. Prettier's glob is `**/*.{js,ts,vue,css,scss}` and - // covers both. `indentation` is also deprecated in stylelint 15 and removed - // in 16, so it is on its way out regardless. - indentation: null, + // The `indentation: null` entry that used to sit here is gone: stylelint + // REMOVED the rule in v16, and from v17 a removed rule name is an "Unknown + // rule" error even when set to null. Keeping the entry cost 213 errors. }, } diff --git a/tests/Unit/Controller/FilesControllerTest.php b/tests/Unit/Controller/FilesControllerTest.php index db9d240112..15ededdb33 100644 --- a/tests/Unit/Controller/FilesControllerTest.php +++ b/tests/Unit/Controller/FilesControllerTest.php @@ -57,6 +57,94 @@ protected function setUp(): void { $this->reflection = new ReflectionClass(FilesController::class); } + /** + * 🔴 THE ONLY WAY IN FOR DESCRIPTION AND CATEGORY. + * + * `FileMapper::setDescriptionForFile()` and `setCategoryForFile()` are + * reached only through this endpoint. Labels had their own route and + * worked, which is why the gap was easy to miss — the feature looked + * present because a third of it was. + * + * @return void + */ + public function testUpdateMetadataPassesEveryFieldThrough(): void { + $this->request->method('getParams')->willReturn([ + 'description' => 'a scanned invoice', + 'category' => 'finance', + 'labels' => ['paid'], + ]); + $this->objectService->method('getObject')->willReturn(new \OCA\OpenRegister\Db\ObjectEntity()); + + $entity = new \OCA\OpenRegister\Db\File(); + $this->fileService->expects($this->once()) + ->method('updateFileMetadata') + ->with(7, 'a scanned invoice', 'finance', ['paid']) + ->willReturn($entity); + + $response = $this->controller->updateMetadata( + register: 'reg', + schema: 'sch', + id: 'obj-1', + fileId: 7 + ); + + $this->assertSame(200, $response->getStatus()); + } + + /** + * 🔑 ABSENT IS NOT EMPTY. The handler treats null as "leave this alone" and + * '' as "clear it", so a caller changing only the category must not have + * its description wiped. `??` would collapse that distinction; this test is + * what stops someone reintroducing it. + * + * @return void + */ + public function testUpdateMetadataLeavesAbsentFieldsAlone(): void { + $this->request->method('getParams')->willReturn(['category' => 'finance']); + $this->objectService->method('getObject')->willReturn(new \OCA\OpenRegister\Db\ObjectEntity()); + + $this->fileService->expects($this->once()) + ->method('updateFileMetadata') + ->with(7, null, 'finance', null) + ->willReturn(new \OCA\OpenRegister\Db\File()); + + $this->controller->updateMetadata(register: 'reg', schema: 'sch', id: 'obj-1', fileId: 7); + } + + /** + * An EMPTY description clears the field rather than being ignored — the + * other half of the same contract. + * + * @return void + */ + public function testUpdateMetadataClearsOnAnEmptyString(): void { + $this->request->method('getParams')->willReturn(['description' => '']); + $this->objectService->method('getObject')->willReturn(new \OCA\OpenRegister\Db\ObjectEntity()); + + $this->fileService->expects($this->once()) + ->method('updateFileMetadata') + ->with(7, '', null, null) + ->willReturn(new \OCA\OpenRegister\Db\File()); + + $this->controller->updateMetadata(register: 'reg', schema: 'sch', id: 'obj-1', fileId: 7); + } + + /** + * A missing object is a 404, and must not reach the service. + * + * @return void + */ + public function testUpdateMetadataOnAMissingObjectIs404(): void { + $this->request->method('getParams')->willReturn(['category' => 'x']); + $this->objectService->method('getObject')->willReturn(null); + $this->fileService->expects($this->never())->method('updateFileMetadata'); + + $this->assertSame( + 404, + $this->controller->updateMetadata(register: 'reg', schema: 'sch', id: 'ghost', fileId: 7)->getStatus() + ); + } + /** * Helper to invoke private methods via reflection. */ diff --git a/tests/Unit/Controller/FlowControllerTest.php b/tests/Unit/Controller/FlowControllerTest.php index 353ae94c29..aa4b40f601 100644 --- a/tests/Unit/Controller/FlowControllerTest.php +++ b/tests/Unit/Controller/FlowControllerTest.php @@ -147,7 +147,8 @@ protected function setUp(): void { // asserting what they were written to assert. The rights themselves // are covered in ActionAuthEveryoneTest, and the refusal path below // overrides this. - $this->access($this->userSession, $this->groupManager, $this->actionAuth) + $this->access($this->userSession, $this->groupManager, $this->actionAuth), + $this->createMock(\OCA\OpenRegister\Service\Flow\FlowVersionService::class) ); }//end setUp() @@ -300,7 +301,8 @@ public function testStateCanServeOneKeyAsAList(): void { $mapper, $this->preflight, $this->flows, - $this->access($this->userSession, $this->groupManager, $this->actionAuth) + $this->access($this->userSession, $this->groupManager, $this->actionAuth), + $this->createMock(\OCA\OpenRegister\Service\Flow\FlowVersionService::class) ); $data = $controller->state(flowId: 'flow-1')->getData(); @@ -350,7 +352,8 @@ public function testStateRefusesAFlowTheCallerMayNotSee(): void { $mapper, $this->preflight, $this->flows, - $this->access($this->userSession, $this->groupManager, $this->actionAuth) + $this->access($this->userSession, $this->groupManager, $this->actionAuth), + $this->createMock(\OCA\OpenRegister\Service\Flow\FlowVersionService::class) ); $response = $controller->state(flowId: 'someone-elses-flow'); @@ -386,7 +389,8 @@ public function testStateStillServesAFlowTheCallerCanSee(): void { $mapper, $this->preflight, $this->flows, - $this->access($this->userSession, $this->groupManager, $this->actionAuth) + $this->access($this->userSession, $this->groupManager, $this->actionAuth), + $this->createMock(\OCA\OpenRegister\Service\Flow\FlowVersionService::class) ); $response = $controller->state(flowId: 'flow-1'); @@ -415,7 +419,8 @@ public function testStateWithoutListIsUnchanged(): void { $mapper, $this->preflight, $this->flows, - $this->access($this->userSession, $this->groupManager, $this->actionAuth) + $this->access($this->userSession, $this->groupManager, $this->actionAuth), + $this->createMock(\OCA\OpenRegister\Service\Flow\FlowVersionService::class) ); $data = $controller->state(flowId: 'flow-1')->getData(); @@ -549,7 +554,8 @@ public function testANonAdminNeverReceivesTheAdminPaletteWhenNoScopeIsSent(): vo $this->createMock(\OCA\OpenRegister\Db\FlowStateMapper::class), $this->preflight, $this->flows, - $this->access($userSession, $groupManager, $this->actionAuth) + $this->access($userSession, $groupManager, $this->actionAuth), + $this->createMock(\OCA\OpenRegister\Service\Flow\FlowVersionService::class) ); $controller->nodeCatalog(); @@ -583,7 +589,8 @@ public function testANonAdminCannotRequestTheAdminScope(): void { $this->createMock(\OCA\OpenRegister\Db\FlowStateMapper::class), $this->preflight, $this->flows, - $this->access($userSession, $groupManager, $this->actionAuth) + $this->access($userSession, $groupManager, $this->actionAuth), + $this->createMock(\OCA\OpenRegister\Service\Flow\FlowVersionService::class) ); $controller->nodeCatalog(); @@ -615,7 +622,8 @@ public function testAnUnresolvableSessionGetsTheUserPalette(): void { $this->createMock(\OCA\OpenRegister\Db\FlowStateMapper::class), $this->preflight, $this->flows, - $this->access($userSession, $groupManager, $this->actionAuth) + $this->access($userSession, $groupManager, $this->actionAuth), + $this->createMock(\OCA\OpenRegister\Service\Flow\FlowVersionService::class) ); $controller->nodeCatalog(); @@ -647,7 +655,8 @@ public function testAWriteIsRefusedWhenTheRightIsNotHeld(): void { $this->createMock(originalClassName: \OCA\OpenRegister\Db\FlowStateMapper::class), $this->preflight, $this->flows, - $this->access($this->userSession, $this->groupManager, $denying) + $this->access($this->userSession, $this->groupManager, $denying), + $this->createMock(\OCA\OpenRegister\Service\Flow\FlowVersionService::class) ); // The flow service must never be reached: a refusal that still wrote diff --git a/tests/Unit/Controller/FlowLifecycleControllerTest.php b/tests/Unit/Controller/FlowLifecycleControllerTest.php new file mode 100644 index 0000000000..6873bbe403 --- /dev/null +++ b/tests/Unit/Controller/FlowLifecycleControllerTest.php @@ -0,0 +1,378 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Controller; + +use OCA\OpenRegister\Controller\FlowController; +use OCA\OpenRegister\Db\Flow; +use OCA\OpenRegister\Db\FlowVersion; +use OCA\OpenRegister\Service\Flow\EventCatalogService; +use OCA\OpenRegister\Service\Flow\FlowAccess; +use OCA\OpenRegister\Service\Flow\FlowLifecycleRefused; +use OCA\OpenRegister\Service\Flow\FlowNodePreflight; +use OCA\OpenRegister\Service\Flow\FlowNodeRegistry; +use OCA\OpenRegister\Service\Flow\FlowService; +use OCA\OpenRegister\Service\Flow\FlowVersionService; +use OCA\OpenRegister\Service\OpenRegisterActionAuthService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; + +class FlowLifecycleControllerTest extends TestCase { + + /** + * @var FlowService|\PHPUnit\Framework\MockObject\MockObject The flow service. + */ + private $flows; + + /** + * @var FlowVersionService|\PHPUnit\Framework\MockObject\MockObject The version service. + */ + private $versions; + + /** + * @var FlowController The controller under test. + */ + private FlowController $controller; + + /** + * Build the controller over mocked collaborators, with rights granted. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->flows = $this->createMock(FlowService::class); + $this->versions = $this->createMock(FlowVersionService::class); + + $auth = $this->createMock(OpenRegisterActionAuthService::class); + $auth->method('can')->willReturn(true); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturn($user); + + $groups = $this->createMock(IGroupManager::class); + $groups->method('isAdmin')->willReturn(true); + + $this->controller = new FlowController( + 'openregister', + $this->createMock(IRequest::class), + $this->createMock(EventCatalogService::class), + $this->createMock(FlowNodeRegistry::class), + $this->createMock(\OCA\OpenRegister\Db\FlowStateMapper::class), + $this->createMock(FlowNodePreflight::class), + $this->flows, + new FlowAccess($session, $groups, $auth), + $this->versions + ); + }//end setUp() + + /** + * A flow the service will hand back. + * + * @return Flow The flow. + */ + private function aFlow(): Flow { + $flow = new Flow(); + $flow->setUuid('flow-1'); + $flow->setLifecycleStatus(FlowVersion::STATUS_PUBLISHED); + + return $flow; + }//end aFlow() + + /** + * 🔴 EDITING A PUBLISHED FLOW IS A 409 WITH A MACHINE-READABLE REASON, + * never a 400 and never a silent success. + * + * @return void + */ + public function testEditingAPublishedFlowAnswers409WithAReason(): void { + $this->flows->method('save')->willThrowException( + new FlowLifecycleRefused( + reason: FlowLifecycleRefused::REASON_IMMUTABLE, + flowId: 'flow-1', + state: FlowVersion::STATUS_PUBLISHED + ) + ); + + $response = $this->controller->update(id: 'flow-1'); + $body = $response->getData(); + + $this->assertSame(Http::STATUS_CONFLICT, $response->getStatus()); + $this->assertSame(FlowLifecycleRefused::REASON_IMMUTABLE, $body['reason']); + $this->assertSame(FlowVersion::STATUS_PUBLISHED, $body['lifecycleStatus']); + $this->assertSame('flow-1', $body['flowId']); + }//end testEditingAPublishedFlowAnswers409WithAReason() + + /** + * Publishing returns the version that went live. + * + * @return void + */ + public function testPublishingReturnsTheNewlyPublishedVersion(): void { + $version = new FlowVersion(); + $version->setFlowUuid('flow-1'); + $version->setVersion(3); + $version->setStatus(FlowVersion::STATUS_PUBLISHED); + + $this->flows->method('find')->willReturn($this->aFlow()); + $this->versions->method('publish')->willReturn($version); + + $body = $this->controller->publish(id: 'flow-1')->getData(); + + $this->assertSame(3, $body['version']); + $this->assertSame(FlowVersion::STATUS_PUBLISHED, $body['status']); + }//end testPublishingReturnsTheNewlyPublishedVersion() + + /** + * Publishing something that is not a draft is refused with its own reason, + * distinct from the immutability one. + * + * @return void + */ + public function testPublishingANonDraftAnswers409(): void { + $this->flows->method('find')->willReturn($this->aFlow()); + $this->versions->method('publish')->willThrowException( + new FlowLifecycleRefused( + reason: FlowLifecycleRefused::REASON_NOT_A_DRAFT, + flowId: 'flow-1', + state: FlowVersion::STATUS_PUBLISHED + ) + ); + + $response = $this->controller->publish(id: 'flow-1'); + + $this->assertSame(Http::STATUS_CONFLICT, $response->getStatus()); + $this->assertSame(FlowLifecycleRefused::REASON_NOT_A_DRAFT, $response->getData()['reason']); + }//end testPublishingANonDraftAnswers409() + + /** + * Creating a draft answers 201 with the new version number. + * + * @return void + */ + public function testCreatingADraftAnswers201(): void { + $draft = new FlowVersion(); + $draft->setFlowUuid('flow-1'); + $draft->setVersion(4); + $draft->setStatus(FlowVersion::STATUS_DRAFT); + + $this->flows->method('find')->willReturn($this->aFlow()); + $this->versions->method('createDraft')->willReturn($draft); + + $response = $this->controller->draft(id: 'flow-1'); + + $this->assertSame(Http::STATUS_CREATED, $response->getStatus()); + $this->assertSame(4, $response->getData()['version']); + $this->assertSame(FlowVersion::STATUS_DRAFT, $response->getData()['status']); + }//end testCreatingADraftAnswers201() + + /** + * Deprecating when nothing is published is refused, not reported as a + * success that did nothing. + * + * @return void + */ + public function testDeprecatingWithNothingPublishedAnswers409(): void { + $this->flows->method('find')->willReturn($this->aFlow()); + $this->versions->method('deprecate')->willThrowException( + new FlowLifecycleRefused( + reason: FlowLifecycleRefused::REASON_NOT_PUBLISHED, + flowId: 'flow-1', + state: null + ) + ); + + $response = $this->controller->deprecate(id: 'flow-1'); + + $this->assertSame(Http::STATUS_CONFLICT, $response->getStatus()); + $this->assertSame(FlowLifecycleRefused::REASON_NOT_PUBLISHED, $response->getData()['reason']); + }//end testDeprecatingWithNothingPublishedAnswers409() + + /** + * A lifecycle action on a flow the caller cannot see is a 404, exactly as + * a read is — the id must never select a row the caller could not list. + * + * @return void + */ + public function testALifecycleActionOnAnInvisibleFlowIs404(): void { + $this->flows->method('find')->willThrowException(new DoesNotExistException('nope')); + + $this->assertSame(Http::STATUS_NOT_FOUND, $this->controller->publish(id: 'ghost')->getStatus()); + $this->assertSame(Http::STATUS_NOT_FOUND, $this->controller->draft(id: 'ghost')->getStatus()); + $this->assertSame(Http::STATUS_NOT_FOUND, $this->controller->deprecate(id: 'ghost')->getStatus()); + $this->assertSame(Http::STATUS_NOT_FOUND, $this->controller->versions(id: 'ghost')->getStatus()); + }//end testALifecycleActionOnAnInvisibleFlowIs404() + + /** + * 🔴 RUNNING AN UNPUBLISHED FLOW IS A 409, NOT A 500. + * + * This escaped as an unhandled exception and reached the client as an HTML + * error page — "the server is broken" for what is actually "publish this + * flow first". Every unit test passed, because they all asserted on the + * EXCEPTION rather than on the response a caller actually receives. The e2e + * caught it; this test exists so a unit run catches it next time. + * + * @return void + */ + public function testRunningAnUnpublishedFlowAnswers409NotAFault(): void { + $this->flows->method('run')->willThrowException( + new FlowLifecycleRefused( + reason: FlowLifecycleRefused::REASON_NO_PUBLISHED_VERSION, + flowId: 'flow-1', + state: null + ) + ); + + $response = $this->controller->run(id: 'flow-1'); + + $this->assertSame(Http::STATUS_CONFLICT, $response->getStatus()); + $this->assertSame( + FlowLifecycleRefused::REASON_NO_PUBLISHED_VERSION, + $response->getData()['reason'] + ); + }//end testRunningAnUnpublishedFlowAnswers409NotAFault() + + /** + * Listing versions returns them with a total, like every other list here. + * + * @return void + */ + public function testListingVersionsReturnsThemWithATotal(): void { + $one = new FlowVersion(); + $one->setVersion(2); + $two = new FlowVersion(); + $two->setVersion(1); + + $this->flows->method('find')->willReturn($this->aFlow()); + $this->versions->method('versionsOf')->willReturn([$one, $two]); + + $body = $this->controller->versions(id: 'flow-1')->getData(); + + $this->assertSame(2, $body['total']); + $this->assertSame(2, $body['results'][0]['version']); + }//end testListingVersionsReturnsThemWithATotal() + + /** + * Reading one version carries the graph it names, so the editor can render + * a historical version read-only without a second round trip. + * + * @return void + */ + public function testReadingOneVersionCarriesItsGraph(): void { + $version = new FlowVersion(); + $version->setVersion(2); + $version->setDefinitionHash('abc'); + + $this->flows->method('find')->willReturn($this->aFlow()); + $this->versions->method('versionOf')->willReturn($version); + $this->versions->method('graphOfVersion')->willReturn(['nodes' => [['id' => 'a']], 'edges' => []]); + + $body = $this->controller->version(id: 'flow-1', version: 2)->getData(); + + $this->assertSame('a', $body['graph']['nodes'][0]['id']); + }//end testReadingOneVersionCarriesItsGraph() + + /** + * A version number that does not exist is a 404, not an empty 200 that a + * client would render as "this version has no steps". + * + * @return void + */ + public function testAnUnknownVersionNumberIs404(): void { + $this->flows->method('find')->willReturn($this->aFlow()); + $this->versions->method('versionOf')->willReturn(null); + + $this->assertSame( + Http::STATUS_NOT_FOUND, + $this->controller->version(id: 'flow-1', version: 99)->getStatus() + ); + }//end testAnUnknownVersionNumberIs404() + + /** + * 🔴 THE EDITOR'S "RUN NOW" IS THE DOCUMENTED DRAFT EXCEPTION. + * + * Versioning made every dispatch require a published version, with one + * carve-out spelled out in `FlowPublishedGraph::overlayOnto`: "an unpinned + * run is the interactive draft test run — the one documented exception to + * 'a draft cannot back a run'". + * + * `flow#run` is the endpoint the editor's Run button posts to, so it IS + * that interactive run. It queued as MANUAL, which is not exempt, so a flow + * that had never been published could not be run from the only screen the + * exception exists for. This pins the trigger it must pass. + * + * @return void + */ + public function testTheEditorRunEndpointQueuesAsTheInteractiveTestRun(): void { + $captured = null; + $this->flows->method('run')->willReturnCallback( + function (string $uuid, array $subject, array $context, bool $sync, string $trigger) use (&$captured) { + $captured = $trigger; + return new \OCA\OpenRegister\Db\FlowRun(); + } + ); + + $this->controller->run(id: 'flow-1'); + + $this->assertSame( + \OCA\OpenRegister\Service\Flow\FlowRunVersionPin::TRIGGER_TEST, + $captured, + 'Run now must queue as the interactive test run, or the draft exception cannot be reached.' + ); + }//end testTheEditorRunEndpointQueuesAsTheInteractiveTestRun() + + /** + * 🔴 A LIFECYCLE REFUSAL FROM THE RUN ENDPOINT IS A 409, NOT A 500. + * + * The refusal carries `reason` and `lifecycleStatus` so the editor can + * offer the right button. Letting it escape uncaught threw that away and + * told the author only that the server broke — which is what a 500 means, + * and this is not one. + * + * @return void + */ + public function testARefusedRunAnswers409WithAReason(): void { + $this->flows->method('run')->willThrowException( + new FlowLifecycleRefused( + reason: FlowLifecycleRefused::REASON_NO_PUBLISHED_VERSION, + flowId: 'flow-1', + state: FlowVersion::STATUS_DRAFT + ) + ); + + $response = $this->controller->run(id: 'flow-1'); + + $this->assertSame(Http::STATUS_CONFLICT, $response->getStatus()); + $this->assertSame( + FlowLifecycleRefused::REASON_NO_PUBLISHED_VERSION, + $response->getData()['reason'] + ); + }//end testARefusedRunAnswers409WithAReason() + +}//end class diff --git a/tests/Unit/Controller/FlowRunControllerTest.php b/tests/Unit/Controller/FlowRunControllerTest.php index 093ca730f8..a8b5efdfea 100644 --- a/tests/Unit/Controller/FlowRunControllerTest.php +++ b/tests/Unit/Controller/FlowRunControllerTest.php @@ -16,6 +16,8 @@ use OCA\OpenRegister\Db\FlowRun; use OCA\OpenRegister\Db\FlowRunMapper; use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Service\Flow\FlowDeadEnd; +use OCA\OpenRegister\Service\Flow\FlowLifecycleRefused; use OCA\OpenRegister\Service\Flow\FlowLocator; use OCA\OpenRegister\Service\Flow\FlowRunService; use OCA\OpenRegister\Service\OrganisationService; @@ -760,4 +762,90 @@ public function testANonArraySlotIsSkipped(): void { $this->assertSame(Http::STATUS_OK, $response->getStatus()); }//end testANonArraySlotIsSkipped() + /** + * Set the request up as a test-run POST for one flow. + * + * @param string $flowId The flow to test-run. + * + * @return void + */ + private function aTestRunOf(string $flowId): void { + $this->request->method('getParam')->willReturnCallback( + static function (string $key, $default = null) use ($flowId) { + return match ($key) { + 'flowId' => $flowId, + 'pins' => [], + default => $default, + }; + } + ); + + $this->flows->method('find')->willReturn(new \OCA\OpenRegister\Db\Flow()); + $this->resolvers->method('resolveFlow')->willReturn(['nodes' => [], 'edges' => []]); + }//end aTestRunOf() + + /** + * 🔴 A LIFECYCLE REFUSAL ON THE TEST-RUN PATH IS A 409, NOT A 500. + * + * `FlowRunController::test()` is the OTHER dispatch a person presses, and it + * let `FlowLifecycleRefused` escape exactly as `FlowController::run()` did: + * the editor got an HTML error page — "the server is broken" — for what is + * actually "publish this flow first". Removing the catch turns this red with + * the exception escaping, which is the defect itself. + * + * @return void + */ + public function testARefusedTestRunIs409WithAReason(): void { + $this->aTestRunOf('flow-1'); + $this->runner->method('queue')->willThrowException( + new FlowLifecycleRefused( + reason: FlowLifecycleRefused::REASON_NO_PUBLISHED_VERSION, + flowId: 'flow-1', + state: null + ) + ); + + $response = $this->controller->test(); + + $this->assertSame( + Http::STATUS_CONFLICT, + $response->getStatus(), + 'a test run refused by the flow lifecycle must be a 409, not a fault' + ); + $this->assertSame( + FlowLifecycleRefused::REASON_NO_PUBLISHED_VERSION, + $response->getData()['reason'], + 'the refusal must name its reason as a field — "publish a version" and ' + . '"create a draft" want opposite buttons from the editor' + ); + }//end testARefusedTestRunIs409WithAReason() + + /** + * A dead end on the test-run path is the same kind of answer: the author + * wired a node a token cannot leave, and the engine has already written the + * sentence that says which one. Escaping as a 500 threw that sentence away. + * + * @return void + */ + public function testADeadEndTestRunIs409NamingTheDefect(): void { + $this->aTestRunOf('flow-2'); + $this->runner->method('queue')->willThrowException( + new FlowDeadEnd(nodeIds: ['step-a']) + ); + + $response = $this->controller->test(); + + $this->assertSame( + Http::STATUS_CONFLICT, + $response->getStatus(), + 'a dead end is the author\'s document, not a server fault' + ); + $this->assertSame('dead-end', $response->getData()['reason']); + $this->assertStringContainsString( + 'step-a', + (string)$response->getData()['error'], + 'the refusal must still name the node, which is the one fact the author needs' + ); + }//end testADeadEndTestRunIs409NamingTheDefect() + }//end class diff --git a/tests/Unit/Controller/SetupControllerTest.php b/tests/Unit/Controller/SetupControllerTest.php new file mode 100644 index 0000000000..1344b420e2 --- /dev/null +++ b/tests/Unit/Controller/SetupControllerTest.php @@ -0,0 +1,115 @@ +appConfig = $this->createMock(IAppConfig::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->demoData = $this->createMock(DemoDataService::class); + + $this->controller = new SetupController( + $this->createMock(IRequest::class), + $this->appConfig, + $this->logger, + $this->demoData + ); + } + + public function testStatusReportsTheDemoDataStep(): void { + $this->appConfig->method('getValueString')->willReturn(''); + + $data = $this->controller->status()->getData(); + + // Absence is the defect this guards: a step the wizard is never told + // about cannot be offered and cannot be completed. + $this->assertArrayHasKey('demo-data', $data['steps']); + $this->assertFalse($data['steps']['demo-data']['done']); + // This app declares no REQUIRED step, so setup must never gate the app. + $this->assertTrue($data['completed']); + $this->assertSame(1, $data['version']); + } + + public function testStatusReportsTheStepDoneOnceDecided(): void { + $this->appConfig->method('getValueString')->willReturn('skipped'); + + $data = $this->controller->status()->getData(); + + $this->assertTrue($data['steps']['demo-data']['done']); + } + + public function testSkippingIsAnAnswerAndIsRecorded(): void { + // Declining must be persisted, otherwise the wizard re-offers the import + // on every visit and "no thanks" is impossible to express. + $this->appConfig->expects($this->once()) + ->method('setValueString') + ->with('openregister', 'demo_data_decided', 'skipped'); + + $response = $this->controller->runAction('skip-demo-data'); + + $this->assertTrue($response->getData()['success']); + } + + public function testUnknownActionIs404(): void { + $response = $this->controller->runAction('not-an-action'); + + $this->assertSame(404, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + } + + public function testInstallReportsHowMuchLanded(): void { + $this->demoData->method('install') + ->willReturn(['objects' => 30, 'registers' => 1, 'schemas' => 4]); + + $this->appConfig->expects($this->once()) + ->method('setValueString') + ->with('openregister', 'demo_data_decided', 'installed'); + + $data = $this->controller->runAction('install-demo-data')->getData(); + + $this->assertTrue($data['success']); + // A success message that names no count cannot be told apart from an + // import that wrote nothing — the defect this programme already shipped. + $this->assertStringContainsString('30', $data['message']); + } + + public function testAFailedInstallIsReportedAndLeavesTheStepUNDECIDED(): void { + $this->demoData->method('install') + ->willThrowException(new RuntimeException('OpenRegister is not installed.')); + + // 🔴 THE POINT OF THIS TEST. Recording the decision here would close the + // step for an operator who asked for demo data and received none: the + // wizard would never offer it again, and nothing would have been + // imported. + $this->appConfig->expects($this->never())->method('setValueString'); + $this->logger->expects($this->once())->method('error'); + + $response = $this->controller->runAction('install-demo-data'); + + $this->assertSame(500, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + $this->assertStringContainsString('OpenRegister is not installed.', $response->getData()['message']); + } +} diff --git a/tests/Unit/Controller/TransferControllerTest.php b/tests/Unit/Controller/TransferControllerTest.php index 1e7096a6f2..c117cc0c08 100644 --- a/tests/Unit/Controller/TransferControllerTest.php +++ b/tests/Unit/Controller/TransferControllerTest.php @@ -51,10 +51,18 @@ class TransferControllerTest extends TestCase { private TransferController $controller; + private \OCA\OpenRegister\Service\Edepot\TransferListService&MockObject $listService; + protected function setUp(): void { $this->recordService = $this->createMock(TransferRecordService::class); $this->jobList = $this->createMock(IJobList::class); $this->request = $this->createMock(IRequest::class); + $this->listService = $this->createMock(\OCA\OpenRegister\Service\Edepot\TransferListService::class); + + $user = $this->createMock(\OCP\IUser::class); + $user->method('getUID')->willReturn('archivist-1'); + $session = $this->createMock(\OCP\IUserSession::class); + $session->method('getUser')->willReturn($user); $this->controller = new TransferController( 'openregister', @@ -62,10 +70,98 @@ protected function setUp(): void { $this->recordService, $this->jobList, $this->createMock(LoggerInterface::class), + $this->listService, + $session, ); }//end setUp() + /** + * 🔴 THE DECISION THAT MAKES THE FLOW REACHABLE AT ALL. + * + * `create()` dispatches only an `approved` list, and nothing could set that + * status: the service method was implemented, specified, and called by + * nobody. A list could be built and then never moved. + * + * @return void + */ + public function testApproveStampsTheActingArchivist(): void { + $this->recordService->method('loadTransferList')->willReturn(['uuid' => 'tl-1', 'status' => 'in_review']); + $this->listService->expects($this->once()) + ->method('approveTransferList') + ->with(['uuid' => 'tl-1', 'status' => 'in_review'], 'archivist-1') + ->willReturn(['uuid' => 'tl-1', 'status' => 'approved']); + + $response = $this->controller->approve(id: 'tl-1'); + + $this->assertSame('approved', $response->getData()['status']); + }//end testApproveStampsTheActingArchivist() + + /** + * Approving a list that is not in review is a REFUSAL the caller can act + * on, not a fault. 409, never 500. + * + * @return void + */ + public function testApprovingAListThatIsNotInReviewAnswers409(): void { + $this->recordService->method('loadTransferList')->willReturn(['uuid' => 'tl-1', 'status' => 'approved']); + $this->listService->method('approveTransferList') + ->willThrowException(new \InvalidArgumentException('Cannot approve transfer list with status')); + + $response = $this->controller->approve(id: 'tl-1'); + + $this->assertSame(\OCP\AppFramework\Http::STATUS_CONFLICT, $response->getStatus()); + $this->assertSame('wrong-status', $response->getData()['reason']); + }//end testApprovingAListThatIsNotInReviewAnswers409() + + /** + * A decision on a list that does not exist is a 404, and must not reach the + * service at all. + * + * @return void + */ + public function testDecidingOnAnUnknownListIs404(): void { + $this->recordService->method('loadTransferList')->willReturn(null); + $this->listService->expects($this->never())->method('approveTransferList'); + + $this->assertSame( + \OCP\AppFramework\Http::STATUS_NOT_FOUND, + $this->controller->approve(id: 'ghost')->getStatus() + ); + }//end testDecidingOnAnUnknownListIs404() + + /** + * 🔑 A REJECTION WITHOUT A REASON IS NOT A REJECTION. An archivist refusing + * a transfer is a records-management decision somebody will have to justify + * later, so the reason is required rather than defaulted to empty. + * + * @return void + */ + public function testRejectingWithoutAReasonIsRefused(): void { + $this->request->method('getParam')->willReturn(''); + $this->listService->expects($this->never())->method('rejectTransferList'); + + $response = $this->controller->reject(id: 'tl-1'); + + $this->assertSame(\OCP\AppFramework\Http::STATUS_BAD_REQUEST, $response->getStatus()); + }//end testRejectingWithoutAReasonIsRefused() + + /** + * A rejection carries its reason through to the service. + * + * @return void + */ + public function testRejectPassesTheReasonThrough(): void { + $this->request->method('getParam')->willReturn('incomplete metadata'); + $this->recordService->method('loadTransferList')->willReturn(['uuid' => 'tl-1', 'status' => 'in_review']); + $this->listService->expects($this->once()) + ->method('rejectTransferList') + ->with($this->anything(), 'archivist-1', 'incomplete metadata') + ->willReturn(['uuid' => 'tl-1', 'status' => 'rejected']); + + $this->assertSame('rejected', $this->controller->reject(id: 'tl-1')->getData()['status']); + }//end testRejectPassesTheReasonThrough() + /** * Index returns the persisted transfer lists with a total. * diff --git a/tests/Unit/Db/AuditFlowAttributionTest.php b/tests/Unit/Db/AuditFlowAttributionTest.php new file mode 100644 index 0000000000..50cc50486c --- /dev/null +++ b/tests/Unit/Db/AuditFlowAttributionTest.php @@ -0,0 +1,130 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + +namespace Unit\Db; + +use OCA\OpenRegister\Db\AuditFlowAttribution; +use OCA\OpenRegister\Db\AuditTrail; +use OCA\OpenRegister\Service\Flow\FlowRunContext; +use OCP\IDBConnection; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use RuntimeException; + +class AuditFlowAttributionTest extends TestCase { + + /** + * A stamper whose container yields whatever is given. + * + * @param mixed $resolved What the container returns for FlowRunContext, + * or an exception instance to throw. + * + * @return AuditFlowAttribution The stamper. + */ + private function stamper(mixed $resolved): AuditFlowAttribution { + $container = $this->createMock(ContainerInterface::class); + + if ($resolved instanceof \Throwable) { + $container->method('get')->willThrowException($resolved); + } else { + $container->method('get')->willReturn($resolved); + } + + return new AuditFlowAttribution($this->createMock(IDBConnection::class), $container); + }//end stamper() + + public function testAnExecutingStepIsStampedOntoTheRow(): void { + $context = new FlowRunContext(); + $context->push(runUuid: 'run-abc', nodeId: 'node-1', sequence: 7); + + $row = new AuditTrail(); + $this->stamper($context)->apply(auditTrail: $row); + + $this->assertSame('run-abc', $row->getFlowRun()); + $this->assertSame('node-1', $row->getFlowNode()); + $this->assertSame(7, $row->getFlowStep()); + }//end testAnExecutingStepIsStampedOntoTheRow() + + /** + * 🔴 Outside a run the row carries NO attribution. + * + * Null is the only way to say "no run" — a run uuid is never an empty + * string, so an empty one would be a claim rather than an absence. + */ + public function testARowWrittenOutsideAnyRunIsUnattributed(): void { + $row = new AuditTrail(); + $this->stamper(new FlowRunContext())->apply(auditTrail: $row); + + $this->assertNull($row->getFlowRun()); + $this->assertNull($row->getFlowNode()); + $this->assertNull($row->getFlowStep()); + }//end testARowWrittenOutsideAnyRunIsUnattributed() + + /** + * An unresolvable context leaves the row unattributed rather than failing. + * + * An audit row is evidence and must survive a bookkeeping problem; an + * unattributed row is honest about what it does not know. + */ + public function testAnUnresolvableContextDoesNotPreventTheRow(): void { + $row = new AuditTrail(); + $this->stamper(new RuntimeException('no such service'))->apply(auditTrail: $row); + + $this->assertNull($row->getFlowRun()); + }//end testAnUnresolvableContextDoesNotPreventTheRow() + + /** + * Something that is not a run context is not trusted to be one. + */ + public function testAWrongTypeFromTheContainerIsIgnored(): void { + $row = new AuditTrail(); + $this->stamper(new \stdClass())->apply(auditTrail: $row); + + $this->assertNull($row->getFlowRun()); + }//end testAWrongTypeFromTheContainerIsIgnored() + + /** + * The INNERMOST frame wins, so a sub-flow's writes are its own. + */ + public function testTheInnermostFrameIsTheOneStamped(): void { + $context = new FlowRunContext(); + $context->push(runUuid: 'parent', nodeId: 'call-sub', sequence: 1); + $context->push(runUuid: 'child', nodeId: 'child-node', sequence: 0); + + $row = new AuditTrail(); + $this->stamper($context)->apply(auditTrail: $row); + + $this->assertSame('child', $row->getFlowRun()); + }//end testTheInnermostFrameIsTheOneStamped() + + /** + * Stamping twice is idempotent — the builder and the insert path both call + * it, and they must not disagree. + */ + public function testStampingTwiceWritesTheSameValues(): void { + $context = new FlowRunContext(); + $context->push(runUuid: 'run-1', nodeId: 'n', sequence: 3); + + $stamper = $this->stamper($context); + $row = new AuditTrail(); + + $stamper->apply(auditTrail: $row); + $stamper->apply(auditTrail: $row); + + $this->assertSame('run-1', $row->getFlowRun()); + $this->assertSame(3, $row->getFlowStep()); + }//end testStampingTwiceWritesTheSameValues() +}//end class diff --git a/tests/Unit/Db/FlowVersionMapperDeleteTest.php b/tests/Unit/Db/FlowVersionMapperDeleteTest.php new file mode 100644 index 0000000000..5500572f74 --- /dev/null +++ b/tests/Unit/Db/FlowVersionMapperDeleteTest.php @@ -0,0 +1,135 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use OCA\OpenRegister\Db\FlowVersionMapper; +use OCP\DB\QueryBuilder\IExpressionBuilder; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use PHPUnit\Framework\TestCase; + +class FlowVersionMapperDeleteTest extends TestCase { + + /** + * Delete scopes to ONE flow and reports how many rows went. + * + * 🔑 THE `where` IS THE WHOLE SAFETY PROPERTY. A delete over this table + * without it removes every version of every flow on the instance, which + * would strand every in-flight run at once. So the test asserts the + * predicate is built against the flow uuid, not merely that a delete ran. + * + * @return void + */ + public function testDeleteByFlowScopesToThatFlowAndCountsTheRows(): void { + $expr = $this->createMock(IExpressionBuilder::class); + $expr->expects($this->once()) + ->method('eq') + ->with('flow_uuid', 'param-token') + ->willReturn('flow_uuid = :p'); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('expr')->willReturn($expr); + $qb->expects($this->once()) + ->method('delete') + ->with('openregister_flow_versions') + ->willReturnSelf(); + $qb->expects($this->once()) + ->method('createNamedParameter') + ->with('flow-1') + ->willReturn('param-token'); + $qb->expects($this->once()) + ->method('where') + ->with('flow_uuid = :p') + ->willReturnSelf(); + $qb->expects($this->once())->method('executeStatement')->willReturn(3); + + $db = $this->createMock(IDBConnection::class); + $db->method('getQueryBuilder')->willReturn($qb); + + $mapper = new FlowVersionMapper($db); + + $this->assertSame(3, $mapper->deleteByFlow(flowUuid: 'flow-1')); + }//end testDeleteByFlowScopesToThatFlowAndCountsTheRows() + + /** + * A flow with no versions deletes nothing and says so, rather than + * reporting a phantom success. + * + * @return void + */ + public function testDeleteByFlowReportsZeroWhenThereWasNothingToRemove(): void { + $expr = $this->createMock(IExpressionBuilder::class); + $expr->method('eq')->willReturn('flow_uuid = :p'); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('expr')->willReturn($expr); + $qb->method('delete')->willReturnSelf(); + $qb->method('createNamedParameter')->willReturn('param-token'); + $qb->method('where')->willReturnSelf(); + $qb->method('executeStatement')->willReturn(0); + + $db = $this->createMock(IDBConnection::class); + $db->method('getQueryBuilder')->willReturn($qb); + + $this->assertSame(0, (new FlowVersionMapper($db))->deleteByFlow(flowUuid: 'never-versioned')); + }//end testDeleteByFlowReportsZeroWhenThereWasNothingToRemove() + + /** + * The orphan sweep deletes only rows whose flow is GONE. + * + * 🔴 THE `notIn` IS THE WHOLE SAFETY PROPERTY. Without the predicate this + * empties the table — every version of every live flow — which would strand + * every in-flight run on the instance. So the test asserts the predicate is + * built against the flows table, not merely that a delete ran. + * + * @return void + */ + public function testDeleteOrphanedOnlyRemovesRowsWhoseFlowIsGone(): void { + $expr = $this->createMock(IExpressionBuilder::class); + $expr->expects($this->once()) + ->method('notIn') + ->with('flow_uuid', $this->anything()) + ->willReturn('flow_uuid NOT IN (...)'); + + $sub = $this->createMock(IQueryBuilder::class); + $sub->method('select')->willReturnSelf(); + $sub->method('from')->willReturnSelf(); + $sub->method('getSQL')->willReturn('SELECT uuid FROM oc_openregister_flows'); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('expr')->willReturn($expr); + $qb->method('createFunction')->willReturnArgument(0); + $qb->expects($this->once()) + ->method('delete') + ->with('openregister_flow_versions') + ->willReturnSelf(); + $qb->expects($this->once()) + ->method('where') + ->with('flow_uuid NOT IN (...)') + ->willReturnSelf(); + $qb->expects($this->once())->method('executeStatement')->willReturn(38); + + $db = $this->createMock(IDBConnection::class); + $db->method('getQueryBuilder')->willReturnOnConsecutiveCalls($qb, $sub); + + $this->assertSame(38, (new FlowVersionMapper($db))->deleteOrphaned()); + }//end testDeleteOrphanedOnlyRemovesRowsWhoseFlowIsGone() +}//end class diff --git a/tests/Unit/Db/OrganisationTest.php b/tests/Unit/Db/OrganisationTest.php index 9ff25cae33..84ea4dd0b2 100644 --- a/tests/Unit/Db/OrganisationTest.php +++ b/tests/Unit/Db/OrganisationTest.php @@ -28,11 +28,66 @@ public function testConstructorRegistersFieldTypes(): void { $this->assertSame('datetime', $fieldTypes['created']); $this->assertSame('datetime', $fieldTypes['updated']); $this->assertSame('boolean', $fieldTypes['active']); - $this->assertSame('integer', $fieldTypes['storage_quota']); - $this->assertSame('integer', $fieldTypes['bandwidth_quota']); - $this->assertSame('integer', $fieldTypes['request_quota']); + // Field types are keyed by the PROPERTY name, not the column name. + // Entity::__call() resolves a setter to lcfirst(substr($method, 3)) and + // looks that up in _fieldTypes, so a snake_case registration matches + // nothing and the cast silently never runs. + $this->assertSame('integer', $fieldTypes['storageQuota']); + $this->assertSame('integer', $fieldTypes['bandwidthQuota']); + $this->assertSame('integer', $fieldTypes['requestQuota']); $this->assertSame('json', $fieldTypes['authorization']); $this->assertSame('string', $fieldTypes['parent']); + + // Identity facet (consolidated from OpenCatalogi's publisher record). + $this->assertSame('string', $fieldTypes['type']); + $this->assertSame('string', $fieldTypes['summary']); + $this->assertSame('string', $fieldTypes['oin']); + $this->assertSame('string', $fieldTypes['tooi']); + $this->assertSame('string', $fieldTypes['rsin']); + $this->assertSame('string', $fieldTypes['kvk']); + $this->assertSame('string', $fieldTypes['pki']); + $this->assertSame('string', $fieldTypes['image']); + + // Relationship facet (consolidated from Stackiq's vendor record). + $this->assertSame('string', $fieldTypes['registrationStatus']); + $this->assertSame('string', $fieldTypes['mergedInto']); + $this->assertSame('datetime', $fieldTypes['mergedAt']); + + // Tenant lifecycle timestamps, likewise keyed by property name. + $this->assertSame('datetime', $fieldTypes['provisionedAt']); + $this->assertSame('datetime', $fieldTypes['suspendedAt']); + $this->assertSame('datetime', $fieldTypes['deprovisionedAt']); + } + + /** + * A database row must hydrate into the entity. + * + * `_fieldTypes` is keyed by PROPERTY name: Entity::fromRow() maps the column + * `provisioned_at` to the property `provisionedAt` and then looks THAT up to + * decide the cast. While the types were registered under the column names + * the datetime cast was unreachable, so fromRow() assigned the raw string + * straight onto a `?DateTime` typed property and threw a TypeError - every + * read of an organisation row carrying a lifecycle timestamp was a 500. + */ + public function testFromRowCastsDatetimeColumnsToDateTimeObjects(): void { + $organisation = Organisation::fromRow( + [ + 'id' => 1, + 'uuid' => '550e8400-e29b-41d4-a716-446655440000', + 'provisioned_at' => '2026-01-02 10:00:00', + 'suspended_at' => '2026-01-03 11:00:00', + 'deprovisioned_at' => '2026-01-04 12:00:00', + 'merged_at' => '2026-01-05 13:00:00', + ] + ); + + $this->assertInstanceOf(DateTime::class, $organisation->getProvisionedAt()); + $this->assertInstanceOf(DateTime::class, $organisation->getSuspendedAt()); + $this->assertInstanceOf(DateTime::class, $organisation->getDeprovisionedAt()); + $this->assertInstanceOf(DateTime::class, $organisation->getMergedAt()); + + $this->assertSame('2026-01-02 10:00:00', $organisation->getProvisionedAt()->format('Y-m-d H:i:s')); + $this->assertSame('2026-01-05 13:00:00', $organisation->getMergedAt()->format('Y-m-d H:i:s')); } public function testConstructorDefaultValues(): void { diff --git a/tests/Unit/Listener/CalculationOnSaveListenerSequenceTest.php b/tests/Unit/Listener/CalculationOnSaveListenerSequenceTest.php new file mode 100644 index 0000000000..62b9d2aae4 --- /dev/null +++ b/tests/Unit/Listener/CalculationOnSaveListenerSequenceTest.php @@ -0,0 +1,235 @@ + "2026-"). + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Listener + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace Unit\Listener; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Event\ObjectCreatingEvent; +use OCA\OpenRegister\Event\ObjectUpdatingEvent; +use OCA\OpenRegister\Listener\CalculationOnSaveListener; +use OCA\OpenRegister\Service\Calculation\CalculationEvaluator; +use OCA\OpenRegister\Service\Calculation\CalculationPayloadBuilder; +use OCA\OpenRegister\Service\Search\PlaceholderResolver; +use OCA\OpenRegister\Service\SequenceService; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Sequence-bearing calculations survive an update. + */ +class CalculationOnSaveListenerSequenceTest extends TestCase { + + /** + * The dossiq `case.identifier` calculation, verbatim: the year of + * `startDate`, a dash, and a yearly running number. + * + * @var array + */ + private const IDENTIFIER_CALC = [ + 'type' => 'string', + 'materialise' => true, + 'expression' => [ + 'concat' => [ + ['year' => ['prop' => 'startDate']], + '-', + ['sequence' => ['scope' => 'yearly', 'pad' => 4]], + ], + ], + ]; + + /** + * A materialised calculation with no `sequence` node — it must keep + * recomputing on update, so the guard is not over-broad. + * + * @var array + */ + private const SLUG_CALC = [ + 'type' => 'string', + 'materialise' => true, + 'expression' => ['concat' => [['prop' => 'title'], '!']], + ]; + + /** @var SchemaMapper&\PHPUnit\Framework\MockObject\MockObject */ + private $schemaMapper; + + /** @var RegisterMapper&\PHPUnit\Framework\MockObject\MockObject */ + private $registerMapper; + + /** @var CalculationPayloadBuilder&\PHPUnit\Framework\MockObject\MockObject */ + private $payloadBuilder; + + /** @var SequenceService&\PHPUnit\Framework\MockObject\MockObject */ + private $sequences; + + private CalculationOnSaveListener $listener; + + /** + * Wire the listener with a REAL evaluator and mocked collaborators. + * + * @return void + */ + protected function setUp(): void { + $userSession = $this->createMock(IUserSession::class); + $userSession->method('getUser')->willReturn(null); + + $this->schemaMapper = $this->createMock(SchemaMapper::class); + $this->registerMapper = $this->createMock(RegisterMapper::class); + $this->payloadBuilder = $this->createMock(CalculationPayloadBuilder::class); + $this->sequences = $this->createMock(SequenceService::class); + + // The payload builder only injects/strips the synthetic @self/@ref/ + // @aggregate keys; none of the calculations under test use them. + $this->payloadBuilder->method('build') + ->willReturnCallback(static fn (ObjectEntity $o): array => $o->getObject()); + $this->payloadBuilder->method('stripSyntheticKeys') + ->willReturnCallback(static fn (array $d): array => $d); + + $register = new Register(); + $register->setId(16); + $this->registerMapper->method('find')->willReturn($register); + + $this->listener = new CalculationOnSaveListener( + $this->schemaMapper, + $this->registerMapper, + new CalculationEvaluator(new PlaceholderResolver($userSession)), + $this->payloadBuilder, + $this->sequences, + $this->createMock(LoggerInterface::class) + ); + + }//end setUp() + + /** + * Build a schema declaring the given calculations. + * + * @param array $calcs The calculations block. + * + * @return Schema + */ + private function schemaWith(array $calcs): Schema { + $schema = new Schema(); + $schema->setId(26); + $schema->setConfiguration(['x-openregister-calculations' => $calcs]); + $this->schemaMapper->method('find')->willReturn($schema); + + return $schema; + }//end schemaWith() + + /** + * Build an object carrying the given business data. + * + * @param array $data The stored object data. + * + * @return ObjectEntity + */ + private function objectWith(array $data): ObjectEntity { + $object = new ObjectEntity(); + $object->setUuid('ce4b0570-3bf4-4133-a08b-3a9b03b2cc20'); + $object->setRegister('16'); + $object->setSchema('26'); + $object->setObject($data); + + return $object; + }//end objectWith() + + /** + * openregister#3075 — on UPDATE the assigned running number survives. + * + * Before the fix the evaluator resolved the context-less `sequence` node to + * null, `concat` rendered "2026-", and the listener wrote that over the + * stored "2026-0013". Any app whose identifier is readOnly then had every + * subsequent edit rejected with "Cannot modify readOnly property". + * + * @return void + */ + public function testUpdateDoesNotOverwriteAnAssignedSequenceIdentifier(): void { + $this->schemaWith(['identifier' => self::IDENTIFIER_CALC]); + $this->sequences->expects($this->never())->method('reserveNext'); + + $object = $this->objectWith( + ['title' => 'Editable case', 'startDate' => '2026-08-29', 'identifier' => '2026-0013'] + ); + + $this->listener->handle(new ObjectUpdatingEvent($object)); + + $this->assertSame('2026-0013', $object->getObject()['identifier']); + + }//end testUpdateDoesNotOverwriteAnAssignedSequenceIdentifier() + + /** + * The guard is scoped to sequence-bearing expressions: every other + * materialised calculation still recomputes on update. + * + * @return void + */ + public function testUpdateStillRecomputesCalculationsWithoutASequence(): void { + $this->schemaWith( + ['identifier' => self::IDENTIFIER_CALC, 'slug' => self::SLUG_CALC] + ); + + $object = $this->objectWith( + [ + 'title' => 'Edited case', + 'startDate' => '2026-08-29', + 'identifier' => '2026-0013', + 'slug' => 'stale', + ] + ); + + $this->listener->handle(new ObjectUpdatingEvent($object)); + + $data = $object->getObject(); + $this->assertSame('Edited case!', $data['slug'], 'non-sequence calculation still recomputes'); + $this->assertSame('2026-0013', $data['identifier'], 'sequence-bearing calculation is left alone'); + + }//end testUpdateStillRecomputesCalculationsWithoutASequence() + + /** + * CREATE is unchanged: the sequence reserves exactly one number and the + * identifier is materialised from it. + * + * @return void + */ + public function testCreateStillReservesAndMaterialisesTheIdentifier(): void { + $this->schemaWith(['identifier' => self::IDENTIFIER_CALC]); + $this->sequences->expects($this->once()) + ->method('reserveNext') + ->with(16, 26, date('Y')) + ->willReturn(13); + + $object = $this->objectWith(['title' => 'New case', 'startDate' => '2026-08-29']); + + $this->listener->handle(new ObjectCreatingEvent($object)); + + $this->assertSame('2026-0013', $object->getObject()['identifier']); + + }//end testCreateStillReservesAndMaterialisesTheIdentifier() + +}//end class diff --git a/tests/Unit/Listener/SchemaFlowImportListenerTest.php b/tests/Unit/Listener/SchemaFlowImportListenerTest.php index fd18ad904f..83f517114c 100644 --- a/tests/Unit/Listener/SchemaFlowImportListenerTest.php +++ b/tests/Unit/Listener/SchemaFlowImportListenerTest.php @@ -15,6 +15,7 @@ use OCA\OpenRegister\Event\SchemaCreatedEvent; use OCA\OpenRegister\Listener\SchemaFlowImportListener; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; /** * `x-openregister-flows` materialises into the flow store on schema save. @@ -39,7 +40,7 @@ class SchemaFlowImportListenerTest extends TestCase { * * @return SchemaFlowImportListener The listener. */ - private function listener(array $existing = []): SchemaFlowImportListener { + private function listener(array $existing = [], ?ContainerInterface $container = null): SchemaFlowImportListener { $this->inserted = []; $this->updated = []; @@ -54,7 +55,58 @@ private function listener(array $existing = []): SchemaFlowImportListener { return $f; }); - return new SchemaFlowImportListener($mapper, new \Psr\Log\NullLogger()); + return new SchemaFlowImportListener($mapper, new \Psr\Log\NullLogger(), $container); + } + + /** + * A container whose OrganisationService answers with $uuid. + * + * @param string|null $uuid What `getOrganisationForNewEntity()` resolves + * to, or null for an instance that can resolve + * no organisation at all. + * @param boolean $blows Whether resolving it throws, which models an + * instance where the service is not registered. + * + * @return ContainerInterface The container. + */ + private function container(?string $uuid, bool $blows = false): ContainerInterface { + $container = $this->createMock(ContainerInterface::class); + + if ($blows === true) { + $container->method('get')->willThrowException(new \RuntimeException('not registered')); + return $container; + } + + $service = new class($uuid) { + public function __construct(private ?string $uuid) { + } + + /** + * The call the listener MUST make. + * + * Not `getActiveOrganisation()`: this one falls back to the DEFAULT + * organisation when there is no session, which is the situation a + * schema import actually runs in. + */ + public function getOrganisationForNewEntity(): ?string { + return $this->uuid; + } + + /** + * Present, and deliberately answering NOTHING. + * + * A session-less import is exactly where this returns null, so a + * listener that reached for it would stamp no organisation — and + * every test here would still pass if this returned a real value. + */ + public function getActiveOrganisation(): ?object { + return null; + } + }; + + $container->method('get')->willReturn($service); + + return $container; } /** @@ -174,4 +226,128 @@ public function testAMalformedDeclarationIsSkippedNotFatal(): void { $this->assertCount(1, $this->inserted); $this->assertSame('Good', $this->inserted[0]->getName()); } -} + + /** + * 🔴 AN IMPORTED FLOW MUST BELONG TO A TENANT. + * + * Every flow READ is organisation-scoped, so a flow stored with a null + * organisation is returned by nothing: it never appears in the flows list, + * so it can never be opened, so it can never be adopted — while sitting + * perfectly intact in the table. Measured 2026-08-28 against a live + * instance: the first shipped `x-openregister-flows` declaration was + * invisible to `/api/flows`, next to two seeded flows that carried an + * organisation and listed fine. + */ + public function testAnImportedFlowIsStampedWithAnOrganisation(): void { + $listener = $this->listener([], $this->container('org-1')); + $this->fire($listener, $this->schema([ + ['name' => 'Triage', 'nodes' => [], 'edges' => []], + ])); + + $this->assertSame('org-1', $this->inserted[0]->getOrganisation()); + } + + /** + * With no container the flow still imports. + * + * The listener must stay constructible on an instance where + * OrganisationService is not registered: refusing the import would turn a + * missing optional service into a failed schema save. + */ + public function testWithNoContainerTheFlowStillImportsWithoutAnOrganisation(): void { + $listener = $this->listener(); + $this->fire($listener, $this->schema([ + ['name' => 'Triage', 'nodes' => [], 'edges' => []], + ])); + + $this->assertCount(1, $this->inserted); + $this->assertNull($this->inserted[0]->getOrganisation()); + } + + /** + * A container that cannot resolve the service is not fatal either. + */ + public function testAnUnresolvableOrganisationServiceIsNotFatal(): void { + $listener = $this->listener([], $this->container(null, blows: true)); + $this->fire($listener, $this->schema([ + ['name' => 'Triage', 'nodes' => [], 'edges' => []], + ])); + + $this->assertCount(1, $this->inserted); + $this->assertNull($this->inserted[0]->getOrganisation()); + } + + /** + * A registered service with NO active organisation resolves to null rather + * than to the empty string, which would be a real-looking tenant that owns + * nothing. + */ + public function testNoActiveOrganisationResolvesToNullNotAnEmptyString(): void { + $listener = $this->listener([], $this->container(null)); + $this->fire($listener, $this->schema([ + ['name' => 'Triage', 'nodes' => [], 'edges' => []], + ])); + + $this->assertNull($this->inserted[0]->getOrganisation()); + } + + /** + * An empty uuid is treated as no organisation for the same reason. + */ + public function testAnEmptyUuidResolvesToNull(): void { + $listener = $this->listener([], $this->container('')); + $this->fire($listener, $this->schema([ + ['name' => 'Triage', 'nodes' => [], 'edges' => []], + ])); + + $this->assertNull($this->inserted[0]->getOrganisation()); + } + + /** + * 🔴 A RE-IMPORT MUST NOT RE-STAMP THE ORGANISATION. + * + * The stamp sits in the create branch beside `enabled`/`owner` precisely so + * an upgrade running as a different tenant cannot move an already-adopted + * flow out from under the organisation using it. + */ + public function testAReimportDoesNotMoveAnExistingFlowToAnotherOrganisation(): void { + $existing = new Flow(); + $existing->setUuid('u1'); + $existing->setApp('openregister'); + $existing->setName('Triage'); + $existing->setTriggerSchema('case'); + $existing->setOrganisation('org-owner'); + + $listener = $this->listener([$existing], $this->container('org-upgrader')); + $this->fire($listener, $this->schema([ + ['name' => 'Triage', 'nodes' => [], 'edges' => []], + ])); + + $this->assertSame('org-owner', $this->updated[0]->getOrganisation()); + } + + /** + * 🔴 IT MUST ASK FOR "AN ORGANISATION FOR A NEW ENTITY", NOT THE ACTIVE ONE. + * + * A schema import runs during install and during `occ maintenance:repair`, + * with no user session — so there IS no active organisation, and a listener + * that asked for one stamped null and the flow was invisible again. The + * double above answers null from `getActiveOrganisation()` precisely so that + * regression cannot pass. + * + * Measured 2026-08-28: the active-organisation version went green on a dev + * stack that happened to have one, and failed in CI, which does not. + */ + public function testItResolvesTheOrganisationTheWayEveryOtherNewEntityDoes(): void { + $listener = $this->listener([], $this->container('org-default')); + $this->fire($listener, $this->schema([ + ['name' => 'Triage', 'nodes' => [], 'edges' => []], + ])); + + $this->assertSame( + 'org-default', + $this->inserted[0]->getOrganisation(), + 'the default organisation is what a session-less import must fall back to' + ); + }//end testItResolvesTheOrganisationTheWayEveryOtherNewEntityDoes() +}//end class diff --git a/tests/Unit/Repair/BackfillFlowPagingTest.php b/tests/Unit/Repair/BackfillFlowPagingTest.php new file mode 100644 index 0000000000..7757b2224b --- /dev/null +++ b/tests/Unit/Repair/BackfillFlowPagingTest.php @@ -0,0 +1,459 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Repair + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Repair; + +use OCA\OpenRegister\Db\Flow; +use OCA\OpenRegister\Db\FlowMapper; +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\FlowVersion; +use OCA\OpenRegister\Db\FlowVersionMapper; +use OCA\OpenRegister\Repair\BackfillFlowTriggerIndex; +use OCA\OpenRegister\Repair\BackfillFlowVersions; +use OCA\OpenRegister\Service\Flow\FlowDefinitionPin; +use OCA\OpenRegister\Service\Flow\FlowTriggerIndex; +use OCP\Migration\IOutput; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Both flow repair steps page to exhaustion. + */ +final class BackfillFlowPagingTest extends TestCase { + + /** + * How many flows the instance under test holds. + * + * Past two full 500-row pages, so a step that stops after one page — or + * after two — is distinguishable from one that reaches the end. + * + * @var integer + */ + private const FLOWS = 1003; + + /** + * Every flow uuid the fixture instance holds, in order. + * + * @return array The uuids. + */ + private function uuids(): array { + $uuids = []; + for ($i = 0; $i < self::FLOWS; $i++) { + $uuids[] = sprintf('flow-%04d', $i); + } + + return $uuids; + }//end uuids() + + /** + * A flow mapper that pages for real. + * + * `findAllFlows()` honours `limit` and `offset` exactly as the real one + * does, which is the whole point: a double that ignored them would let an + * unpaged caller pass and the test would prove nothing. + * + * @param array $uuids The uuids the instance holds. + * @param array $seenRef Receives one `{limit, offset}` entry per call. + * + * @return FlowMapper The configured double. + */ + private function pagingMapper(array $uuids, array &$seenRef): FlowMapper { + $mapper = $this->createMock(FlowMapper::class); + $mapper->method('findAllFlows')->willReturnCallback( + function ( + ?string $app = null, + ?string $applicationSlug = null, + ?string $organisation = null, + ?bool $enabled = null, + int $limit = 100, + int $offset = 0, + ) use ($uuids, &$seenRef): array { + $seenRef[] = ['limit' => $limit, 'offset' => $offset]; + + $page = []; + foreach (array_slice($uuids, $offset, $limit) as $uuid) { + $flow = new Flow(); + $flow->setUuid($uuid); + $flow->setNodes([]); + $flow->setEdges([]); + $page[] = $flow; + } + + return $page; + } + ); + + return $mapper; + }//end pagingMapper() + + /** + * 🔴 THE VERSION BACK-FILL MUST VERSION EVERY FLOW. + * + * Asserted on the flows actually VERSIONED — the version rows inserted — + * rather than on the number of mapper calls, because "it paged" is not the + * claim; "no flow was left unrunnable" is. Reverting `everyFlow()` to a bare + * `findAllFlows()` turns this red at 100 of 1,003. + * + * @return void + */ + public function testTheVersionBackfillVersionsEveryFlowNotTheFirstPage(): void { + $calls = []; + $mapper = $this->pagingMapper($this->uuids(), $calls); + + $versioned = []; + $versions = $this->createMock(FlowVersionMapper::class); + $versions->method('highestVersion')->willReturn(0); + $versions->method('insert')->willReturnCallback( + function (FlowVersion $version) use (&$versioned) { + $versioned[] = (string)$version->getFlowUuid(); + + return $version; + } + ); + + $pin = $this->createMock(FlowDefinitionPin::class); + $pin->method('pin')->willReturn('deadbeef'); + + $runs = $this->createMock(FlowRunMapper::class); + $runs->method('pinUnversionedActive')->willReturn(0); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + function (string $id) use ($mapper, $versions, $pin, $runs) { + return match ($id) { + FlowVersionMapper::class => $versions, + FlowDefinitionPin::class => $pin, + FlowRunMapper::class => $runs, + default => $mapper, + }; + } + ); + + $step = new BackfillFlowVersions($container, $this->createMock(LoggerInterface::class)); + $step->run($this->createMock(IOutput::class)); + + $this->assertCount( + self::FLOWS, + $versioned, + 'every flow on the instance must get a published version 1 — a flow the ' + . 'back-fill skipped is a flow that backs no run' + ); + $this->assertSame( + $this->uuids(), + $versioned, + 'the pages must be distinct and in order — an offset that does not ' + . 'advance re-versions page one and never reaches the tail' + ); + }//end testTheVersionBackfillVersionsEveryFlowNotTheFirstPage() + + /** + * The pager asks for pages, and asks for the NEXT one each time. + * + * Complements the assertion above rather than repeating it: that one proves + * the outcome, this one proves the mechanism, so a future implementation + * that reached the same total by asking for one enormous page — which + * truncates silently the day an instance outgrows it — is still visible. + * + * @return void + */ + public function testTheVersionBackfillAdvancesItsOffset(): void { + $calls = []; + $mapper = $this->pagingMapper($this->uuids(), $calls); + + $versions = $this->createMock(FlowVersionMapper::class); + $versions->method('highestVersion')->willReturn(1); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + function (string $id) use ($mapper, $versions) { + return match ($id) { + FlowVersionMapper::class => $versions, + default => $mapper, + }; + } + ); + + $step = new BackfillFlowVersions($container, $this->createMock(LoggerInterface::class)); + $step->run($this->createMock(IOutput::class)); + + $this->assertSame( + [0, 500, 1000], + array_column($calls, 'offset'), + 'the back-fill must walk the instance page by page until a short page ends it' + ); + }//end testTheVersionBackfillAdvancesItsOffset() + + /** + * 🔑 IDEMPOTENT BY QUERY. A flow that already carries a version row is left + * alone, so `occ maintenance:repair` can be re-run without inventing a + * second version 1. + * + * @return void + */ + public function testAnAlreadyVersionedFlowIsNotVersionedAgain(): void { + $calls = []; + $mapper = $this->pagingMapper(['flow-0000'], $calls); + + $versions = $this->createMock(FlowVersionMapper::class); + $versions->method('highestVersion')->willReturn(3); + $versions->expects($this->never())->method('insert'); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + function (string $id) use ($mapper, $versions) { + return match ($id) { + FlowVersionMapper::class => $versions, + default => $mapper, + }; + } + ); + + $step = new BackfillFlowVersions($container, $this->createMock(LoggerInterface::class)); + $step->run($this->createMock(IOutput::class)); + }//end testAnAlreadyVersionedFlowIsNotVersionedAgain() + + /** + * 🔴 THE TRIGGER INDEX MUST BE DERIVED FROM EVERY FLOW. + * + * The same paging bug, with a silent symptom: a flow past the first page is + * never subscribed, so it never fires, and nothing anywhere says so. + * Asserted on the flows handed to `rebuild()`, because that set IS the + * subscription. Reverting the loop to a bare `findAllFlows()` turns this red + * at 100 of 1,003. + * + * @return void + */ + public function testTheTriggerIndexBackfillDerivesFromEveryFlow(): void { + $calls = []; + $mapper = $this->pagingMapper($this->uuids(), $calls); + + $rebuilt = null; + $index = $this->createMock(FlowTriggerIndex::class); + $index->method('rebuild')->willReturnCallback( + function (array $flows) use (&$rebuilt): array { + $rebuilt = $flows; + + return ['indexed' => count($flows), 'rows' => count($flows), 'unconverted' => []]; + } + ); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + function (string $id) use ($mapper, $index) { + return match ($id) { + FlowTriggerIndex::class => $index, + default => $mapper, + }; + } + ); + + $step = new BackfillFlowTriggerIndex($container, $this->createMock(LoggerInterface::class)); + $step->run($this->createMock(IOutput::class)); + + $this->assertNotNull($rebuilt, 'the index was never rebuilt at all'); + $this->assertCount( + self::FLOWS, + $rebuilt, + 'every flow must reach the index — one that does not is never subscribed ' + . 'and never fires, with nothing to say so' + ); + $this->assertSame( + $this->uuids(), + array_map(static fn (Flow $flow): string => (string)$flow->getUuid(), $rebuilt), + 'the pages must be distinct and in order' + ); + }//end testTheTriggerIndexBackfillDerivesFromEveryFlow() + + /** + * An instance with no flows says so and rebuilds nothing. + * + * The short-circuit matters: `rebuild([])` would report "0 of 0 flows" as a + * success line, which reads identically to a back-fill that failed to load + * any. + * + * @return void + */ + public function testAnInstanceWithNoFlowsRebuildsNothing(): void { + $calls = []; + $mapper = $this->pagingMapper([], $calls); + + $index = $this->createMock(FlowTriggerIndex::class); + $index->expects($this->never())->method('rebuild'); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + function (string $id) use ($mapper, $index) { + return match ($id) { + FlowTriggerIndex::class => $index, + default => $mapper, + }; + } + ); + + $step = new BackfillFlowTriggerIndex($container, $this->createMock(LoggerInterface::class)); + $step->run($this->createMock(IOutput::class)); + + $this->assertSame( + [['limit' => 500, 'offset' => 0]], + $calls, + 'an empty instance is one short page, not a loop' + ); + }//end testAnInstanceWithNoFlowsRebuildsNothing() + + /** + * 🔑 ONE FLOW'S FAILURE MUST NOT COST THE OTHERS THEIR VERSIONS. + * + * An aborted loop here is the difference between one unrunnable flow and an + * unrunnable instance — and this step exists precisely because an instance + * where nothing has a published version is an outage. So a flow whose + * definition cannot be stored is warned about and skipped, and the walk + * continues. + * + * Asserted on the flows that DID get versioned, not on the warning: a + * version that reports the failure and then stops is exactly the behaviour + * being ruled out. + * + * @return void + */ + public function testOneFlowThatCannotBeStoredDoesNotStopTheRest(): void { + $calls = []; + $mapper = $this->pagingMapper(['flow-0000', 'flow-0001', 'flow-0002'], $calls); + + $versioned = []; + $versions = $this->createMock(FlowVersionMapper::class); + $versions->method('highestVersion')->willReturn(0); + $versions->method('insert')->willReturnCallback( + function (FlowVersion $version) use (&$versioned) { + $versioned[] = (string)$version->getFlowUuid(); + + return $version; + } + ); + + // The middle flow cannot be pinned. `backfillOne()` turns a null hash + // into a RuntimeException, which is what the per-flow catch is for. + $pin = $this->createMock(FlowDefinitionPin::class); + $pin->method('pin')->willReturnCallback( + static fn (array $flow, string $flowId): ?string => $flowId === 'flow-0001' ? null : 'deadbeef' + ); + + $runs = $this->createMock(FlowRunMapper::class); + $runs->method('pinUnversionedActive')->willReturn(0); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + function (string $id) use ($mapper, $versions, $pin, $runs) { + return match ($id) { + FlowVersionMapper::class => $versions, + FlowDefinitionPin::class => $pin, + FlowRunMapper::class => $runs, + default => $mapper, + }; + } + ); + + $step = new BackfillFlowVersions($container, $this->createMock(LoggerInterface::class)); + $step->run($this->createMock(IOutput::class)); + + $this->assertSame( + ['flow-0000', 'flow-0002'], + $versioned, + 'the flows after the failing one must still be versioned — an aborted ' + . 'walk turns one broken flow into a broken instance' + ); + }//end testOneFlowThatCannotBeStoredDoesNotStopTheRest() + + /** + * 🔑 THE TRIGGER BACK-FILL NEVER THROWS. It runs inside `occ + * maintenance:repair` and during upgrade: an exception escaping here aborts + * the upgrade, which is strictly worse than an index that needs rebuilding. + * + * Asserted by calling it — the failure mode is an exception leaving this + * method, so a test that completes IS the assertion, and it is made explicit + * with `expectNotToPerformAssertions()` being deliberately NOT used: the + * warning is asserted instead, so the test cannot pass by never reaching the + * rebuild at all. + * + * @return void + */ + public function testAFailingRebuildIsWarnedAboutRatherThanThrown(): void { + $calls = []; + $mapper = $this->pagingMapper(['flow-0000'], $calls); + + $index = $this->createMock(FlowTriggerIndex::class); + $index->method('rebuild')->willThrowException( + new \RuntimeException('the trigger table is missing') + ); + + $warned = []; + $output = $this->createMock(IOutput::class); + $output->method('warning')->willReturnCallback( + function (string $message) use (&$warned): void { + $warned[] = $message; + } + ); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + function (string $id) use ($mapper, $index) { + return match ($id) { + FlowTriggerIndex::class => $index, + default => $mapper, + }; + } + ); + + $step = new BackfillFlowTriggerIndex($container, $this->createMock(LoggerInterface::class)); + $step->run($output); + + $this->assertCount( + 1, + $warned, + 'a failed rebuild must be reported, not swallowed and not thrown — an ' + . 'exception here aborts the upgrade that is carrying the fix' + ); + $this->assertStringContainsString('the trigger table is missing', $warned[0]); + }//end testAFailingRebuildIsWarnedAboutRatherThanThrown() + +}//end class diff --git a/tests/Unit/Repair/RechainAuditTrailForFlowAttributionTest.php b/tests/Unit/Repair/RechainAuditTrailForFlowAttributionTest.php new file mode 100644 index 0000000000..1424296a1d --- /dev/null +++ b/tests/Unit/Repair/RechainAuditTrailForFlowAttributionTest.php @@ -0,0 +1,233 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + +namespace Unit\Repair; + +use OCA\OpenRegister\Repair\RechainAuditTrailForFlowAttribution; +use OCA\OpenRegister\Service\AuditHashService; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IAppConfig; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use RuntimeException; + +class RechainAuditTrailForFlowAttributionTest extends TestCase { + + /** + * An app-config double backed by a plain array. + * + * @param boolean $writable Whether writes are kept. False models a config + * backend that silently drops them. + * + * @return IAppConfig The config double. + */ + private function config(bool $writable = true, array &$store = []): IAppConfig { + $config = $this->createMock(IAppConfig::class); + + // A regular closure with `use (&$store)`, NOT an arrow function: `fn` + // captures by VALUE at definition time, so the read-back would see the + // store as it was before any write. That mistake made this double report + // a dropped write on every call — which is exactly what the code under + // test refuses on, so the first run of these tests failed for a reason + // that had nothing to do with the code. + $config->method('getValueString')->willReturnCallback( + function (string $app, string $key, string $default = '') use (&$store): string { + return ($store[$key] ?? $default); + } + ); + + $config->method('setValueString')->willReturnCallback( + static function (string $app, string $key, string $value) use (&$store, $writable): bool { + if ($writable === true) { + $store[$key] = $value; + } + + return true; + } + ); + + return $config; + }//end config() + + /** + * A database double whose audit-trail query returns no rows. + * + * An empty table is the right fixture for these tests: the ORDERING and the + * REFUSALS are what is under test, and they do not depend on there being + * rows. Chain-walking itself is covered by the AuditHashService suites. + * + * @return IDBConnection The database double. + */ + private function emptyDb(): IDBConnection { + $result = $this->createMock(\OCP\DB\IResult::class); + $result->method('fetchAll')->willReturn([]); + + $expr = $this->createMock(\OCP\DB\QueryBuilder\IExpressionBuilder::class); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('select')->willReturnSelf(); + $qb->method('from')->willReturnSelf(); + $qb->method('where')->willReturnSelf(); + $qb->method('andWhere')->willReturnSelf(); + $qb->method('orderBy')->willReturnSelf(); + $qb->method('setMaxResults')->willReturnSelf(); + $qb->method('expr')->willReturn($expr); + $qb->method('createNamedParameter')->willReturn(':p'); + $qb->method('executeQuery')->willReturn($result); + + $db = $this->createMock(IDBConnection::class); + $db->method('getQueryBuilder')->willReturn($qb); + + return $db; + }//end emptyDb() + + public function testItRecordsAVerdictAndThenReSeals(): void { + $store = []; + $hashes = $this->createMock(AuditHashService::class); + $hashes->expects($this->once())->method('rechainAll') + ->willReturn(['rechained' => 3, 'tombstonesPreserved' => 1]); + + $step = new RechainAuditTrailForFlowAttribution( + $this->emptyDb(), + $this->config(store: $store), + $hashes, + new NullLogger() + ); + + $step->run($this->createMock(IOutput::class)); + + $this->assertArrayHasKey( + RechainAuditTrailForFlowAttribution::VERDICT_KEY, + $store, + 'The state of the chain before the re-seal is the one fact the re-seal destroys.' + ); + $this->assertArrayHasKey(RechainAuditTrailForFlowAttribution::DONE_KEY, $store); + + $verdict = json_decode($store[RechainAuditTrailForFlowAttribution::VERDICT_KEY], true); + $this->assertSame('openregister-genesis-v1', $verdict['seedFrom']); + $this->assertSame('openregister-genesis-v2', $verdict['seedTo']); + }//end testItRecordsAVerdictAndThenReSeals() + + /** + * 🔴 NO VERDICT, NO RE-CHAIN. + * + * A re-chain that leaves no account of the chain it replaced has no remedy, + * so this is the one refusal that must hold even at the cost of blocking an + * upgrade. + */ + public function testItRefusesToReSealWhenTheVerdictCannotBeStored(): void { + $store = []; + $hashes = $this->createMock(AuditHashService::class); + $hashes->expects($this->never())->method('rechainAll'); + + $step = new RechainAuditTrailForFlowAttribution( + $this->emptyDb(), + $this->config(writable: false, store: $store), + $hashes, + new NullLogger() + ); + + $output = $this->createMock(IOutput::class); + $output->expects($this->atLeastOnce())->method('warning'); + + $step->run($output); + + $this->assertArrayNotHasKey(RechainAuditTrailForFlowAttribution::DONE_KEY, $store); + }//end testItRefusesToReSealWhenTheVerdictCannotBeStored() + + /** + * 🔴 It does not run twice. + * + * After the first pass every row is sealed under v2, so a second pre-verify + * would compare v2 rows against the v1 form, report a false break, and + * overwrite the real verdict with a meaningless one. + */ + public function testASecondRunIsANoOp(): void { + $store = [RechainAuditTrailForFlowAttribution::DONE_KEY => '2026-08-28T00:00:00+00:00']; + + $hashes = $this->createMock(AuditHashService::class); + $hashes->expects($this->never())->method('rechainAll'); + + $step = new RechainAuditTrailForFlowAttribution( + $this->emptyDb(), + $this->config(store: $store), + $hashes, + new NullLogger() + ); + + $output = $this->createMock(IOutput::class); + $output->expects($this->once())->method('info'); + + $step->run($output); + }//end testASecondRunIsANoOp() + + /** + * A failure inside the re-seal must not leave the step marked done. + * + * Otherwise the next `occ maintenance:repair` skips it, and the table is + * left half-sealed with nothing left to finish it. + */ + public function testAFailedReSealDoesNotMarkTheStepDone(): void { + $store = []; + $hashes = $this->createMock(AuditHashService::class); + $hashes->method('rechainAll')->willThrowException(new RuntimeException('lock unavailable')); + + $step = new RechainAuditTrailForFlowAttribution( + $this->emptyDb(), + $this->config(store: $store), + $hashes, + new NullLogger() + ); + + try { + $step->run($this->createMock(IOutput::class)); + } catch (RuntimeException $e) { + // The step does not swallow it; maintenance:repair reports it. + } + + $this->assertArrayNotHasKey( + RechainAuditTrailForFlowAttribution::DONE_KEY, + $store, + 'A half-finished re-seal must not look complete.' + ); + $this->assertArrayHasKey( + RechainAuditTrailForFlowAttribution::VERDICT_KEY, + $store, + 'The verdict is written first and survives the failure.' + ); + }//end testAFailedReSealDoesNotMarkTheStepDone() + + public function testItNamesItselfForTheRepairRunner(): void { + $step = new RechainAuditTrailForFlowAttribution( + $this->emptyDb(), + $this->config(), + $this->createMock(AuditHashService::class), + new NullLogger() + ); + + $this->assertStringContainsString('audit chain', strtolower($step->getName())); + }//end testItNamesItselfForTheRepairRunner() +}//end class diff --git a/tests/Unit/Service/AuditCanonicalV1Test.php b/tests/Unit/Service/AuditCanonicalV1Test.php new file mode 100644 index 0000000000..0434a0d4f0 --- /dev/null +++ b/tests/Unit/Service/AuditCanonicalV1Test.php @@ -0,0 +1,134 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + +namespace Unit\Service; + +use OCA\OpenRegister\Db\AuditTrail; +use OCA\OpenRegister\Service\AuditCanonicalV1; +use PHPUnit\Framework\TestCase; + +class AuditCanonicalV1Test extends TestCase { + + /** + * A row with a couple of fields set and everything else at its default. + */ + private function row(): AuditTrail { + $entry = new AuditTrail(); + $entry->setId(42); + $entry->setAction('create'); + + return $entry; + }//end row() + + /** + * 🔒 THE FREEZE. The exact bytes a v1 row was hashed over. + * + * If this assertion fails, the v1 form has moved and every hash sealed under + * seed v1 has become unverifiable. The correct response is to restore the + * old behaviour, NOT to update the expectation — updating it is how the + * check silently stops meaning anything. + */ + public function testTheV1CanonicalFormIsFrozen(): void { + $expected = '{"action":"create","changed":null,"confidentiality":null,"created":null,' + . '"expires":null,"id":42,"ipAddress":null,"object":null,"objectUuid":null,' + . '"organisationId":null,"organisationIdType":null,"paramsDigest":null,' + . '"processingActivityId":null,"processingActivityUrl":null,"processingId":null,' + . '"register":null,"registerUuid":null,"request":null,"resultSummary":null,' + . '"retentionPeriod":null,"schema":null,"schemaUuid":null,"session":null,' + . '"size":null,"toolId":null,"user":null,"userName":null,"uuid":null,"version":null}'; + + $this->assertSame($expected, AuditCanonicalV1::canonicalJson(entry: $this->row())); + }//end testTheV1CanonicalFormIsFrozen() + + /** + * The v1 form does not contain the fields v2 added. + * + * Stated separately from the freeze above because it is the specific + * regression this class was written to prevent, and a separate failure + * message says so directly. + */ + public function testTheV1FormExcludesTheFlowAttributionFields(): void { + $canonical = AuditCanonicalV1::canonicalJson(entry: $this->row()); + + $this->assertStringNotContainsString('flowRun', $canonical); + $this->assertStringNotContainsString('flowNode', $canonical); + $this->assertStringNotContainsString('flowStep', $canonical); + }//end testTheV1FormExcludesTheFlowAttributionFields() + + /** + * 🔑 THE PROPERTY THE MIGRATION DEPENDS ON. + * + * Two rows differing ONLY in flow attribution must canonicalise identically + * under v1. That is what makes a pre-existing row — which has no attribution + * — verify against a hash sealed before the columns existed. + */ + public function testAttributionDoesNotChangeTheV1Form(): void { + $without = $this->row(); + + $with = $this->row(); + $with->setFlowRun('run-abc'); + $with->setFlowNode('node-1'); + $with->setFlowStep(3); + + $this->assertSame( + AuditCanonicalV1::canonicalJson(entry: $without), + AuditCanonicalV1::canonicalJson(entry: $with), + 'The v1 form must be blind to fields v2 introduced, or no pre-existing row can verify.' + ); + }//end testAttributionDoesNotChangeTheV1Form() + + /** + * A change to any v1 field DOES change the form — the canonicaliser is + * blind to the new fields, not blind in general. + */ + public function testAV1FieldStillChangesTheForm(): void { + $before = AuditCanonicalV1::canonicalJson(entry: $this->row()); + + $changed = $this->row(); + $changed->setAction('delete'); + + $this->assertNotSame($before, AuditCanonicalV1::canonicalJson(entry: $changed)); + }//end testAV1FieldStillChangesTheForm() + + /** + * The v1 genesis seed is the v1 seed, not the current one. + */ + public function testTheGenesisHashIsTheV1Seed(): void { + $this->assertSame('openregister-genesis-v1', AuditCanonicalV1::GENESIS_SEED); + $this->assertSame( + hash('sha256', 'openregister-genesis-v1'), + AuditCanonicalV1::genesisHash() + ); + }//end testTheGenesisHashIsTheV1Seed() + + /** + * Hashing chains the predecessor in, so the same row after a different + * predecessor hashes differently. + */ + public function testTheHashChainsThePredecessor(): void { + $row = $this->row(); + + $this->assertNotSame( + AuditCanonicalV1::computeHash(entry: $row, previousHash: 'aaa'), + AuditCanonicalV1::computeHash(entry: $row, previousHash: 'bbb') + ); + }//end testTheHashChainsThePredecessor() +}//end class diff --git a/tests/Unit/Service/AuditHashServiceTest.php b/tests/Unit/Service/AuditHashServiceTest.php index 8f00e691ec..fd910d9239 100644 --- a/tests/Unit/Service/AuditHashServiceTest.php +++ b/tests/Unit/Service/AuditHashServiceTest.php @@ -36,12 +36,26 @@ protected function setUp(): void { ); } + /** + * The chain's seed is v2. + * + * Moved from v1 when the flow-attribution fields joined the canonical JSON + * (ADR-003 Rule 4: the seed and the canonical form are one identity and are + * versioned together). The v1 value is asserted to be DIFFERENT rather than + * simply dropped, so a revert of the seed — which would silently make every + * re-sealed row unverifiable — fails here instead of in production. + */ public function testGetGenesisHash(): void { - $expected = hash('sha256', 'openregister-genesis-v1'); + $expected = hash('sha256', 'openregister-genesis-v2'); $result = $this->service->getGenesisHash(); $this->assertSame($expected, $result); $this->assertSame(64, strlen($result)); + $this->assertNotSame( + hash('sha256', 'openregister-genesis-v1'), + $result, + 'The seed must not fall back to v1: rows re-sealed under v2 would stop verifying.' + ); } public function testGetGenesisHashIsConsistent(): void { diff --git a/tests/Unit/Service/DemoDataServiceTest.php b/tests/Unit/Service/DemoDataServiceTest.php new file mode 100644 index 0000000000..b71ee1b41c --- /dev/null +++ b/tests/Unit/Service/DemoDataServiceTest.php @@ -0,0 +1,134 @@ +appPath = sys_get_temp_dir() . '/or-demo-' . uniqid(); + mkdir($this->appPath . '/lib/Settings', 0777, true); + + $this->appManager = $this->createMock(IAppManager::class); + $this->appManager->method('getAppPath')->willReturn($this->appPath); + $this->appManager->method('getAppVersion')->willReturn('1.2.3'); + $this->appManager->method('getInstalledApps')->willReturn(['openregister']); + + $this->container = $this->createMock(ContainerInterface::class); + } + + protected function tearDown(): void { + $file = $this->descriptor(); + if (is_file($file) === true) { + unlink($file); + } + + @rmdir($this->appPath . '/lib/Settings'); + @rmdir($this->appPath . '/lib'); + @rmdir($this->appPath); + } + + private function descriptor(): string { + return $this->appPath . '/lib/Settings/openregister_mock_register.json'; + } + + private function service(): DemoDataService { + return new DemoDataService( + $this->appManager, + $this->container, + $this->createMock(LoggerInterface::class) + ); + } + + public function testIsAvailableIsFalseWithoutADescriptor(): void { + $this->assertFalse($this->service()->isAvailable()); + } + + public function testIsAvailableIsTrueWithADescriptor(): void { + file_put_contents($this->descriptor(), '{}'); + + $this->assertTrue($this->service()->isAvailable()); + } + + public function testInstallThrowsWhenNoDatasetShips(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/No demo dataset/'); + + $this->service()->install(); + } + + public function testInstallThrowsOnInvalidJson(): void { + file_put_contents($this->descriptor(), 'not json at all'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/not valid JSON/'); + + $this->service()->install(); + } + + public function testInstallNamesTheMissingAppWhenOpenRegisterIsAbsent(): void { + file_put_contents($this->descriptor(), '{"components":{"objects":[]}}'); + $this->appManager = $this->createMock(IAppManager::class); + $this->appManager->method('getAppPath')->willReturn($this->appPath); + $this->appManager->method('getInstalledApps')->willReturn([]); + + // 🔴 The message must NAME the missing app. Asking the container for a + // class from an app that is not installed otherwise surfaces as an error + // about a class the operator never mentioned. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/OpenRegister/'); + + $this->service()->install(); + } + + public function testInstallCountsTheObjectsInTheFileNotTheImportersReply(): void { + file_put_contents( + $this->descriptor(), + json_encode(['components' => ['objects' => [['a' => 1], ['b' => 2], ['c' => 3]]]]) + ); + + // 🔴 THE PARAMETER NAMES ARE THE CONTRACT. install() calls this with + // named arguments, so a fake whose parameters are named differently + // fails at the call site rather than validating anything. + $importer = new class { + public array $seen = []; + + public function importFromApp(string $appId, array $data, string $version, bool $force): array { + $this->seen = ['appId' => $appId, 'version' => $version, 'force' => $force]; + + // Deliberately reports FEWER than the file holds: an object whose + // schema does not resolve is skipped, and the operator is told + // what was ASKED FOR so the discrepancy stays visible. + return ['registers' => [1], 'schemas' => [1, 1]]; + } + }; + $this->container->method('get')->willReturn($importer); + + $result = $this->service()->install(); + + $this->assertSame(3, $result['objects']); + $this->assertSame(1, $result['registers']); + $this->assertSame(2, $result['schemas']); + + // Its own configuration namespace, so a demo import cannot mask — or be + // masked by — a pending real configuration update. + $this->assertSame('openregister.demo', $importer->seen['appId']); + $this->assertTrue($importer->seen['force']); + } +} diff --git a/tests/Unit/Service/Flow/FlowDefinitionPinTest.php b/tests/Unit/Service/Flow/FlowDefinitionPinTest.php new file mode 100644 index 0000000000..b6fe64ff86 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowDefinitionPinTest.php @@ -0,0 +1,285 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use OCA\OpenRegister\Db\FlowDefinition; +use OCA\OpenRegister\Db\FlowDefinitionMapper; +use OCA\OpenRegister\Service\Flow\FlowDefinitionPin; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use RuntimeException; + +class FlowDefinitionPinTest extends TestCase { + + /** + * @var array A fake definition store, keyed by hash. + */ + private array $store = []; + + /** + * A pin over an in-memory store. + * + * @param boolean $storeFails Whether writes throw. + * @param boolean $readFails Whether reads throw. + * + * @return FlowDefinitionPin The pin. + */ + private function pin(bool $storeFails = false, bool $readFails = false): FlowDefinitionPin { + $mapper = $this->createMock(FlowDefinitionMapper::class); + + $mapper->method('store')->willReturnCallback( + function (string $hash, string $definition, ?string $flowUuid = null) use ($storeFails): ?FlowDefinition { + if ($storeFails === true) { + throw new RuntimeException('write failed'); + } + + $e = new FlowDefinition(); + $e->setHash($hash); + $e->setDefinition($definition); + $e->setFlowUuid($flowUuid); + $this->store[$hash] = $e; + + return $e; + } + ); + + $mapper->method('findByHash')->willReturnCallback( + function (string $hash) use ($readFails): ?FlowDefinition { + if ($readFails === true) { + throw new RuntimeException('read failed'); + } + + return ($this->store[$hash] ?? null); + } + ); + + return new FlowDefinitionPin($mapper, new NullLogger()); + }//end pin() + + /** + * A two-node graph. + * + * @return array The graph. + */ + private function twoNodeGraph(): array { + return [ + 'nodes' => [['id' => 'a', 'type' => 'start'], ['id' => 'b', 'type' => 'end']], + 'edges' => [['from' => 'a', 'to' => 'b']], + 'limits' => [], + 'executionMode' => 'async', + ]; + }//end twoNodeGraph() + + /** + * 🔴 THE DEFECT. Pin a graph, then edit the flow into something else. The + * hash taken before the edit must still resolve to the ORIGINAL graph — + * that is what lets a run suspended for two weeks finish on the process it + * began, which ADR-098 Decision 6 requires before a human task node may + * suspend a run at all. + * + * @return void + */ + public function testAPinnedGraphIsUnaffectedByEditingTheFlow(): void { + $pin = $this->pin(); + + $before = $this->twoNodeGraph(); + $hash = $pin->pin(flow: $before, flowId: 'flow-1'); + $this->assertNotNull($hash); + + // The author now rewrites the flow completely. + $after = [ + 'nodes' => [['id' => 'x', 'type' => 'start']], + 'edges' => [], + 'limits' => [], + 'executionMode' => 'sync', + ]; + $pin->pin(flow: $after, flowId: 'flow-1'); + + $resolved = $pin->graphFor($hash); + + $this->assertSame($before['nodes'], $resolved['nodes'], 'the pinned graph must not follow the edit'); + $this->assertSame('async', $resolved['executionMode']); + }//end testAPinnedGraphIsUnaffectedByEditingTheFlow() + + /** + * 🔴 NO FALLBACK. A missing definition must produce null so the caller can + * fail the run naming its version — never a substitute graph. + * + * @return void + */ + public function testAMissingDefinitionResolvesToNullRatherThanAnySubstitute(): void { + $this->assertNull($this->pin()->graphFor('0000000000000000000000000000000000000000000000000000000000000000')); + }//end testAMissingDefinitionResolvesToNullRatherThanAnySubstitute() + + /** + * A read failure is not grounds to serve a different graph either. + * + * @return void + */ + public function testAReadFailureResolvesToNull(): void { + $pin = $this->pin(); + $hash = $pin->pin(flow: $this->twoNodeGraph(), flowId: 'flow-1'); + + $this->assertNull($this->pin(readFails: true)->graphFor($hash)); + }//end testAReadFailureResolvesToNull() + + /** + * An unreadable definition resolves to null, not to an empty graph that + * would let a run "complete" without executing anything. + * + * @return void + */ + public function testAnUnreadableDefinitionResolvesToNull(): void { + $broken = new FlowDefinition(); + $broken->setHash('deadbeef'); + $broken->setDefinition('{not json'); + $this->store['deadbeef'] = $broken; + + $this->assertNull($this->pin()->graphFor('deadbeef')); + }//end testAnUnreadableDefinitionResolvesToNull() + + /** + * An absent hash is not a lookup at all. + * + * @return void + */ + public function testAnEmptyHashResolvesToNull(): void { + $pin = $this->pin(); + + $this->assertNull($pin->graphFor(null)); + $this->assertNull($pin->graphFor(' ')); + }//end testAnEmptyHashResolvesToNull() + + /** + * Key order must not change the hash, or the same graph loaded through two + * code paths would store two rows and the dedupe would be off. + * + * @return void + */ + public function testKeyOrderDoesNotChangeTheHash(): void { + $pin = $this->pin(); + + $a = ['nodes' => [['id' => 'a']], 'edges' => [], 'limits' => [], 'executionMode' => 'async']; + $b = ['executionMode' => 'async', 'limits' => [], 'edges' => [], 'nodes' => [['id' => 'a']]]; + + $this->assertSame($pin->pin(flow: $a, flowId: 'f'), $pin->pin(flow: $b, flowId: 'f')); + }//end testKeyOrderDoesNotChangeTheHash() + + /** + * A different graph must hash differently, or an edit would be invisible. + * + * @return void + */ + public function testADifferentGraphHashesDifferently(): void { + $pin = $this->pin(); + + $a = $this->twoNodeGraph(); + $b = $this->twoNodeGraph(); + $b['nodes'][] = ['id' => 'c', 'type' => 'end']; + + $this->assertNotSame($pin->pin(flow: $a, flowId: 'f'), $pin->pin(flow: $b, flowId: 'f')); + }//end testADifferentGraphHashesDifferently() + + /** + * Node ORDER is part of the graph: lists keep their order through + * canonicalisation, only maps are sorted. Sorting lists too would make two + * genuinely different graphs share a hash. + * + * @return void + */ + public function testReorderingNodesIsADifferentDefinition(): void { + $pin = $this->pin(); + + $a = ['nodes' => [['id' => 'a'], ['id' => 'b']], 'edges' => []]; + $b = ['nodes' => [['id' => 'b'], ['id' => 'a']], 'edges' => []]; + + $this->assertNotSame($pin->pin(flow: $a, flowId: 'f'), $pin->pin(flow: $b, flowId: 'f')); + }//end testReorderingNodesIsADifferentDefinition() + + /** + * 🔴 AUTHORIZATION IS NOT PART OF THE PINNED CONTENT. Pinning the owner or + * the organisation would freeze a grant into the definition, so revoking + * access would stop mattering to any run already queued. Pin the shape of + * the work; never pin the right to do it. + * + * @return void + */ + public function testOwnerAndOrganisationAreNotPartOfTheHash(): void { + $pin = $this->pin(); + + $a = $this->twoNodeGraph() + ['owner' => 'alice', 'organisation' => 'org-1']; + $b = $this->twoNodeGraph() + ['owner' => 'bob', 'organisation' => 'org-2']; + + $this->assertSame( + $pin->pin(flow: $a, flowId: 'f'), + $pin->pin(flow: $b, flowId: 'f'), + 'changing who may run a flow must not produce a different definition' + ); + }//end testOwnerAndOrganisationAreNotPartOfTheHash() + + /** + * Pinning an unedited flow twice must store ONE row, or a busy trigger + * would fill the table with identical copies of the same 4 KB graph. + * + * @return void + */ + public function testPinningAnUneditedFlowTwiceDedupes(): void { + $pin = $this->pin(); + $graph = $this->twoNodeGraph(); + + $pin->pin(flow: $graph, flowId: 'f'); + $pin->pin(flow: $graph, flowId: 'f'); + + $this->assertCount(1, $this->store); + }//end testPinningAnUneditedFlowTwiceDedupes() + + /** + * A storage failure yields no hash. The caller must then refuse to publish + * rather than name a graph that is not there. + * + * @return void + */ + public function testAStorageFailureYieldsNoHash(): void { + $this->assertNull($this->pin(storeFails: true)->pin(flow: $this->twoNodeGraph(), flowId: 'f')); + }//end testAStorageFailureYieldsNoHash() + + /** + * A graph with nothing in it cannot be canonicalised into a meaningful + * definition, and must not silently hash to a shared "empty" row that + * several broken flows would then share. + * + * @return void + */ + public function testTheStoredDefinitionRoundTrips(): void { + $pin = $this->pin(); + $graph = $this->twoNodeGraph(); + + $hash = $pin->pin(flow: $graph, flowId: 'f'); + $back = $pin->graphFor($hash); + + $this->assertSame($graph['nodes'], $back['nodes']); + $this->assertSame($graph['edges'], $back['edges']); + }//end testTheStoredDefinitionRoundTrips() +}//end class diff --git a/tests/Unit/Service/Flow/FlowDeleteCascadeTest.php b/tests/Unit/Service/Flow/FlowDeleteCascadeTest.php index 6924c5d0aa..10719cfbd7 100644 --- a/tests/Unit/Service/Flow/FlowDeleteCascadeTest.php +++ b/tests/Unit/Service/Flow/FlowDeleteCascadeTest.php @@ -39,6 +39,7 @@ use OCA\OpenRegister\Db\FlowRunMapper; use OCA\OpenRegister\Db\FlowRunStepMapper; use OCA\OpenRegister\Db\FlowStateMapper; +use OCA\OpenRegister\Db\FlowVersionMapper; use OCA\OpenRegister\Service\Flow\FlowRunAdvancer; use OCA\OpenRegister\Service\Flow\FlowRunService; use OCA\OpenRegister\Service\Flow\FlowService; @@ -64,6 +65,39 @@ final class FlowDeleteCascadeTest extends TestCase { * * @return FlowService The service under test. */ + /** + * @var FlowVersionMapper|null The version mapper the next service will get. + */ + private ?FlowVersionMapper $versionMapper = null; + + /** + * 🔴 THE CASCADE MEMBER THAT WAS MISSING. delete() already swept triggers, + * runs, steps and state; the version table was added later and never joined + * that list, so every deleted flow left its versions behind — 38 orphans + * measured on a dev instance, unreachable because every version read is + * keyed by flow. + * + * @return void + */ + public function testDeleteAlsoSweepsTheFlowsVersionRows(): void { + $uuid = 'flow-uuid-4'; + + $versions = $this->createMock(FlowVersionMapper::class); + $versions->expects($this->once())->method('deleteByFlow')->with($uuid)->willReturn(2); + $this->versionMapper = $versions; + + $runs = $this->createMock(FlowRunMapper::class); + $runs->method('deleteByFlow')->willReturn([]); + + $this->serviceWith( + $this->flowMapperFor($uuid), + $runs, + $this->createMock(FlowRunStepMapper::class), + $this->createMock(FlowStateMapper::class) + )->delete(uuid: $uuid); + + }//end testDeleteAlsoSweepsTheFlowsVersionRows() + private function serviceWith( FlowMapper $mapper, FlowRunMapper $runs, @@ -113,7 +147,20 @@ public function getActiveOrganisation(): object { }; $container = $this->createMock(ContainerInterface::class); - $container->method('get')->willReturn($organisationService); + // 🔑 The container answers BY CLASS here. It used to return the + // organisation stub for every get(), which meant the version cascade + // ran, threw "no such method", and was swallowed by delete()'s own + // try/catch — the line executed and asserted nothing. + $versions = ($this->versionMapper ?? $this->createMock(FlowVersionMapper::class)); + $container->method('get')->willReturnCallback( + static function (string $id) use ($organisationService, $versions): object { + if ($id === FlowVersionMapper::class) { + return $versions; + } + + return $organisationService; + } + ); return new FlowService( $mapper, diff --git a/tests/Unit/Service/Flow/FlowEngineAttributionTest.php b/tests/Unit/Service/Flow/FlowEngineAttributionTest.php new file mode 100644 index 0000000000..21d5ff05a1 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowEngineAttributionTest.php @@ -0,0 +1,275 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-object-attribution/specs/flow-engine/spec.md + */ + +namespace Unit\Service\Flow; + +use OCA\OpenRegister\Service\Flow\FlowDefinitionBuilder; +use OCA\OpenRegister\Service\Flow\FlowEngine; +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowRunContext; +use OCA\OpenRegister\Service\Flow\FlowStepDispatcher; +use OCA\OpenRegister\Service\Flow\FlowStop; +use OCA\OpenRegister\Service\Flow\FlowSuspension; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore; + +/** + * A subject whose marking is a plain property. + */ +class AttributionSubject { + + public $marking = []; +}//end class + +/** + * Reads the ambient frame at the moment each step runs, then optionally fails. + * + * Sampling INSIDE the step is the point: the frame has to be correct while the + * write would be happening, not merely at some point during the run. + */ +class AttributionSamplingDispatcher implements FlowStepDispatcher { + + /** + * The frame observed during each step, keyed by step id. + */ + public array $frames = []; + + public function __construct( + private readonly FlowRunContext $context, + private readonly ?string $failOn = null, + private readonly ?string $throwKind = null, + ) { + }//end __construct() + + public function dispatch(array $step, array $items, array $context): array { + $name = (string)($step['id'] ?? ''); + $this->frames[$name] = $this->context->current(); + + if ($this->failOn !== null && $name === $this->failOn) { + if ($this->throwKind === 'stop') { + throw new FlowStop('stopped on purpose'); + } + + if ($this->throwKind === 'suspend') { + // Named argument: the first positional parameter is the resume + // DateTime, not the reason. + throw new FlowSuspension(reason: 'waiting on purpose'); + } + + throw new RuntimeException('step blew up'); + } + + $out = []; + foreach ($items as $index => $item) { + $out[] = FlowItems::item(json: (array)($item['json'] ?? []), binary: [], fromItemIndex: $index); + } + + return $out; + }//end dispatch() +}//end class + +class FlowEngineAttributionTest extends TestCase { + + private FlowRunContext $context; + + private FlowEngine $engine; + + protected function setUp(): void { + $this->context = new FlowRunContext(); + $this->engine = new FlowEngine( + new FlowDefinitionBuilder(), + $this->createMock(LoggerInterface::class), + null, + null, + null, + $this->context + ); + }//end setUp() + + /** + * A two-node flow. + */ + private function linearFlow(): array { + return [ + 'id' => 'linear', + 'nodes' => [ + ['id' => 'first', 'type' => 'test.step'], + ['id' => 'second', 'type' => 'test.step'], + ], + 'edges' => [['id' => 'first-second', 'from' => 'first', 'to' => 'second']], + ]; + }//end linearFlow() + + private function runFlow(array $flow, FlowStepDispatcher $dispatcher, array $context = []): array { + return $this->engine->run( + $flow, + new MethodMarkingStore(false, 'marking'), + new AttributionSubject(), + $dispatcher, + $context + ); + }//end runFlow() + + /** + * The attribution context the engine is handed for a run. + */ + private function attributionContext(string $run = 'run-abc', int $base = 0): array { + return [ + FlowRunContext::CONTEXT_RUN => $run, + FlowRunContext::CONTEXT_BASE => $base, + ]; + }//end attributionContext() + + /** + * Each step sees its OWN node id and its own step number. + */ + public function testEachStepSeesItsOwnFrame(): void { + $dispatcher = new AttributionSamplingDispatcher(context: $this->context); + + $this->runFlow($this->linearFlow(), $dispatcher, $this->attributionContext()); + + $this->assertSame( + ['run' => 'run-abc', 'node' => 'first', 'step' => 0], + $dispatcher->frames['first'] + ); + $this->assertSame( + ['run' => 'run-abc', 'node' => 'second', 'step' => 1], + $dispatcher->frames['second'] + ); + }//end testEachStepSeesItsOwnFrame() + + /** + * The step number continues from the base, so a resumed run keeps numbering + * where it stopped instead of restarting and colliding with its own history. + */ + public function testStepNumbersContinueFromTheBase(): void { + $dispatcher = new AttributionSamplingDispatcher(context: $this->context); + + $this->runFlow($this->linearFlow(), $dispatcher, $this->attributionContext(base: 12)); + + $this->assertSame(12, $dispatcher->frames['first']['step']); + $this->assertSame(13, $dispatcher->frames['second']['step']); + }//end testStepNumbersContinueFromTheBase() + + /** + * 🔴 THE LEAK TEST. Nothing is attributed once the run is over. + * + * Delete the `finally` in FlowEngine and this is the assertion that goes + * red. Every other test in this file still passes. + */ + public function testNothingIsAttributedAfterASuccessfulRun(): void { + $dispatcher = new AttributionSamplingDispatcher(context: $this->context); + + $this->runFlow($this->linearFlow(), $dispatcher, $this->attributionContext()); + + $this->assertNull( + $this->context->current(), + 'A write after the run must be unattributed; a standing frame files it under a finished run.' + ); + $this->assertSame(0, $this->context->depth()); + }//end testNothingIsAttributedAfterASuccessfulRun() + + /** + * 🔴 A THROWING step still clears its frame. + * + * The failure path is the one where a success-path pop would have been + * skipped, so this is where a leak would actually happen in production. + */ + public function testAThrowingStepStillClearsItsFrame(): void { + $dispatcher = new AttributionSamplingDispatcher( + context: $this->context, + failOn: 'first' + ); + + $this->runFlow($this->linearFlow(), $dispatcher, $this->attributionContext()); + + $this->assertNull($this->context->current()); + $this->assertSame(0, $this->context->depth()); + }//end testAThrowingStepStillClearsItsFrame() + + /** + * 🔴 A stopped run leaves nothing behind. A Stop returns from INSIDE the + * catch, so only a `finally` pops it. + */ + public function testAStoppedRunClearsItsFrame(): void { + $dispatcher = new AttributionSamplingDispatcher( + context: $this->context, + failOn: 'first', + throwKind: 'stop' + ); + + $result = $this->runFlow($this->linearFlow(), $dispatcher, $this->attributionContext()); + + $this->assertSame(FlowEngine::STATUS_STOPPED, $result['status']); + $this->assertNull($this->context->current()); + $this->assertSame(0, $this->context->depth()); + }//end testAStoppedRunClearsItsFrame() + + /** + * 🔴 A suspended run leaves nothing behind either — and this is the one that + * matters most in practice, because a suspended run is precisely the one + * whose process goes on to do other things. + */ + public function testASuspendedRunClearsItsFrame(): void { + $dispatcher = new AttributionSamplingDispatcher( + context: $this->context, + failOn: 'first', + throwKind: 'suspend' + ); + + $result = $this->runFlow($this->linearFlow(), $dispatcher, $this->attributionContext()); + + $this->assertSame(FlowEngine::STATUS_SUSPENDED, $result['status']); + $this->assertNull($this->context->current()); + $this->assertSame(0, $this->context->depth()); + }//end testASuspendedRunClearsItsFrame() + + /** + * Two runs walked in sequence — as FlowRunWorker does — attribute to + * themselves and not to their predecessor. + */ + public function testASecondRunIsNotAttributedToTheFirst(): void { + $first = new AttributionSamplingDispatcher(context: $this->context); + $this->runFlow($this->linearFlow(), $first, $this->attributionContext(run: 'run-1')); + + $second = new AttributionSamplingDispatcher(context: $this->context); + $this->runFlow($this->linearFlow(), $second, $this->attributionContext(run: 'run-2')); + + $this->assertSame('run-1', $first->frames['first']['run']); + $this->assertSame('run-2', $second->frames['first']['run']); + $this->assertNull($this->context->current()); + }//end testASecondRunIsNotAttributedToTheFirst() + + /** + * A run with no attribution context attributes nothing — the flow tester and + * the node unit tests dispatch this way, and must not crash or invent a run. + */ + public function testARunWithoutAttributionContextAttributesNothing(): void { + $dispatcher = new AttributionSamplingDispatcher(context: $this->context); + + $this->runFlow($this->linearFlow(), $dispatcher); + + $this->assertNull($dispatcher->frames['first']); + $this->assertNull($this->context->current()); + }//end testARunWithoutAttributionContextAttributesNothing() +}//end class diff --git a/tests/Unit/Service/Flow/FlowLifecycleGuardTest.php b/tests/Unit/Service/Flow/FlowLifecycleGuardTest.php new file mode 100644 index 0000000000..64f8d7f465 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowLifecycleGuardTest.php @@ -0,0 +1,221 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use OCA\OpenRegister\Db\FlowRunMapper; +use OCA\OpenRegister\Db\FlowVersion; +use OCA\OpenRegister\Service\Flow\FlowLifecycleGuard; +use OCA\OpenRegister\Service\Flow\FlowLifecycleRefused; +use OCA\OpenRegister\Service\Flow\FlowNodePreflight; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +class FlowLifecycleGuardTest extends TestCase { + + /** + * A guard whose run mapper reports $pinned active runs. + * + * @param integer $pinned How many runs are pinned to the version asked about. + * @param array $deadEnds Step ids the preflight reports as dead ends. + * + * @return FlowLifecycleGuard The guard. + */ + private function guard(int $pinned = 0, array $deadEnds = []): FlowLifecycleGuard { + $runs = $this->createMock(FlowRunMapper::class); + $runs->method('countActivePinnedTo')->willReturn($pinned); + + // The preflight is a double, deliberately. Whether a given graph HAS a + // dead end is FlowNodePreflight's question and has its own tests; what + // this suite asserts is that the guard turns that verdict into a + // refusal instead of a logged shrug. + $preflight = $this->createMock(FlowNodePreflight::class); + $preflight->method('inspect')->willReturn([ + 'warnings' => array_map( + static fn (string $step): array => [ + 'reason' => FlowNodePreflight::REASON_DEAD_END, + 'step' => $step, + ], + $deadEnds + ), + ]); + + return new FlowLifecycleGuard($runs, $preflight, new NullLogger()); + }//end guard() + + /** + * Assert a refusal carrying an exact reason. + * + * @param string $reason The expected REASON_* value. + * @param callable $act The call that must refuse. + * + * @return void + */ + private function assertRefusedWith(string $reason, callable $act): void { + try { + $act(); + } catch (FlowLifecycleRefused $refusal) { + $this->assertSame($reason, $refusal->getReason()); + return; + } + + $this->fail('Expected a FlowLifecycleRefused with reason "' . $reason . '", but nothing was thrown.'); + }//end assertRefusedWith() + + /** + * 🔴 A PUBLISHED VERSION IS IMMUTABLE. Silently applying the edit, or + * turning it into a new version behind the author's back, are both worse + * than refusing: the first changes a live process without anybody + * deciding to, the second publishes work nobody reviewed. + * + * @return void + */ + public function testEditingAPublishedFlowIsRefused(): void { + $this->assertRefusedWith( + FlowLifecycleRefused::REASON_IMMUTABLE, + fn () => $this->guard()->refuseEditUnlessDraft('f', FlowVersion::STATUS_PUBLISHED) + ); + }//end testEditingAPublishedFlowIsRefused() + + /** + * A deprecated version is immutable on the same terms. + * + * @return void + */ + public function testEditingADeprecatedFlowIsRefused(): void { + $this->assertRefusedWith( + FlowLifecycleRefused::REASON_IMMUTABLE, + fn () => $this->guard()->refuseEditUnlessDraft('f', FlowVersion::STATUS_DEPRECATED) + ); + }//end testEditingADeprecatedFlowIsRefused() + + /** + * A draft is what an author is supposed to be editing. + * + * @return void + */ + public function testEditingADraftIsAllowed(): void { + $this->guard()->refuseEditUnlessDraft('f', FlowVersion::STATUS_DRAFT); + + $this->expectNotToPerformAssertions(); + }//end testEditingADraftIsAllowed() + + /** + * A flow with no lifecycle recorded at all is a pre-versioning flow being + * saved during the upgrade window. Refusing those would make the upgrade + * itself lock every author out of every flow. + * + * @return void + */ + public function testAFlowWithNoLifecycleYetIsNotRefused(): void { + $this->guard()->refuseEditUnlessDraft('f', null); + + $this->expectNotToPerformAssertions(); + }//end testAFlowWithNoLifecycleYetIsNotRefused() + + /** + * Only a draft may be published. + * + * @return void + */ + public function testPublishingSomethingAlreadyPublishedIsRefused(): void { + $this->assertRefusedWith( + FlowLifecycleRefused::REASON_NOT_A_DRAFT, + fn () => $this->guard()->refusePublishUnlessDraft('f', FlowVersion::STATUS_PUBLISHED) + ); + }//end testPublishingSomethingAlreadyPublishedIsRefused() + + /** + * Only a published version may be deprecated — and "nothing is published" + * is reported as such rather than as a success that did nothing. + * + * @return void + */ + public function testDeprecatingWhenNothingIsPublishedIsRefused(): void { + $this->assertRefusedWith( + FlowLifecycleRefused::REASON_NOT_PUBLISHED, + fn () => $this->guard()->refuseDeprecateUnlessPublished('f', null) + ); + }//end testDeprecatingWhenNothingIsPublishedIsRefused() + + /** + * 🔴 THE PREFLIGHT JUDGES THE GRAPH BEING PUBLISHED. A node a token cannot + * leave would make a run stop there and still be reported as completed — + * the quietest possible failure, and the reason publishing checks at all. + * + * @return void + */ + public function testPublishingAGraphWithADeadEndIsRefused(): void { + $graph = [ + 'nodes' => [ + ['id' => 'start', 'type' => 'test.wait'], + ['id' => 'orphan', 'type' => 'test.wait'], + ], + 'edges' => [['from' => 'start', 'to' => 'orphan']], + ]; + + $this->assertRefusedWith( + FlowLifecycleRefused::REASON_DEAD_END, + fn () => $this->guard(deadEnds: ['orphan'])->refusePublishOnDeadEnd('f', $graph) + ); + }//end testPublishingAGraphWithADeadEndIsRefused() + + /** + * 🔴 A VERSION A RUN STILL NEEDS MAY NOT BE REMOVED. Removing it would + * strand that run: it cannot be advanced, and it must not be moved onto a + * different graph. + * + * @return void + */ + public function testRemovingAVersionAnActiveRunIsPinnedToIsRefused(): void { + $this->assertRefusedWith( + FlowLifecycleRefused::REASON_VERSION_IN_USE, + fn () => $this->guard(pinned: 3)->refuseRemoveWhilePinned('f', 2) + ); + }//end testRemovingAVersionAnActiveRunIsPinnedToIsRefused() + + /** + * A version nothing is pinned to may be removed. + * + * @return void + */ + public function testRemovingAnUnusedVersionIsAllowed(): void { + $this->guard(pinned: 0)->refuseRemoveWhilePinned('f', 2); + + $this->expectNotToPerformAssertions(); + }//end testRemovingAnUnusedVersionIsAllowed() + + /** + * The refusal names the flow, so a log line is actionable without a + * debugger. + * + * @return void + */ + public function testTheRefusalNamesTheFlowAndItsState(): void { + try { + $this->guard()->refuseEditUnlessDraft('flow-42', FlowVersion::STATUS_PUBLISHED); + $this->fail('expected a refusal'); + } catch (FlowLifecycleRefused $refusal) { + $this->assertSame('flow-42', $refusal->getFlowId()); + $this->assertSame(FlowVersion::STATUS_PUBLISHED, $refusal->getState()); + $this->assertStringContainsString('flow-42', $refusal->getMessage()); + } + }//end testTheRefusalNamesTheFlowAndItsState() +}//end class diff --git a/tests/Unit/Service/Flow/FlowRunAssigneeTest.php b/tests/Unit/Service/Flow/FlowRunAssigneeTest.php new file mode 100644 index 0000000000..74559aaca9 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowRunAssigneeTest.php @@ -0,0 +1,154 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-suspended-on-an-external-signal-must-be-reachable + */ + +namespace Unit\Service\Flow; + +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Service\Flow\FlowResumeState; +use OCA\OpenRegister\Service\Flow\FlowRunAssignee; +use OCP\IGroupManager; +use PHPUnit\Framework\TestCase; + +class FlowRunAssigneeTest extends TestCase { + + /** + * A run whose resume slots are exactly as given. + * + * @param array $slots The per-node resume slots. + * + * @return FlowRun The run. + */ + private function runWithSlots(array $slots): FlowRun { + $run = new FlowRun(); + $run->setContext([FlowResumeState::CONTEXT_KEY => $slots]); + + return $run; + }//end runWithSlots() + + public function testAnUnaskedSlotNamesNobody(): void { + // A slot with no askedAt has not asked anything, so its assignee is not + // the person currently being waited on. + $run = $this->runWithSlots(['node-a' => ['assignee' => 'alice']]); + + $this->assertSame('', (new FlowRunAssignee())->recordedFor(run: $run)); + }//end testAnUnaskedSlotNamesNobody() + + public function testTheAskingSlotNamesItsAssignee(): void { + $run = $this->runWithSlots([ + 'node-a' => ['assignee' => 'alice'], + 'node-b' => ['askedAt' => '2026-08-28T10:00:00+00:00', 'assignee' => 'bob'], + ]); + + $this->assertSame('bob', (new FlowRunAssignee())->recordedFor(run: $run)); + }//end testTheAskingSlotNamesItsAssignee() + + public function testTheAssigneeMayAnswer(): void { + $run = $this->runWithSlots(['n' => ['askedAt' => 'now', 'assignee' => 'bob']]); + + $this->assertTrue((new FlowRunAssignee())->mayAnswer(run: $run, uid: 'bob')); + }//end testTheAssigneeMayAnswer() + + public function testSomebodyElseMayNot(): void { + $run = $this->runWithSlots(['n' => ['askedAt' => 'now', 'assignee' => 'bob']]); + + $this->assertFalse((new FlowRunAssignee())->mayAnswer(run: $run, uid: 'carol')); + }//end testSomebodyElseMayNot() + + /** + * 🔴 The unassigned case is deliberately OPEN. + * + * Webhook and child-run signals are not human decisions and record no + * assignee. An implementation that fails closed here would break every one + * of them — and would do it while looking more secure. + */ + public function testAnUnassignedStepIsAnswerableByAnyone(): void { + $run = $this->runWithSlots(['n' => ['askedAt' => 'now']]); + + $assignee = new FlowRunAssignee(); + + $this->assertTrue($assignee->mayAnswer(run: $run, uid: 'anyone')); + $this->assertTrue($assignee->mayAnswer(run: $run, uid: null)); + }//end testAnUnassignedStepIsAnswerableByAnyone() + + /** + * 🔴 An ASSIGNED step is never anonymous. + */ + public function testAnAssignedStepRefusesAnAnonymousCaller(): void { + $run = $this->runWithSlots(['n' => ['askedAt' => 'now', 'assignee' => 'bob']]); + + $assignee = new FlowRunAssignee(); + + $this->assertFalse($assignee->mayAnswer(run: $run, uid: null)); + $this->assertFalse($assignee->mayAnswer(run: $run, uid: '')); + }//end testAnAssignedStepRefusesAnAnonymousCaller() + + /** + * 🔴 THE GROUP BRANCH. A group-assigned step admits its members. + */ + public function testAGroupMemberMayAnswerAGroupAssignedStep(): void { + $run = $this->runWithSlots(['n' => ['askedAt' => 'now', 'assignee' => 'behandelaars']]); + + $groups = $this->createMock(IGroupManager::class); + $groups->method('isInGroup')->with('carol', 'behandelaars')->willReturn(true); + + $this->assertTrue( + (new FlowRunAssignee(groupManager: $groups))->mayAnswer(run: $run, uid: 'carol') + ); + }//end testAGroupMemberMayAnswerAGroupAssignedStep() + + public function testANonMemberMayNot(): void { + $run = $this->runWithSlots(['n' => ['askedAt' => 'now', 'assignee' => 'behandelaars']]); + + $groups = $this->createMock(IGroupManager::class); + $groups->method('isInGroup')->willReturn(false); + + $this->assertFalse( + (new FlowRunAssignee(groupManager: $groups))->mayAnswer(run: $run, uid: 'carol') + ); + }//end testANonMemberMayNot() + + /** + * With no group manager the group branch REFUSES rather than admits. + * + * Asserted explicitly so the fail-closed direction is a stated property + * rather than something inferred from a passing suite elsewhere. + */ + public function testWithoutAGroupManagerAGroupAssignmentRefuses(): void { + $run = $this->runWithSlots(['n' => ['askedAt' => 'now', 'assignee' => 'behandelaars']]); + + $this->assertFalse((new FlowRunAssignee())->mayAnswer(run: $run, uid: 'carol')); + }//end testWithoutAGroupManagerAGroupAssignmentRefuses() + + public function testARunWithNoSlotsNamesNobodyAndIsOpen(): void { + $run = new FlowRun(); + + $assignee = new FlowRunAssignee(); + + $this->assertSame('', $assignee->recordedFor(run: $run)); + $this->assertTrue($assignee->mayAnswer(run: $run, uid: 'anyone')); + }//end testARunWithNoSlotsNamesNobodyAndIsOpen() +}//end class diff --git a/tests/Unit/Service/Flow/FlowRunAttributionTest.php b/tests/Unit/Service/Flow/FlowRunAttributionTest.php index f7cd54f0dc..ec830c476e 100644 --- a/tests/Unit/Service/Flow/FlowRunAttributionTest.php +++ b/tests/Unit/Service/Flow/FlowRunAttributionTest.php @@ -48,6 +48,8 @@ * tests pin the behaviour at the one place they all pass through. */ class FlowRunAttributionTest extends TestCase { + use PublishedVersionDouble; + /** * Build the service with a flow the mapper will return. * @@ -76,13 +78,21 @@ private function service(?Flow $flow, ?string $activeOrg = null, ?DelegationVerd $delegation->method('verdictFor')->willReturn($verdict); } + $versions = $this->publishedVersionMapper(); $container = $this->createMock(ContainerInterface::class); $container->method('get')->willReturnCallback( - function (string $id) use ($flowMapper, $activeOrg, $delegation): object { + function (string $id) use ($flowMapper, $activeOrg, $delegation, $versions): object { if ($id === 'OCA\OpenRegister\Db\FlowMapper') { return $flowMapper; } + // Version 1 is live. These fixtures are about ATTRIBUTION, so + // they must reach the attribution code rather than stopping at + // the unpublished-flow refusal that now precedes it. + if ($id === \OCA\OpenRegister\Db\FlowVersionMapper::class) { + return $versions; + } + if ($id === DelegationService::class && $delegation !== null) { return $delegation; } diff --git a/tests/Unit/Service/Flow/FlowRunContextTest.php b/tests/Unit/Service/Flow/FlowRunContextTest.php new file mode 100644 index 0000000000..c5971e8269 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowRunContextTest.php @@ -0,0 +1,135 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + +namespace Unit\Service\Flow; + +use OCA\OpenRegister\Service\Flow\FlowRunContext; +use PHPUnit\Framework\TestCase; + +class FlowRunContextTest extends TestCase { + + private FlowRunContext $context; + + protected function setUp(): void { + $this->context = new FlowRunContext(); + }//end setUp() + + /** + * Outside any run there is nothing to attribute to. + */ + public function testEmptyStackAttributesNothing(): void { + $this->assertNull($this->context->current()); + $this->assertSame(0, $this->context->depth()); + }//end testEmptyStackAttributesNothing() + + /** + * A pushed frame is what a write is filed under. + */ + public function testCurrentReturnsThePushedFrame(): void { + $this->context->push(runUuid: 'run-a', nodeId: 'node-1', sequence: 7); + + $this->assertSame( + ['run' => 'run-a', 'node' => 'node-1', 'step' => 7], + $this->context->current() + ); + }//end testCurrentReturnsThePushedFrame() + + /** + * Popping restores the ENCLOSING frame, not emptiness. + * + * This is the sub-flow case. If pop cleared instead of restoring, a parent + * run's remaining steps would silently stop being attributed the moment it + * called a sub-flow — and nothing about the resulting rows would look wrong. + */ + public function testNestedPopRestoresTheParentFrame(): void { + $this->context->push(runUuid: 'parent', nodeId: 'call-subflow', sequence: 2); + $this->context->push(runUuid: 'child', nodeId: 'child-node', sequence: 0); + + $this->assertSame('child', $this->context->current()['run']); + + $this->context->pop(); + + $this->assertSame( + 'parent', + $this->context->current()['run'], + 'A sub-flow returning must hand the parent run back its own attribution.' + ); + }//end testNestedPopRestoresTheParentFrame() + + /** + * A run that is not attributable does not INHERIT the enclosing one. + * + * The wrong behaviour here files a child's writes under its parent: still a + * plausible-looking row, attributed to a run that never performed it. + */ + public function testUnattributableFrameDoesNotInheritTheParent(): void { + $this->context->push(runUuid: 'parent', nodeId: 'outer', sequence: 1); + $this->context->push(runUuid: null, nodeId: 'inner', sequence: 0); + + $this->assertNull( + $this->context->current(), + 'An inner hop with no run must attribute to nothing, not to the run outside it.' + ); + + $this->context->pop(); + + $this->assertSame('parent', $this->context->current()['run']); + }//end testUnattributableFrameDoesNotInheritTheParent() + + /** + * An empty run uuid is the same as none. A run uuid is never blank, so a + * blank one is a bug upstream and must not become an attribution of "". + */ + public function testBlankRunUuidIsNotAnAttribution(): void { + $this->context->push(runUuid: ' ', nodeId: 'node', sequence: 0); + + $this->assertNull($this->context->current()); + $this->assertSame(1, $this->context->depth(), 'It still occupies a frame, so pop stays paired.'); + }//end testBlankRunUuidIsNotAnAttribution() + + /** + * Popping more than was pushed must not throw. + * + * pop() is called from a `finally`. A `finally` that throws REPLACES the + * exception the step actually failed with, turning a diagnosable node + * failure into a bookkeeping error about the context. + */ + public function testPoppingAnEmptyStackIsHarmless(): void { + $this->context->pop(); + $this->context->pop(); + + $this->assertNull($this->context->current()); + $this->assertSame(0, $this->context->depth()); + }//end testPoppingAnEmptyStackIsHarmless() + + /** + * Two sequential runs do not bleed into one another. + * + * FlowRunWorker advances several runs per process, so a leak here crosses + * runs rather than merely steps. + */ + public function testSequentialRunsDoNotBleed(): void { + $this->context->push(runUuid: 'run-1', nodeId: 'n', sequence: 0); + $this->context->pop(); + + $this->assertNull($this->context->current(), 'A finished run must leave nothing behind.'); + + $this->context->push(runUuid: 'run-2', nodeId: 'n', sequence: 0); + + $this->assertSame('run-2', $this->context->current()['run']); + }//end testSequentialRunsDoNotBleed() +}//end class diff --git a/tests/Unit/Service/Flow/FlowRunRetryTest.php b/tests/Unit/Service/Flow/FlowRunRetryTest.php index 4e2184fefc..aa67c371da 100644 --- a/tests/Unit/Service/Flow/FlowRunRetryTest.php +++ b/tests/Unit/Service/Flow/FlowRunRetryTest.php @@ -19,6 +19,8 @@ use Psr\Log\NullLogger; class FlowRunRetryTest extends TestCase { + use \OCA\OpenRegister\Tests\Unit\Service\Flow\PublishedVersionDouble; + private FlowRunMapper $mapper; private FlowRunService $service; @@ -43,7 +45,24 @@ protected function setUp(): void { */ private function noOrganisationContainer(): ContainerInterface { $container = $this->createMock(ContainerInterface::class); - $container->method('get')->willThrowException(new \RuntimeException('not available')); + // Since versioning, queue() refuses a flow with no published version. + // These fixtures are about what a run DOES, so they say version 1 is + // live; the refusal itself has its own tests. + $versions = $this->publishedVersionMapper(); + $pin = $this->pinReturning(); + $container->method('get')->willReturnCallback( + function (string $id) use ($versions, $pin): object { + if ($id === \OCA\OpenRegister\Db\FlowVersionMapper::class) { + return $versions; + } + + if ($id === \OCA\OpenRegister\Service\Flow\FlowDefinitionPin::class) { + return $pin; + } + + throw new \RuntimeException('not available'); + } + ); return $container; } diff --git a/tests/Unit/Service/Flow/FlowRunServiceTest.php b/tests/Unit/Service/Flow/FlowRunServiceTest.php index 9ebc4f0617..6f7df821d9 100644 --- a/tests/Unit/Service/Flow/FlowRunServiceTest.php +++ b/tests/Unit/Service/Flow/FlowRunServiceTest.php @@ -110,6 +110,8 @@ public function execute(array $items, array $config, array $context): array { } class FlowRunServiceTest extends TestCase { + use \OCA\OpenRegister\Tests\Unit\Service\Flow\PublishedVersionDouble; + private FlowRunMapper $mapper; private FlowRunService $service; private WaitingNode $waiter; @@ -141,7 +143,24 @@ function (Event $event): void { // No OrganisationService in the container — the cron/unit case, where a // queued run is recorded with no organisation rather than a guessed one. $container = $this->createMock(ContainerInterface::class); - $container->method('get')->willThrowException(new \RuntimeException('not available')); + // Since versioning, queue() refuses a flow with no published version. + // These fixtures are about what a run DOES, so they say version 1 is + // live; the refusal itself has its own tests. + $versions = $this->publishedVersionMapper(); + $pin = $this->pinReturning(); + $container->method('get')->willReturnCallback( + function (string $id) use ($versions, $pin): object { + if ($id === \OCA\OpenRegister\Db\FlowVersionMapper::class) { + return $versions; + } + + if ($id === \OCA\OpenRegister\Service\Flow\FlowDefinitionPin::class) { + return $pin; + } + + throw new \RuntimeException('not available'); + } + ); $this->service = new FlowRunService( $this->mapper, diff --git a/tests/Unit/Service/Flow/FlowStepHistoryTest.php b/tests/Unit/Service/Flow/FlowStepHistoryTest.php new file mode 100644 index 0000000000..3729514ffb --- /dev/null +++ b/tests/Unit/Service/Flow/FlowStepHistoryTest.php @@ -0,0 +1,222 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + +namespace Unit\Service\Flow; + +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunStep; +use OCA\OpenRegister\Db\FlowRunStepMapper; +use OCA\OpenRegister\Service\Flow\FlowStepHistory; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +class FlowStepHistoryTest extends TestCase { + + /** + * A run with a uuid. + * + * @param string $uuid The uuid. + * + * @return FlowRun The run. + * + * Named aRun(), not run(): PHPUnit's TestCase::run() is final. + */ + private function aRun(string $uuid = 'run-1'): FlowRun { + $run = new FlowRun(); + $run->setUuid($uuid); + $run->setFlowId('flow-1'); + + return $run; + }//end aRun() + + public function testWithNoStepMapperNumberingStartsAtZero(): void { + $history = new FlowStepHistory(); + + $this->assertSame(0, $history->baseFor(runUuid: 'run-1')); + }//end testWithNoStepMapperNumberingStartsAtZero() + + public function testAnEmptyRunUuidNumbersFromZero(): void { + $steps = $this->createMock(FlowRunStepMapper::class); + $steps->expects($this->never())->method('highestSequence'); + + $this->assertSame(0, (new FlowStepHistory(steps: $steps))->baseFor(runUuid: ' ')); + }//end testAnEmptyRunUuidNumbersFromZero() + + /** + * The base CONTINUES from what is already recorded. + * + * A run that suspends and resumes days later must read as one ordered + * history, not two starting at zero. + */ + public function testTheBaseContinuesFromTheHighestRecordedSequence(): void { + $steps = $this->createMock(FlowRunStepMapper::class); + $steps->method('highestSequence')->with('run-1')->willReturn(11); + + $this->assertSame(12, (new FlowStepHistory(steps: $steps))->baseFor(runUuid: 'run-1')); + }//end testTheBaseContinuesFromTheHighestRecordedSequence() + + /** + * A failed read degrades to zero rather than failing the run. + * + * The work is the run; the numbering is the account of it. Wrong only in + * its offset, and still ordering the run's writes among themselves. + */ + public function testAFailedReadDegradesToZeroAndIsLogged(): void { + $steps = $this->createMock(FlowRunStepMapper::class); + $steps->method('highestSequence')->willThrowException(new RuntimeException('db gone')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('warning'); + + $history = new FlowStepHistory(steps: $steps, logger: $logger); + + $this->assertSame(0, $history->baseFor(runUuid: 'run-1')); + }//end testAFailedReadDegradesToZeroAndIsLogged() + + public function testRecordingWithoutAStepMapperIsANoOp(): void { + $history = new FlowStepHistory(); + + $history->record(run: $this->aRun(), entries: [['transition' => 'a', 'status' => 'completed']]); + + $this->addToAssertionCount(1); + }//end testRecordingWithoutAStepMapperIsANoOp() + + public function testNoEntriesRecordsNothing(): void { + $steps = $this->createMock(FlowRunStepMapper::class); + $steps->expects($this->never())->method('insert'); + + (new FlowStepHistory(steps: $steps))->record(run: $this->aRun(), entries: []); + }//end testNoEntriesRecordsNothing() + + /** + * 🔑 THE NUMBERS THE ROWS GET ARE THE NUMBERS ATTRIBUTION PREDICTED. + * + * `baseFor()` is what the engine stamped onto audit rows before the walk; + * these are the rows written after it. Asserted together, in one test, + * because their agreement is the property — checking either alone would + * pass while they disagreed. + */ + public function testRecordedSequencesContinueFromTheSameBaseAttributionUsed(): void { + $steps = $this->createMock(FlowRunStepMapper::class); + $steps->method('highestSequence')->willReturn(4); + + $recorded = []; + $steps->method('insert')->willReturnCallback( + function (FlowRunStep $step) use (&$recorded) { + $recorded[] = [$step->getNodeId(), $step->getSequence()]; + + return $step; + } + ); + + $history = new FlowStepHistory(steps: $steps); + + $this->assertSame(5, $history->baseFor(runUuid: 'run-1')); + + $history->record( + run: $this->aRun(), + entries: [ + ['transition' => 'first', 'status' => 'completed'], + ['transition' => 'second', 'status' => 'completed'], + ] + ); + + $this->assertSame([['first', 5], ['second', 6]], $recorded); + }//end testRecordedSequencesContinueFromTheSameBaseAttributionUsed() + + public function testAFailedInsertDoesNotStopTheRemainingSteps(): void { + $steps = $this->createMock(FlowRunStepMapper::class); + $steps->method('highestSequence')->willReturn(0); + + $seen = []; + $steps->method('insert')->willReturnCallback( + function (FlowRunStep $step) use (&$seen) { + $seen[] = $step->getNodeId(); + if ($step->getNodeId() === 'first') { + throw new RuntimeException('write failed'); + } + + return $step; + } + ); + + (new FlowStepHistory(steps: $steps, logger: $this->createMock(LoggerInterface::class)))->record( + run: $this->aRun(), + entries: [ + ['transition' => 'first', 'status' => 'completed'], + ['transition' => 'second', 'status' => 'completed'], + ] + ); + + $this->assertSame(['first', 'second'], $seen, 'One unwritable row must not cost the rest their history.'); + }//end testAFailedInsertDoesNotStopTheRemainingSteps() + + /** + * A non-array entry is skipped rather than crashing the recorder. + */ + public function testMalformedEntriesAreSkipped(): void { + $steps = $this->createMock(FlowRunStepMapper::class); + $steps->method('highestSequence')->willReturn(0); + + $count = 0; + $steps->method('insert')->willReturnCallback( + function (FlowRunStep $step) use (&$count) { + $count++; + + return $step; + } + ); + + (new FlowStepHistory(steps: $steps))->record( + run: $this->aRun(), + entries: ['not an array', ['transition' => 'real', 'status' => 'completed']] + ); + + $this->assertSame(1, $count); + }//end testMalformedEntriesAreSkipped() + + /** + * A stopped step's REASON lands in the error column. + * + * A thrown step and a deliberately stopped one are each something a person + * needs to read back, and they arrive under different keys. + */ + public function testAStopReasonIsRecordedAsTheStepsError(): void { + $steps = $this->createMock(FlowRunStepMapper::class); + $steps->method('highestSequence')->willReturn(0); + + $errors = []; + $steps->method('insert')->willReturnCallback( + function (FlowRunStep $step) use (&$errors) { + $errors[] = $step->getError(); + + return $step; + } + ); + + (new FlowStepHistory(steps: $steps))->record( + run: $this->aRun(), + entries: [ + ['transition' => 'a', 'status' => 'failed', 'error' => 'it threw'], + ['transition' => 'b', 'status' => 'stopped', 'reason' => 'author stopped it'], + ] + ); + + $this->assertSame(['it threw', 'author stopped it'], $errors); + }//end testAStopReasonIsRecordedAsTheStepsError() +}//end class diff --git a/tests/Unit/Service/Flow/FlowTriggerIndexPublishedTest.php b/tests/Unit/Service/Flow/FlowTriggerIndexPublishedTest.php new file mode 100644 index 0000000000..7d584acbf3 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowTriggerIndexPublishedTest.php @@ -0,0 +1,159 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use OCA\OpenRegister\Db\Flow; +use OCA\OpenRegister\Db\FlowTriggerMapper; +use OCA\OpenRegister\Service\Flow\FlowPublishedGraph; +use OCA\OpenRegister\Service\Flow\FlowTriggerDerivation; +use OCA\OpenRegister\Service\Flow\FlowTriggerIndex; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +class FlowTriggerIndexPublishedTest extends TestCase { + + /** + * @var array What replaceFor() was last called with. + */ + private array $written = []; + + /** + * An index whose resolver answers $publishedGraph for any flow. + * + * @param array|null $publishedGraph The published graph, or null for none. + * + * @return FlowTriggerIndex The index. + */ + private function index(?array $publishedGraph): FlowTriggerIndex { + $mapper = $this->createMock(FlowTriggerMapper::class); + $mapper->method('replaceFor')->willReturnCallback( + function (string $flowUuid, array $triggers, bool $enabled): int { + $this->written = ['flow' => $flowUuid, 'triggers' => $triggers, 'enabled' => $enabled]; + + return count($triggers); + } + ); + $published = $this->createMock(FlowPublishedGraph::class); + $published->method('graphOf')->willReturn($publishedGraph); + + return new FlowTriggerIndex( + $mapper, + new FlowTriggerDerivation(), + new NullLogger(), + $published + ); + }//end index() + + /** + * A flow whose HEAD holds $nodes. + * + * @param array $nodes The head's nodes. + * + * @return Flow The flow. + */ + private function flowWithHead(array $nodes): Flow { + $flow = new Flow(); + $flow->setUuid('flow-1'); + $flow->setEnabled(true); + $flow->setNodes($nodes); + $flow->setEdges([]); + + return $flow; + }//end flowWithHead() + + /** + * One object trigger node. + * + * @param string $event The event it subscribes to. + * + * @return array The node. + */ + private function triggerNode(string $event): array { + return [ + 'id' => 'trigger-' . $event, + 'type' => 'openregister.trigger-object', + 'config' => ['event' => $event, 'register' => 'reg', 'schema' => 'sch'], + ]; + }//end triggerNode() + + /** + * 🔴 THE HEAD'S DRAFT TRIGGER MUST NOT BE SUBSCRIBED, and the published + * one must not be dropped. The head here says `object.updated`; the + * published version says `object.created`. Only the published one may + * reach the index. + * + * @return void + */ + public function testTheIndexFollowsThePublishedVersionNotTheHead(): void { + $index = $this->index(['nodes' => [$this->triggerNode('object.created')], 'edges' => []]); + + $index->reindex(flow: $this->flowWithHead([$this->triggerNode('object.updated')])); + + $events = array_column($this->written['triggers'], 'event'); + + $this->assertSame(['object.created'], $events, 'the draft head must not be subscribed'); + }//end testTheIndexFollowsThePublishedVersionNotTheHead() + + /** + * A flow with no published version subscribes to NOTHING — that is what + * makes "a draft's trigger nodes match nothing" true by construction + * rather than by a filter on the read path. + * + * @return void + */ + public function testAFlowWithNoPublishedVersionSubscribesToNothing(): void { + $index = $this->index(null); + + $index->reindex(flow: $this->flowWithHead([$this->triggerNode('object.created')])); + + $this->assertSame([], $this->written['triggers']); + }//end testAFlowWithNoPublishedVersionSubscribesToNothing() + + /** + * `enabled` comes from the LIVE flow, not from the published version. + * Switching a flow off must take effect at once and has nothing to do with + * which version is published. + * + * @return void + */ + public function testEnabledComesFromTheLiveFlow(): void { + $index = $this->index(['nodes' => [$this->triggerNode('object.created')], 'edges' => []]); + + $flow = $this->flowWithHead([]); + $flow->setEnabled(false); + $index->reindex(flow: $flow); + + $this->assertFalse($this->written['enabled']); + }//end testEnabledComesFromTheLiveFlow() + + /** + * The rows are written against the flow's own uuid, not the carrier's — a + * detached carrier that lost the id would silently index nothing. + * + * @return void + */ + public function testTheRowsAreKeyedOnTheFlowsOwnUuid(): void { + $index = $this->index(['nodes' => [$this->triggerNode('object.created')], 'edges' => []]); + + $index->reindex(flow: $this->flowWithHead([])); + + $this->assertSame('flow-1', $this->written['flow']); + }//end testTheRowsAreKeyedOnTheFlowsOwnUuid() +}//end class diff --git a/tests/Unit/Service/Flow/FlowTriggerServiceTest.php b/tests/Unit/Service/Flow/FlowTriggerServiceTest.php index a3c75809a3..06791b0b15 100644 --- a/tests/Unit/Service/Flow/FlowTriggerServiceTest.php +++ b/tests/Unit/Service/Flow/FlowTriggerServiceTest.php @@ -26,6 +26,8 @@ * them is FlowLocatorTest, which covers the store read directly. */ class FlowTriggerServiceTest extends TestCase { + use \OCA\OpenRegister\Tests\Unit\Service\Flow\PublishedVersionDouble; + /** * A locator that reports the given flow ids for any trigger. * @@ -55,7 +57,24 @@ function (FlowRun $r) use (&$queued) { // No OrganisationService in the container — a run queued with no session // is recorded unattributed rather than guessed at. $container = $this->createMock(\Psr\Container\ContainerInterface::class); - $container->method('get')->willThrowException(new \RuntimeException('not available')); + // Since versioning, queue() refuses a flow with no published version. + // These fixtures are about what a run DOES, so they say version 1 is + // live; the refusal itself has its own tests. + $versions = $this->publishedVersionMapper(); + $pin = $this->pinReturning(); + $container->method('get')->willReturnCallback( + function (string $id) use ($versions, $pin): object { + if ($id === \OCA\OpenRegister\Db\FlowVersionMapper::class) { + return $versions; + } + + if ($id === \OCA\OpenRegister\Service\Flow\FlowDefinitionPin::class) { + return $pin; + } + + throw new \RuntimeException('not available'); + } + ); $runner = new FlowRunService( $mapper, diff --git a/tests/Unit/Service/Flow/FlowVersionPinningTest.php b/tests/Unit/Service/Flow/FlowVersionPinningTest.php new file mode 100644 index 0000000000..db99c7aeb2 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowVersionPinningTest.php @@ -0,0 +1,352 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use OCA\OpenRegister\Db\FlowDefinition; +use OCA\OpenRegister\Db\FlowDefinitionMapper; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowVersion; +use OCA\OpenRegister\Db\FlowVersionMapper; +use OCA\OpenRegister\Service\Flow\FlowDefinitionPin; +use OCA\OpenRegister\Service\Flow\FlowLifecycleRefused; +use OCA\OpenRegister\Service\Flow\FlowPublishedGraph; +use OCA\OpenRegister\Service\Flow\FlowRunVersionPin; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\NullLogger; +use RuntimeException; + +class FlowVersionPinningTest extends TestCase { + + /** + * @var array> Graphs by hash. + */ + private array $definitions = []; + + /** + * @var array Version rows, keyed "flow:version". + */ + private array $versions = []; + + /** + * Register a version of a flow whose graph is $graph. + * + * @param string $flowId The flow. + * @param int $number The version number. + * @param array $graph The graph that version names. + * + * @return void + */ + private function giveVersion(string $flowId, int $number, array $graph): void { + $hash = 'hash-' . $flowId . '-' . $number; + $this->definitions[$hash] = $graph; + + $version = new FlowVersion(); + $version->setFlowUuid($flowId); + $version->setVersion($number); + $version->setStatus(FlowVersion::STATUS_PUBLISHED); + $version->setDefinitionHash($hash); + + $this->versions[$flowId . ':' . $number] = $version; + }//end giveVersion() + + /** + * Forget a version, as deleting it would. + * + * @param string $flowId The flow. + * @param int $number The version number. + * + * @return void + */ + private function removeVersion(string $flowId, int $number): void { + unset($this->versions[$flowId . ':' . $number]); + }//end removeVersion() + + /** + * A resolver over the registered versions and definitions. + * + * @return FlowPublishedGraph The resolver. + */ + private function graphs(): FlowPublishedGraph { + $versionMapper = $this->createMock(FlowVersionMapper::class); + $versionMapper->method('find')->willReturnCallback( + fn (string $flowUuid, int $number): ?FlowVersion => ($this->versions[$flowUuid . ':' . $number] ?? null) + ); + + $definitionMapper = $this->createMock(FlowDefinitionMapper::class); + $definitionMapper->method('findByHash')->willReturnCallback( + function (string $hash): ?FlowDefinition { + if (isset($this->definitions[$hash]) === false) { + return null; + } + + $entity = new FlowDefinition(); + $entity->setHash($hash); + $entity->setDefinition((string)json_encode($this->definitions[$hash])); + + return $entity; + } + ); + + $pin = new FlowDefinitionPin($definitionMapper, new NullLogger()); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + function (string $id) use ($versionMapper, $pin): object { + if ($id === FlowVersionMapper::class) { + return $versionMapper; + } + + return $pin; + } + ); + + return new FlowPublishedGraph($container); + }//end graphs() + + /** + * A dispatch pin over the registered versions. + * + * @param boolean $published Whether the flow has a published version. + * + * @return FlowRunVersionPin The pin. + */ + private function versionPin(bool $published): FlowRunVersionPin { + $mapper = $this->createMock(FlowVersionMapper::class); + $mapper->method('findPublished')->willReturnCallback( + function (string $flowUuid) use ($published): ?FlowVersion { + if ($published === false) { + return null; + } + + return ($this->versions[$flowUuid . ':1'] ?? null); + } + ); + + // FlowMapper and FlowNodePreflight are deliberately absent: the + // dead-end preflight returns early when it cannot load them, which is + // the documented behaviour ("a flow we cannot load is not a flow we can + // judge") and keeps this fixture about VERSIONS. + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + function (string $id) use ($mapper): object { + if ($id === FlowVersionMapper::class) { + return $mapper; + } + + throw new RuntimeException('not available'); + } + ); + + return new FlowRunVersionPin($container, new NullLogger()); + }//end versionPin() + + /** + * A run pinned to a version of a flow. + * + * @param string $flowId The flow. + * @param int|null $version The pinned version, or null for unpinned. + * + * @return FlowRun The run. + */ + private function pinnedRun(string $flowId, ?int $version): FlowRun { + $run = new FlowRun(); + $run->setFlowId($flowId); + $run->setFlowVersion($version); + $run->setStatus(FlowRun::STATUS_SUSPENDED); + + return $run; + }//end pinnedRun() + + /** + * 🔴 THE DEFECT. A run suspended on a human step for a fortnight, resumed + * after its author published a rewritten version, must finish the process + * it began. ADR-098 Decision 6 forbids shipping human task nodes without + * this, and dossiq already ships two. + * + * @return void + */ + public function testARunSuspendedAcrossAPublishResumesOnItsOwnVersion(): void { + $v1 = ['nodes' => [['id' => 'ask'], ['id' => 'file']], 'edges' => [['from' => 'ask', 'to' => 'file']]]; + $v2 = ['nodes' => [['id' => 'renamed']], 'edges' => []]; + + $this->giveVersion('case-flow', 1, $v1); + $this->giveVersion('case-flow', 2, $v2); + + // The LIVE document is version 2 — that is what resolving the flow by + // id gives you today. + $live = $v2 + ['id' => 'case-flow', 'owner' => 'alice']; + + $walked = $this->graphs()->overlayOnto(run: $this->pinnedRun('case-flow', 1), live: $live); + + $this->assertSame($v1['nodes'], $walked['nodes'], 'the run must walk version 1'); + $this->assertSame($v1['edges'], $walked['edges']); + }//end testARunSuspendedAcrossAPublishResumesOnItsOwnVersion() + + /** + * 🔴 AUTHORIZATION RE-RESOLVES; THE GRAPH DOES NOT. Pinning the owner too + * would make revoking access cosmetic for exactly the long-running work + * nobody is watching. + * + * @return void + */ + public function testTheOwnerComesFromTheLiveDocumentNotThePin(): void { + $this->giveVersion('f', 1, ['nodes' => [['id' => 'a']], 'edges' => [], 'owner' => 'old-owner']); + + $walked = $this->graphs()->overlayOnto( + run: $this->pinnedRun('f', 1), + live: ['nodes' => [], 'edges' => [], 'owner' => 'current-owner'] + ); + + $this->assertSame('current-owner', $walked['owner']); + }//end testTheOwnerComesFromTheLiveDocumentNotThePin() + + /** + * Two runs of one flow on different versions, advanced in the same worker + * pass, must each receive their own graph. A resolver cached by flow alone + * would serve one run's graph to the other. + * + * @return void + */ + public function testTwoVersionsOfOneFlowAdvanceInOneBatchWithoutCrossTalk(): void { + $this->giveVersion('f', 1, ['nodes' => [['id' => 'one']], 'edges' => []]); + $this->giveVersion('f', 2, ['nodes' => [['id' => 'two']], 'edges' => []]); + + $graphs = $this->graphs(); + $live = ['nodes' => [['id' => 'two']], 'edges' => []]; + + $first = $graphs->overlayOnto(run: $this->pinnedRun('f', 1), live: $live); + $second = $graphs->overlayOnto(run: $this->pinnedRun('f', 2), live: $live); + + $this->assertSame('one', $first['nodes'][0]['id']); + $this->assertSame('two', $second['nodes'][0]['id']); + }//end testTwoVersionsOfOneFlowAdvanceInOneBatchWithoutCrossTalk() + + /** + * 🔴 A DELETED VERSION FAILS THE RUN. Null is the signal the caller turns + * into a failure naming the version — never a substitution. + * + * @return void + */ + public function testARunWhosePinnedVersionWasDeletedResolvesToNothing(): void { + $this->giveVersion('f', 2, ['nodes' => [['id' => 'a']], 'edges' => []]); + $this->removeVersion('f', 2); + + $this->assertNull( + $this->graphs()->overlayOnto(run: $this->pinnedRun('f', 2), live: ['nodes' => [], 'edges' => []]) + ); + }//end testARunWhosePinnedVersionWasDeletedResolvesToNothing() + + /** + * 🔴 A NEWER VERSION IS NOT A SUBSTITUTE. Even with version 3 published and + * healthy, a run pinned to the missing version 2 must not be promoted onto + * it: the run's marking, its taken decisions and its log all belong to the + * version it started on. + * + * @return void + */ + public function testANewerPublishedVersionIsNotSubstitutedForAMissingPin(): void { + $this->giveVersion('f', 3, ['nodes' => [['id' => 'newest']], 'edges' => []]); + + $walked = $this->graphs()->overlayOnto( + run: $this->pinnedRun('f', 2), + live: ['nodes' => [['id' => 'newest']], 'edges' => []] + ); + + $this->assertNull($walked, 'a run must fail rather than adopt a version it did not start on'); + }//end testANewerPublishedVersionIsNotSubstitutedForAMissingPin() + + /** + * The one documented exception: an interactive test run of a draft carries + * its own snapshot and is not pinned to a published version. + * + * @return void + */ + public function testAnUnpinnedTestRunWalksTheDocumentItWasGiven(): void { + $live = ['nodes' => [['id' => 'draft-step']], 'edges' => []]; + + $this->assertSame( + $live, + $this->graphs()->overlayOnto(run: $this->pinnedRun('f', null), live: $live) + ); + }//end testAnUnpinnedTestRunWalksTheDocumentItWasGiven() + + /** + * 🔴 THE TEST-RUN EXEMPTION IS NARROW. An interactive test run may walk a + * draft — publishing is what you do AFTER testing, so refusing would make a + * flow untestable until it went live. Every OTHER trigger of an + * unpublished flow is still refused, which is what keeps the exemption from + * becoming a way to run drafts on real data. + * + * @return void + */ + public function testOnlyTheTestTriggerMayDispatchAnUnpublishedFlow(): void { + $pin = $this->versionPin(published: false); + + $this->assertNull( + $pin->requirePublishedAndSound(flowId: 'f', trigger: 'test'), + 'a test run of a draft is permitted, and is unpinned' + ); + + foreach (['manual', 'object.created', 'schedule', 'sub-flow', 'mcp'] as $trigger) { + try { + $pin->requirePublishedAndSound(flowId: 'f', trigger: $trigger); + $this->fail('"' . $trigger . '" must not dispatch an unpublished flow'); + } catch (FlowLifecycleRefused $refused) { + $this->assertSame( + FlowLifecycleRefused::REASON_NO_PUBLISHED_VERSION, + $refused->getReason() + ); + } + } + }//end testOnlyTheTestTriggerMayDispatchAnUnpublishedFlow() + + /** + * A test run of a flow that IS published still pins to it — the exemption + * is about drafts, not about test runs skipping versioning. + * + * @return void + */ + public function testATestRunOfAPublishedFlowStillPinsToIt(): void { + $this->giveVersion('f', 1, ['nodes' => [], 'edges' => []]); + + $version = $this->versionPin(published: true) + ->requirePublishedAndSound(flowId: 'f', trigger: 'test'); + + $this->assertNotNull($version); + $this->assertSame(1, $version->getVersion()); + }//end testATestRunOfAPublishedFlowStillPinsToIt() + + /** + * A version row whose definition row is gone is as unresolvable as a + * missing version — it must not degrade into an empty graph, which would + * let a run "complete" without executing anything. + * + * @return void + */ + public function testAVersionWhoseDefinitionIsMissingResolvesToNothing(): void { + $this->giveVersion('f', 1, ['nodes' => [['id' => 'a']], 'edges' => []]); + $this->definitions = []; + + $this->assertNull( + $this->graphs()->overlayOnto(run: $this->pinnedRun('f', 1), live: ['nodes' => [], 'edges' => []]) + ); + }//end testAVersionWhoseDefinitionIsMissingResolvesToNothing() +}//end class diff --git a/tests/Unit/Service/Flow/PublishedVersionDouble.php b/tests/Unit/Service/Flow/PublishedVersionDouble.php new file mode 100644 index 0000000000..038102082a --- /dev/null +++ b/tests/Unit/Service/Flow/PublishedVersionDouble.php @@ -0,0 +1,110 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Flow; + +use OCA\OpenRegister\Db\FlowDefinition; +use OCA\OpenRegister\Db\FlowDefinitionMapper; +use OCA\OpenRegister\Db\FlowVersion; +use OCA\OpenRegister\Db\FlowVersionMapper; +use OCA\OpenRegister\Service\Flow\FlowDefinitionPin; +use Psr\Log\NullLogger; + +trait PublishedVersionDouble { + /** + * A version mapper reporting version 1 published for every flow. + * + * @param string $hash The definition hash the version names. + * + * @return FlowVersionMapper The double. + */ + private function publishedVersionMapper(string $hash = 'test-hash'): FlowVersionMapper { + $mapper = $this->createMock(FlowVersionMapper::class); + + $mapper->method('findPublished')->willReturnCallback( + function (string $flowUuid) use ($hash): FlowVersion { + $v = new FlowVersion(); + $v->setFlowUuid($flowUuid); + $v->setVersion(1); + $v->setStatus(FlowVersion::STATUS_PUBLISHED); + $v->setDefinitionHash($hash); + + return $v; + } + ); + + $mapper->method('find')->willReturnCallback( + function (string $flowUuid, int $number) use ($hash): ?FlowVersion { + if ($number !== 1) { + return null; + } + + $v = new FlowVersion(); + $v->setFlowUuid($flowUuid); + $v->setVersion(1); + $v->setStatus(FlowVersion::STATUS_PUBLISHED); + $v->setDefinitionHash($hash); + + return $v; + } + ); + + return $mapper; + }//end publishedVersionMapper() + + /** + * A pin whose stored definition is the given graph. + * + * 🔑 THE DEFAULT DELIBERATELY CARRIES NONE OF THE FOUR GRAPH KEYS. These + * suites are about what a run DOES — suspending, resuming, carrying its + * items, acting as the right user — not about which graph is pinned. A + * definition with no `nodes`/`edges`/`limits`/`executionMode` makes + * `overlayOnto()` copy nothing, so the fixture's own document is what runs, + * while the real lookup path (version row -> hash -> definition) is still + * exercised rather than stubbed past. Which graph a pin selects has its own + * tests in FlowDefinitionPinTest and FlowVersionPinningTest. + * + * @param array $graph The graph the hash resolves to. + * @param string $hash The hash. + * + * @return FlowDefinitionPin The pin. + */ + private function pinReturning(array $graph = ['pinnedBy' => 'test'], string $hash = 'test-hash'): FlowDefinitionPin { + $definitions = $this->createMock(FlowDefinitionMapper::class); + + $entity = new FlowDefinition(); + $entity->setHash($hash); + $entity->setDefinition((string)json_encode($graph)); + + $definitions->method('findByHash')->willReturnCallback( + function (string $wanted) use ($hash, $entity): ?FlowDefinition { + if ($wanted !== $hash) { + return null; + } + + return $entity; + } + ); + + return new FlowDefinitionPin($definitions, new NullLogger()); + }//end pinReturning() +}//end trait diff --git a/tests/Unit/Service/LogServiceTest.php b/tests/Unit/Service/LogServiceTest.php index 5fa3eb8836..ecba7cfb3a 100644 --- a/tests/Unit/Service/LogServiceTest.php +++ b/tests/Unit/Service/LogServiceTest.php @@ -200,64 +200,6 @@ public function testGetLog(): void { $this->assertInstanceOf(AuditTrail::class, $result); } - public function testDeleteLogSuccess(): void { - $auditTrail = new AuditTrail(); - $this->auditTrailMapper->method('find')->willReturn($auditTrail); - $this->auditTrailMapper->expects($this->once())->method('delete'); - - $result = $this->service->deleteLog(1); - - $this->assertTrue($result); - } - - public function testDeleteLogThrowsOnFailure(): void { - $this->auditTrailMapper->method('find') - ->willThrowException(new Exception('Not found')); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('Failed to delete audit trail'); - - $this->service->deleteLog(999); - } - - public function testDeleteLogsByIds(): void { - $auditTrail = new AuditTrail(); - $this->auditTrailMapper->method('find')->willReturn($auditTrail); - $this->auditTrailMapper->expects($this->exactly(2))->method('delete'); - - $result = $this->service->deleteLogs(['ids' => [1, 2]]); - - $this->assertSame(2, $result['deleted']); - $this->assertSame(0, $result['failed']); - } - - public function testDeleteLogsByIdsWithFailures(): void { - $auditTrail = new AuditTrail(); - $this->auditTrailMapper->method('find') - ->willReturnOnConsecutiveCalls( - $auditTrail, - $this->throwException(new Exception('Not found')) - ); - - $result = $this->service->deleteLogs(['ids' => [1, 2]]); - - $this->assertSame(1, $result['deleted']); - $this->assertSame(1, $result['failed']); - } - - public function testDeleteLogsByFilters(): void { - $auditTrail1 = new AuditTrail(); - $auditTrail2 = new AuditTrail(); - $this->auditTrailMapper->method('findAll')->willReturn([$auditTrail1, $auditTrail2]); - $this->auditTrailMapper->expects($this->exactly(2))->method('delete'); - - $result = $this->service->deleteLogs(['filters' => ['action' => 'create']]); - - $this->assertSame(2, $result['deleted']); - $this->assertSame(0, $result['failed']); - $this->assertSame(2, $result['total']); - } - public function testExportLogsJson(): void { $mockLog = $this->createMock(\JsonSerializable::class); $mockLog->method('jsonSerialize')->willReturn([ diff --git a/tests/Unit/Service/Object/ValidateObjectCoverageTest.php b/tests/Unit/Service/Object/ValidateObjectCoverageTest.php index 5de7067b0f..3d70a47f52 100644 --- a/tests/Unit/Service/Object/ValidateObjectCoverageTest.php +++ b/tests/Unit/Service/Object/ValidateObjectCoverageTest.php @@ -692,6 +692,31 @@ public function testHandleCustomValidationException(): void { $this->assertSame('Validation failed', $data['message']); } + public function testHandleValidationExceptionWithNoOpisErrorStillReturns400(): void { + // 🔴 THE REGRESSION THIS GUARDS. `ValidationException::getErrors()` is + // typed `?ValidationError` and IS null whenever the exception is thrown + // with a message alone — readOnly enforcement on update does exactly + // that, and says so where it throws. Passing that null to + // `ErrorFormatter::format()` is a TypeError, so the 400 this method + // exists to produce became a 500 with the reason nowhere in it. + // + // Measured on dossiq: every case edit answered 500, the server log + // carrying `ErrorFormatter::format(): Argument #1 ($error) must be of + // type ValidationError, null given`, and the edit silently never saved. + $exception = new ValidationException(message: 'Cannot modify readOnly property: reference'); + + $response = $this->handler->handleValidationException($exception); + + $this->assertInstanceOf(JSONResponse::class, $response); + $this->assertSame(400, $response->getStatus()); + $data = $response->getData(); + $this->assertSame('error', $data['status']); + // The REASON must survive into the response — that is the whole point + // of the 400, and what the TypeError was destroying. + $this->assertStringContainsString('readOnly', $data['errors'][0]['message']); + $this->assertSame([], $data['errors'][0]['errors']); + } + // ========================================================================= // generateErrorMessage // ========================================================================= diff --git a/tests/demo-e2e/README.md b/tests/demo-e2e/README.md new file mode 100644 index 0000000000..db531e4338 --- /dev/null +++ b/tests/demo-e2e/README.md @@ -0,0 +1,67 @@ +# Demo-environment end-to-end checks + +Validates a running demo environment — the one a `-compose.yaml` brings up — +against the steps its own documentation tells the reader to run. + +## Why it lives outside `tests/e2e/` + +`playwright.config.ts` at the repository root sets `testDir: './tests/e2e'`, so +anything placed there runs in CI. This suite needs an **already booted demo** to +point at, which CI does not have. Put here, CI never collects it, and there is no +skip to misread later: a suite that silently skips in CI looks identical to one +that ran and found nothing. + +## Running it + +Boot a demo first, then point the suite at it: + +```bash +docker compose -f portaliq-compose.yaml up -d + +DEMO_APP=portaliq \ +DEMO_BASE_URL=http://localhost:8613 \ +DEMO_HAS_PORTAL=1 \ +npx playwright test --config tests/demo-e2e/playwright.config.ts +``` + +| Variable | Meaning | +| --- | --- | +| `DEMO_APP` | The app id under test. Default `portaliq`. | +| `DEMO_BASE_URL` | The booted demo. Default `http://localhost:8613`. | +| `DEMO_HAS_PORTAL` | Set to `1` when the demo installs `portaliq`; the two portal tests skip otherwise. | + +`DEMO_HAS_PORTAL` is an explicit opt-in rather than an auto-detect, and that is +deliberate. A suite that decides for itself whether a portal *should* exist +cannot tell "this demo has no portal" from "the portal failed to seed", and +would report the second as a skip. + +## What it asserts, and why none of it is a status code + +Nextcloud serves its page shell before an app decides whether it has anything to +render, so an app URL returns HTTP 200 even when it resolves to nothing at all. +Two measurements from building this suite make the point: + +- **The login page is served with HTTP 200.** Basic auth authenticates + OpenRegister's API routes but not a browser *navigation* — Nextcloud redirects + that to `/login`, which answers 200. A test asserting only on the status code + passes while sitting on the login screen. That is why the page test logs in + properly and then asserts on content and on the resulting URL. +- **An unauthenticated app URL answers 401 on a perfectly healthy demo.** The + demo documentation used to describe exactly that request as a pass. + +So the assertions are: `status.php` reports `installed:true` (an uninstalled +instance also answers 200); OpenRegister returns a non-empty register list (an +empty one means the configuration was never imported, which from outside is +indistinguishable from "nothing configured yet"); the app page renders its own +content; and, where a portal is installed, the portal API names a real site +rather than answering `{"error":"not_found"}` with a 200. + +## It has been shown to fail + +A suite that has only ever passed is not evidence. Both controls were run: + +- pointed at an app that is not installed → the two app-reachability tests fail +- portal assertions forced on against a demo with no portal → both portal tests fail + +Verified green against two independently booted demos: `portaliq` (6 passed) and +`shillinq` (4 passed, 2 correctly skipped). diff --git a/tests/demo-e2e/demo-journey.spec.ts b/tests/demo-e2e/demo-journey.spec.ts new file mode 100644 index 0000000000..d3bb7eef4b --- /dev/null +++ b/tests/demo-e2e/demo-journey.spec.ts @@ -0,0 +1,145 @@ +import { test, expect, request as pwRequest } from '@playwright/test' + +/** + * The demo environment, checked the way its documentation says to check it. + * + * The point of these assertions is that they can fail. Nextcloud serves its + * page shell before an app decides whether it has anything to render, so an + * app URL returns HTTP 200 even when it resolves to nothing at all -- which + * is exactly why none of these assert on a status code alone. + */ + +const APP = process.env.DEMO_APP || 'portaliq' +const BASE = process.env.DEMO_BASE_URL || 'http://localhost:8613' +const HAS_PORTAL = process.env.DEMO_HAS_PORTAL === '1' +// `send: 'always'` is load-bearing. By default Playwright withholds Basic +// credentials until it sees a 401 challenge, and Nextcloud answers an app page +// with a bare 401 carrying no WWW-Authenticate header -- so the retry never +// fires and every authenticated assertion fails against a healthy demo. +const CREDS = { username: 'admin', password: 'admin', send: 'always' as const } + +test.describe(`demo environment: ${APP}`, () => { + test('Nextcloud reports itself installed, not merely reachable', async () => { + const api = await pwRequest.newContext({ baseURL: BASE }) + const res = await api.get('/status.php') + expect(res.status()).toBe(200) + + const body = await res.json() + // `installed:false` also returns 200. The flag is the assertion, not the code. + expect(body.installed).toBe(true) + expect(body.maintenance).toBe(false) + expect(body.needsDbUpgrade).toBe(false) + await api.dispose() + }) + + test('OpenRegister has registers, so the app has something to attach to', async () => { + const api = await pwRequest.newContext({ + baseURL: BASE, + httpCredentials: CREDS, + }) + const res = await api.get('/apps/openregister/api/registers') + expect(res.status()).toBe(200) + + const body = await res.json() + const rows = body.results ?? body + expect(Array.isArray(rows)).toBe(true) + + // An empty list here is the failure this whole demo exists to catch: it + // means the register configuration was never imported, which from outside + // is indistinguishable from "nothing configured yet". + expect(rows.length).toBeGreaterThan(0) + + // A register with no slug is a row that exists but resolves to nothing. + for (const r of rows.slice(0, 5)) { + expect(r.slug ?? r.title).toBeTruthy() + } + await api.dispose() + }) + + test('the app page renders its own UI, not just a Nextcloud shell', async ({ + browser, + }) => { + // Basic auth is NOT enough for a page navigation. Nextcloud accepts it on + // API routes but redirects a browser navigation to /login -- and serves + // that login page with HTTP 200. A test asserting only on the status code + // would pass while sitting on the login screen. Measured here, which is + // why this logs in properly and then asserts on content. + const ctx = await browser.newContext() + const page = await ctx.newPage() + + await page.goto(`${BASE}/login`) + await page + .getByRole('textbox', { name: /account name or email/i }) + .fill(CREDS.username) + await page.getByRole('textbox', { name: /^password$/i }).fill(CREDS.password) + // `exact` matters: "Log in with a device" also matches a loose /log in/. + await page.getByRole('button', { name: 'Log in', exact: true }).click() + await page.waitForURL((u) => !u.pathname.startsWith('/login'), { + timeout: 30_000, + }) + + const entry = + APP === 'thematiq' ? '/settings/admin/theming' : `/apps/${APP}/` + const res = await page.goto(`${BASE}${entry}`) + expect(res?.status()).toBe(200) + + // The login page is also 200, so prove we are not on it. + expect(page.url()).not.toContain('/login') + + // The shell alone would satisfy a status check. Require app content. + await expect( + page.locator('#content, #app-content, .app-content').first(), + ).toBeVisible({ timeout: 30_000 }) + + const text = (await page.locator('body').innerText()).trim() + expect(text.length).toBeGreaterThan(50) + // A Nextcloud error page also returns 200 in some configurations. + expect(text).not.toMatch( + /Internal Server Error|not installed|Page not found/i, + ) + await ctx.close() + }) + + test('the app is enabled and reachable under its own id', async () => { + const api = await pwRequest.newContext({ + baseURL: BASE, + httpCredentials: CREDS, + }) + const entry = + APP === 'thematiq' ? '/settings/admin/theming' : `/apps/${APP}/` + const res = await api.get(entry, { maxRedirects: 5 }) + + // Unauthenticated this is 401 on a perfectly healthy demo -- the defect + // the documentation used to describe as a pass. + expect(res.status()).toBe(200) + await api.dispose() + }) + + test('the public portal resolves to a real site', async () => { + test.skip(!HAS_PORTAL, 'this demo does not install portaliq') + + const api = await pwRequest.newContext({ baseURL: BASE }) + const res = await api.get('/apps/portaliq/api/content/site?portal=demo') + expect(res.status()).toBe(200) + + const body = await res.json() + // Portaliq answers {"error":"not_found"} with a 200 when the slug resolves + // to nothing, so the body is the assertion. + expect(body.error).toBeUndefined() + expect(body.slug).toBe('demo') + expect(body.title).toBeTruthy() + await api.dispose() + }) + + test('the portal page is served without a login', async ({ page }) => { + test.skip(!HAS_PORTAL, 'this demo does not install portaliq') + + const res = await page.goto(`${BASE}/apps/portaliq/site?portal=demo`) + expect(res?.status()).toBe(200) + + const text = (await page.locator('body').innerText()).trim() + expect(text.length).toBeGreaterThan(50) + // Reaching the login form means the portal did not resolve as public. + expect(text).not.toMatch(/Log in|Wachtwoord vergeten/i) + }) +}) diff --git a/tests/demo-e2e/docs-sites.spec.ts b/tests/demo-e2e/docs-sites.spec.ts new file mode 100644 index 0000000000..a09ad30237 --- /dev/null +++ b/tests/demo-e2e/docs-sites.spec.ts @@ -0,0 +1,104 @@ +import { test, expect, request as pwRequest } from '@playwright/test' + +/** + * The fleet's documentation sites, checked in a real browser. + * + * The curl sweep that drove the rollout asserts status codes and redirect + * targets. That is necessary and not sufficient: a docs site can answer 200 + * with a blank shell, a build error page, or a Docusaurus 404 route -- all of + * which are 200s. So this renders the pages and asserts on their content. + */ + +const APPS = [ + 'openregister', + 'opencatalogi', + 'integriq', + 'filinq', + 'thematiq', + 'launchpad', + 'stackiq', + 'larpinq', + 'zaakafhandelapp', + 'dossiq', + 'pipelinq', + 'shillinq', + 'learniq', + 'portaliq', + 'decidiq', + 'buildiq', + 'keepiq', + 'hermiq', + 'humaniq', + 'versioniq', + 'planninq', +] + +// retired hostname -> canonical app +const RENAMED: Record = { + openconnector: 'integriq', + docudesk: 'filinq', + nldesign: 'thematiq', + softwarecatalog: 'stackiq', + larpingapp: 'larpinq', + procest: 'dossiq', + decidesk: 'decidiq', + openbuild: 'buildiq', + doriath: 'keepiq', + hrmq: 'humaniq', + 'app-versions': 'versioniq', + planix: 'planninq', + scholiq: 'learniq', +} + +test.describe('docs sites render', () => { + for (const app of APPS) { + test(`${app}: the demo setup page renders real content`, async ({ + page, + }) => { + const url = `https://${app}.conduction.nl/docs/Installation/demo-environment/` + const res = await page.goto(url, { waitUntil: 'domcontentloaded' }) + expect(res?.status()).toBe(200) + + // A Docusaurus 404 route also answers 200, so assert we are not on one. + const body = (await page.locator('body').innerText()).trim() + expect(body).not.toMatch( + /Page Not Found|We could not find what you were looking for/i, + ) + + // The page must carry its own heading and the compose file's name -- + // content that only the real page has. + await expect(page.locator('article, main').first()).toBeVisible() + expect(body).toMatch(/Run a local demo/i) + expect(body).toContain(`${app}-compose.yaml`) + }) + } +}) + +test.describe('retired hostnames redirect', () => { + for (const [old, canonical] of Object.entries(RENAMED)) { + test(`${old} -> ${canonical}, in a browser`, async ({ page }) => { + // Follow the redirect the way a reader's browser would, and assert on + // where it LANDS rather than on the 301 alone. + await page.goto(`https://${old}.conduction.nl/`, { + waitUntil: 'domcontentloaded', + }) + expect(new URL(page.url()).hostname).toBe(`${canonical}.conduction.nl`) + + const body = (await page.locator('body').innerText()).trim() + expect(body.length).toBeGreaterThan(50) + }) + + test(`${old}: a deep link keeps its path and query`, async () => { + const api = await pwRequest.newContext() + const path = '/docs/Installation/demo-environment/?q=1' + const res = await api.get(`https://${old}.conduction.nl${path}`, { + maxRedirects: 0, + }) + expect(res.status()).toBe(301) + expect(res.headers()['location']).toBe( + `https://${canonical}.conduction.nl${path}`, + ) + await api.dispose() + }) + } +}) diff --git a/tests/demo-e2e/playwright.config.ts b/tests/demo-e2e/playwright.config.ts new file mode 100644 index 0000000000..063fda5c10 --- /dev/null +++ b/tests/demo-e2e/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from '@playwright/test' + +/** + * Validates a generated per-app demo environment against the steps its own + * documentation tells the reader to run. + * + * It is pointed at an ALREADY BOOTED demo via DEMO_BASE_URL rather than + * starting one itself. A spec that boots its own fixture proves the fixture + * works; this one has to prove the documented instructions work. + */ +export default defineConfig({ + testDir: '.', + timeout: 60_000, + expect: { timeout: 15_000 }, + reporter: [['list']], + use: { + baseURL: process.env.DEMO_BASE_URL || 'http://localhost:8613', + ignoreHTTPSErrors: true, + }, +}) diff --git a/tests/e2e/ci/flow-controls.spec.ts b/tests/e2e/ci/flow-controls.spec.ts index db85f64f5b..7efa83c50d 100644 --- a/tests/e2e/ci/flow-controls.spec.ts +++ b/tests/e2e/ci/flow-controls.spec.ts @@ -86,6 +86,14 @@ * the app calls VALID (one terminal node) instead of one it is obliged to * refuse. See step 2. * + * ⚠️ THAT TABLE IS HISTORY, NOT A LIVE REPRODUCTION. It describes the DEAD-END + * 500 fixed above, and it has already been read once as though it explained a + * later, unrelated 500 — sending the reader looking for something the run path + * does per NODE. It does not: the second 500 was `FlowLifecycleRefused` + * escaping `FlowController::run()` because a freshly created flow is a DRAFT + * and a draft backs no run. Node count had nothing to do with it. Read this + * table as a record of what step 2 fixed, and nothing else. + * * So this spec now waits for the state a save actually REQUIRES rather than for * a wall clock, and asserts the create and run RESPONSES rather than inferring * success from a route and a poll. The three 15-20s budgets it used to sit out @@ -503,7 +511,53 @@ test('flow controls render, and a flow can be built, saved and run', async ({ timeout: 5_000, }) - // 4. RUN NOW CREATES A RUN. Asserted against the API rather than the + // 4. PUBLISH. A DRAFT BACKS NO RUN — this is a step in the journey, not + // a workaround for one. + // + // `flow-definition-versioning` made a flow's graph a versioned document, + // and a flow is CREATED as a draft: "A run SHALL be queued against the + // flow's `published` version. A `draft` or `deprecated` version SHALL + // NOT back a newly queued run." So from that change onward the author's + // journey is build → save → PUBLISH → run, and a spec that skipped the + // third step was asserting a contract the app deliberately no longer + // offers. + // + // It surfaced as `POST .../run` answering 500, because + // `FlowLifecycleRefused` escaped `FlowController::run()` unhandled. That + // half is a real defect and is fixed separately — the refusal is now a + // 409 naming `no-published-version`. But 409 is not 201 either: making + // this spec pass by relaxing step 5 to "409 is fine" would delete the + // only end-to-end proof that a flow can actually be RUN from the editor. + // + // The opposite shortcut — letting `/run` quietly fall back to a draft + // test run — was considered and rejected: `tests/e2e/flow-engine.spec.ts` + // asserts in two places that this exact endpoint refuses a draft AND a + // deprecated flow with `no-published-version`, and silently running a + // retired process is a worse defect than the one being fixed. + // + // So publish, through the editor's own control, the way an author does. + // Asserted on the BADGE rather than on the click, for the reason the + // lifecycle specs already give: a button that posts and silently fails + // looks exactly like one that worked, and the badge only reads + // "Published" once the store has re-read the flow from the server. + const publishButton = page.locator('[data-testid="flow-publish"]') + await expect( + publishButton, + 'the editor offers no Publish control, so a flow built here can never ' + + 'be run: a draft backs no run, and publishing is the only thing ' + + 'that changes that. Needs @conduction/nextcloud-vue >= 2.24.0.', + ).toBeVisible({ timeout: 10_000 }) + + await clickThemed(publishButton) + + await expect( + page.locator('[data-testid="flow-lifecycle"]'), + 'Publish was pressed but the flow never became published — the store ' + + 'still shows the draft it started as, so the POST was rejected or ' + + 'swallowed', + ).toHaveText('Published', { timeout: 10_000 }) + + // 5. RUN NOW CREATES A RUN. Asserted against the API rather than the // rendered status, because the status a run is DISPLAYED with // depends on whether the worker has reached it yet. const queued = page.waitForResponse( diff --git a/tests/e2e/ci/playwright.config.ts b/tests/e2e/ci/playwright.config.ts index 22387a8cb8..659fe1414e 100644 --- a/tests/e2e/ci/playwright.config.ts +++ b/tests/e2e/ci/playwright.config.ts @@ -194,6 +194,37 @@ export default defineConfig({ // and "update" audit entries EXIST unconditionally, so a broken audit // trail fails loudly before this skip is ever reachable. 'workflows/object-lifecycle-workflows.spec.ts', + + // Admitted 2026-08-29. The ADR-111 demo-data step, which had no coverage + // here at all: this file shipped to development with the setup wizard and + // never ran, because nothing runs unless it is named in this list. It is + // the only check that the wizard's install WRITES something — the defect + // this programme already shipped once was an import that reported success + // and seeded zero objects, which every mocked unit test passed. + // + // Checked per criterion: + // 1. Hermetic — it seeds nothing beforehand and depends on no + // pre-seeded data. It drives the app's own documented setup + // contract (`/api/setup/status`, `/api/setup/action/{id}`) from + // inside the authenticated admin page, which is also the only + // vantage point that can show the AuthorizedAdminSetting middleware + // admitting a real session. + // 2. Self-cleaning — it takes this criterion's SECOND branch. The + // install is real and creates the eight demo registers, so its + // `afterAll` deletes them, resolved by SLUG so an aborted run still + // tears down. Without that they would land in the lists the specs + // above assert on, which is precisely why the CI seed settles the + // demo-data decision as *skipped* rather than installing it. + // 3. No conditional-assert guards — zero `.catch(() => false)` in the + // assertions. The only `.catch()` calls are in the teardown, where + // a failed cleanup must not fail a passing run. + // 4. No `test.skip` at all, so nothing here is skippable on a healthy + // instance. + // + // ⚠️ It carries `test.slow()` on the install arm deliberately: that arm + // is a REAL import, measured at 42.8s on dossiq and 49.6s on shillinq, + // and it exceeded the 30s default on one run before the annotation. + 'spec-coverage/demo-data-setup-step.spec.ts', ], globalSetup: path.resolve(__dirname, '../global-setup.ts'), timeout: 45_000, diff --git a/tests/e2e/ci/seed.sh b/tests/e2e/ci/seed.sh index 47cff955ad..4f6966f9df 100755 --- a/tests/e2e/ci/seed.sh +++ b/tests/e2e/ci/seed.sh @@ -65,4 +65,50 @@ else echo "e2e-other already in $GROUP (or could not be added) — the specs will verify" fi +# NEXTCLOUD'S OWN FIRST-RUN WIZARD BLOCKS THE ACCOUNTS THIS SCRIPT JUST MADE. +# +# `firstrunwizard` opens a modal on a new account's first page load -- the +# "A collaboration platform that puts you in control" panel. Its mask sits over +# the app, so every click the specs make is swallowed. It is not the app's own +# setup wizard, and the specs' Escape-based dismissal does not close it: +# object-shares-tab reported `a modal is still covering the page after three +# Escapes`, and flow-controls reported the connection never reaching the canvas +# -- two unrelated-looking failures with one cause. +# +# The accounts above are created fresh every time, so they always meet it. The +# app is disabled instance-wide rather than per-user because there is no +# per-user "mark seen" that occ exposes, and a test instance has no use for it. +if php occ app:disable firstrunwizard; then + echo 'disabled firstrunwizard' +else + echo 'firstrunwizard already disabled (or not installed)' +fi + echo 'e2e seed complete' + +# ── Settle the demo-data decision ──────────────────────────────────────────── +# 🔴 OR THE SETUP WIZARD MASKS EVERY CLICK. ADR-111 added an OPTIONAL +# `demo-data` step, and CnAppRoot opens the non-gating wizard as a full modal +# mask while ANY optional step that is not info/summary is reported not-done — +# in every fresh browser context, so once per spec. +# +# Measured on this app's development at the ADR-111 merge: clicks failed with +# "locator resolved to