diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 00000000..f5f4f071
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,36 @@
+# CODEOWNERS — auto-request reviewers based on path domain.
+# Last-match-wins; broad rules first, specific overrides below.
+# Created 2026-05-03 from the OR-abstraction audit follow-up.
+
+# Default: every named codeowner reviews unmatched paths.
+* @rubenvdlinde @rjzondervan @Rem-Dam @remko48 @WilcoLouwerse @bbrands02 @SudoThijn
+
+# Backend (PHP) — services, controllers, mappers, db, migration, etc.
+lib/ @bbrands02 @rjzondervan @WilcoLouwerse
+appinfo/ @bbrands02 @rjzondervan @WilcoLouwerse
+**/*.php @bbrands02 @rjzondervan @WilcoLouwerse
+phpcs.xml @bbrands02 @rjzondervan @WilcoLouwerse
+phpmd.xml @bbrands02 @rjzondervan @WilcoLouwerse
+phpstan.neon @bbrands02 @rjzondervan @WilcoLouwerse
+phpstan-baseline.neon @bbrands02 @rjzondervan @WilcoLouwerse
+phpmd.baseline.xml @bbrands02 @rjzondervan @WilcoLouwerse
+composer.json @bbrands02 @rjzondervan @WilcoLouwerse
+composer.lock @bbrands02 @rjzondervan @WilcoLouwerse
+
+# Frontend (Vue / TS / JS) — components, stores, pages, build config.
+src/ @SudoThijn @remko48
+**/*.vue @SudoThijn @remko48
+**/*.ts @SudoThijn @remko48
+**/*.js @SudoThijn @remko48
+package.json @SudoThijn @remko48
+package-lock.json @SudoThijn @remko48
+jest.config.js @SudoThijn @remko48
+playwright.config.ts @SudoThijn @remko48
+webpack.config.js @SudoThijn @remko48
+babel.config.js @SudoThijn @remko48
+
+# Specs / docs / ADRs / openspec.
+openspec/ @rubenvdlinde @Rem-Dam
+docs/ @rubenvdlinde @Rem-Dam
+**/*.md @rubenvdlinde @Rem-Dam
+README.md @rubenvdlinde @Rem-Dam
diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index e5f30ff5..d03e8bfe 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -18,3 +18,21 @@ jobs:
enable-phpmetrics: true
enable-frontend: true
enable-eslint: true
+ # Hard gates:
+ # - PHPUnit — wired in wave3.10. 4-way matrix (PHP 8.3/8.4 ×
+ # NC stable31/stable32) all green.
+ # - Newman — wired alongside the postman collection drift
+ # sweep (PR #123 follow-up): six real backend bugs fixed
+ # (Templates SQL, Feeds 2× constraint, addWidget TypeError,
+ # Lock-on-nonexistent, share TypeError, addRule TypeError),
+ # postman fixture-id wiring repaired and assertion drift
+ # resolved. 196 assertions / 0 failures locally.
+ #
+ # Playwright remains disabled — the shared workflow's
+ # PHP-built-in-server lifetime is the blocker (cross-repo PR
+ # `ConductionNL/.github#37`). The `tests/e2e/global-setup.ts`
+ # `ensureBundleBuilt()` helper is in place so once the shared
+ # workflow lands, flipping the gate Just Works.
+ enable-phpunit: true
+ enable-newman: true
+ newman-environment-path: "tests/integration/local.env.json"
diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
index cf65b43c..2277de36 100644
--- a/.github/workflows/documentation.yml
+++ b/.github/workflows/documentation.yml
@@ -10,4 +10,4 @@ jobs:
deploy:
uses: ConductionNL/.github/.github/workflows/documentation.yml@main
with:
- cname: mydash.app
+ cname: mydash.conduction.nl
diff --git a/.gitignore b/.gitignore
index 311f7857..da994211 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,6 +45,12 @@ Thumbs.db
/phpqa/
/quality-reports/
+# Playwright e2e
+/tests/e2e/.auth/
+/test-results/
+/playwright-report/
+/playwright/.cache/
+
# PHP
/composer.phar
@@ -60,3 +66,8 @@ Thumbs.db
sbom.cdx.json
bom-php.cdx.json
bom-npm.cdx.json
+
+# Research notes (local-only)
+/docs/sendent-analysis.md
+/sendent-workspace-main/
+/2026.*_sendent-workspace-main.zip
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 38680434..7aace7ac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,17 +6,69 @@ All notable changes to this project will be documented in this file.
### Added
-- **Initial-state contract** (REQ-INIT-001..REQ-INIT-005). Workspace and admin
- pages now route every initial-state key through the typed PHP service
- `OCA\MyDash\Service\InitialStateBuilder` (with a `Page` enum and required-key
- enforcement via `MissingInitialStateException`). The mirrored typed JS reader
- `src/utils/loadInitialState.js` returns a default-filled object and warns when
- `INITIAL_STATE_SCHEMA_VERSION` (currently `1`) drifts between server and
- client. To add a key: update the spec Data Model (REQ-INIT-002), bump
- `INITIAL_STATE_SCHEMA_VERSION` in both PHP and JS, and add the setter +
- reader entry in the same commit. Direct calls to
- `IInitialState::provideInitialState` from controllers / settings, and direct
- `loadState('mydash', ...)` calls from `src/`, are forbidden by lint.
+- **Per-user RSS / Atom dashboard feeds** (REQ-FEED-001..009): users can
+ now opt-in to a personal feed of their accessible dashboards via
+ `GET /api/feed/token`. New routes `POST /api/feed/token/regenerate`
+ (atomic rotate), `DELETE /api/feed/token` (idempotent soft-revoke),
+ and the public `GET /feed/{token}.xml` (no Nextcloud auth — gated
+ only by the opaque token). Tokens are 32 random bytes encoded
+ URL-safe base64 (~43 chars, 256-bit entropy, non-enumerable). Feed
+ output is RSS 2.0 by default; `Accept: application/atom+xml` (or the
+ `?format=atom` query fallback) switches to Atom 1.0. Visibility
+ reuses `DashboardService::getVisibleToUser()` so private dashboards
+ never leak to public feed consumers; item count is capped at the
+ admin-tunable `mydash.feed_item_cap` (default 50). New
+ `oc_mydash_feed_tokens` table with `UNIQUE(user_id)` enforces the
+ one-token-per-user rotation invariant.
+
+### Changed
+
+- **GridStack bumped from `^10.3.1` to `^12.2.1`** (resolved 12.6.0). Major
+ version bump bundled with the responsive-grid-breakpoints change. The
+ `GridStack.init` signature, the `change` event payload, the
+ `engine.nodes` accessor, the `removeWidget(el, removeDOM)` call, and the
+ `enable()`/`disable()` lifecycle methods used by `DashboardGrid.vue` are
+ unchanged across the v10 -> v12 jump, so no caller-side breakage was
+ observed during the bump. Downstream forks pinning a narrower
+ `gridstack` range will need to widen their dependency.
+
+### Added
+
+- **Responsive grid breakpoints** (REQ-GRID-007 / REQ-GRID-012 /
+ REQ-GRID-013): the GridStack instance now reflows proportionally at four
+ viewport widths instead of staying fixed-12-column on narrow screens.
+ Breakpoints `[{w:1400,c:12},{w:1100,c:8},{w:768,c:4},{w:480,c:1}]` with
+ the `moveScale` layout algorithm. Geometry constants (`CELL_HEIGHT = 60`,
+ `GRID_MARGIN = 8`, `BREAKPOINTS`) live in
+ `src/composables/useGridManager.js` as the single source of truth and
+ are mirrored to the CSS custom property `--mydash-cell-height` at
+ init time so `calc()` expressions stay in sync. Cell height moved from
+ the previously documented 80 px to 60 px to better support multi-row
+ info widgets; flip the `CELL_HEIGHT` constant in the composable (single
+ edit) if a denser/looser default is preferred.
+
+- **Initial-state contract** (REQ-INIT-001..006): `lib/Service/InitialStateBuilder.php`
+ centralises the per-page initial-state payload pushed via Nextcloud's
+ `IInitialState` service. The matching JS reader at
+ `src/utils/loadInitialState.js` returns a typed default-filled object for
+ the workspace and admin pages. Both sides stamp / validate a
+ `_schemaVersion` constant; deploy skew between PHP and JS surfaces as a
+ console warning at runtime.
+
+ Adding, removing, or renaming a key requires four coordinated edits in
+ the same commit:
+ 1. update the spec Data Model in
+ `openspec/specs/initial-state-contract/spec.md`,
+ 2. bump `INITIAL_STATE_SCHEMA_VERSION` in
+ `lib/Service/InitialStateBuilder.php` AND
+ `src/utils/loadInitialState.js`,
+ 3. add (or remove) the typed setter in the PHP builder and the matching
+ entry in the JS reader's `PAGE_KEYS` table,
+ 4. update the controller(s) that call the builder.
+
+ CI guards (`composer lint:initial-state`, `npm run lint:initial-state`)
+ forbid direct `IInitialState::provideInitialState()` calls outside the
+ builder and direct `loadState('mydash', ...)` calls outside the reader.
## 0.1.0 - Initial Release
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 2a068c38..6ecd5853 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -55,16 +55,26 @@ npm start # Dev server at http://localhost:3000 with hot reload
Simply add or edit Markdown files in the `docs/` folder. The sidebar is auto-generated from the folder structure. Changes will appear on the product page after pushing to `development`.
-## Security review checkboxes
+## Adding a widget type
-### Extending the SVG whitelist
+The widget registry (`src/constants/widgetRegistry.js`) is the single source of truth for "addable" custom widget types (REQ-WDG-014). Because many widget capabilities ship in parallel branches that all touch the same object, the file is a heavy merge-conflict site -- a regression that silently drops a type would silently disappear from the Add Custom Widget picker without any test signal. To add a new widget type:
-`lib/Service/SvgSanitiser.php` ships a deliberately conservative whitelist of 24 element types and 50 attribute types — see `ALLOWED_ELEMENTS` and `ALLOWED_ATTRIBUTES` (REQ-RES-010 / REQ-RES-011 in the `resource-uploads` capability).
+- Update `src/constants/widgetRegistry.js` with the new entry (`renderer`, `form`, `defaultContent`, `displayName`, `icon`).
+- Update `EXPECTED_TYPES` in `src/constants/__tests__/widgetRegistry.completeness.spec.js` (REQ-WDG-023) so the completeness guard stays in sync. The test will fail with a precise diff if you forget.
+- Implement the renderer + form Vue components following the pattern of an existing widget (e.g. `LabelWidget.vue` + `LabelForm.vue`).
+- Add i18n keys for the type's `displayName` and any user-facing form labels to all four `l10n/` files (`en.js`, `en.json`, `nl.js`, `nl.json`).
+- Add the canonical capability spec under `openspec/specs//spec.md` so the type's REQ-* identifiers, defaults, and acceptance scenarios are documented.
-Adding any element or attribute to either constant is a **security review checkbox**, not an editorial change. Before merging an addition, confirm:
+## Security review checklist
-- the element / attribute carries no executable surface (no script, no event handler, no foreign content, no URL fetch outside the existing `href` filter);
-- the addition is justified by a real upload that is currently being rejected, not a speculative future need;
-- a corresponding unit test covers the new whitelist entry.
+### Extending the SVG sanitiser whitelist
-The sanitiser runs server-side BEFORE the size cap (REQ-RES-009), so an over-permissive whitelist becomes stored XSS the moment a sanitised-looking SVG is rendered back into a logged-in user's browser.
+`lib/Service/SvgSanitiser.php` enforces a deliberately conservative whitelist of allowed SVG element and attribute names (REQ-RES-010 / REQ-RES-011 in `openspec/specs/resource-uploads/spec.md`). Every uploaded SVG is parsed via `DOMDocument` with `LIBXML_NONET | LIBXML_NOENT` and the resulting tree is filtered against `ALLOWED_ELEMENTS` and `ALLOWED_ATTRIBUTES`. Anything not on those lists is removed before persistence, and the persisted bytes (NOT the original) are what subsequently get served back to other users' browsers.
+
+If you propose adding a new element name to `ALLOWED_ELEMENTS` or a new attribute to `ALLOWED_ATTRIBUTES`:
+
+- A security review is required before merge (XSS surface change).
+- Verify the new element / attribute cannot carry executable payloads in any browser SVG renderer (e.g. `` `attributeName` injection, `` event triggering, etc.).
+- Add a PHPUnit scenario covering the new surface, plus a negative scenario showing that a known-bad construct involving the new name is still rejected.
+- Update REQ-RES-010 / REQ-RES-011 in the canonical spec to reflect the new whitelist size and add the element / attribute name explicitly.
+- The sanitiser runs server-side BEFORE the size cap (REQ-RES-009), so an over-permissive whitelist becomes stored XSS the moment a sanitised-looking SVG is rendered back into a logged-in user's browser.
diff --git a/README.md b/README.md
index 9795da66..c366e972 100644
--- a/README.md
+++ b/README.md
@@ -12,13 +12,15 @@
-
+
---
MyDash supercharges the Nextcloud dashboard. Create multiple personalized workspaces with drag-and-drop widgets, custom shortcut tiles, and smart visibility rules — then let admins roll out templates to entire teams. It works with every existing Nextcloud dashboard widget out of the box, supporting both the v1 and v2 Dashboard APIs.
+📚 **[Step-by-step tutorials](https://mydash.conduction.nl/docs/category/tutorials)** — user + admin walkthroughs with screenshots, kept in sync with the live UI via the [journeydoc](https://github.com/ConductionNL/hydra/blob/development/openspec/architecture/adr-030-journeydoc-pattern.md) capture spec.
+
## Screenshots
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 42679400..6c5e306e 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -46,7 +46,7 @@ Ideaal voor organisaties die consistente, samengestelde dashboards willen voor h
Vrij en open source onder de EUPL-1.2-licentie.
]]>
- 1.0.3-unstable.4
+ 1.0.4-unstable.0
EUPL-1.2
Conduction
MyDash
@@ -71,11 +71,45 @@ Vrij en open source onder de EUPL-1.2-licentie.
+
+
+
+
+
+
+ OCA\MyDash\BackgroundJob\OrphanedDataCleanupJob
+
+
OCA\MyDash\Settings\MyDashAdmin
OCA\MyDash\Settings\MyDashAdminSection
+
+
+ OCA\MyDash\Command\ExportCommand
+ OCA\MyDash\Command\ImportCommand
+
+ OCA\MyDash\Command\ImportConfluenceCommand
+
+ OCA\MyDash\Command\DashboardListCommand
+ OCA\MyDash\Command\DashboardShowCommand
+ OCA\MyDash\Command\DashboardDeleteCommand
+ OCA\MyDash\Command\DashboardDebugShareCommand
+ OCA\MyDash\Command\UserRevokeFeedTokenCommand
+ OCA\MyDash\Command\I18nExportStringsCommand
+ OCA\MyDash\Command\I18nMigrateLanguageStructureCommand
+ OCA\MyDash\Command\I18nCopyNavigationCommand
+
+ OCA\MyDash\Command\CleanupScanCommand
+ OCA\MyDash\Command\CleanupPurgeCommand
+
+ OCA\MyDash\Command\DemoShowcasesInstallCommand
+ OCA\MyDash\Command\DemoShowcasesListCommand
+
+ OCA\MyDash\Command\SetupCommand
+
+
MyDash
@@ -84,4 +118,11 @@ Vrij en open source onder de EUPL-1.2-licentie.
-5
+
+
+
+ OCA\MyDash\Activity\Extension
+
diff --git a/appinfo/routes.php b/appinfo/routes.php
index 588d474b..b899e7bf 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -16,58 +16,205 @@
// Main page
['name' => 'page#index', 'url' => '/', 'verb' => 'GET'],
- // User dashboard endpoints
+ // User dashboard endpoints (REQ-DASH-002..010).
+ // NOTE: specific routes (`/visible`, `/group/...`, `/active`, `/{uuid}/fork`) MUST precede the
+ // wildcard `{id}` routes — Symfony matches the first that fits and
+ // would otherwise route them to the personal `getById` handler.
['name' => 'dashboard_api#list', 'url' => '/api/dashboards', 'verb' => 'GET'],
+ // Visible-to-user resolution endpoint (REQ-DASH-013).
['name' => 'dashboard_api#visible', 'url' => '/api/dashboards/visible', 'verb' => 'GET'],
// REQ-DASH-019: persist active-dashboard preference. Registered BEFORE
// the group-scoped routes that share the /api/dashboards/ prefix so the
// router matches the literal 'active' segment before any {groupId} wildcard.
['name' => 'dashboard_api#setActiveDashboard', 'url' => '/api/dashboards/active', 'verb' => 'POST'],
+ // Wave3.7: explicit default-dashboard pin. Distinct from the
+ // `active` pref above which auto-overwrites on every switch.
+ ['name' => 'dashboard_api#setDefaultDashboard', 'url' => '/api/dashboards/default', 'verb' => 'POST'],
+ ['name' => 'dashboard_api#getDefaultDashboard', 'url' => '/api/dashboards/default', 'verb' => 'GET'],
// REQ-DASH-020..022: fork a visible dashboard as a personal copy.
// Registered BEFORE the group-scoped {groupId} wildcard routes to
// prevent the literal 'fork' suffix being consumed by any wildcard.
['name' => 'dashboard_api#fork', 'url' => '/api/dashboards/{uuid}/fork', 'verb' => 'POST',
'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
- ['name' => 'dashboard_api#getActive', 'url' => '/api/dashboard', 'verb' => 'GET'],
- ['name' => 'dashboard_api#create', 'url' => '/api/dashboard', 'verb' => 'POST'],
- ['name' => 'dashboard_api#update', 'url' => '/api/dashboard/{id}', 'verb' => 'PUT'],
- ['name' => 'dashboard_api#delete', 'url' => '/api/dashboard/{id}', 'verb' => 'DELETE'],
- ['name' => 'dashboard_api#activate', 'url' => '/api/dashboard/{id}/activate', 'verb' => 'POST'],
+ // REQ-DASH-032..034: publication-state actions on a single dashboard.
+ // Registered alongside `fork` so the literal `publish` / `unpublish` /
+ // `schedule` suffixes precede the group-scoped `{groupId}` wildcards.
+ ['name' => 'dashboard_api#publish', 'url' => '/api/dashboards/{uuid}/publish', 'verb' => 'POST',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ ['name' => 'dashboard_api#unpublish', 'url' => '/api/dashboards/{uuid}/unpublish', 'verb' => 'POST',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ ['name' => 'dashboard_api#schedule', 'url' => '/api/dashboards/{uuid}/schedule', 'verb' => 'POST',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ // REQ-DASH-038..044: per-language dashboard content variants.
+ // All routes are anchored under `/api/dashboards/{uuid}/translations`
+ // and registered BEFORE the wildcard / group-scoped routes so the
+ // literal `translations` segment is never consumed by a wildcard.
+ ['name' => 'dashboard_translation_api#list',
+ 'url' => '/api/dashboards/{uuid}/translations', 'verb' => 'GET',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ ['name' => 'dashboard_translation_api#create',
+ 'url' => '/api/dashboards/{uuid}/translations', 'verb' => 'POST',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ ['name' => 'dashboard_translation_api#update',
+ 'url' => '/api/dashboards/{uuid}/translations/{lang}', 'verb' => 'PUT',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+', 'lang' => '[A-Za-z0-9_\-]+']],
+ ['name' => 'dashboard_translation_api#destroy',
+ 'url' => '/api/dashboards/{uuid}/translations/{lang}', 'verb' => 'DELETE',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+', 'lang' => '[A-Za-z0-9_\-]+']],
+ ['name' => 'dashboard_translation_api#setPrimary',
+ 'url' => '/api/dashboards/{uuid}/translations/{lang}/set-primary', 'verb' => 'POST',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+', 'lang' => '[A-Za-z0-9_\-]+']],
+ // Read-side resolver — returns the dashboard payload plus the
+ // matched translation envelope (REQ-DASH-039). Optional `?lang=`
+ // query parameter overrides the user's Nextcloud locale.
+ ['name' => 'dashboard_translation_api#resolved',
+ 'url' => '/api/dashboards/{uuid}/resolved', 'verb' => 'GET',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
- // Group-shared dashboard endpoints (REQ-DASH-014). The groupId
- // path parameter accepts any valid Nextcloud group ID plus the
- // reserved 'default' sentinel (REQ-DASH-012).
+ // REQ-ANLT-002: record a dashboard view event. Authed users only;
+ // the controller short-circuits silently when the user has opted
+ // out (REQ-ANLT-004) or analytics is globally disabled
+ // (REQ-ANLT-005). Returns HTTP 204 on success, 404 when the
+ // dashboard does not exist.
+ ['name' => 'dashboard_api#viewEvent', 'url' => '/api/dashboards/{uuid}/view-event', 'verb' => 'POST',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ // REQ-DASH-026: nested dashboard tree.
+ ['name' => 'dashboard_api#tree', 'url' => '/api/dashboards/tree', 'verb' => 'GET'],
+ // REQ-DASH-027: slug-chain path resolution. The {path} placeholder
+ // allows slashes so `/marketing/campaigns/q1` resolves verbatim.
+ ['name' => 'dashboard_api#byPath', 'url' => '/api/dashboards/by-path/{path}', 'verb' => 'GET',
+ 'requirements' => ['path' => '.+']],
+
+ // Dashboard comments endpoints (REQ-CMNT-001..009). Threaded
+ // comments backed by Nextcloud's `ICommentsManager` with
+ // object type `mydash_dashboard`. The literal `/comments`
+ // segment disambiguates from the `{groupId}` wildcard below
+ // — both share the `/api/dashboards/{uuid}/...` prefix.
+ ['name' => 'dashboard_comments_api#index',
+ 'url' => '/api/dashboards/{uuid}/comments', 'verb' => 'GET',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ ['name' => 'dashboard_comments_api#create',
+ 'url' => '/api/dashboards/{uuid}/comments', 'verb' => 'POST',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ ['name' => 'dashboard_comments_api#update',
+ 'url' => '/api/dashboards/{uuid}/comments/{id}', 'verb' => 'PUT',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+', 'id' => '\d+']],
+ ['name' => 'dashboard_comments_api#destroy',
+ 'url' => '/api/dashboards/{uuid}/comments/{id}', 'verb' => 'DELETE',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+', 'id' => '\d+']],
+
+ // Group-shared dashboard CRUD (REQ-DASH-014). All five routes are
+ // scoped to a single `groupId` (real Nextcloud group id or the
+ // reserved literal `default`).
['name' => 'dashboard_api#listGroup', 'url' => '/api/dashboards/group/{groupId}', 'verb' => 'GET',
'requirements' => ['groupId' => '[^/]+']],
['name' => 'dashboard_api#createGroup', 'url' => '/api/dashboards/group/{groupId}', 'verb' => 'POST',
'requirements' => ['groupId' => '[^/]+']],
+ // Default-flip endpoint (REQ-DASH-015). Body: {"uuid": "..."}.
+ ['name' => 'dashboard_api#setGroupDefault', 'url' => '/api/dashboards/group/{groupId}/default', 'verb' => 'POST',
+ 'requirements' => ['groupId' => '[^/]+']],
['name' => 'dashboard_api#getGroup', 'url' => '/api/dashboards/group/{groupId}/{uuid}', 'verb' => 'GET',
'requirements' => ['groupId' => '[^/]+', 'uuid' => '[A-Za-z0-9\-]+']],
['name' => 'dashboard_api#updateGroup', 'url' => '/api/dashboards/group/{groupId}/{uuid}', 'verb' => 'PUT',
'requirements' => ['groupId' => '[^/]+', 'uuid' => '[A-Za-z0-9\-]+']],
['name' => 'dashboard_api#deleteGroup', 'url' => '/api/dashboards/group/{groupId}/{uuid}', 'verb' => 'DELETE',
'requirements' => ['groupId' => '[^/]+', 'uuid' => '[A-Za-z0-9\-]+']],
- // Default-flip endpoint (REQ-DASH-015). Body: {"uuid": "..."}.
- ['name' => 'dashboard_api#setGroupDefault', 'url' => '/api/dashboards/group/{groupId}/default', 'verb' => 'POST',
- 'requirements' => ['groupId' => '[^/]+']],
+
+ // Personal-scope endpoints (must come AFTER `/api/dashboards/...`
+ // specific routes above to avoid wildcard hijack).
+ ['name' => 'dashboard_api#getActive', 'url' => '/api/dashboard', 'verb' => 'GET'],
+ ['name' => 'dashboard_api#create', 'url' => '/api/dashboard', 'verb' => 'POST'],
+ ['name' => 'dashboard_api#show', 'url' => '/api/dashboard/{id}', 'verb' => 'GET'],
+ ['name' => 'dashboard_api#update', 'url' => '/api/dashboard/{id}', 'verb' => 'PUT'],
+ ['name' => 'dashboard_api#delete', 'url' => '/api/dashboard/{id}', 'verb' => 'DELETE'],
+ ['name' => 'dashboard_api#activate', 'url' => '/api/dashboard/{id}/activate', 'verb' => 'POST'],
+
+ // Dashboard editing-lock endpoints (REQ-LOCK-001..008).
+ // Four verbs on a single lock resource URL plus the admin
+ // `force-release` action. Registered BEFORE the personal-scope
+ // `/api/dashboard/{id}` group so the literal `lock` segment
+ // always wins against any wildcard. The `force-release` route
+ // MUST precede the bare `lock` routes for the same reason.
+ ['name' => 'dashboard_lock_api#forceRelease',
+ 'url' => '/api/dashboards/{uuid}/lock/force-release', 'verb' => 'POST',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ ['name' => 'dashboard_lock_api#acquire', 'url' => '/api/dashboards/{uuid}/lock', 'verb' => 'POST',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ ['name' => 'dashboard_lock_api#heartbeat', 'url' => '/api/dashboards/{uuid}/lock', 'verb' => 'PUT',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ ['name' => 'dashboard_lock_api#release', 'url' => '/api/dashboards/{uuid}/lock', 'verb' => 'DELETE',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ ['name' => 'dashboard_lock_api#get', 'url' => '/api/dashboards/{uuid}/lock', 'verb' => 'GET',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
// Dashboard sharing endpoints (REQ-SHARE-001..010).
- // Per-row operations.
['name' => 'dashboard_share_api#index', 'url' => '/api/dashboard/{id}/shares', 'verb' => 'GET'],
['name' => 'dashboard_share_api#create', 'url' => '/api/dashboard/{id}/shares', 'verb' => 'POST'],
- ['name' => 'dashboard_share_api#destroy', 'url' => '/api/dashboard/share/{shareId}', 'verb' => 'DELETE'],
// Bulk replace — REQ-SHARE-009.
['name' => 'dashboard_share_api#replace', 'url' => '/api/dashboard/{id}/shares', 'verb' => 'PUT'],
+ ['name' => 'dashboard_share_api#destroy', 'url' => '/api/dashboard/share/{shareId}', 'verb' => 'DELETE'],
+ ['name' => 'dashboard_share_api#searchSharees', 'url' => '/api/sharees', 'verb' => 'GET'],
// Revoke all for recipient — REQ-SHARE-010.
['name' => 'dashboard_share_api#revokeForRecipient',
'url' => '/api/sharees/{shareType}/{shareWith}', 'verb' => 'DELETE',
'requirements' => ['shareType' => '[^/]+', 'shareWith' => '[^/]+']],
+ // Dashboard reaction endpoints (REQ-RXN-001..004). Routes use the
+ // `{uuid}` segment so they nest cleanly under `/api/dashboards/`
+ // — these come AFTER the `/api/dashboards/visible|active|fork`
+ // specific routes above to avoid wildcard hijack. The
+ // reactors-by-emoji route is registered before the simpler
+ // summary route so the `/{emoji}/users` suffix is matched
+ // before the parent path.
+ ['name' => 'dashboard_reaction_api#getReactorsByEmoji',
+ 'url' => '/api/dashboards/{uuid}/reactions/{emoji}/users', 'verb' => 'GET',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+', 'emoji' => '.+']],
+ ['name' => 'dashboard_reaction_api#removeReaction',
+ 'url' => '/api/dashboards/{uuid}/reactions/{emoji}', 'verb' => 'DELETE',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+', 'emoji' => '.+']],
+ ['name' => 'dashboard_reaction_api#getReactions',
+ 'url' => '/api/dashboards/{uuid}/reactions', 'verb' => 'GET',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ ['name' => 'dashboard_reaction_api#addReaction',
+ 'url' => '/api/dashboards/{uuid}/reactions', 'verb' => 'POST',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+
+ // Dashboard versioning endpoints (REQ-VERS-001..009).
+ // `{uuid}` is the dashboard UUID; `{versionNumber}` is the integer
+ // version number. Routes are registered BEFORE the personal
+ // `/api/dashboard/{id}` PUT/DELETE handlers because they share
+ // the literal `/api/dashboards/...` prefix with the visible /
+ // tree / fork routes higher up; they MUST stay grouped under
+ // the plural `/api/dashboards/` namespace.
+ ['name' => 'dashboard_version_api#listVersions',
+ 'url' => '/api/dashboards/{uuid}/versions', 'verb' => 'GET',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ ['name' => 'dashboard_version_api#createVersion',
+ 'url' => '/api/dashboards/{uuid}/versions', 'verb' => 'POST',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ ['name' => 'dashboard_version_api#fetchVersion',
+ 'url' => '/api/dashboards/{uuid}/versions/{versionNumber}', 'verb' => 'GET',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+', 'versionNumber' => '\d+']],
+ ['name' => 'dashboard_version_api#restoreVersion',
+ 'url' => '/api/dashboards/{uuid}/versions/{versionNumber}/restore', 'verb' => 'POST',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+', 'versionNumber' => '\d+']],
+
// Widget endpoints
['name' => 'widget_api#listAvailable', 'url' => '/api/widgets', 'verb' => 'GET'],
['name' => 'widget_api#getItems', 'url' => '/api/widgets/items', 'verb' => 'GET'],
+ // REQ-NEWS-003: news widget items endpoint. Registered BEFORE the
+ // wildcard `/api/widgets/{placementId}` PUT/DELETE routes so the
+ // literal `news` segment is matched first by Symfony's router.
+ ['name' => 'widget_api#newsItems', 'url' => '/api/widgets/news/{placementId}/items', 'verb' => 'GET',
+ 'requirements' => ['placementId' => '\d+']],
['name' => 'widget_api#addWidget', 'url' => '/api/dashboard/{dashboardId}/widgets', 'verb' => 'POST'],
['name' => 'widget_api#addTile', 'url' => '/api/dashboard/{dashboardId}/tile', 'verb' => 'POST'],
+ // REQ-CAL-003: calendar widget events endpoint. Registered BEFORE
+ // the wildcard `/api/widgets/{placementId}` PUT/DELETE so the
+ // literal `calendar` segment is matched first.
+ ['name' => 'widget_api#calendarEvents',
+ 'url' => '/api/widgets/calendar/{placementId}/events', 'verb' => 'GET',
+ 'requirements' => ['placementId' => '\d+']],
['name' => 'widget_api#updatePlacement', 'url' => '/api/widgets/{placementId}', 'verb' => 'PUT'],
['name' => 'widget_api#removePlacement', 'url' => '/api/widgets/{placementId}', 'verb' => 'DELETE'],
@@ -83,34 +230,52 @@
['name' => 'rule_api#updateRule', 'url' => '/api/rules/{ruleId}', 'verb' => 'PUT'],
['name' => 'rule_api#deleteRule', 'url' => '/api/rules/{ruleId}', 'verb' => 'DELETE'],
- // Admin endpoints
- ['name' => 'admin#listTemplates', 'url' => '/api/admin/templates', 'verb' => 'GET'],
- ['name' => 'admin#createTemplate', 'url' => '/api/admin/templates', 'verb' => 'POST'],
- ['name' => 'admin#getTemplate', 'url' => '/api/admin/templates/{id}', 'verb' => 'GET'],
- ['name' => 'admin#updateTemplate', 'url' => '/api/admin/templates/{id}', 'verb' => 'PUT'],
- ['name' => 'admin#deleteTemplate', 'url' => '/api/admin/templates/{id}', 'verb' => 'DELETE'],
- ['name' => 'admin#getSettings', 'url' => '/api/admin/settings', 'verb' => 'GET'],
- ['name' => 'admin#updateSettings', 'url' => '/api/admin/settings', 'verb' => 'PUT'],
- ['name' => 'admin#listGroups', 'url' => '/api/admin/groups', 'verb' => 'GET'],
- ['name' => 'admin#updateGroupOrder', 'url' => '/api/admin/groups', 'verb' => 'POST'],
+ // Role-feature permissions (REQ-RFP-001..010). Admin-only — the
+ // controller calls `requireAdmin()` on every method. Sits with
+ // the rest of the admin-scoped routes; the duplicate
+ // admin#listTemplates / admin#getSettings / resource#upload
+ // entries from the PR's original routes diff were dropped here
+ // because they already live in dev under the same names.
+ ['name' => 'role_feature_permission_api#listPermissions', 'url' => '/api/role-feature-permissions', 'verb' => 'GET'],
+ ['name' => 'role_feature_permission_api#savePermission', 'url' => '/api/role-feature-permissions', 'verb' => 'POST'],
+ ['name' => 'role_feature_permission_api#deletePermission', 'url' => '/api/role-feature-permissions/{id}', 'verb' => 'DELETE',
+ 'requirements' => ['id' => '\d+']],
+ ['name' => 'role_feature_permission_api#listLayoutDefaults', 'url' => '/api/role-layout-defaults', 'verb' => 'GET'],
+ ['name' => 'role_feature_permission_api#saveLayoutDefault', 'url' => '/api/role-layout-defaults', 'verb' => 'POST'],
+ ['name' => 'role_feature_permission_api#deleteLayoutDefault', 'url' => '/api/role-layout-defaults/{id}', 'verb' => 'DELETE',
+ 'requirements' => ['id' => '\d+']],
- // Resource uploads (admin-only base64 mini file API)
- ['name' => 'resource#upload', 'url' => '/api/resources', 'verb' => 'POST'],
-
- // File creation endpoint (REQ-LBN-004). Non-admin; strict server-side
- // validation via FileService (filename regex, dir traversal, extension
- // allow-list). See lib/Controller/FileController.php.
+ // File creation endpoint (REQ-LBN-004) — link-button-widget
+ // createFile flow. POST-only; validates filename, dir, and the
+ // admin-configured extension allow-list before touching storage.
['name' => 'file#createFile', 'url' => '/api/files/create', 'verb' => 'POST'],
+ // Files widget endpoints (REQ-FLS-003, REQ-FLS-007, REQ-FLS-008).
+ // All three are scoped to a placement id so the controller can
+ // re-read the placement's `widgetContent` JSON and re-validate
+ // per-viewer permission on every request.
+ ['name' => 'files_widget#contents',
+ 'url' => '/api/widgets/files/{placementId}/contents', 'verb' => 'GET',
+ 'requirements' => ['placementId' => '\d+']],
+ ['name' => 'files_widget#upload',
+ 'url' => '/api/widgets/files/{placementId}/upload', 'verb' => 'POST',
+ 'requirements' => ['placementId' => '\d+']],
+ ['name' => 'files_widget#destroy',
+ 'url' => '/api/widgets/files/{placementId}/files/{fileId}', 'verb' => 'DELETE',
+ 'requirements' => ['placementId' => '\d+', 'fileId' => '\d+']],
+
+ // Resource endpoints (REQ-RES-001..008).
+ // Specific routes precede the wildcard `/resource/{filename}`
+ // route so that any future addition of `/resource/...` paths
+ // stays unambiguous. The non-OCS `/resource/{filename}`
+ // streamer is intentionally NOT under `/api/...` because it
+ // returns binary bytes, not a JSON envelope.
+ ['name' => 'resource#upload', 'url' => '/api/resources', 'verb' => 'POST'],
// Resource listing — REQ-RES-007. Logged-in user only (no admin
// gate); the listed names are already referenced from rendered
// dashboards so admin gating would lock dashboards out of their
- // own assets. Registered under the standard `routes` array
- // alongside the existing POST upload (mydash currently has no
- // OCS infrastructure — using a plain web route keeps the read
- // surface consistent with the upload surface).
+ // own assets.
['name' => 'resource_serve#listResources', 'url' => '/api/resources', 'verb' => 'GET'],
-
// Public resource serving — REQ-RES-006. NON-OCS plain web
// route returning a StreamResponse with extension-derived
// Content-Type and a one-year immutable cache header. The
@@ -119,5 +284,192 @@
// defence in depth).
['name' => 'resource_serve#getResource', 'url' => '/resource/{filename}', 'verb' => 'GET',
'requirements' => ['filename' => '[^/]+']],
+
+ // Per-user RSS / Atom feed endpoints (REQ-FEED-001..009).
+ // The three /api/feed/token routes are authenticated; the
+ // public /feed/{token}.xml route is gated only by the opaque
+ // token in the URL path. Specific `/regenerate` precedes the
+ // catch-all `{token}.xml` route below.
+ ['name' => 'feed#getToken', 'url' => '/api/feed/token', 'verb' => 'GET'],
+ ['name' => 'feed#regenerateToken', 'url' => '/api/feed/token/regenerate', 'verb' => 'POST'],
+ ['name' => 'feed#revokeToken', 'url' => '/api/feed/token', 'verb' => 'DELETE'],
+ ['name' => 'feed#publicFeed', 'url' => '/feed/{token}.xml', 'verb' => 'GET',
+ 'requirements' => ['token' => '[A-Za-z0-9_\-]+']],
+
+ // Template gallery + save-as-template (REQ-TMPL-014, REQ-TMPL-015).
+ // `gallery` is logged-in-user only (not admin); `saveAsTemplate` is
+ // owner-only with the check inside the service. Registered BEFORE
+ // the personal `/api/dashboard/{id}` routes so the literal
+ // `templates/gallery` and `save-as-template` segments win in the
+ // router (Symfony first-match).
+ ['name' => 'template#gallery', 'url' => '/api/templates/gallery', 'verb' => 'GET'],
+ ['name' => 'template#saveAsTemplate', 'url' => '/api/dashboards/{uuid}/save-as-template', 'verb' => 'POST',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+
+ // Admin endpoints
+ ['name' => 'admin#listTemplates', 'url' => '/api/admin/templates', 'verb' => 'GET'],
+ ['name' => 'admin#createTemplate', 'url' => '/api/admin/templates', 'verb' => 'POST'],
+ // Preview-image upload — REQ-TMPL-017. Registered BEFORE the
+ // `/api/admin/templates/{id}` wildcard routes so the literal
+ // `{uuid}/preview-image` suffix matches first.
+ ['name' => 'admin#uploadTemplatePreviewImage', 'url' => '/api/admin/templates/{uuid}/preview-image', 'verb' => 'POST',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ ['name' => 'admin#getTemplate', 'url' => '/api/admin/templates/{id}', 'verb' => 'GET'],
+ ['name' => 'admin#updateTemplate', 'url' => '/api/admin/templates/{id}', 'verb' => 'PUT'],
+ ['name' => 'admin#deleteTemplate', 'url' => '/api/admin/templates/{id}', 'verb' => 'DELETE'],
+ ['name' => 'admin#getSettings', 'url' => '/api/admin/settings', 'verb' => 'GET'],
+ ['name' => 'admin#updateSettings', 'url' => '/api/admin/settings', 'verb' => 'PUT'],
+
+ // Global footer settings (REQ-FTR-001, REQ-FTR-010). Both
+ // admin-only via runtime `IGroupManager::isAdmin` check inside
+ // the controller. The PUT verb is a partial-patch contract —
+ // only keys present in the body are mutated.
+ ['name' => 'admin#getFooterSettings', 'url' => '/api/admin/footer-settings', 'verb' => 'GET'],
+ ['name' => 'admin#updateFooterSettings', 'url' => '/api/admin/footer-settings', 'verb' => 'PUT'],
+
+ // Admin group-priority order endpoints (REQ-ASET-012,
+ // REQ-ASET-013, REQ-ASET-014). Both admin-only via runtime
+ // `IGroupManager::isAdmin` check inside the controller.
+ ['name' => 'admin_settings#listGroups', 'url' => '/api/admin/groups', 'verb' => 'GET'],
+ ['name' => 'admin_settings#updateGroupOrder', 'url' => '/api/admin/groups', 'verb' => 'POST'],
+
+ // Dashboard export / import (REQ-EXIM-002..004). Both admin-only
+ // via runtime `IGroupManager::isAdmin` check inside the
+ // controller; the routes carry no CSRF token because the import
+ // path accepts multipart uploads from CLI tools as well as the
+ // admin UI.
+ ['name' => 'admin#export', 'url' => '/api/admin/export', 'verb' => 'POST'],
+ ['name' => 'admin#import', 'url' => '/api/admin/import', 'verb' => 'POST'],
+
+ // People widget (REQ-PPL-003). Paginated user-directory endpoint
+ // for the `people` MyDash widget. Authenticated users only;
+ // returns `{users, total, hasMore}` with offset-based pagination.
+ ['name' => 'people_widget#getUsers', 'url' => '/api/people', 'verb' => 'GET'],
+
+ // Setup wizard endpoints (REQ-WIZ-008, REQ-WIZ-009, REQ-WIZ-003).
+ // Admin-only via runtime `IGroupManager::isAdmin` check inside the
+ // controller. The state endpoint also drives the "Run setup wizard"
+ // banner gate on the admin page; the storage endpoint persists the
+ // Step 2 choice immediately on `Next`.
+ ['name' => 'admin#getWizardState', 'url' => '/api/admin/setup-wizard/state', 'verb' => 'GET'],
+ ['name' => 'admin#completeWizard', 'url' => '/api/admin/setup-wizard/complete', 'verb' => 'POST'],
+ ['name' => 'admin#setWizardStorage', 'url' => '/api/admin/setup-wizard/storage', 'verb' => 'POST'],
+
+ // Confluence HTML export importer (REQ-CFLI-001..012). Admin-only
+ // via runtime `IGroupManager::isAdmin` check inside the
+ // controller. The dry-run route MUST precede the bare /confluence
+ // route so the literal `dry-run` segment matches before the
+ // import handler claims it.
+ ['name' => 'confluence_import#dryRun',
+ 'url' => '/api/admin/import/confluence/dry-run', 'verb' => 'POST'],
+ ['name' => 'confluence_import#import',
+ 'url' => '/api/admin/import/confluence', 'verb' => 'POST'],
+
+ // Admin role-assignment endpoints (REQ-ROLE-004, REQ-ROLE-006).
+ // All NC-admin-gated via `requireAdmin()` inside the controller.
+ ['name' => 'admin#listRoles', 'url' => '/api/admin/roles', 'verb' => 'GET'],
+ ['name' => 'admin#createRole', 'url' => '/api/admin/roles', 'verb' => 'POST'],
+ ['name' => 'admin#deleteRole', 'url' => '/api/admin/roles/{id}', 'verb' => 'DELETE',
+ 'requirements' => ['id' => '\d+']],
+ // Self-introspection endpoint — any authenticated user.
+ ['name' => 'admin#getMyRole', 'url' => '/api/me/role', 'verb' => 'GET'],
+
+ // Dashboard metadata-fields admin registry (REQ-MDFL-001..003).
+ // All admin-only via runtime `IGroupManager::isAdmin` check.
+ ['name' => 'metadata_admin#listFields', 'url' => '/api/admin/metadata-fields', 'verb' => 'GET'],
+ ['name' => 'metadata_admin#createField', 'url' => '/api/admin/metadata-fields', 'verb' => 'POST'],
+ ['name' => 'metadata_admin#getField', 'url' => '/api/admin/metadata-fields/{id}', 'verb' => 'GET',
+ 'requirements' => ['id' => '\d+']],
+ ['name' => 'metadata_admin#updateField', 'url' => '/api/admin/metadata-fields/{id}', 'verb' => 'PUT',
+ 'requirements' => ['id' => '\d+']],
+ ['name' => 'metadata_admin#deleteField', 'url' => '/api/admin/metadata-fields/{id}', 'verb' => 'DELETE',
+ 'requirements' => ['id' => '\d+']],
+
+ // Dashboard metadata read/write per dashboard
+ // (REQ-MDFL-004..006, REQ-MDFL-008). Specific routes already
+ // declared above precede the wildcard `{id}` patterns; the
+ // `{uuid}/metadata` URLs cannot collide with them because the
+ // `metadata` literal is unique to this capability.
+ ['name' => 'dashboard_metadata#getMetadata', 'url' => '/api/dashboards/{uuid}/metadata', 'verb' => 'GET'],
+ ['name' => 'dashboard_metadata#setMetadata', 'url' => '/api/dashboards/{uuid}/metadata', 'verb' => 'PUT'],
+
+ // Dashboard view-analytics admin endpoints (REQ-ANLT-006..010).
+ // All admin-only via runtime `IGroupManager::isAdmin` check
+ // inside the controller. The literal `top` and `summary` /
+ // `export` segments precede the `{uuid}` wildcard so the router
+ // matches them before falling through to the per-dashboard
+ // breakdown endpoint.
+ ['name' => 'analytics#topDashboards', 'url' => '/api/admin/analytics/dashboards/top', 'verb' => 'GET'],
+ ['name' => 'analytics#instanceSummary', 'url' => '/api/admin/analytics/summary', 'verb' => 'GET'],
+ ['name' => 'analytics#exportCsv', 'url' => '/api/admin/analytics/export', 'verb' => 'GET'],
+ ['name' => 'analytics#dashboardDetail', 'url' => '/api/admin/analytics/dashboards/{uuid}', 'verb' => 'GET',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+
+ // Background feed-refresh trigger (REQ-FRJ-010). Admin-only via
+ // runtime `IGroupManager::isAdmin` check inside the controller.
+ ['name' => 'admin#refreshFeedsNow', 'url' => '/api/admin/feeds/refresh-now', 'verb' => 'POST'],
+
+ // Orphaned-data cleanup admin endpoints (REQ-CLN-004, REQ-CLN-005).
+ // Both routes admin-only via runtime `IGroupManager::isAdmin`
+ // check inside `AdminCleanupController::requireAdmin()`. Mounted
+ // under `/api/admin/cleanup/...` to mirror the existing
+ // `/api/admin/...` admin surface.
+ ['name' => 'admin_cleanup#scan', 'url' => '/api/admin/cleanup/scan', 'verb' => 'GET'],
+ ['name' => 'admin_cleanup#purge', 'url' => '/api/admin/cleanup/purge', 'verb' => 'POST'],
+
+ // Org-wide navigation editor (REQ-ONAV-001..012). The /position
+ // routes MUST precede the bare /org-navigation routes so the
+ // literal `position` suffix is matched before the wildcard
+ // {lang} query string is parsed.
+ ['name' => 'admin_org_navigation#getPosition',
+ 'url' => '/api/admin/org-navigation/position', 'verb' => 'GET'],
+ ['name' => 'admin_org_navigation#updatePosition',
+ 'url' => '/api/admin/org-navigation/position', 'verb' => 'PUT'],
+ ['name' => 'admin_org_navigation#getOrgNavigation',
+ 'url' => '/api/admin/org-navigation', 'verb' => 'GET'],
+ ['name' => 'admin_org_navigation#updateOrgNavigation',
+ 'url' => '/api/admin/org-navigation', 'verb' => 'PUT'],
+
+ // Dashboard bulk operations (REQ-BULK-001..011). Admin-only via
+ // runtime `IGroupManager::isAdmin` check inside the controller;
+ // the all-or-nothing permission pre-check + per-request size cap
+ // enforcement live in BulkOperationService.
+ ['name' => 'admin_bulk#bulkDelete',
+ 'url' => '/api/admin/dashboards/bulk-delete', 'verb' => 'POST'],
+ ['name' => 'admin_bulk#bulkMove',
+ 'url' => '/api/admin/dashboards/bulk-move', 'verb' => 'POST'],
+ ['name' => 'admin_bulk#bulkStatus',
+ 'url' => '/api/admin/dashboards/bulk-status', 'verb' => 'POST'],
+ ['name' => 'admin_bulk#bulkReindex',
+ 'url' => '/api/admin/dashboards/bulk-reindex', 'verb' => 'POST'],
+
+ // Demo showcases (REQ-DEMO-002..006). Admin-only via runtime
+ // `IGroupManager::isAdmin` check inside the controller. The
+ // install / destroy routes carry the showcase ID as a path
+ // segment with `[a-z0-9\-]+` requirement so curl typos surface
+ // as routing 404s rather than reaching the controller.
+ ['name' => 'admin_demo_showcases#index',
+ 'url' => '/api/admin/demo-showcases', 'verb' => 'GET'],
+ ['name' => 'admin_demo_showcases#install',
+ 'url' => '/api/admin/demo-showcases/{id}/install', 'verb' => 'POST',
+ 'requirements' => ['id' => '[a-z0-9\-]+']],
+ ['name' => 'admin_demo_showcases#destroy',
+ 'url' => '/api/admin/demo-showcases/{id}', 'verb' => 'DELETE',
+ 'requirements' => ['id' => '[a-z0-9\-]+']],
+
+ // Resolve a dashboard's canonical slug-chain path (used by the
+ // frontend for outbound URL sync after a sidebar switch).
+ // Registered BEFORE the catch-all deep-link route so the literal
+ // `/api/dashboards/{uuid}/path` segment is matched first.
+ ['name' => 'dashboard_api#computePath', 'url' => '/api/dashboards/{uuid}/path', 'verb' => 'GET',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+
+ // Deep-link slug-chain → dashboard. MUST be the last route in the
+ // table so every literal `/api/...` and explicit page route is
+ // matched first. The negative-lookahead requirement keeps this
+ // catch-all from swallowing API requests if a future API route is
+ // inadvertently added below.
+ ['name' => 'page#deepLink', 'url' => '/{deepLink}', 'verb' => 'GET',
+ 'requirements' => ['deepLink' => '(?!api(?:/|$)).+']],
],
];
diff --git a/composer.json b/composer.json
index dd072397..ad355a63 100644
--- a/composer.json
+++ b/composer.json
@@ -30,8 +30,17 @@
"OCA\\MyDash\\": "lib/"
}
},
+ "autoload-dev": {
+ "psr-4": {
+ "OCP\\": "vendor/nextcloud/ocp/OCP/",
+ "NCU\\": "vendor/nextcloud/ocp/NCU/",
+ "Unit\\": "tests/Unit/"
+ }
+ },
"scripts": {
"lint": "find . -name \\*.php -not -path './vendor/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l",
+ "lint:initial-state": "php scripts/lint-initial-state.php",
+ "lint:spec-annotations": "php tools/check-spec-annotations.php",
"cs:check": "./vendor/bin/phpcs --standard=phpcs.xml",
"cs:fix": "./vendor/bin/phpcbf --standard=phpcs.xml",
"phpcs": "./vendor/bin/phpcs --standard=phpcs.xml",
@@ -43,11 +52,14 @@
"psalm": "./vendor/bin/psalm --threads=1 --no-cache || echo 'Psalm not installed, skipping...'",
"phpstan": "./vendor/bin/phpstan analyse --memory-limit=1G",
"test": "phpunit --configuration phpunit.xml",
- "test:unit": "./vendor/bin/phpunit --colors=always || echo 'Tests require Nextcloud environment, skipping...'",
- "test:all": "./vendor/bin/phpunit --colors=always || echo 'Tests require Nextcloud environment, skipping...'",
+ "test:unit": "./vendor/bin/phpunit --configuration phpunit.xml --colors=always || echo 'Tests require Nextcloud environment, skipping...'",
+ "test:all": "./vendor/bin/phpunit --configuration phpunit.xml --colors=always || echo 'Tests require Nextcloud environment, skipping...'",
+ "test:integration": "if command -v newman >/dev/null 2>&1; then newman run tests/integration/mydash.postman_collection.json --environment tests/integration/local.env.json; else npx --yes newman run tests/integration/mydash.postman_collection.json --environment tests/integration/local.env.json; fi",
+ "newman": "@test:integration",
+ "newman:coverage": "node tests/integration/.coverage-check.js",
"check": "E=0; for CMD in lint phpcs psalm test:unit; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E",
"check:full": "E=0; for CMD in lint phpcs psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E",
- "check:strict": "E=0; for CMD in lint phpcs phpmd psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E",
+ "check:strict": "E=0; for CMD in lint lint:initial-state lint:spec-annotations phpcs phpmd psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E",
"fix": [
"@cs:fix"
],
@@ -60,7 +72,7 @@
"qa:full": [
"@phpqa:full"
],
- "test:coverage": "./vendor/bin/phpunit --coverage-html=coverage/html --coverage-clover=coverage/clover.xml --colors=always",
+ "test:coverage": "./vendor/bin/phpunit --configuration phpunit.xml --coverage-html=coverage/html --coverage-clover=coverage/clover.xml --colors=always",
"coverage:check": "php -r \"\\$xml = simplexml_load_file('coverage/clover.xml'); \\$metrics = \\$xml->project->metrics; \\$statements = (int)\\$metrics['statements']; \\$covered = (int)\\$metrics['coveredstatements']; \\$percentage = \\$statements > 0 ? round((\\$covered / \\$statements) * 100, 2) : 0; echo 'Coverage: ' . \\$percentage . '%' . PHP_EOL; exit(\\$percentage < 75 ? 1 : 0);\"",
"quality:phpcs-score": "./vendor/bin/phpcs --standard=phpcs.xml --report=json lib/ | php -r \"\\$json = json_decode(file_get_contents('php://stdin'), true); \\$errors = \\$json['totals']['errors'] ?? 0; \\$warnings = \\$json['totals']['warnings'] ?? 0; \\$score = 1000 - \\$errors - (\\$warnings / 2); echo 'PHPCS Score: ' . \\$score . ' (Errors: ' . \\$errors . ', Warnings: ' . \\$warnings . ')' . PHP_EOL;\"",
"quality:phpmd-score": "phpmd lib/ json phpmd.xml | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$violations = count(\\$json['files'] ?? []); \\$score = 1000 - (\\$violations * 10); echo 'PHPMD Score: ' . \\$score . ' (Violations: ' . \\$violations . ')' . PHP_EOL;\" || echo 'PHPMD not available'",
diff --git a/css/mydash.css b/css/mydash.css
index 966ee729..a558d69a 100644
--- a/css/mydash.css
+++ b/css/mydash.css
@@ -1,8 +1,18 @@
/**
* SPDX-FileCopyrightText: 2024 MyDash Contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * --mydash-cell-height is set by `useGridManager.syncCellHeightCssVar()` at
+ * grid-init time from the JS `CELL_HEIGHT` constant (REQ-GRID-012). The
+ * `:root` declaration below is a fallback for the brief flash before init
+ * runs and for any non-Vue rendering surface (e.g. settings page admin
+ * shell) that still references the variable.
*/
+:root {
+ --mydash-cell-height: 60px;
+}
+
/* ============================================
MYDASH APP CONTENT
============================================ */
@@ -32,11 +42,11 @@
opacity: 1;
}
-/* Responsive */
-@media (max-width: 768px) {
- .grid-stack-item {
- width: 100% !important;
- position: relative !important;
- left: 0 !important;
- }
-}
+/*
+ * Responsive layout is handled by GridStack v12 via `columnOpts.breakpoints`
+ * (see REQ-GRID-007 and `src/composables/useGridManager.js`). We deliberately
+ * do NOT force `width: 100% !important` on `.grid-stack-item` at narrow
+ * viewports here — that override fought with GridStack's own column-reflow
+ * math and produced double-stacking artefacts. The `c: 1` entry in the
+ * BREAKPOINTS table already gives every widget the full row at <= 480 px.
+ */
diff --git a/data/demo-showcases/de-bron/de-bron.zip b/data/demo-showcases/de-bron/de-bron.zip
new file mode 100644
index 00000000..bd7aa9e8
Binary files /dev/null and b/data/demo-showcases/de-bron/de-bron.zip differ
diff --git a/data/demo-showcases/de-linden/de-linden.zip b/data/demo-showcases/de-linden/de-linden.zip
new file mode 100644
index 00000000..323cf1dc
Binary files /dev/null and b/data/demo-showcases/de-linden/de-linden.zip differ
diff --git a/data/demo-showcases/gemeente-duin/gemeente-duin.zip b/data/demo-showcases/gemeente-duin/gemeente-duin.zip
new file mode 100644
index 00000000..6a3c6133
Binary files /dev/null and b/data/demo-showcases/gemeente-duin/gemeente-duin.zip differ
diff --git a/data/demo-showcases/horizon-labs/horizon-labs.zip b/data/demo-showcases/horizon-labs/horizon-labs.zip
new file mode 100644
index 00000000..ea74bf3f
Binary files /dev/null and b/data/demo-showcases/horizon-labs/horizon-labs.zip differ
diff --git a/data/demo-showcases/van-der-berg/van-der-berg.zip b/data/demo-showcases/van-der-berg/van-der-berg.zip
new file mode 100644
index 00000000..3d7c4d77
Binary files /dev/null and b/data/demo-showcases/van-der-berg/van-der-berg.zip differ
diff --git a/docs/adr-audit.md b/docs/adr-audit.md
index 18cb4a72..708f8230 100644
--- a/docs/adr-audit.md
+++ b/docs/adr-audit.md
@@ -24,7 +24,7 @@ the template + ADR cleanup PR.
| 003 | Thin controllers | ✅ | most methods under 20 lines; `DashboardApiController::update` is the largest at ~45 lines due to metadata-vs-layout branch |
| 003 | DI via constructor + `private readonly` | ✅ | verified across `lib/Controller/` and `lib/Service/` |
| 003 | No `\OC::$server` or static locators | ✅ | post-PR: `grep -rnE '\\OC::\\$server\\|Server::get(\\|new \\OC_' lib/` returns zero |
-| 003 | `@spec` on every class + public method | ❌ | **deferred** — 0 of ~215 public methods tagged. Tracked as [openspec change + issue](#follow-ups) |
+| 003 | `@spec` on every class + public method | ✅ | 62 `@spec` tags now annotate every public method that maps to a capability Requirement (Bucket 1 from `openspec/coverage-report.md`); enforced by `composer lint:spec-annotations` against `tools/spec-annotations-allowlist.txt` |
| 003 | Specific routes before wildcard | ✅ | no wildcard routes |
| **004** Frontend | Vue 2 + Pinia + Options API | ✅ | Vue 2.7 + Pinia stores + Webpack (template-aligned) |
| 004 | Never import from `@nextcloud/vue` directly — use `@conduction/nextcloud-vue` | ✅ | post-PR: all 9 direct imports migrated |
@@ -63,25 +63,21 @@ the template + ADR cleanup PR.
## Summary
-- **Compliant:** 25 rules (incl. compound rules)
+- **Compliant:** 26 rules (incl. compound rules)
- **Partial:** 4 rules (ADR-005 `PasswordConfirmationRequired`, ADR-008
PHPUnit breadth, ADR-010 deep WCAG AA, ADR-018 `header-actions`
slot by design)
-- **Gaps:** 2 rules (ADR-003 `@spec` tag coverage, ADR-008 Newman)
+- **Gaps:** 1 rule (ADR-008 Newman)
- **N/A:** 14 rules (no OR consumption, no registry provider, no fine-
grained actions, hydra-infra rules)
## Follow-ups
-The two remaining `❌` items are tracked as formal OpenSpec changes +
-GitHub issues so Hydra's pipeline can pick them up after this PR
+The remaining `❌` item is tracked as a formal OpenSpec change +
+GitHub issue so Hydra's pipeline can pick it up after this PR
merges:
-1. **`@spec` annotation pass across 64 PHP files / 215 public methods**
- — needs a full `/opsx-annotate` run against the 10 archived changes
- in `openspec/changes/archive/`. Scope is too large to pack into this
- PR.
-2. **Newman / Postman integration collection** — covering the 17 OCS
+1. **Newman / Postman integration collection** — covering the 17 OCS
endpoints, with env-placeholder credentials and CI wiring.
Partial items that may warrant their own follow-up PRs later (not
diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js
index d0341887..d8da29c1 100644
--- a/docs/docusaurus.config.js
+++ b/docs/docusaurus.config.js
@@ -1,112 +1,134 @@
-// @ts-check
-
-/** @type {import('@docusaurus/types').Config} */
-const config = {
- title: 'MyDash',
- tagline: 'Your customizable dashboard for Nextcloud',
- url: 'https://mydash.app',
- baseUrl: '/',
-
- // GitHub pages deployment config
- organizationName: 'ConductionNL',
- projectName: 'mydash',
- trailingSlash: false,
-
- onBrokenLinks: 'warn',
- onBrokenMarkdownLinks: 'warn',
-
- i18n: {
- defaultLocale: 'en',
- locales: ['en', 'nl'],
- localeConfigs: {
- en: { label: 'English' },
- nl: { label: 'Nederlands' },
- },
- },
-
- presets: [
- [
- 'classic',
- /** @type {import('@docusaurus/preset-classic').Options} */
- ({
- docs: {
- path: './',
- exclude: ['**/node_modules/**'],
- sidebarPath: require.resolve('./sidebars.js'),
- editUrl:
- 'https://github.com/ConductionNL/mydash/tree/main/docs/',
- },
- blog: false,
- theme: {
- customCss: require.resolve('./src/css/custom.css'),
- },
- }),
- ],
- ],
-
- themeConfig:
- /** @type {import('@docusaurus/preset-classic').ThemeConfig} */
- ({
- navbar: {
- title: 'MyDash',
- logo: {
- alt: 'MyDash Logo',
- src: 'img/logo.svg',
- },
- items: [
- {
- type: 'docSidebar',
- sidebarId: 'tutorialSidebar',
- position: 'left',
- label: 'Documentation',
- },
- {
- href: 'https://github.com/ConductionNL/mydash',
- label: 'GitHub',
- position: 'right',
- },
- {
- type: 'localeDropdown',
- position: 'right',
- },
- ],
- },
- footer: {
- style: 'dark',
- links: [
- {
- title: 'Docs',
- items: [
- {
- label: 'Documentation',
- to: '/docs/intro',
- },
- ],
- },
- {
- title: 'Community',
- items: [
- {
- label: 'GitHub',
- href: 'https://github.com/ConductionNL/mydash',
- },
- ],
- },
- ],
- copyright: `Copyright © ${new Date().getFullYear()} for Open Webconcept by Conduction B.V. `,
- },
- prism: {
- theme: require('prism-react-renderer/themes/github'),
- darkTheme: require('prism-react-renderer/themes/dracula'),
- },
- mermaid: {
- theme: { light: 'default', dark: 'dark' },
- },
- }),
- markdown: {
- mermaid: true,
- },
- themes: ['@docusaurus/theme-mermaid'],
-};
-
-module.exports = config;
+// @ts-check
+
+/**
+ * MyDash documentation site.
+ *
+ * Built on @conduction/docusaurus-preset for brand defaults (tokens,
+ * theme swizzles for Navbar / Footer, four-locale i18n scaffolding,
+ * KvK / BTW copyright). Site-specific overrides — locales, sidebar
+ * path, mermaid theme, custom prism themes, mydash-only navbar items —
+ * are passed through createConfig() opts.
+ */
+
+const { createConfig, baseFooterLinks } = require('@conduction/docusaurus-preset');
+
+/* createConfig replaces themes wholesale when `themes:` is passed, so
+ we re-include the brand theme plugin alongside @docusaurus/theme-mermaid.
+ Without the brand theme entry the Navbar/Footer swizzles and
+ brand.css auto-load would silently drop. */
+const BRAND_THEME = require.resolve('@conduction/docusaurus-preset/theme');
+
+const config = createConfig({
+ title: 'MyDash',
+ tagline: 'Your customizable dashboard for Nextcloud',
+ url: 'https://mydash.conduction.nl',
+ baseUrl: '/',
+
+ organizationName: 'ConductionNL',
+ projectName: 'mydash',
+
+ /* English-only for now. Dutch was dropped on the previous config
+ because i18n/nl/ carries stale strings without translated markdown
+ and broke Dutch SSR on a handful of recent doc pages. Re-enable by
+ adding 'nl' back once the Dutch translation pass has been completed
+ or the metadata audited for stale references. The brand preset's
+ default i18n block (nl/en/de/fr) is replaced wholesale here. */
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en'],
+ localeConfigs: {
+ en: { label: 'English' },
+ },
+ },
+
+ /* The mydash docs source lives at the repo root of `docs/` rather
+ than under a `docs/` subfolder, so we override the preset's default
+ `presets:` block to point `docs.path` at './' and disable the blog
+ plugin. customCss carries mydash-specific CSS only — brand tokens
+ and the theme swizzles are auto-loaded by the brand theme entry in
+ `themes:` below. */
+ presets: [
+ [
+ 'classic',
+ {
+ docs: {
+ path: './',
+ exclude: ['**/node_modules/**'],
+ sidebarPath: require.resolve('./sidebars.js'),
+ editUrl: 'https://github.com/ConductionNL/mydash/tree/main/docs/',
+ },
+ blog: false,
+ theme: {
+ customCss: require.resolve('./src/css/custom.css'),
+ },
+ },
+ ],
+ ],
+
+ themes: [BRAND_THEME, '@docusaurus/theme-mermaid'],
+
+ /* Brand navbar provides locale dropdown + GitHub by default; we
+ replace items[] with mydash's own (Documentation sidebar link,
+ mydash GitHub link). Object.assign in createConfig is shallow, so
+ items: replaces wholesale — re-include the locale dropdown and
+ add the mydash GitHub repo link explicitly. */
+ navbar: {
+ items: [
+ {
+ type: 'docSidebar',
+ sidebarId: 'tutorialSidebar',
+ position: 'left',
+ label: 'Documentation',
+ },
+ {
+ href: 'https://github.com/ConductionNL/mydash',
+ label: 'GitHub',
+ position: 'right',
+ },
+ { type: 'localeDropdown', position: 'right' },
+ ],
+ },
+
+ /* Per-property footer override (preset 1.2.0+): we pass `links` only,
+ so the brand `style: 'dark'` and the brand KvK/BTW/IBAN/address
+ copyright string both inherit unchanged. Mydash currently ships
+ just the brand "Conduction" column; site-specific Product / Support
+ columns will be added in a follow-up. */
+ footer: {
+ links: baseFooterLinks().filter((column) => column.title === 'Conduction'),
+ },
+
+ /* themeConfig is shallow-merged into the preset's defaults
+ (colorMode + navbar + footer). prism + mermaid land alongside. */
+ themeConfig: {
+ prism: {
+ theme: require('prism-react-renderer/themes/github'),
+ darkTheme: require('prism-react-renderer/themes/dracula'),
+ },
+ mermaid: {
+ theme: { light: 'default', dark: 'dark' },
+ },
+ },
+});
+
+/* createConfig doesn't pass-through arbitrary top-level fields; assign
+ markdown + onBrokenAnchors + trailingSlash directly so they make it
+ into the final Docusaurus config. trailingSlash is flipped to false
+ because the previous config locked it that way and the GH Pages CNAME
+ target depends on it (true would 301-redirect to /-suffix URLs). */
+config.trailingSlash = false;
+config.onBrokenAnchors = 'warn';
+config.markdown = {
+ mermaid: true,
+ /* Tutorial pages reference screenshots that are populated by
+ `tests/e2e/docs-screenshots.spec.ts`. The Playwright capture run is
+ separate from the docs build, so the build needs to succeed even
+ when a fresh checkout doesn't have every PNG yet. Warn instead of
+ failing — the absence is visible at preview time and the capture
+ spec brings everything back on demand. */
+ hooks: {
+ onBrokenMarkdownImages: 'warn',
+ },
+};
+
+module.exports = config;
diff --git a/docs/extending.md b/docs/extending.md
new file mode 100644
index 00000000..772195a7
--- /dev/null
+++ b/docs/extending.md
@@ -0,0 +1,174 @@
+# Extending MyDash
+
+This guide is for app developers who want their content to appear on a MyDash dashboard. There are two extension paths and you pick one based on **where the data lives**:
+
+| You have… | Use… | Where the code lives |
+|---|---|---|
+| A Nextcloud app with PHP services and a dedicated widget UI | A **widget** registered through Nextcloud's standard `IWidget` API | Inside your app (`lib/Dashboard/`, `src/`) |
+| Records in OpenRegister (objects, schemas) you want to surface as a card | A **dynamic widget driven by OpenRegister** — registered the same way, but the UI fetches its data from the OpenRegister REST/GraphQL API at render time | Inside your app, but the body of the widget is data-driven from OpenRegister |
+
+Both paths produce something users can place via the **Add Widget** picker, drag around the grid, and resize. MyDash itself does not need to be modified — it auto-discovers any widget registered through Nextcloud's `OCP\Dashboard\IManager`.
+
+If you are looking to add a **shortcut tile** (a static link card a user creates themselves, not driven by app data), see [Custom Tiles](features/tiles.md) instead — that path requires no PHP at all.
+
+---
+
+## Path 1 — Add a widget from a Nextcloud app
+
+This is the standard Nextcloud Dashboard API. MyDash treats every widget registered this way as a first-class citizen.
+
+### 1. Implement `OCP\Dashboard\IWidget`
+
+Create a class under `lib/Dashboard/` in your app:
+
+```php
+l10n->t('My widget'); }
+ public function getOrder(): int { return 10; }
+ public function getIconClass(): string { return 'icon-yourapp'; }
+ public function getUrl(): ?string { return null; }
+
+ public function load(): void {
+ Util::addScript(Application::APP_ID, Application::APP_ID . '-myWidget');
+ Util::addStyle(Application::APP_ID, 'dashboardWidgets');
+ }
+}
+```
+
+The `getId()` value is the contract between PHP and JavaScript — it must match the string passed to `OCA.Dashboard.register(...)` in step 3.
+
+### 2. Register the widget with Nextcloud
+
+In your app's `lib/AppInfo/Application.php`, register the widget inside `register()`:
+
+```php
+public function register(IRegistrationContext $context): void {
+ $context->registerDashboardWidget(MyWidget::class);
+}
+```
+
+`IManager::getWidgets()` will pick it up on next request — no cache invalidation needed.
+
+### 3. Build a Vue entry-point that registers a renderer
+
+Add a webpack entry in your app's `webpack.config.js`:
+
+```js
+webpackConfig.entry = {
+ main: { import: path.join(__dirname, 'src', 'main.js'), filename: appId + '-main.js' },
+ myWidget: { import: path.join(__dirname, 'src', 'myWidget.js'), filename: appId + '-myWidget.js' },
+}
+```
+
+Create `src/myWidget.js`:
+
+```js
+import Vue from 'vue'
+import { PiniaVuePlugin } from 'pinia'
+import pinia from './pinia.js'
+import MyWidget from './views/widgets/MyWidget.vue'
+
+Vue.use(PiniaVuePlugin)
+
+OCA.Dashboard.register('yourapp_my_widget', (el, { widget }) => {
+ Vue.mixin({ methods: { t, n } })
+ const View = Vue.extend(MyWidget)
+ new View({
+ pinia,
+ propsData: { title: widget.title },
+ }).$mount(el)
+})
+```
+
+The first argument to `OCA.Dashboard.register` **MUST** equal the PHP `getId()` — that's how Nextcloud finds your renderer when MyDash mounts the widget.
+
+### 4. Bundle-size note
+
+Each widget entry-point is currently bundled independently, which means Vue + `@nextcloud/vue` are inlined per entry. When you add a third or fourth widget to the same app, configure webpack `optimization.splitChunks` with `chunks: 'all'` and a `cacheGroups.vendor` rule covering `vue`, `@nextcloud/vue`, `pinia`, and `vue-material-design-icons`, then add a second `Util::addScript()` call in `load()` for the shared chunk. See `pipelinq` and `procest` for a working example. Without this each widget tile costs **~5–10 MB** of duplicated framework code in the user's browser.
+
+### 5. Where the widget appears
+
+MyDash's `index()` controller builds the **Add Widget** picker from `IManager::getWidgets()` (via `WidgetService::getAvailableWidgets()`). Your widget shows up automatically with the title and icon-class declared in your `IWidget`. No template hooks, no front-end registration step in MyDash.
+
+---
+
+## Path 2 — Drive a widget from OpenRegister
+
+OpenRegister stores arbitrary objects against schemas. A common pattern is "show a list of register objects as a dashboard card" — invoices due this week, recently created cases, top contacts. There is **no separate dashboard API for this** — you register a normal `IWidget` and the widget's Vue component fetches its data from the OpenRegister API at runtime.
+
+### What stays the same
+
+- The PHP class still implements `IWidget` and lives under `lib/Dashboard/`.
+- The webpack entry-point and `OCA.Dashboard.register(...)` registration follow Path 1 verbatim.
+- `getId()` ↔ `OCA.Dashboard.register` contract is unchanged.
+
+### What changes — the Vue layer
+
+Your widget component fetches its rows from OpenRegister using `@nextcloud/axios`:
+
+```vue
+
+```
+
+The widget tile is now a thin presentation layer; the data shape is whatever OpenRegister returns for the register/schema pair you query.
+
+### When the data lives in OpenRegister but the *widget* logic is generic
+
+If you want one widget that can be re-pointed at any register/schema (a "list widget" picker), expose `register` and `schema` as configuration on the placement and read them via `widget.config` in the `OCA.Dashboard.register` callback. MyDash's placement editor will surface them under the widget's settings panel as long as they are declared in the widget's `IWidget` options.
+
+### Why not a separate "OpenRegister widget API"?
+
+Two reasons. First, the `IWidget` contract is the only widget API Nextcloud knows — anything else would be a bridge that ultimately delegates back to it. Second, the widget UI varies wildly by use case (a count, a chart, a list, a heat-map) so a generic "render this register as a widget" component would be either too restrictive or too configurable to be useful. Keeping the contract small and pushing the data fetch into the Vue layer keeps each widget cohesive.
+
+---
+
+## Testing your widget
+
+| Step | Command |
+|---|---|
+| Install the app | `occ app:enable yourapp` |
+| Force MyDash to re-list | Reload `/apps/mydash/` |
+| Open the picker | Click **Add widget** in edit mode |
+| Confirm registration | Your widget appears with the title from `getTitle()` |
+
+If the picker shows the widget but it renders blank, the most common cause is that `OCA.Dashboard.register('id', ...)` was called with a different id than `getId()` returns — Nextcloud's registry silently ignores unmatched callbacks. Check the browser console for the warning `No callback registered for widget 'yourapp_my_widget'`.
+
+## See also
+
+- [Widgets vs Tiles](widgets-vs-tiles.md) — the broader explainer on the two surface types in MyDash
+- [Custom Tiles](features/tiles.md) — for static shortcut cards a user creates without writing code
+- [Widgets feature reference](features/widgets.md) — what users can do with widgets once they appear
+- Nextcloud's [`OCP\Dashboard\IWidget` reference](https://docs.nextcloud.com/server/latest/developer_manual/digging_deeper/dashboard.html)
diff --git a/docs/features/admin-settings.md b/docs/features/admin-settings.md
index 694d321b..d6e7ef3e 100644
--- a/docs/features/admin-settings.md
+++ b/docs/features/admin-settings.md
@@ -26,4 +26,4 @@ Admin settings provide Nextcloud administrators with global configuration option
## Screenshot
-
+
diff --git a/docs/features/admin-templates.md b/docs/features/admin-templates.md
index e0be0a44..28d202e7 100644
--- a/docs/features/admin-templates.md
+++ b/docs/features/admin-templates.md
@@ -31,4 +31,4 @@ Admin templates allow Nextcloud administrators to create pre-configured dashboar
## Screenshot
-
+
diff --git a/docs/features/conditional-visibility.md b/docs/features/conditional-visibility.md
index e7790542..23a95788 100644
--- a/docs/features/conditional-visibility.md
+++ b/docs/features/conditional-visibility.md
@@ -29,4 +29,4 @@ Conditional visibility allows widget placements to be shown or hidden based on d
## Screenshot
-
+
diff --git a/docs/features/dashboard-deeplinking.md b/docs/features/dashboard-deeplinking.md
new file mode 100644
index 00000000..fd2def7a
--- /dev/null
+++ b/docs/features/dashboard-deeplinking.md
@@ -0,0 +1,91 @@
+# Dashboard deep-linking
+
+Each dashboard has its own URL — paste, bookmark, or share it. Visit
+`/apps/mydash/{slug-chain}` and the workspace opens directly on that
+dashboard. Switching dashboards in the sidebar updates the URL via
+`pushState`; back/forward navigates between dashboards.
+
+## URL format
+
+```
+/apps/mydash/ → resolver default
+/apps/mydash/finance → top-level slug
+/apps/mydash/finance/q1-roadmap → nested via slug-chain
+/apps/mydash/finance/q1-roadmap/details → arbitrarily deep
+```
+
+Slugs use lowercase ASCII letters, digits, and dashes (per the
+dashboards capability's REQ-DASH-024). Multi-segment chains reflect
+parent → child hierarchy.
+
+## How it works
+
+### Inbound (URL → state)
+
+1. User visits `/apps/mydash/some-slug`.
+2. PHP catch-all route `page#deepLink` matches and dispatches to
+ `PageController::deepLink($deepLink)`.
+3. The controller resolves the slug-chain via
+ `DashboardTreeService::resolvePath()`.
+4. If resolved AND the user can read the dashboard → uses it as
+ active.
+5. If unresolved or no permission → silently falls back to the
+ seven-step resolver, logs a warning. **Never 404s** — old
+ bookmarks always land on something.
+6. Server pushes `deepLinkPath` (the canonical slug-chain) into
+ initial state.
+7. Frontend reads `deepLinkPath` and `replaceState`s the URL to the
+ canonical form. Stale URLs (parent renamed) get normalised
+ in-place without a reload.
+
+### Outbound (state → URL)
+
+1. User clicks a row in the sidebar.
+2. `switchDashboard()` updates `activeDashboard.uuid`.
+3. A watcher on `activeDashboard.uuid` fetches the canonical path
+ via `GET /api/dashboards/{uuid}/path`.
+4. If non-empty → `history.pushState({uuid}, '', '/apps/mydash/' + path)`.
+5. If empty (NULL slug — unaddressable dashboard) → no `pushState`.
+
+### Back / forward
+
+A `popstate` listener on `Views.vue` mount:
+
+1. Strips `/apps/mydash/` from `window.location.pathname`.
+2. Calls `getDashboardByPath(path)` to resolve.
+3. Calls `switchDashboard(uuid)`.
+
+Back / forward between dashboards then matches user expectation.
+
+## Why the catch-all is safe for `/api/...`
+
+The route registration:
+
+```php
+['name' => 'page#deepLink', 'url' => '/{deepLink}', 'verb' => 'GET',
+ 'requirements' => ['deepLink' => '(?!api(?:/|$)).+']],
+```
+
+The negative-lookahead `(?!api(?:/|$))` excludes any path starting
+with `api/` (or just `api`). The Newman integration suite asserts
+`GET /api/health` returns 200 as a regression check.
+
+The catch-all MUST stay LAST in `appinfo/routes.php` so every literal
+`/api/...` route matches first.
+
+## Endpoints
+
+| Method | Endpoint | Purpose |
+|---|---|---|
+| GET | `/api/dashboards/by-path/{path}` | Resolve slug-chain → dashboard envelope (requirements `path: .+`) |
+| GET | `/api/dashboards/{uuid}/path` | Compute canonical slug-chain for a dashboard UUID |
+| GET | `/{deepLink}` (catch-all) | Renders the workspace page with the dashboard pre-resolved |
+
+## Tutorials
+
+- [Bookmark or share a dashboard URL](/docs/tutorials/user/deep-link)
+
+## Spec reference
+
+[`openspec/specs/dashboard-deeplinking/spec.md`](../../openspec/specs/dashboard-deeplinking/spec.md)
+— REQ-DDL-001 through REQ-DDL-007.
diff --git a/docs/features/dashboards.md b/docs/features/dashboards.md
index fd32197f..7b236050 100644
--- a/docs/features/dashboards.md
+++ b/docs/features/dashboards.md
@@ -25,4 +25,4 @@ Dashboards are the core organizational unit in MyDash. Each user can create and
## Screenshot
-
+
diff --git a/docs/features/default-widget-bundle.md b/docs/features/default-widget-bundle.md
new file mode 100644
index 00000000..8834dcc6
--- /dev/null
+++ b/docs/features/default-widget-bundle.md
@@ -0,0 +1,114 @@
+# Default widget bundle
+
+Every newly-created personal dashboard ships with four pre-configured
+widgets so first-create users land on a non-empty grid. Plus a loading
+shim that hides the empty-state CTA during the initial fetch.
+
+## What you get
+
+A new dashboard renders with this layout on a 12-column grid:
+
+| Position | Widget | Where it points |
+|---|---|---|
+| top-left (0–3, 0–2) | Conduction tile | https://conduction.nl |
+| top-middle (4–7, 0–2) | Sendent tile | https://sendent.com |
+| top-right (8–11, 0–2) | Nextcloud tile | https://nextcloud.com |
+| bottom (0–11, 3–7) | Files widget | user's root folder |
+
+Users can immediately drag, resize, replace, or remove any of these.
+
+## When it fires
+
+| Path | Seeded? |
+|---|---|
+| Sidebar **+ Add dashboard** button (`POST /api/dashboard`) | ✅ yes |
+| First-login bootstrap when no admin template applies | ✅ yes (via `createDefaultPlacements()` delegate) |
+| First-login bootstrap when an admin template applies | ❌ no — template-defined widgets only |
+| First-login bootstrap when role-defaults seed something | ❌ no — role-defaults take precedence |
+
+## How it's wired
+
+```
+POST /api/dashboard
+ → DashboardApiController::create()
+ → DashboardService::createDashboard(seedDefaults: true)
+ → DashboardService::seedDefaultWidgets($dashboardId)
+ → 4 × placementMapper->insert()
+ ← envelope: { dashboard, placements: [4 entries] }
+```
+
+The frontend store reads `response.data.placements ?? []` and
+populates `widgetPlacements` directly — no second round-trip.
+
+## Why `tileType='preset'`
+
+Each tile has `tileType='preset'`. This is required for
+[`WidgetPlacement::jsonSerialize()`](../../lib/Db/WidgetPlacement.php)
+to emit the flat `tile*` fields the renderer reads. The `'preset'`
+sentinel is intentionally distinct from the legacy `'custom'` value
+used by the deprecated `oc_mydash_tiles` table — `'custom'` routes
+through the pre-registry tile path in `DashboardGrid.vue`, while
+`'preset'` keeps the placements on the registry-backed `TileWidget`
+renderer.
+
+## Loading shim
+
+The empty-state CTA ("No dashboard yet" / Create dashboard) used to
+flash during the initial fetch because `activeDashboard` is null
+until `loadDashboards()` resolves. [Views.vue](../../src/views/Views.vue)
+now renders `NcLoadingIcon` while `loading=true` and
+`activeDashboard` is null:
+
+```html
+
+
+
+
+
+
+
+```
+
+The empty-state still renders for the legitimate "no dashboard
+exists yet" case (e.g. personal-dashboards disabled by admin AND no
+group default).
+
+## Tutorials
+
+- [Open MyDash for the first time](/docs/tutorials/user/first-launch)
+- [Create a new dashboard](/docs/tutorials/user/create-dashboard)
+
+## Spec reference
+
+[`openspec/specs/default-widget-bundle/spec.md`](../../openspec/specs/default-widget-bundle/spec.md)
+— REQ-DWB-001 through REQ-DWB-006.
+
+## API endpoint shape
+
+```http
+POST /api/dashboard
+Content-Type: application/json
+
+{
+ "name": "My Dashboard",
+ "description": "Optional",
+ "icon": null
+}
+
+→ 201 Created
+{
+ "data": {
+ "dashboard": { "id": …, "uuid": …, "name": "My Dashboard", … },
+ "placements": [
+ {
+ "widgetId": "tile",
+ "tileType": "preset",
+ "tileTitle": "Conduction",
+ "tileLinkValue": "https://conduction.nl",
+ "gridX": 0, "gridY": 0, "gridWidth": 4, "gridHeight": 3
+ },
+ … (3 more)
+ ]
+ }
+}
+```
diff --git a/docs/features/effective-default-marker.md b/docs/features/effective-default-marker.md
new file mode 100644
index 00000000..552bf926
--- /dev/null
+++ b/docs/features/effective-default-marker.md
@@ -0,0 +1,67 @@
+# Effective default marker
+
+The dashboard switcher sidebar marks the user's *effective default
+dashboard* — the one MyDash opens automatically when they visit
+`/apps/mydash/` cold — with a small ★ icon and a hover tooltip.
+
+## What you see
+
+A star icon between the dashboard's own icon and its label, on
+whichever sidebar row is the user's effective default:
+
+
+
+The marker carries:
+
+- `title="Default dashboard — opens automatically when you visit MyDash"` — browser tooltip on hover
+- `aria-label="Default dashboard"` — for screen readers
+- `color: var(--color-warning)` — picks up Nextcloud theme overrides
+
+## "Effective default" — the chain
+
+The marker mirrors the resolver's first five steps (REQ-DASH-018):
+
+```
+1. defaultUuid (the user's pin) ← always wins
+2. primary-group row with isDefault=1 ← admin-set group default
+3. default-group row with isDefault=1 ← org-wide group default
+4. first primary-group row ← when no flag
+5. first default-group row ← when no flag
+6. first personal dashboard ← intentionally NOT marked
+```
+
+Step 6 is excluded by design — silently starring an arbitrary
+personal dashboard the user never marked is more confusing than
+helpful. If no pin and no group default applies, no marker renders.
+
+## Setting / clearing the pin
+
+1. Open the sidebar.
+2. Click the cog (⚙️) on the row you want as your default.
+3. Click **Set as default**.
+4. The marker appears next to that row immediately.
+
+To clear: same flow, the menu entry now reads **Default dashboard**
+with a filled-star icon. Clicking it again clears the pin.
+
+After clearing, the marker either disappears (if no group default
+applies) or moves to the resolver's group fallback.
+
+## Where in the code
+
+| What | File |
+|---|---|
+| `effectiveDefaultUuid` computed | [DashboardSwitcherSidebar.vue](../../src/components/Workspace/DashboardSwitcherSidebar.vue) |
+| `isDefaultDashboard()` helper | same file |
+| `` rendering | same file (3 row templates: primary-group, default-group, personal) |
+| Pin persistence (server) | `setDefaultDashboardPreference()` in `dashboard.js` store |
+| Pin endpoint | `POST /api/dashboards/default` |
+
+## Tutorials
+
+- [Set a default dashboard](/docs/tutorials/user/set-default)
+
+## Spec reference
+
+[`openspec/specs/effective-default-marker/spec.md`](../../openspec/specs/effective-default-marker/spec.md)
+— REQ-EDM-001 through REQ-EDM-005.
diff --git a/docs/features/grid-layout.md b/docs/features/grid-layout.md
index 7f47e05e..275c69f1 100644
--- a/docs/features/grid-layout.md
+++ b/docs/features/grid-layout.md
@@ -26,4 +26,4 @@ The grid layout system powers the drag-and-drop dashboard experience in MyDash,
## Screenshot
-
+
diff --git a/docs/features/permissions.md b/docs/features/permissions.md
index 7289390c..b8492fa4 100644
--- a/docs/features/permissions.md
+++ b/docs/features/permissions.md
@@ -20,4 +20,4 @@ Permission levels control what users can do with their dashboards, especially fo
## Screenshot
-
+
diff --git a/docs/features/prometheus-metrics.md b/docs/features/prometheus-metrics.md
index 7ea90537..f33bf139 100644
--- a/docs/features/prometheus-metrics.md
+++ b/docs/features/prometheus-metrics.md
@@ -33,4 +33,4 @@ MyDash exposes application metrics in Prometheus text exposition format for moni
## Screenshot
-
+
diff --git a/docs/features/tiles.md b/docs/features/tiles.md
index b7a97078..338a22a0 100644
--- a/docs/features/tiles.md
+++ b/docs/features/tiles.md
@@ -22,4 +22,4 @@ Custom tiles are user-created shortcut cards that provide quick access to Nextcl
## Screenshot
-
+
diff --git a/docs/features/widgets.md b/docs/features/widgets.md
index bcadf32e..55f8ab0a 100644
--- a/docs/features/widgets.md
+++ b/docs/features/widgets.md
@@ -23,4 +23,4 @@ Widgets are the primary content blocks on MyDash dashboards. MyDash integrates w
## Screenshot
-
+
diff --git a/docs/i18n/nl/docusaurus-plugin-content-docs/current.json b/docs/i18n/nl/docusaurus-plugin-content-docs/current.json
index 960970a3..b5916a58 100644
--- a/docs/i18n/nl/docusaurus-plugin-content-docs/current.json
+++ b/docs/i18n/nl/docusaurus-plugin-content-docs/current.json
@@ -2,5 +2,45 @@
"version.label": {
"message": "Volgende",
"description": "The label for version current"
+ },
+ "sidebar.tutorialSidebar.category.Tutorials": {
+ "message": "Tutorials",
+ "description": "The label for category 'Tutorials' in sidebar 'tutorialSidebar'"
+ },
+ "sidebar.tutorialSidebar.category.Tutorials.link.generated-index.title": {
+ "message": "MyDash tutorials",
+ "description": "The generated-index page title for category 'Tutorials' in sidebar 'tutorialSidebar'"
+ },
+ "sidebar.tutorialSidebar.category.Tutorials.link.generated-index.description": {
+ "message": "Step-by-step walkthroughs for everyday MyDash tasks. The user track covers personal-dashboard workflows; the admin track covers org-wide configuration and policy.",
+ "description": "The generated-index page description for category 'Tutorials' in sidebar 'tutorialSidebar'"
+ },
+ "sidebar.tutorialSidebar.category.User guide": {
+ "message": "User guide",
+ "description": "The label for category 'User guide' in sidebar 'tutorialSidebar'"
+ },
+ "sidebar.tutorialSidebar.category.User guide.link.generated-index.title": {
+ "message": "User guide",
+ "description": "The generated-index page title for category 'User guide' in sidebar 'tutorialSidebar'"
+ },
+ "sidebar.tutorialSidebar.category.User guide.link.generated-index.description": {
+ "message": "Workflows for individual MyDash users — opening the app, customizing dashboards, switching between them, and pinning a default.",
+ "description": "The generated-index page description for category 'User guide' in sidebar 'tutorialSidebar'"
+ },
+ "sidebar.tutorialSidebar.category.Admin guide": {
+ "message": "Admin guide",
+ "description": "The label for category 'Admin guide' in sidebar 'tutorialSidebar'"
+ },
+ "sidebar.tutorialSidebar.category.Admin guide.link.generated-index.title": {
+ "message": "Admin guide",
+ "description": "The generated-index page title for category 'Admin guide' in sidebar 'tutorialSidebar'"
+ },
+ "sidebar.tutorialSidebar.category.Admin guide.link.generated-index.description": {
+ "message": "Org-wide MyDash administration — toggling personal dashboards, authoring admin templates, marking group defaults, and restricting widgets per role.",
+ "description": "The generated-index page description for category 'Admin guide' in sidebar 'tutorialSidebar'"
+ },
+ "sidebar.tutorialSidebar.category.MyDash — Features": {
+ "message": "MyDash — Features",
+ "description": "The label for category 'MyDash — Features' in sidebar 'tutorialSidebar'"
}
}
diff --git a/docs/package-lock.json b/docs/package-lock.json
index 27419544..2542c349 100644
--- a/docs/package-lock.json
+++ b/docs/package-lock.json
@@ -8,6 +8,7 @@
"name": "mydash-docs",
"version": "0.0.0",
"dependencies": {
+ "@conduction/docusaurus-preset": "^1.2.1",
"@docusaurus/core": "^3.7.0",
"@docusaurus/preset-classic": "^3.7.0",
"@docusaurus/theme-mermaid": "^3.7.0",
@@ -2038,6 +2039,18 @@
"node": ">=0.1.90"
}
},
+ "node_modules/@conduction/docusaurus-preset": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@conduction/docusaurus-preset/-/docusaurus-preset-1.2.1.tgz",
+ "integrity": "sha512-NwkcafPoNaBJLiLyDFCYn45GQUfRQILZmiC2Ms9t2ev2j1+lPE+v5ie7uB/3y2Jba+VC0AlfrBf8qMjAf8mI7A==",
+ "license": "EUPL-1.2",
+ "peerDependencies": {
+ "@docusaurus/core": "^3.0.0",
+ "@docusaurus/preset-classic": "^3.0.0",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ }
+ },
"node_modules/@csstools/cascade-layer-name-parser": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-2.0.5.tgz",
diff --git a/docs/package.json b/docs/package.json
index 1eafdf5b..12177bac 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -15,6 +15,7 @@
"ci": "npm ci --legacy-peer-deps && npm run build"
},
"dependencies": {
+ "@conduction/docusaurus-preset": "^1.2.1",
"@docusaurus/core": "^3.7.0",
"@docusaurus/preset-classic": "^3.7.0",
"@docusaurus/theme-mermaid": "^3.7.0",
diff --git a/docs/role-based-content.md b/docs/role-based-content.md
new file mode 100644
index 00000000..944a86cc
--- /dev/null
+++ b/docs/role-based-content.md
@@ -0,0 +1,127 @@
+# Role-based dashboard content
+
+## What it does
+
+MyDash can restrict which dashboard widgets a user can add to their dashboard
+based on their Nextcloud group(s). When a user belongs to multiple groups,
+the effective allowed-widget set is resolved deterministically via the
+admin-configured `group_order` priority list, with a deny-wins rule for
+cross-group conflicts.
+
+For new users (no admin template applies), MyDash can also seed a starter
+dashboard layout from per-group `RoleLayoutDefault` rows so the first-paint
+experience is tailored to their role.
+
+When **no** RoleFeaturePermission rows exist for any of a user's groups,
+MyDash behaves exactly as before — the full widget catalogue is shown
+(REQ-RFP-009 backwards-compat).
+
+## Key concepts
+
+### RoleFeaturePermission
+
+One row per Nextcloud group. Fields:
+
+| Field | Type | Notes |
+|---|---|---|
+| `groupId` | string | Nextcloud group ID; unique. The reserved id `default` is the catch-all fallback. |
+| `name` | string | Display name shown in the admin UI. |
+| `description` | string | Optional notes. |
+| `allowedWidgets` | string[] | Widget IDs this group may add to their dashboard. Empty = none allowed. |
+| `deniedWidgets` | string[] | Widget IDs explicitly blocked, even if a different group allows them (deny-wins). |
+| `priorityWeights` | object | `{widgetId: integer}` — higher value = higher seeding priority. |
+
+### RoleLayoutDefault
+
+One row per `(groupId, widgetId)` combination. Captures the seed grid
+position and size for one widget when MyDash creates a fresh dashboard for a
+user in that group and no admin template matches.
+
+| Field | Type | Notes |
+|---|---|---|
+| `groupId` | string | The group this default applies to. |
+| `widgetId` | string | The widget to seed at this slot. |
+| `gridX/gridY/gridWidth/gridHeight` | int | Slot geometry (0-based; min size 1). |
+| `sortOrder` | int | Render order within the layout (lower = earlier). |
+| `isCompulsory` | bool | When true, the user can't remove the widget from the seeded layout. |
+
+## Multi-group resolution
+
+Users often belong to multiple groups. MyDash walks the configured
+`group_order` (set in admin settings) and:
+
+1. Picks the first group in `group_order` that the user belongs to AND has a
+ RoleFeaturePermission row → that group's `allowedWidgets` becomes the
+ base set.
+2. Subsequent groups in `group_order` that the user is also in widen the
+ base set (union of their `allowedWidgets`).
+3. ANY group's `deniedWidgets` is removed from the final set (deny-wins).
+4. If no `group_order` group matched, MyDash falls back to the row whose
+ `groupId` is exactly `default`.
+5. If no `default` row exists either, returns null = no restriction (the
+ full catalogue is shown).
+
+Worked example. `group_order = ['managers', 'employees']`. Alice is in
+`['employees', 'managers']`.
+
+| Group | Allowed | Denied |
+|---|---|---|
+| `managers` | `['analytics', 'activity', 'recommendations']` | `[]` |
+| `employees` | `['activity', 'recommendations', 'notes']` | `['analytics']` |
+
+Walk: managers matches first → base = `['analytics', 'activity',
+'recommendations']`. employees also matches → union = `['analytics',
+'activity', 'recommendations', 'notes']`. employees has `denied=['analytics']`
+→ final = `['activity', 'recommendations', 'notes']`.
+
+## Admin UI
+
+Settings → MyDash settings → **Role-based widget permissions**. Lets admins:
+
+- See all configured RoleFeaturePermission rows with allowed/denied widget
+ chips.
+- Add a new permission for a Nextcloud group.
+- Edit allowed/denied widget lists (comma-separated free text).
+- Delete a permission.
+
+The RoleLayoutDefault rows are managed only via the API for now; an admin
+UI for them lands in a follow-up.
+
+## Admin API
+
+Admin-only — the controller calls a `requireAdmin()` guard on every method.
+
+- `GET /api/role-feature-permissions` — list all permissions
+- `POST /api/role-feature-permissions` — upsert by `groupId`
+- `DELETE /api/role-feature-permissions/{id}` — delete by id
+- `GET /api/role-layout-defaults` — list all defaults
+- `POST /api/role-layout-defaults` — upsert by `(groupId, widgetId)`
+- `DELETE /api/role-layout-defaults/{id}` — delete by id
+
+`POST` accepts JSON: `{name, description?, groupId, allowedWidgets,
+deniedWidgets?, priorityWeights?}` for permissions and `{name, groupId,
+widgetId, gridX, gridY, gridWidth, gridHeight, sortOrder, isCompulsory?,
+description?}` for defaults.
+
+## Initial-state contract
+
+The workspace page now ships `allowedWidgets` in its initial state
+(REQ-RFP-010). Possible values:
+
+- `null` — no role-feature-permissions configured for the caller; the
+ frontend must treat this as "no restriction" and show the full
+ catalogue (legacy behaviour).
+- `string[]` — the effective allowed widget set for the caller; the
+ frontend should not show or render any widget whose id is missing.
+
+## Migration
+
+Migration `Version001007Date20260501120000` creates two tables:
+
+- `mydash_role_feature_perms` — one row per group, unique on `group_id`
+- `mydash_role_layout_defaults` — one row per `(group_id, widget_id)`,
+ unique on the combination
+
+Both tables are empty on first migration; nothing is seeded. Existing users
+are unaffected until an admin adds their first row (REQ-RFP-009 scenario:
+"empty config → full catalogue").
diff --git a/docs/screenshots/tutorials/admin/01-admin-settings.png b/docs/screenshots/tutorials/admin/01-admin-settings.png
new file mode 100644
index 00000000..3b447854
Binary files /dev/null and b/docs/screenshots/tutorials/admin/01-admin-settings.png differ
diff --git a/docs/screenshots/tutorials/admin/01-toggle.png b/docs/screenshots/tutorials/admin/01-toggle.png
new file mode 100644
index 00000000..3b447854
Binary files /dev/null and b/docs/screenshots/tutorials/admin/01-toggle.png differ
diff --git a/docs/screenshots/tutorials/admin/02-template-create.png b/docs/screenshots/tutorials/admin/02-template-create.png
new file mode 100644
index 00000000..a4713525
Binary files /dev/null and b/docs/screenshots/tutorials/admin/02-template-create.png differ
diff --git a/docs/screenshots/tutorials/admin/02-template-edit.png b/docs/screenshots/tutorials/admin/02-template-edit.png
new file mode 100644
index 00000000..adf11223
Binary files /dev/null and b/docs/screenshots/tutorials/admin/02-template-edit.png differ
diff --git a/docs/screenshots/tutorials/admin/02-templates-list.png b/docs/screenshots/tutorials/admin/02-templates-list.png
new file mode 100644
index 00000000..bc050402
Binary files /dev/null and b/docs/screenshots/tutorials/admin/02-templates-list.png differ
diff --git a/docs/screenshots/tutorials/admin/03-group-cog.png b/docs/screenshots/tutorials/admin/03-group-cog.png
new file mode 100644
index 00000000..c68e2dcf
Binary files /dev/null and b/docs/screenshots/tutorials/admin/03-group-cog.png differ
diff --git a/docs/screenshots/tutorials/admin/04-role-create.png b/docs/screenshots/tutorials/admin/04-role-create.png
new file mode 100644
index 00000000..e4b05fe8
Binary files /dev/null and b/docs/screenshots/tutorials/admin/04-role-create.png differ
diff --git a/docs/screenshots/tutorials/admin/04-roles-tab.png b/docs/screenshots/tutorials/admin/04-roles-tab.png
new file mode 100644
index 00000000..b1839f8c
Binary files /dev/null and b/docs/screenshots/tutorials/admin/04-roles-tab.png differ
diff --git a/docs/screenshots/tutorials/admin/05-bulk-panel.png b/docs/screenshots/tutorials/admin/05-bulk-panel.png
new file mode 100644
index 00000000..fbcf808c
Binary files /dev/null and b/docs/screenshots/tutorials/admin/05-bulk-panel.png differ
diff --git a/docs/screenshots/tutorials/user/01-first-launch-overview.png b/docs/screenshots/tutorials/user/01-first-launch-overview.png
new file mode 100644
index 00000000..71ba497f
Binary files /dev/null and b/docs/screenshots/tutorials/user/01-first-launch-overview.png differ
diff --git a/docs/screenshots/tutorials/user/02-create-add-button.png b/docs/screenshots/tutorials/user/02-create-add-button.png
new file mode 100644
index 00000000..b5e445c4
Binary files /dev/null and b/docs/screenshots/tutorials/user/02-create-add-button.png differ
diff --git a/docs/screenshots/tutorials/user/02-create-modal.png b/docs/screenshots/tutorials/user/02-create-modal.png
new file mode 100644
index 00000000..2107ae28
Binary files /dev/null and b/docs/screenshots/tutorials/user/02-create-modal.png differ
diff --git a/docs/screenshots/tutorials/user/02-sidebar-open.png b/docs/screenshots/tutorials/user/02-sidebar-open.png
new file mode 100644
index 00000000..635355ec
Binary files /dev/null and b/docs/screenshots/tutorials/user/02-sidebar-open.png differ
diff --git a/docs/screenshots/tutorials/user/03-cog-menu.png b/docs/screenshots/tutorials/user/03-cog-menu.png
new file mode 100644
index 00000000..904ad999
Binary files /dev/null and b/docs/screenshots/tutorials/user/03-cog-menu.png differ
diff --git a/docs/screenshots/tutorials/user/03-edit-mode-enter.png b/docs/screenshots/tutorials/user/03-edit-mode-enter.png
new file mode 100644
index 00000000..815b8fbd
Binary files /dev/null and b/docs/screenshots/tutorials/user/03-edit-mode-enter.png differ
diff --git a/docs/screenshots/tutorials/user/03-widget-added.png b/docs/screenshots/tutorials/user/03-widget-added.png
new file mode 100644
index 00000000..525e64ab
Binary files /dev/null and b/docs/screenshots/tutorials/user/03-widget-added.png differ
diff --git a/docs/screenshots/tutorials/user/03-widget-form.png b/docs/screenshots/tutorials/user/03-widget-form.png
new file mode 100644
index 00000000..238c2026
Binary files /dev/null and b/docs/screenshots/tutorials/user/03-widget-form.png differ
diff --git a/docs/screenshots/tutorials/user/03-widget-picker.png b/docs/screenshots/tutorials/user/03-widget-picker.png
new file mode 100644
index 00000000..e6a2d0d1
Binary files /dev/null and b/docs/screenshots/tutorials/user/03-widget-picker.png differ
diff --git a/docs/screenshots/tutorials/user/04-dragging.png b/docs/screenshots/tutorials/user/04-dragging.png
new file mode 100644
index 00000000..981d1643
Binary files /dev/null and b/docs/screenshots/tutorials/user/04-dragging.png differ
diff --git a/docs/screenshots/tutorials/user/04-edit-mode.png b/docs/screenshots/tutorials/user/04-edit-mode.png
new file mode 100644
index 00000000..2ac1c064
Binary files /dev/null and b/docs/screenshots/tutorials/user/04-edit-mode.png differ
diff --git a/docs/screenshots/tutorials/user/04-reflowed.png b/docs/screenshots/tutorials/user/04-reflowed.png
new file mode 100644
index 00000000..cd6b40c4
Binary files /dev/null and b/docs/screenshots/tutorials/user/04-reflowed.png differ
diff --git a/docs/screenshots/tutorials/user/07-default-marker.png b/docs/screenshots/tutorials/user/07-default-marker.png
new file mode 100644
index 00000000..6aff0490
Binary files /dev/null and b/docs/screenshots/tutorials/user/07-default-marker.png differ
diff --git a/docs/screenshots/tutorials/user/08-url-bar.png b/docs/screenshots/tutorials/user/08-url-bar.png
new file mode 100644
index 00000000..5f167f38
Binary files /dev/null and b/docs/screenshots/tutorials/user/08-url-bar.png differ
diff --git a/docs/screenshots/tutorials/user/09-after-switch.png b/docs/screenshots/tutorials/user/09-after-switch.png
new file mode 100644
index 00000000..880c1b2e
Binary files /dev/null and b/docs/screenshots/tutorials/user/09-after-switch.png differ
diff --git a/docs/screenshots/tutorials/user/10-config-modal.png b/docs/screenshots/tutorials/user/10-config-modal.png
new file mode 100644
index 00000000..8a72253a
Binary files /dev/null and b/docs/screenshots/tutorials/user/10-config-modal.png differ
diff --git a/docs/screenshots/tutorials/user/10-delete-confirm.png b/docs/screenshots/tutorials/user/10-delete-confirm.png
new file mode 100644
index 00000000..8dd4c625
Binary files /dev/null and b/docs/screenshots/tutorials/user/10-delete-confirm.png differ
diff --git a/docs/static/CNAME b/docs/static/CNAME
index 37344b67..ca7998be 100644
--- a/docs/static/CNAME
+++ b/docs/static/CNAME
@@ -1 +1 @@
-mydash.app
+mydash.conduction.nl
diff --git a/docs/static/screenshots/admin-settings.png b/docs/static/screenshots/admin-settings.png
new file mode 100644
index 00000000..d96f2972
Binary files /dev/null and b/docs/static/screenshots/admin-settings.png differ
diff --git a/docs/static/screenshots/customize-panel-dashboards.png b/docs/static/screenshots/customize-panel-dashboards.png
new file mode 100644
index 00000000..c50e87d1
Binary files /dev/null and b/docs/static/screenshots/customize-panel-dashboards.png differ
diff --git a/docs/static/screenshots/customize-panel-widgets.png b/docs/static/screenshots/customize-panel-widgets.png
new file mode 100644
index 00000000..a1ddde18
Binary files /dev/null and b/docs/static/screenshots/customize-panel-widgets.png differ
diff --git a/docs/static/screenshots/mydash-dashboard-overview.png b/docs/static/screenshots/mydash-dashboard-overview.png
new file mode 100644
index 00000000..af39d3b7
Binary files /dev/null and b/docs/static/screenshots/mydash-dashboard-overview.png differ
diff --git a/docs/static/screenshots/template-create-modal.png b/docs/static/screenshots/template-create-modal.png
new file mode 100644
index 00000000..19241f0b
Binary files /dev/null and b/docs/static/screenshots/template-create-modal.png differ
diff --git a/docs/static/screenshots/tutorials/admin/01-admin-settings.png b/docs/static/screenshots/tutorials/admin/01-admin-settings.png
new file mode 100644
index 00000000..3b447854
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/01-admin-settings.png differ
diff --git a/docs/static/screenshots/tutorials/admin/01-toggle.png b/docs/static/screenshots/tutorials/admin/01-toggle.png
new file mode 100644
index 00000000..3b447854
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/01-toggle.png differ
diff --git a/docs/static/screenshots/tutorials/admin/02-template-create.png b/docs/static/screenshots/tutorials/admin/02-template-create.png
new file mode 100644
index 00000000..a4713525
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/02-template-create.png differ
diff --git a/docs/static/screenshots/tutorials/admin/02-template-edit.png b/docs/static/screenshots/tutorials/admin/02-template-edit.png
new file mode 100644
index 00000000..adf11223
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/02-template-edit.png differ
diff --git a/docs/static/screenshots/tutorials/admin/02-templates-list.png b/docs/static/screenshots/tutorials/admin/02-templates-list.png
new file mode 100644
index 00000000..bc050402
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/02-templates-list.png differ
diff --git a/docs/static/screenshots/tutorials/admin/03-group-cog.png b/docs/static/screenshots/tutorials/admin/03-group-cog.png
new file mode 100644
index 00000000..c68e2dcf
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/03-group-cog.png differ
diff --git a/docs/static/screenshots/tutorials/admin/04-role-create.png b/docs/static/screenshots/tutorials/admin/04-role-create.png
new file mode 100644
index 00000000..e4b05fe8
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/04-role-create.png differ
diff --git a/docs/static/screenshots/tutorials/admin/04-roles-tab.png b/docs/static/screenshots/tutorials/admin/04-roles-tab.png
new file mode 100644
index 00000000..b1839f8c
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/04-roles-tab.png differ
diff --git a/docs/static/screenshots/tutorials/admin/05-bulk-panel.png b/docs/static/screenshots/tutorials/admin/05-bulk-panel.png
new file mode 100644
index 00000000..fbcf808c
Binary files /dev/null and b/docs/static/screenshots/tutorials/admin/05-bulk-panel.png differ
diff --git a/docs/static/screenshots/tutorials/user/01-first-launch-overview.png b/docs/static/screenshots/tutorials/user/01-first-launch-overview.png
new file mode 100644
index 00000000..71ba497f
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/01-first-launch-overview.png differ
diff --git a/docs/static/screenshots/tutorials/user/02-create-add-button.png b/docs/static/screenshots/tutorials/user/02-create-add-button.png
new file mode 100644
index 00000000..b5e445c4
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/02-create-add-button.png differ
diff --git a/docs/static/screenshots/tutorials/user/02-create-modal.png b/docs/static/screenshots/tutorials/user/02-create-modal.png
new file mode 100644
index 00000000..2107ae28
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/02-create-modal.png differ
diff --git a/docs/static/screenshots/tutorials/user/02-sidebar-open.png b/docs/static/screenshots/tutorials/user/02-sidebar-open.png
new file mode 100644
index 00000000..635355ec
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/02-sidebar-open.png differ
diff --git a/docs/static/screenshots/tutorials/user/03-cog-menu.png b/docs/static/screenshots/tutorials/user/03-cog-menu.png
new file mode 100644
index 00000000..904ad999
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/03-cog-menu.png differ
diff --git a/docs/static/screenshots/tutorials/user/03-edit-mode-enter.png b/docs/static/screenshots/tutorials/user/03-edit-mode-enter.png
new file mode 100644
index 00000000..815b8fbd
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/03-edit-mode-enter.png differ
diff --git a/docs/static/screenshots/tutorials/user/03-widget-added.png b/docs/static/screenshots/tutorials/user/03-widget-added.png
new file mode 100644
index 00000000..525e64ab
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/03-widget-added.png differ
diff --git a/docs/static/screenshots/tutorials/user/03-widget-form.png b/docs/static/screenshots/tutorials/user/03-widget-form.png
new file mode 100644
index 00000000..238c2026
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/03-widget-form.png differ
diff --git a/docs/static/screenshots/tutorials/user/03-widget-picker.png b/docs/static/screenshots/tutorials/user/03-widget-picker.png
new file mode 100644
index 00000000..e6a2d0d1
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/03-widget-picker.png differ
diff --git a/docs/static/screenshots/tutorials/user/04-dragging.png b/docs/static/screenshots/tutorials/user/04-dragging.png
new file mode 100644
index 00000000..981d1643
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/04-dragging.png differ
diff --git a/docs/static/screenshots/tutorials/user/04-edit-mode.png b/docs/static/screenshots/tutorials/user/04-edit-mode.png
new file mode 100644
index 00000000..2ac1c064
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/04-edit-mode.png differ
diff --git a/docs/static/screenshots/tutorials/user/04-reflowed.png b/docs/static/screenshots/tutorials/user/04-reflowed.png
new file mode 100644
index 00000000..cd6b40c4
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/04-reflowed.png differ
diff --git a/docs/static/screenshots/tutorials/user/07-default-marker.png b/docs/static/screenshots/tutorials/user/07-default-marker.png
new file mode 100644
index 00000000..6aff0490
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/07-default-marker.png differ
diff --git a/docs/static/screenshots/tutorials/user/08-url-bar.png b/docs/static/screenshots/tutorials/user/08-url-bar.png
new file mode 100644
index 00000000..5f167f38
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/08-url-bar.png differ
diff --git a/docs/static/screenshots/tutorials/user/09-after-switch.png b/docs/static/screenshots/tutorials/user/09-after-switch.png
new file mode 100644
index 00000000..880c1b2e
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/09-after-switch.png differ
diff --git a/docs/static/screenshots/tutorials/user/10-config-modal.png b/docs/static/screenshots/tutorials/user/10-config-modal.png
new file mode 100644
index 00000000..8a72253a
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/10-config-modal.png differ
diff --git a/docs/static/screenshots/tutorials/user/10-delete-confirm.png b/docs/static/screenshots/tutorials/user/10-delete-confirm.png
new file mode 100644
index 00000000..8dd4c625
Binary files /dev/null and b/docs/static/screenshots/tutorials/user/10-delete-confirm.png differ
diff --git a/docs/tutorials/_category_.json b/docs/tutorials/_category_.json
new file mode 100644
index 00000000..ab92676a
--- /dev/null
+++ b/docs/tutorials/_category_.json
@@ -0,0 +1,11 @@
+{
+ "label": "Tutorials",
+ "position": 2,
+ "collapsible": true,
+ "collapsed": false,
+ "link": {
+ "type": "generated-index",
+ "title": "MyDash tutorials",
+ "description": "Step-by-step walkthroughs for everyday MyDash tasks. The user track covers personal-dashboard workflows; the admin track covers org-wide configuration and policy."
+ }
+}
diff --git a/docs/tutorials/admin/01-toggle-personal-dashboards.md b/docs/tutorials/admin/01-toggle-personal-dashboards.md
new file mode 100644
index 00000000..2c12899c
--- /dev/null
+++ b/docs/tutorials/admin/01-toggle-personal-dashboards.md
@@ -0,0 +1,63 @@
+---
+sidebar_position: 1
+title: Enable or disable personal dashboards
+description: Allow or prevent end users from creating their own personal dashboards across the instance.
+---
+
+# Enable or disable personal dashboards
+
+The `allow_user_dashboards` flag is the master kill-switch for end-user dashboard creation. When off, MyDash hides the **+ Add dashboard** button, blocks `POST /api/dashboard`, and shows users a localised explainer in the empty state.
+
+## Goal
+
+Toggle the flag on or off and verify the user UI reflects the change.
+
+## Prerequisites
+
+- You must be a Nextcloud admin.
+
+## Steps
+
+### 1. Open MyDash admin settings
+
+Settings menu (avatar) → **Administration settings** → **MyDash** in the left nav.
+
+
+
+### 2. Find the **Personal dashboards** section
+
+The section is near the top of the page. The toggle is labelled **Allow users to create personal dashboards**.
+
+
+
+### 3. Flip the toggle
+
+The change persists immediately — no Save button. The flag is stored in `oc_mydash_admin_settings` (key `allow_user_dashboards`).
+
+### 4. Verify on the user side
+
+Open MyDash as a non-admin user.
+
+- **Toggle on** → **+ Add dashboard** is visible in the sidebar; the empty state shows the standard "Create your first dashboard" CTA.
+- **Toggle off** → button hidden; empty state shows "Personal dashboards are not enabled by your administrator."
+
+
+
+## Behaviour notes
+
+- **Existing personal dashboards survive.** Disabling doesn't delete user dashboards — they remain accessible. Only creation is blocked.
+- **Backend enforcement.** Even if a curl request bypasses the UI, the `POST /api/dashboard` controller calls `assertPersonalDashboardsAllowed()` and responds with `403 personal_dashboards_disabled` when the flag is off.
+- **Group-shared dashboards are unaffected.** Admin templates and group defaults render regardless.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| Toggle is missing | Confirm the user is actually a Nextcloud admin (`occ user:info `). |
+| Users still see + Add after disable | Apache `apc` cache. `docker exec nextcloud apache2ctl graceful` to refresh. |
+| Newly-created users land on no-dashboard | Either the toggle is off, OR no admin template applies — see [Create an admin template](02-admin-templates.md). |
+
+## Reference
+
+- [Admin settings feature reference](../../features/admin-settings.md)
+- [Permissions reference](../../features/permissions.md)
diff --git a/docs/tutorials/admin/02-admin-templates.md b/docs/tutorials/admin/02-admin-templates.md
new file mode 100644
index 00000000..38807387
--- /dev/null
+++ b/docs/tutorials/admin/02-admin-templates.md
@@ -0,0 +1,82 @@
+---
+sidebar_position: 2
+title: Create an admin template dashboard
+description: Build a dashboard centrally and roll it out to every user (or to a specific group) on first login.
+---
+
+# Create an admin template dashboard
+
+An **admin template** is a dashboard the admin authors and assigns to one or more groups. The first time a member of an assigned group opens MyDash, they get a personal copy of the template's layout. Their copy is independent — they can edit, add, and remove widgets without affecting the template or other users.
+
+## When to use this
+
+- You want every member of a group to start with the same dashboard (KPIs, mandatory tools, brand tiles).
+- You're rolling out MyDash for the first time and don't want users to face an empty grid.
+- You manage a multi-tenant Nextcloud where each tenant gets a different starting layout.
+
+## Goal
+
+Author a template, assign it to a group, and verify a member of that group lands on a fresh copy on first login.
+
+## Prerequisites
+
+- You must be a Nextcloud admin.
+- The target group exists in Nextcloud (`occ group:add my-group` if not).
+
+## Steps
+
+### 1. Open MyDash admin settings
+
+Avatar → **Administration settings** → **MyDash**.
+
+### 2. Scroll to **Admin templates**
+
+The section lists every existing template with name, assigned groups, and edit / delete actions.
+
+
+
+### 3. Click **Create template**
+
+The template-create modal opens. Required fields:
+
+- **Name** — internal label for admins. Not user-visible (the user sees a regular dashboard with whatever name you set there).
+- **Group order priority** — comma-separated group ids in priority order. The resolver picks the first group in this list that the user belongs to.
+
+
+
+Optional fields:
+
+- **Compulsory widgets** — list of widget IDs that users cannot remove from their copy. Useful for mandatory KPI cards.
+- **Lock layout** — when on, users get `view_only` permission on their copy (no drag/resize/add/remove). They still see all the data, just can't reshape it.
+
+### 4. Save and edit the template's layout
+
+Saving creates the template row. Click **Edit layout** on the new template — MyDash opens a regular dashboard editor scoped to the template. Add widgets, position them, configure them — exactly like editing a personal dashboard.
+
+
+
+### 5. Verify with a test user
+
+`occ user:add testuser` (or pick an existing member of the assigned group). Log in as that user and open MyDash. The first visit clones the template into a personal dashboard named **My Dashboard** (default) — visible in **MY DASHBOARDS** in the sidebar.
+
+
+
+## Behaviour notes
+
+- **One-shot clone, not a live link.** The user's copy diverges from the template the moment either side changes. Editing the template later doesn't push changes to existing copies — only new users get the latest.
+- **Compulsory widgets stay compulsory across the clone.** They render with a small lock icon in the user's view; **Remove** is greyed out.
+- **Multiple groups → priority list wins.** A user in groups `engineering` and `marketing` with priorities `[engineering, marketing]` gets the engineering template even if marketing's was created later.
+- **No matching group → fallback resolver.** Users who match no template land on the seven-step default-resolution chain: pin → group default → admin defaults → first available.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| Test user sees the empty state, not the template | The user isn't actually in the assigned group, or the group isn't in the priority list. Check `occ user:info`. |
+| Template's compulsory widget shows up but user can remove it | Check the compulsory flag is set on that placement (`isCompulsory=1` in `oc_mydash_widget_placements`). |
+| Template edits aren't propagating | Expected. Templates are clone-on-first-login, not live. |
+
+## Reference
+
+- [Admin templates feature reference](../../features/admin-templates.md)
+- [Permissions reference](../../features/permissions.md)
diff --git a/docs/tutorials/admin/03-group-defaults.md b/docs/tutorials/admin/03-group-defaults.md
new file mode 100644
index 00000000..f98f810b
--- /dev/null
+++ b/docs/tutorials/admin/03-group-defaults.md
@@ -0,0 +1,59 @@
+---
+sidebar_position: 3
+title: Mark a group's default dashboard
+description: Pin a shared dashboard so it's the landing page for everyone in a Nextcloud group.
+---
+
+# Mark a group's default dashboard
+
+While [admin templates](02-admin-templates.md) clone a layout into each user's personal space, **group defaults** keep a shared dashboard visible to everyone in a group. Group defaults appear in the user's sidebar under the **DEFAULT** section and are read-only by default.
+
+## Goal
+
+Mark an existing dashboard as the default for a Nextcloud group, and verify members of that group see it under **DEFAULT**.
+
+## Prerequisites
+
+- A dashboard already exists (created by an admin in the admin context, not a personal user dashboard).
+- The dashboard is shared with the target group via the group-share flow (see admin templates above for the share-with-group path).
+- You must be a Nextcloud admin.
+
+## Steps
+
+### 1. Find the dashboard you want to default
+
+Open MyDash as the admin and navigate to the group dashboard. Open its cog menu.
+
+
+
+### 2. Click **Set as default for <group>**
+
+This is admin-only. The action issues `POST /api/dashboards/group//default` with the dashboard's UUID. The service flips the previous group default off and the new one on **in a single transaction** (REQ-DASH-015), so members never see a flash with no default.
+
+
+
+### 3. Verify on a member's view
+
+Log in as a member of the group. Their sidebar's **DEFAULT** section now lists the dashboard you marked.
+
+
+
+## Behaviour notes
+
+- **One default per group.** Marking a new default for a group automatically clears the previous one — atomic via a database transaction. There's no "two defaults" risk.
+- **Cross-group safety.** If you accidentally try to mark a dashboard as default for a group it isn't shared with, the service rejects with 404 (existence is not leaked across group boundaries — REQ-DASH-015 scenario).
+- **The user's personal pin still wins.** A user who manually pinned their own dashboard via [Set a default dashboard](../user/07-set-default.md) lands on their pin, not the group default. The group default is the fallback when no personal pin exists.
+- **Cleared default → resolver fallback.** If you clear a group default without setting a new one, members land on the org-wide default-group default (or further down the chain).
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| The action is missing from the cog menu | The dashboard isn't shared with the group, or you're not an admin. |
+| Members see no change after marking | OPcache. `docker exec nextcloud apache2ctl graceful` (the dashboard list is cached per-request). |
+| "Cross-group default" error | The dashboard's `groupId` doesn't match the target group. Reshare it first. |
+
+## Reference
+
+- [Dashboards feature reference](../../features/dashboards.md)
+- API: `POST /api/dashboards/group/{groupId}/default`
diff --git a/docs/tutorials/admin/04-restrict-widgets.md b/docs/tutorials/admin/04-restrict-widgets.md
new file mode 100644
index 00000000..d468a136
--- /dev/null
+++ b/docs/tutorials/admin/04-restrict-widgets.md
@@ -0,0 +1,74 @@
+---
+sidebar_position: 4
+title: Restrict widgets per role
+description: Limit which widget types a group of users can see and add.
+---
+
+# Restrict widgets per role
+
+The role-based-content feature (RFP) lets an admin define an allow-list of widget IDs per role, where a role is a Nextcloud group plus a per-feature permission set. Members of restricted roles see a filtered widget picker and a filtered AddWidgetModal type list.
+
+## When to use this
+
+- You want to keep certain widgets out of specific groups (e.g. hide HR-Notes from engineering).
+- You want to enforce a minimal widget set for compliance / security reasons.
+- You're rolling out MyDash to non-technical users and want to reduce cognitive load by hiding rarely-used widgets.
+
+## Goal
+
+Define a role with a widget allow-list, assign it to a group, and verify members see only the allowed widgets.
+
+## Prerequisites
+
+- You must be a Nextcloud admin.
+- The target group exists.
+
+## Steps
+
+### 1. Open MyDash admin settings → **Roles**
+
+
+
+### 2. Click **Create role**
+
+Required fields:
+
+- **Name** — internal label.
+- **Group** — Nextcloud group id this role applies to.
+- **Allowed widgets** — multi-select of widget IDs (e.g. `recommendations`, `activity`, `tile`, `files`, `text`).
+
+
+
+Leave the allow-list empty to mean "no restriction" (the default for users not in any role).
+
+### 3. Save
+
+The role row appears in the list. The allow-list is persisted in `oc_mydash_roles` and surfaced via `RoleFeaturePermissionService::getAllowedWidgetIds(userId)`.
+
+### 4. Verify on a member's view
+
+As a member of the assigned group, open MyDash and the widget picker:
+
+
+
+Only the allowed widget IDs appear. Existing placements of disallowed widgets keep rendering — the filter only applies to **adding new** widgets.
+
+## Behaviour notes
+
+- **Permissive default.** Users with no matching role see the full widget catalogue. No role assignment ⇒ no restriction.
+- **Multiple groups, multiple roles → union.** A user in groups `eng` and `pm` with roles defining different allow-lists sees the **union** of all their roles' allowed widgets.
+- **Role changes take effect on next page load.** The allow-list is baked into initial state, not refetched live.
+- **Backend enforcement is on display only.** The RFP allow-list is filtering the picker, not blocking the API. A determined user could still POST a placement with a disallowed widgetId — that's by design (the backend doesn't gate on widget type for placement creation, only on dashboard permission). If you need hard backend enforcement, file an issue.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| Member still sees all widgets | Reload the page. Allow-list is read on initial state, not live. |
+| Allow-list empty but member sees nothing | Empty list = "no restriction" (full catalogue). If you want to show *nothing*, that's not a supported state — use admin templates with `view_only` instead. |
+| Two roles conflict | Multi-role membership is a union. There's no priority / override. |
+
+## Reference
+
+- [Role-based content feature reference](../../role-based-content.md)
+- [Permissions reference](../../features/permissions.md)
diff --git a/docs/tutorials/admin/05-bulk-operations.md b/docs/tutorials/admin/05-bulk-operations.md
new file mode 100644
index 00000000..7d7e1c8a
--- /dev/null
+++ b/docs/tutorials/admin/05-bulk-operations.md
@@ -0,0 +1,80 @@
+---
+sidebar_position: 5
+title: Bulk operations on dashboards
+description: Move, delete, or reindex many dashboards in one shot from the admin UI.
+---
+
+# Bulk operations on dashboards
+
+Bulk operations are admin-only and let you act on many dashboards at once instead of one cog-menu click at a time. Available operations:
+
+- **Bulk move** — reassign a set of dashboards to a different group.
+- **Bulk delete** — drop a set of dashboards (and their placements).
+- **Bulk reindex** — refresh the search index entries (rare; useful after a schema change or a search fall-out).
+- **Bulk status** — read a set's publication state without changing anything.
+
+All four sit behind admin endpoints; the UI is in **Admin settings → MyDash → Bulk operations**.
+
+## When to use this
+
+- Cleaning up after a bad migration that left orphan dashboards.
+- Reorganising groups (e.g. merging `eng-old` into `eng`).
+- Refreshing search results after touching widget content fields.
+
+## Goal
+
+Move every dashboard in `eng-old` into `eng`, then delete the now-empty `eng-old` group.
+
+## Prerequisites
+
+- You must be a Nextcloud admin.
+- Take a database backup first. Bulk delete is destructive and irreversible.
+
+## Steps
+
+### 1. Open Bulk operations
+
+
+
+### 2. Filter the dashboard list
+
+Filter controls: by group id, by `source`, by date range, by name match. The selected rows update live.
+
+
+
+### 3. Pick **Move** and the target group
+
+Select the rows (or **Select all matching**), open the action dropdown, pick **Move**, type or pick the target group id (`eng`).
+
+
+
+### 4. Confirm
+
+A confirmation dialog summarises what's about to happen — count, source group, target group, the same fields as a `dry-run` query.
+
+The action issues `POST /api/admin/dashboards/bulk-move` with the UUIDs and target group id. Service-side this is a single transaction: all rows or nothing.
+
+
+
+### 5. Repeat for **Delete** on the empty group
+
+After the move, refilter on `eng-old` (now zero hits if move succeeded), or just delete the group itself outside MyDash via `occ group:delete eng-old`.
+
+## Behaviour notes
+
+- **All-or-nothing transactions.** Move and delete wrap in a DB transaction. A partial failure rolls back, so you never end up with half-moved sets.
+- **Audit trail.** Every bulk operation logs to the Nextcloud activity stream with the actor user id, action, and affected count. Useful for incident response.
+- **Reindex is async.** Bulk reindex enqueues background jobs — the response returns immediately with the job count, and reindexing happens in `cron.php` runs.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| "Some rows could not be moved" | The transaction rolled back. Look at the error detail — likely a slug-collision in the target group. Rename a colliding dashboard first. |
+| Bulk delete didn't reduce row count | Pre-check the filter; you may have deleted nothing. |
+| Reindex queued but search still stale | `cron.php` hasn't run since enqueue. Trigger manually: `docker exec nextcloud php cron.php`. |
+
+## Reference
+
+- [Dashboards feature reference](../../features/dashboards.md)
+- API: `POST /api/admin/dashboards/bulk-{move,delete,reindex,status}`
diff --git a/docs/tutorials/admin/_category_.json b/docs/tutorials/admin/_category_.json
new file mode 100644
index 00000000..4c74ef3c
--- /dev/null
+++ b/docs/tutorials/admin/_category_.json
@@ -0,0 +1,11 @@
+{
+ "label": "Admin guide",
+ "position": 2,
+ "collapsible": true,
+ "collapsed": true,
+ "link": {
+ "type": "generated-index",
+ "title": "Admin guide",
+ "description": "Org-wide MyDash administration — toggling personal dashboards, authoring admin templates, marking group defaults, and restricting widgets per role."
+ }
+}
diff --git a/docs/tutorials/user/01-first-launch.md b/docs/tutorials/user/01-first-launch.md
new file mode 100644
index 00000000..1402b8c6
--- /dev/null
+++ b/docs/tutorials/user/01-first-launch.md
@@ -0,0 +1,59 @@
+---
+sidebar_position: 1
+title: Open MyDash for the first time
+description: What you see the first time you open MyDash, and what each part of the screen does.
+---
+
+# Open MyDash for the first time
+
+The very first time you open MyDash, the app creates a personal dashboard for you and seeds it with a default widget bundle so you have something to look at right away. This page is a tour of what's on screen.
+
+## Goal
+
+Recognise the parts of the MyDash workspace and know where to find the controls you'll use in every other tutorial.
+
+## Steps
+
+### 1. Click the MyDash icon in the Nextcloud app launcher
+
+Or visit `/apps/mydash/` directly. After a moment of loading you land on **My Dashboard** with the default widget bundle already in place:
+
+
+
+The bundle ships with three preconfigured tile widgets on the top row — Conduction, Sendent, and Nextcloud — plus a Files widget below.
+
+### 2. Open the dashboard sidebar
+
+Click the **hamburger** button in the top-right to slide out the sidebar:
+
+
+
+The sidebar is your jumping-off point for everything — switching between dashboards, creating new ones, configuring an existing one, and pinning a default.
+
+### 3. Scan the sidebar layout
+
+Three sections, top-down:
+
+- **DEFAULT** — group-shared dashboards your administrator marked as defaults for your group. Read-only by default.
+- **MY DASHBOARDS** — your personal dashboards. The currently-active one is highlighted in primary colour.
+- **Powered by** footer — brand attribution, doesn't affect functionality.
+
+A ★ next to a row marks that dashboard as your **default** — the one MyDash opens automatically when you visit `/apps/mydash/` cold. See [Set a default dashboard](07-set-default.md).
+
+### 4. Open a dashboard's cog menu
+
+Each row carries a cog (⚙️) on the right. Click it for the per-dashboard action menu:
+
+
+
+From here you can edit the layout, configure metadata, add custom widgets, pin as default, and delete. Most of the rest of the user guide is built around these actions.
+
+## What's next
+
+- [Create a new dashboard](02-create-dashboard.md) — start with a fresh canvas.
+- [Add a widget](03-add-widget.md) — drop content blocks onto a dashboard.
+
+## Reference
+
+- [Dashboards feature reference](../../features/dashboards.md)
+- [Widgets feature reference](../../features/widgets.md)
diff --git a/docs/tutorials/user/02-create-dashboard.md b/docs/tutorials/user/02-create-dashboard.md
new file mode 100644
index 00000000..6cbc8c70
--- /dev/null
+++ b/docs/tutorials/user/02-create-dashboard.md
@@ -0,0 +1,58 @@
+---
+sidebar_position: 2
+title: Create a new dashboard
+description: Add a new personal dashboard alongside the one you already have.
+---
+
+# Create a new dashboard
+
+Each personal dashboard is an independent canvas — its own layout, its own widget set, its own URL. You can have as many as you need (e.g. one per project, one per workflow, one per role).
+
+## Goal
+
+Create a new personal dashboard, give it a name, optionally add a description and an icon, and land on it ready to add widgets.
+
+## Prerequisites
+
+- Personal dashboards must be enabled by your admin. If the **+ Add dashboard** button is hidden in the sidebar, it has been switched off — see your administrator (or the [admin guide](../admin/01-toggle-personal-dashboards.md)).
+
+## Steps
+
+### 1. Open the sidebar and click **+ Add dashboard**
+
+
+
+### 2. Fill in the create modal
+
+A configuration modal opens with these fields:
+
+- **Name** — required. Used for the sidebar label and the URL slug.
+- **Description** — optional. Shown in admin tooling and inside the configuration modal.
+- **Icon** — optional. Pick from the registered icon set, or paste a URL for a custom icon (see [Dashboard icons capability](../../features/dashboards.md)).
+
+
+
+### 3. Click **Save**
+
+The new dashboard is auto-activated, appears at the top of **MY DASHBOARDS** in the sidebar, and is bootstrapped with the default widget bundle (three tiles + a Files widget). You can now [add more widgets](03-add-widget.md), [reposition them](04-reposition-resize.md), or [pin this as your default](07-set-default.md).
+
+
+
+## Verification
+
+- The sidebar shows your new dashboard's name, highlighted as active.
+- The URL bar reads `/apps/mydash/` — the slug is auto-derived from the name.
+- The grid contains the four default placements (Conduction tile, Sendent tile, Nextcloud tile, Files widget).
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| **+ Add dashboard** button is missing | Personal dashboards are disabled by your admin. |
+| Save button is disabled | The Name field is empty — required. |
+| "Slug must be unique among siblings" error | A dashboard with the same auto-derived slug already exists. Pick a different name or set an explicit slug via [Dashboard configuration](10-rename-or-delete.md). |
+
+## Reference
+
+- [Dashboards feature reference](../../features/dashboards.md)
+- [Grid layout reference](../../features/grid-layout.md)
diff --git a/docs/tutorials/user/03-add-widget.md b/docs/tutorials/user/03-add-widget.md
new file mode 100644
index 00000000..4d06a855
--- /dev/null
+++ b/docs/tutorials/user/03-add-widget.md
@@ -0,0 +1,75 @@
+---
+sidebar_position: 3
+title: Add a widget
+description: Drop a built-in Nextcloud widget or a MyDash custom widget onto a dashboard.
+---
+
+# Add a widget
+
+Widgets are the content blocks on a dashboard. MyDash supports two families:
+
+- **Nextcloud widgets** — Mail, Activity, Calendar, Talk, Files, etc. Discovered automatically from every installed Nextcloud app via the `IManager` widget API.
+- **MyDash custom widgets** — Label, Text, Image, Link button, Divider, Files (rich), People, Quicklinks, News, Video, Calendar, Links, Menu, Container, Tile.
+
+Both families use the same picker UI and live on the same grid.
+
+## Goal
+
+Add a new widget to your active dashboard.
+
+## Prerequisites
+
+- A dashboard you can edit (your own personal dashboard, or one your admin granted you `add_only` or `full` permission on).
+
+## Steps
+
+### 1. Enter edit mode
+
+Open the active dashboard's cog menu and click **Edit dashboard**. The grid shows resize handles on each placement.
+
+
+
+### 2. Open the **Add widget** modal
+
+From the cog menu pick **Add custom widget…** for the MyDash custom families, or click the empty-cell **+** marker that appears in edit mode for built-in Nextcloud widgets.
+
+
+
+### 3. Pick a widget type
+
+The picker groups widgets by family. Hover for a one-line description. Custom widgets that haven't shipped a configuration form yet are filtered out — only types you can actually configure show.
+
+### 4. Configure the per-type form
+
+Each custom widget type opens its own form. Some examples:
+
+- **Tile** — title, icon (registry key, URL, emoji, or SVG), background colour, link type (`app` or `url`), link value.
+- **Text** — markdown body, font size, alignment, optional table mode.
+- **Files** — folder path, view mode (list / grid), optional filters and upload toggle.
+- **Link button** — single-link button or list mode with multiple links.
+
+
+
+### 5. Click **Save**
+
+The new placement appears in the grid at the next available cell. You can immediately drag it where you want — see [Reposition & resize widgets](04-reposition-resize.md).
+
+
+
+## Verification
+
+- The widget renders with the content you configured.
+- Reloading the page brings it back at the same grid position with the same content (changes are persisted to `oc_mydash_widget_placements`).
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| Widget type isn't in the picker | Either the type has no configuration form yet, or your admin's role-based widget allow-list excludes it (see [admin: restrict widgets per role](../admin/04-restrict-widgets.md)). |
+| Widget renders empty | The type-specific config (e.g. Tile `linkValue`) wasn't filled. Right-click → **Edit** to fix. |
+| "You don't have permission" toast | The dashboard's permission level is `view_only` for you. Owners or admins can change it. |
+
+## Reference
+
+- [Widgets feature reference](../../features/widgets.md)
+- [Widgets vs tiles explainer](../../widgets-vs-tiles.md)
diff --git a/docs/tutorials/user/04-reposition-resize.md b/docs/tutorials/user/04-reposition-resize.md
new file mode 100644
index 00000000..d7074560
--- /dev/null
+++ b/docs/tutorials/user/04-reposition-resize.md
@@ -0,0 +1,68 @@
+---
+sidebar_position: 4
+title: Reposition & resize widgets
+description: Drag and resize widgets on the grid; the layout snaps and saves automatically.
+---
+
+# Reposition & resize widgets
+
+The MyDash grid is built on **GridStack** — a 12-column responsive grid where every widget snaps to whole-cell boundaries. Layouts persist as soon as you let go of the mouse.
+
+## Goal
+
+Move a widget to a new position and resize it.
+
+## Prerequisites
+
+- A dashboard you can edit (`add_only` or `full` permission).
+- Two or more widgets on the dashboard so the swap-on-collision behaviour is visible.
+
+## Steps
+
+### 1. Enter edit mode
+
+Cog menu → **Edit dashboard**. Drag/resize handles appear on every placement; the cog button on the active row flips to **Save dashboard** with a save icon.
+
+
+
+### 2. Reposition by dragging the title bar
+
+Click and hold anywhere on a widget's header (or its drag handle if the widget overrides the title row). Drop targets light up as you move; release to snap into place. Other widgets reflow around the drop point.
+
+
+
+
+
+### 3. Resize from the corner / edge handles
+
+Each widget has a bottom-right corner handle for diagonal resize and edge handles for single-axis resize. Minimum size is 2×2 cells; the registry-defined widget size hints take precedence on first placement but you can resize freely afterwards.
+
+
+
+### 4. Save the layout
+
+Click **Save dashboard** in the top-right (the cog button switches into save mode while editing). MyDash issues a single `PUT /api/dashboard/{id}` with the new layout.
+
+
+
+:::tip
+The grid auto-saves on drop, so the explicit Save button is mostly a confirmation that no other layout changes are pending. If you reload mid-edit you'll see the auto-saved state, not a draft.
+:::
+
+## Verification
+
+- Reload the page. Widgets are at the new positions and sizes.
+- Open the database (or `GET /api/dashboard`) — the placement rows reflect the new `gridX` / `gridY` / `gridWidth` / `gridHeight`.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| Widget snaps back after release | The drop target collided with a `compulsory` widget your admin pinned. Move it to a free cell instead. |
+| Can't resize below a certain width | The widget's per-type minimum (set in `widgetRegistry.js`) is enforced. Widen the column count via [Dashboard configuration](10-rename-or-delete.md) if you need more horizontal space. |
+| Drag handle is captured by the widget body | Some widgets handle their own click events. Drag from the top edge of the title bar instead. |
+
+## Reference
+
+- [Grid layout reference](../../features/grid-layout.md)
+- [Permissions reference](../../features/permissions.md)
diff --git a/docs/tutorials/user/05-edit-content.md b/docs/tutorials/user/05-edit-content.md
new file mode 100644
index 00000000..04fe8170
--- /dev/null
+++ b/docs/tutorials/user/05-edit-content.md
@@ -0,0 +1,77 @@
+---
+sidebar_position: 5
+title: Edit widget content & style
+description: Change a widget's content, colours, custom title, or border without removing it.
+---
+
+# Edit widget content & style
+
+Each placement carries two layers of configuration:
+
+- **Content** — type-specific fields (text body, link URL, folder path, …). Changes the widget's payload.
+- **Style** — borders, background colour, custom title override, custom icon override. Cosmetic.
+
+Both are editable post-add without removing the widget.
+
+## Goal
+
+Edit a widget you already added — both its content and its visual style.
+
+## Prerequisites
+
+- A dashboard you can edit, with at least one widget on it.
+
+## Steps
+
+### 1. Right-click the widget
+
+In edit mode, right-clicking a widget opens a context menu anchored at the cursor:
+
+
+
+Options:
+- **Edit** — opens the per-type configuration form (same as during add).
+- **Style** — opens the cosmetic style editor.
+- **Remove** — see [Remove a widget](06-remove-widget.md).
+- **Cancel** — close the menu.
+
+### 2. Edit content
+
+Pick **Edit**. The same `AddWidgetModal` you used to add the widget reopens, this time pre-filled with the current placement's content. Change fields and **Save**.
+
+
+
+### 3. Edit style
+
+Pick **Style** instead. The dedicated `WidgetStyleEditor` opens with these controls:
+
+- **Custom title** — overrides the widget's default title (leave blank for default).
+- **Custom icon** — registry key, URL, or empty for default.
+- **Show title** — toggle the title bar on/off.
+- **Border** — colour and thickness.
+- **Background colour** — solid, transparent, or theme-bound.
+
+
+
+The style is persisted as a JSON blob in `placement.styleConfig`; it doesn't touch the widget's content.
+
+### 4. Save
+
+Both modals close on **Save** and the change is reflected immediately.
+
+## Verification
+
+- Reload the page. Content / style changes are still applied.
+- The widget header reflects any custom title; widget background reflects any custom colour.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| **Edit** is disabled | The widget type has no configuration form (renderer-only widgets). Use **Style** for cosmetics, or remove and re-add. |
+| Custom icon doesn't render | The icon string isn't a registered registry key and not a valid URL. See [Dashboard icons capability](../../features/dashboards.md). |
+| Title row gone after toggling **Show title** | Re-open the style editor and toggle it back on, OR set a custom title. |
+
+## Reference
+
+- [Widgets feature reference](../../features/widgets.md)
diff --git a/docs/tutorials/user/06-remove-widget.md b/docs/tutorials/user/06-remove-widget.md
new file mode 100644
index 00000000..814ae1c5
--- /dev/null
+++ b/docs/tutorials/user/06-remove-widget.md
@@ -0,0 +1,53 @@
+---
+sidebar_position: 6
+title: Remove a widget
+description: Take a widget off the dashboard without deleting the dashboard itself.
+---
+
+# Remove a widget
+
+Removing a widget deletes its placement row (`oc_mydash_widget_placements`) but doesn't touch the dashboard or the widget's underlying data.
+
+## Goal
+
+Remove one widget from a dashboard.
+
+## Prerequisites
+
+- A dashboard you can edit.
+- The widget must not be marked **compulsory** by an admin template (compulsory widgets can only be removed by an admin).
+
+## Steps
+
+### 1. Right-click the widget
+
+In edit mode, right-click anywhere on the widget. The context menu opens at the cursor.
+
+
+
+### 2. Click **Remove**
+
+The menu auto-closes and the placement disappears from the grid. The DELETE call fires immediately; there is no undo.
+
+
+
+:::caution
+The remove is destructive. If you're unsure, [edit the style](05-edit-content.md) and toggle **Show title** off — it hides the placement without deleting it.
+:::
+
+## Verification
+
+- The widget is gone from the grid.
+- Reload — it stays gone (the row was deleted server-side).
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| **Remove** is greyed out | The widget is `isCompulsory=1` on this dashboard (admin-pinned). Ask your admin to lift it. |
+| Removing throws "permission denied" | Your permission level on the dashboard is `view_only`. |
+
+## Reference
+
+- [Widgets feature reference](../../features/widgets.md)
+- [Permissions reference](../../features/permissions.md)
diff --git a/docs/tutorials/user/07-set-default.md b/docs/tutorials/user/07-set-default.md
new file mode 100644
index 00000000..1855b68b
--- /dev/null
+++ b/docs/tutorials/user/07-set-default.md
@@ -0,0 +1,63 @@
+---
+sidebar_position: 7
+title: Set a default dashboard
+description: Pin one dashboard so MyDash opens to it automatically.
+---
+
+# Set a default dashboard
+
+When you visit `/apps/mydash/` cold (no specific dashboard URL) MyDash needs to pick one to land you on. The default-resolution chain is:
+
+1. **Your pin** — the dashboard you marked **Default dashboard** in the cog menu.
+2. Your primary group's default dashboard (if your admin set one).
+3. The org-wide default group's default dashboard.
+4. The first available group dashboard.
+5. The first available default-group dashboard.
+6. The first of your personal dashboards.
+
+This page is about step 1 — the per-user pin. It always wins over the resolver fallback chain.
+
+## Goal
+
+Pin a dashboard as your personal default; verify the ★ marker appears in the sidebar.
+
+## Steps
+
+### 1. Open the sidebar and click the cog on the row you want as default
+
+
+
+### 2. Click **Set as default**
+
+(If the row is already your default the entry reads **Default dashboard** with a filled-star icon — clicking it now would clear the pin.)
+
+### 3. Watch the ★ appear next to that row
+
+The sidebar redraws with a small star to the left of the dashboard name on the pinned row:
+
+
+
+The marker carries a tooltip on hover: *"Default dashboard — opens automatically when you visit MyDash"*. Screen readers announce the same string via `aria-label`.
+
+### 4. Verify on next cold-load
+
+Close the tab. Visit `/apps/mydash/` from scratch. You land on the pinned dashboard.
+
+## Behaviour notes
+
+- **The marker is reactive.** It moves immediately when you pin a different dashboard or clear the pin.
+- **The marker can land on any section.** Personal pin in **MY DASHBOARDS**, group dashboard in the primary-group section, default-group dashboard in **DEFAULT** — all carry the same star.
+- **No pin set?** The marker still appears on whichever group dashboard the resolver would land on (the same fallback chain — steps 2-5 above).
+
+
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| Star doesn't appear after clicking | Reload the page once. If it's still missing, check that your environment is running development-branch mydash (the marker shipped recently). |
+| The star is on the wrong row after a rename | The pin is by UUID, which is stable across renames — so this shouldn't happen. If it does, clear and re-pin. |
+
+## Reference
+
+- [Permissions reference](../../features/permissions.md)
diff --git a/docs/tutorials/user/08-deep-link.md b/docs/tutorials/user/08-deep-link.md
new file mode 100644
index 00000000..165b6cf6
--- /dev/null
+++ b/docs/tutorials/user/08-deep-link.md
@@ -0,0 +1,79 @@
+---
+sidebar_position: 8
+title: Bookmark or share a dashboard URL
+description: Each dashboard has its own URL — paste, bookmark, or share it.
+---
+
+# Bookmark or share a dashboard URL
+
+Every dashboard has a stable, friendly URL based on its slug-chain. You can paste it into the address bar, bookmark it, or share it with a colleague who has access to that dashboard.
+
+## URL format
+
+```
+http://your-nextcloud.example/apps/mydash/
+```
+
+For nested dashboards (parent → child), the chain reflects the hierarchy:
+
+```
+/apps/mydash/finance
+/apps/mydash/finance/q1-roadmap
+/apps/mydash/finance/q1-roadmap/details
+```
+
+The slug for a dashboard is auto-derived from its name when you create it; you can set an explicit slug via [Dashboard configuration](10-rename-or-delete.md).
+
+## Goal
+
+Open a dashboard via its URL, and verify the URL stays in sync as you switch dashboards inside the app.
+
+## Steps
+
+### 1. Find the slug for a dashboard
+
+The address bar already shows it after the slash:
+
+
+
+Or check **Dashboard configuration…** in the cog menu — the slug is editable there.
+
+### 2. Paste the URL into a new tab
+
+Open a new tab and paste `http://localhost:8080/apps/mydash/`. MyDash loads with that dashboard already active — no extra clicks needed.
+
+
+
+### 3. Switch dashboards in the sidebar
+
+Click another row. The URL updates immediately via `history.pushState` — the page doesn't reload but the address bar is now the slug-chain of the new dashboard.
+
+
+
+### 4. Use the browser back / forward buttons
+
+Each switch pushed a history entry. **Back** returns you to the previous dashboard; **Forward** goes the other way. The dashboard re-resolves via the existing `getDashboardByPath` API, so your active dashboard tracks the URL exactly as expected.
+
+## Behaviour notes
+
+- **Stale or unknown slug.** If you visit a URL whose slug-chain doesn't resolve (renamed, deleted, you don't have access), MyDash silently falls back to your default dashboard rather than 404. Old bookmarks always land on something.
+- **Renamed parents.** When a parent dashboard is renamed, the canonical slug-chain for its descendants changes. The server normalises the URL on every load — visiting `/apps/mydash/old-parent/child` lands on the same dashboard, and the address bar updates to `/apps/mydash/new-parent/child` automatically (via `history.replaceState`).
+- **Dashboard with no slug.** Some dashboards have no slug (NULL slug field). They have no addressable URL — the URL bar shows just `/apps/mydash/` and switching to one doesn't push history.
+
+## Verification
+
+- Cold-load the URL → the dashboard renders without a flash of the empty state.
+- Sidebar click → URL changes. Browser back → URL reverts. Active dashboard matches the URL.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| URL doesn't change when switching | The destination dashboard has no slug. Set one in **Dashboard configuration…**. |
+| Cold-load lands on the wrong dashboard | The URL slug doesn't resolve to a visible-to-you dashboard — fallback fired. Check the slug spelling. |
+| API requests 404 after a deep-link | Shouldn't happen — the catch-all route excludes `/api/...`. If you see this, file an issue with the request URL. |
+
+## Reference
+
+- [Dashboards feature reference](../../features/dashboards.md)
+- API endpoint: `GET /api/dashboards/by-path/{path}` and `GET /api/dashboards/{uuid}/path`
diff --git a/docs/tutorials/user/09-switch-dashboards.md b/docs/tutorials/user/09-switch-dashboards.md
new file mode 100644
index 00000000..fb654062
--- /dev/null
+++ b/docs/tutorials/user/09-switch-dashboards.md
@@ -0,0 +1,48 @@
+---
+sidebar_position: 9
+title: Switch between dashboards
+description: Move between your dashboards via the sidebar.
+---
+
+# Switch between dashboards
+
+The sidebar is the only switching surface — there's no top toolbar dropdown after `runtime-shell-trim`.
+
+## Goal
+
+Switch from the active dashboard to a different one.
+
+## Steps
+
+### 1. Open the sidebar
+
+Click the hamburger button (top-right). The sidebar slides in from the left:
+
+
+
+### 2. Click the row of the dashboard you want
+
+The sidebar closes immediately (per `REQ-SWITCH-002`); the workspace re-renders with the selected dashboard's layout. The URL updates via `history.pushState` so back/forward navigates between dashboards (see [Bookmark or share a dashboard URL](08-deep-link.md)).
+
+
+
+### 3. The active row is highlighted
+
+When you reopen the sidebar later, the now-active dashboard's row carries the `.active` class (primary-colour highlight) so you can tell at a glance where you are.
+
+## Behaviour notes
+
+- **Edit mode resets on switch.** If you were in edit mode on the previous dashboard, your unsaved changes were already auto-saved (per drop), but the new dashboard opens in view mode. Re-enter edit mode via the cog menu on the new active row.
+- **Sidebar auto-closes.** This is intentional — keep the sidebar a transient overlay rather than a persistent navigation pane.
+- **Keyboard.** With the sidebar open, **Tab** through rows; **Enter** or **Space** activates a row exactly like a click.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| Sidebar doesn't close after click | A dashboard switch failed (e.g. you no longer have access). Check the toast for the error. |
+| Active highlight is on the wrong row | Reload the page once. The store reconciles `activeDashboardId` with the server's truth. |
+
+## Reference
+
+- [Dashboards feature reference](../../features/dashboards.md)
diff --git a/docs/tutorials/user/10-rename-or-delete.md b/docs/tutorials/user/10-rename-or-delete.md
new file mode 100644
index 00000000..941fb0eb
--- /dev/null
+++ b/docs/tutorials/user/10-rename-or-delete.md
@@ -0,0 +1,78 @@
+---
+sidebar_position: 10
+title: Rename or delete a dashboard
+description: Edit a dashboard's name, description, icon, slug, or remove it entirely.
+---
+
+# Rename or delete a dashboard
+
+Both flows live behind the same modal: **Dashboard configuration…** opens an editor; the same modal carries the **Delete** button.
+
+## Goal
+
+Change a dashboard's metadata (name, description, icon, slug) — or delete it.
+
+## Prerequisites
+
+- You must own the dashboard (or be an admin) to delete it.
+- At least one personal dashboard must remain — MyDash blocks the delete on your last one.
+
+## Steps
+
+### 1. Open the cog menu on the row
+
+
+
+### 2. Click **Dashboard configuration…**
+
+The configuration modal opens with the current values pre-filled:
+
+
+
+Editable fields:
+
+- **Name** — required.
+- **Description** — optional.
+- **Icon** — registry key, URL, or empty for default.
+- **Slug** — affects the dashboard's URL. Auto-derived from the name on first save; you can override.
+- **Sort order** — position in its sibling list.
+- **Set as default** — toggle (same effect as the cog menu's **Set as default**).
+
+### 3a. Rename — change fields and click **Save**
+
+The dashboard updates in place. Slug changes propagate to the URL: if you were viewing `/apps/mydash/old-slug` your address bar updates to the new slug via `history.replaceState`.
+
+### 3b. Delete — click the **Delete dashboard** button
+
+A confirmation prompt appears. Confirming issues `DELETE /api/dashboard/{id}`; the row is removed from the sidebar and the active dashboard falls back to your default (or the next available).
+
+
+
+:::danger Permanent
+Deleting a dashboard removes it AND every widget placement on it. There is no undo. Tile data, widget configs, and the dashboard row itself are all dropped.
+:::
+
+## Verification (rename)
+
+- Sidebar row shows the new name.
+- URL bar reflects the new slug if you were on that dashboard.
+- The change persists across reloads.
+
+## Verification (delete)
+
+- Sidebar row is gone.
+- Active dashboard is now your default (or the next available).
+- `GET /api/dashboards` returns the same row count minus one.
+
+## Common issues
+
+| Symptom | Fix |
+|---|---|
+| **Delete dashboard** is greyed out | This is your last personal dashboard. Create another one first. |
+| "Slug must be unique among siblings" on save | Pick a different slug or rename a sibling. |
+| Slug field rejected | Slugs allow lowercase ASCII letters, digits, and dashes. The configuration modal validates client-side. |
+
+## Reference
+
+- [Dashboards feature reference](../../features/dashboards.md)
+- API: `PUT /api/dashboard/{id}`, `DELETE /api/dashboard/{id}`
diff --git a/docs/tutorials/user/_category_.json b/docs/tutorials/user/_category_.json
new file mode 100644
index 00000000..a1d03550
--- /dev/null
+++ b/docs/tutorials/user/_category_.json
@@ -0,0 +1,11 @@
+{
+ "label": "User guide",
+ "position": 1,
+ "collapsible": true,
+ "collapsed": false,
+ "link": {
+ "type": "generated-index",
+ "title": "User guide",
+ "description": "Workflows for individual MyDash users — opening the app, customizing dashboards, switching between them, and pinning a default."
+ }
+}
diff --git a/eslint.config.js b/eslint.config.js
index 59c1fd63..b3480510 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -41,7 +41,15 @@ module.exports = defineConfig([{
'no-debugger': 'off',
},
}, {
- files: ['src/**/*.test.js', 'src/**/*.spec.js', 'src/__tests__/**/*.js'],
+ // Test files may import devDependencies (vitest, @vue/test-utils, etc.)
+ // without violating `n/no-unpublished-import`.
+ files: [
+ 'src/**/__tests__/**/*.{js,ts}',
+ 'src/**/*.test.js',
+ 'src/**/*.spec.js',
+ 'src/__tests__/**/*.js',
+ 'tests/**/*.{js,ts}',
+ ],
rules: {
'n/no-unpublished-import': 'off',
},
diff --git a/img/activity/dashboard_commented.svg b/img/activity/dashboard_commented.svg
new file mode 100644
index 00000000..271a1bcc
--- /dev/null
+++ b/img/activity/dashboard_commented.svg
@@ -0,0 +1 @@
+
diff --git a/img/activity/dashboard_created.svg b/img/activity/dashboard_created.svg
new file mode 100644
index 00000000..b7a73a48
--- /dev/null
+++ b/img/activity/dashboard_created.svg
@@ -0,0 +1 @@
+
diff --git a/img/activity/dashboard_deleted.svg b/img/activity/dashboard_deleted.svg
new file mode 100644
index 00000000..2b756049
--- /dev/null
+++ b/img/activity/dashboard_deleted.svg
@@ -0,0 +1 @@
+
diff --git a/img/activity/dashboard_lock_overridden.svg b/img/activity/dashboard_lock_overridden.svg
new file mode 100644
index 00000000..8aca1b18
--- /dev/null
+++ b/img/activity/dashboard_lock_overridden.svg
@@ -0,0 +1 @@
+
diff --git a/img/activity/dashboard_public_share_created.svg b/img/activity/dashboard_public_share_created.svg
new file mode 100644
index 00000000..e098389d
--- /dev/null
+++ b/img/activity/dashboard_public_share_created.svg
@@ -0,0 +1 @@
+
diff --git a/img/activity/dashboard_published.svg b/img/activity/dashboard_published.svg
new file mode 100644
index 00000000..d9062069
--- /dev/null
+++ b/img/activity/dashboard_published.svg
@@ -0,0 +1 @@
+
diff --git a/img/activity/dashboard_reacted.svg b/img/activity/dashboard_reacted.svg
new file mode 100644
index 00000000..587b79ab
--- /dev/null
+++ b/img/activity/dashboard_reacted.svg
@@ -0,0 +1 @@
+
diff --git a/img/activity/dashboard_restored.svg b/img/activity/dashboard_restored.svg
new file mode 100644
index 00000000..f8f43d87
--- /dev/null
+++ b/img/activity/dashboard_restored.svg
@@ -0,0 +1 @@
+
diff --git a/img/activity/dashboard_role_changed.svg b/img/activity/dashboard_role_changed.svg
new file mode 100644
index 00000000..6bbd7aa6
--- /dev/null
+++ b/img/activity/dashboard_role_changed.svg
@@ -0,0 +1 @@
+
diff --git a/img/activity/dashboard_scheduled.svg b/img/activity/dashboard_scheduled.svg
new file mode 100644
index 00000000..490a3098
--- /dev/null
+++ b/img/activity/dashboard_scheduled.svg
@@ -0,0 +1 @@
+
diff --git a/img/activity/dashboard_shared.svg b/img/activity/dashboard_shared.svg
new file mode 100644
index 00000000..4b28a12c
--- /dev/null
+++ b/img/activity/dashboard_shared.svg
@@ -0,0 +1 @@
+
diff --git a/img/activity/dashboard_unpublished.svg b/img/activity/dashboard_unpublished.svg
new file mode 100644
index 00000000..680cd09b
--- /dev/null
+++ b/img/activity/dashboard_unpublished.svg
@@ -0,0 +1 @@
+
diff --git a/img/activity/dashboard_updated.svg b/img/activity/dashboard_updated.svg
new file mode 100644
index 00000000..852fdd46
--- /dev/null
+++ b/img/activity/dashboard_updated.svg
@@ -0,0 +1 @@
+
diff --git a/img/activity/mydash.svg b/img/activity/mydash.svg
new file mode 100644
index 00000000..d661b00c
--- /dev/null
+++ b/img/activity/mydash.svg
@@ -0,0 +1 @@
+
diff --git a/img/conduction-logo.png b/img/conduction-logo.png
new file mode 100644
index 00000000..72bfe982
Binary files /dev/null and b/img/conduction-logo.png differ
diff --git a/img/sendent-logo.png b/img/sendent-logo.png
new file mode 100644
index 00000000..f76a4b19
Binary files /dev/null and b/img/sendent-logo.png differ
diff --git a/img/showcases/de-bron.png b/img/showcases/de-bron.png
new file mode 100644
index 00000000..d202abbb
Binary files /dev/null and b/img/showcases/de-bron.png differ
diff --git a/img/showcases/de-linden.png b/img/showcases/de-linden.png
new file mode 100644
index 00000000..d202abbb
Binary files /dev/null and b/img/showcases/de-linden.png differ
diff --git a/img/showcases/gemeente-duin.png b/img/showcases/gemeente-duin.png
new file mode 100644
index 00000000..d202abbb
Binary files /dev/null and b/img/showcases/gemeente-duin.png differ
diff --git a/img/showcases/horizon-labs.png b/img/showcases/horizon-labs.png
new file mode 100644
index 00000000..d202abbb
Binary files /dev/null and b/img/showcases/horizon-labs.png differ
diff --git a/img/showcases/van-der-berg.png b/img/showcases/van-der-berg.png
new file mode 100644
index 00000000..d202abbb
Binary files /dev/null and b/img/showcases/van-der-berg.png differ
diff --git a/l10n/en.js b/l10n/en.js
index 59a0730f..067263d8 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1,26 +1,52 @@
OC.L10N.register(
"mydash",
{
+ "— Choose —" : "— Choose —",
+ "Dashboard metadata" : "Dashboard metadata",
+ "Failed to update dashboard metadata" : "Failed to update dashboard metadata",
+ "No metadata fields are configured. Ask an administrator to add some." : "No metadata fields are configured. Ask an administrator to add some.",
+ "Save metadata" : "Save metadata",
+ "Saving…" : "Saving…",
+ "Yes" : "Yes",
+ "No" : "No",
+ "%1$s is now yours" : "%1$s is now yours",
+ "%1$s shared %2$s with you" : "%1$s shared %2$s with you",
+ "%1$s shared **%2$s** with you" : "%1$s shared **%2$s** with you",
+ "(removed)" : "(removed)",
+ "**%1$s** is now yours" : "**%1$s** is now yours",
+ "+ New Dashboard" : "+ New Dashboard",
"Access denied" : "Access denied",
+ "Action Type" : "Action Type",
"Active" : "Active",
+ "Active groups" : "Active groups",
"Add" : "Add",
+ "Add Widget" : "Add Widget",
+ "Add custom widget…" : "Add custom widget…",
+ "Add dashboard" : "Add dashboard",
+ "Add link" : "Add link",
"Add only" : "Add only",
"Add to dashboard" : "Add to dashboard",
+ "Add-only access" : "Add-only access",
+ "Admin privileges required" : "Admin privileges required",
"Airplane" : "Airplane",
"Alignment" : "Alignment",
"All users" : "All users",
"Allow users to create custom dashboards" : "Allow users to create custom dashboards",
"Allow users to have multiple dashboards" : "Allow users to have multiple dashboards",
+ "Allowed file extensions" : "Allowed file extensions",
+ "Allowed image formats: jpeg, jpg, png, gif, svg, webp" : "Allowed image formats: jpeg, jpg, png, gif, svg, webp",
"Already added" : "Already added",
"Alt Text" : "Alt Text",
"Are you sure you want to delete this dashboard?" : "Are you sure you want to delete this dashboard?",
"Are you sure you want to delete this template?" : "Are you sure you want to delete this template?",
+ "At least one link is required for list mode" : "At least one link is required for list mode",
"Audio" : "Audio",
"Background" : "Background",
"Background Color" : "Background Color",
"Background color" : "Background color",
"Bell" : "Bell",
"Bike" : "Bike",
+ "Body must contain a base64 data URL" : "Body must contain a base64 data URL",
"Bold" : "Bold",
"Building" : "Building",
"Bus" : "Bus",
@@ -29,32 +55,47 @@ OC.L10N.register(
"Camera" : "Camera",
"Cancel" : "Cancel",
"Cannot delete the only dashboard in the group" : "Cannot delete the only dashboard in the group",
+ "Cannot exceed maximum tree depth of 5 levels" : "Cannot exceed maximum tree depth of 5 levels",
"Car" : "Car",
"Center" : "Center",
"Certificate" : "Certificate",
"Checkmark" : "Checkmark",
+ "Choose a widget…" : "Choose a widget…",
"Clock" : "Clock",
"Close" : "Close",
"Cogwheel" : "Cogwheel",
"Color" : "Color",
+ "Comma-separated list of file extensions admins allow link-button widgets to create" : "Comma-separated list of file extensions admins allow link-button widgets to create",
+ "Compact" : "Compact",
"Comment" : "Comment",
"Configure dashboard permissions and defaults" : "Configure dashboard permissions and defaults",
+ "Contact your administrator" : "Contact your administrator",
"Contacts" : "Contacts",
"Contain" : "Contain",
"Cover" : "Cover",
+ "Create" : "Create",
+ "Create Document" : "Create Document",
+ "Create File" : "Create File",
"Create dashboard" : "Create dashboard",
"Create dashboard templates that will be applied to users based on their groups." : "Create dashboard templates that will be applied to users based on their groups.",
"Create template" : "Create template",
"Create tile" : "Create tile",
+ "Create your first dashboard" : "Create your first dashboard",
"Create your first dashboard to get started" : "Create your first dashboard to get started",
+ "Creating…" : "Creating…",
"Custom title" : "Custom title",
"Customize" : "Customize",
"Customize widget" : "Customize widget",
"Dashboard creation not allowed" : "Dashboard creation not allowed",
"Dashboard does not belong to this group" : "Dashboard does not belong to this group",
+ "Dashboard has children. Use ?cascade=true to delete the subtree." : "Dashboard has children. Use ?cascade=true to delete the subtree.",
+ "Dashboard not found at path" : "Dashboard not found at path",
"Dashboard name" : "Dashboard name",
+ "Dashboard not found" : "Dashboard not found",
+ "Dashboard not found in group" : "Dashboard not found in group",
"Dashboard templates" : "Dashboard templates",
"Dashboards" : "Dashboards",
+ "Declared image type does not match file content" : "Declared image type does not match file content",
"Default" : "Default",
"Default grid columns" : "Default grid columns",
"Default permission level" : "Default permission level",
@@ -62,70 +103,135 @@ OC.L10N.register(
"Delete" : "Delete",
"Delete dashboard" : "Delete dashboard",
"Description" : "Description",
+ "Disabling this only blocks creating new personal dashboards. Existing personal dashboards remain visible and editable." : "Disabling this only blocks creating new personal dashboards. Existing personal dashboards remain visible and editable.",
+ "Display Mode" : "Display Mode",
"Document" : "Document",
"Documentation" : "Documentation",
"Download" : "Download",
+ "Drag groups between the columns to control which Nextcloud groups MyDash uses, and in what order. The first active group becomes the user's primary workspace." : "Drag groups between the columns to control which Nextcloud groups MyDash uses, and in what order. The first active group becomes the user's primary workspace.",
+ "Each link requires a label and a URL" : "Each link requires a label and a URL",
"Earth" : "Earth",
"Edit" : "Edit",
+ "Edit Widget" : "Edit Widget",
"Edit dashboard" : "Edit dashboard",
"Edit template" : "Edit template",
"Edit tile" : "Edit tile",
+ "Enter filename" : "Enter filename",
"Enter tile title" : "Enter tile title",
"Euro" : "Euro",
+ "External Link" : "External Link",
+ "Failed to create document" : "Failed to create document",
+ "Failed to fork dashboard" : "Failed to fork dashboard",
+ "Failed to load group list." : "Failed to load group list.",
+ "Failed to save group order." : "Failed to save group order.",
+ "Failed to set the group default dashboard" : "Failed to set the group default dashboard",
+ "Failed to store resource" : "Failed to store resource",
+ "Failed to upload icon" : "Failed to upload icon",
"Failed to upload image" : "Failed to upload image",
"Favorite" : "Favorite",
+ "File Extension" : "File Extension",
+ "File Name" : "File Name",
"Files" : "Files",
"Fill" : "Fill",
+ "Filter" : "Filter",
+ "Filter active groups" : "Filter active groups",
+ "Filter inactive groups" : "Filter inactive groups",
"Fit" : "Fit",
"Flower" : "Flower",
"Folder" : "Folder",
"Font Size" : "Font Size",
"Font Weight" : "Font Weight",
+ "Font size" : "Font size",
"Forbidden: admin only" : "Forbidden: admin only",
+ "Full access" : "Full access",
"Full customization" : "Full customization",
"Group" : "Group",
+ "Group dashboards" : "Group dashboards",
+ "Group order saved." : "Group order saved.",
+ "Group priority order" : "Group priority order",
+ "Group-shared dashboards" : "Group-shared dashboards",
"Heart" : "Heart",
"Home" : "Home",
+ "Horizontal (cards)" : "Horizontal (cards)",
"House" : "House",
"Icon" : "Icon",
+ "Icon (optional)" : "Icon (optional)",
+ "Icon preview" : "Icon preview",
"Image" : "Image",
"Image URL is required" : "Image URL is required",
+ "Image content appears to be corrupt" : "Image content appears to be corrupt",
"Image failed to load" : "Image failed to load",
+ "Inactive groups" : "Inactive groups",
"Integration" : "Integration",
+ "Internal Function" : "Internal Function",
+ "Invalid permissionLevel" : "Invalid permissionLevel",
+ "Invalid shareType" : "Invalid shareType",
"Justify" : "Justify",
"Label" : "Label",
+ "Label is required" : "Label is required",
+ "Label text" : "Label text",
"Label text is required" : "Label text is required",
"Left" : "Left",
"Light bulb" : "Light bulb",
"Lightning" : "Lightning",
"Link" : "Link",
"Link (optional)" : "Link (optional)",
+ "Link Button" : "Link Button",
+ "Links" : "Links",
+ "List Item Spacing" : "List Item Spacing",
+ "List Orientation" : "List Orientation",
+ "List of links" : "List of links",
+ "Loading group dashboards…" : "Loading group dashboards…",
+ "Loading group list…" : "Loading group list…",
+ "Loading…" : "Loading…",
"Mail" : "Mail",
"Manage dashboards" : "Manage dashboards",
"Map" : "Map",
+ "Maximum image size: 5 MB. Allowed types: jpeg, jpg, png, gif, svg, webp." : "Maximum image size: 5 MB. Allowed types: jpeg, jpg, png, gif, svg, webp.",
+ "Maximum of 20 links per list widget" : "Maximum of 20 links per list widget",
+ "Maximum size is 5MB" : "Maximum size is 5MB",
"Megaphone" : "Megaphone",
"Missing tile ID" : "Missing tile ID",
"Monitoring" : "Monitoring",
"Monument" : "Monument",
+ "Move link down" : "Move link down",
+ "Move link up" : "Move link up",
+ "Move to active" : "Move to active",
+ "Move to inactive" : "Move to inactive",
"Multiple dashboards not allowed" : "Multiple dashboards not allowed",
+ "My Dashboards" : "My Dashboards",
+ "My copy of %s" : "My copy of %s",
"My dashboard" : "My dashboard",
+ "My dashboards" : "My dashboards",
"My template" : "My template",
"MyDash" : "MyDash",
"MyDash settings" : "MyDash settings",
"New tile" : "New tile",
+ "Nextcloud Widget" : "Nextcloud Widget",
+ "No active groups. Drag groups here from the inactive column." : "No active groups. Drag groups here from the inactive column.",
"No dashboard available" : "No dashboard available",
"No dashboard yet" : "No dashboard yet",
+ "No dashboards available" : "No dashboards available",
"No dashboards yet" : "No dashboards yet",
+ "No group-shared dashboards in this group yet." : "No group-shared dashboards in this group yet.",
"No image" : "No image",
+ "No inactive groups." : "No inactive groups.",
+ "No items available" : "No items available",
+ "No links yet. Click \"Add link\" to add one." : "No links yet. Click \"Add link\" to add one.",
+ "No matches." : "No matches.",
"No templates yet" : "No templates yet",
"No text content" : "No text content",
+ "No widget types available" : "No widget types available",
"No widgets found" : "No widgets found",
"None" : "None",
"Normal" : "Normal",
"Not logged in" : "Not logged in",
"Office" : "Office",
+ "Open menu" : "Open menu",
+ "Parent dashboard not found" : "Parent dashboard not found",
"Optional description" : "Optional description",
"Or enter Image URL" : "Or enter Image URL",
+ "Ownership transferred after the previous owner was removed" : "Ownership transferred after the previous owner was removed",
"Park" : "Park",
"Parking" : "Parking",
"Permission level" : "Permission level",
@@ -133,19 +239,36 @@ OC.L10N.register(
"Personal dashboards are not enabled by your administrator" : "Personal dashboards are not enabled by your administrator",
"Phone" : "Phone",
"Picture" : "Picture",
+ "Please enter a file name" : "Please enter a file name",
+ "Powered by" : "Powered by",
+ "Promote a single dashboard per group as the default. Members of the group will land on it when they have no personal preference yet." : "Promote a single dashboard per group as the default. Members of the group will land on it when they have no personal preference yet.",
+ "Remove" : "Remove",
+ "Remove link" : "Remove link",
"Reset" : "Reset",
+ "Resource exceeds the 5 MB serving cap" : "Resource exceeds the 5 MB serving cap",
"Right" : "Right",
"SVG could not be parsed or contained no allowed content" : "SVG could not be parsed or contained no allowed content",
"Save" : "Save",
+ "Save failed" : "Save failed",
+ "Saving…" : "Saving…",
"Search" : "Search",
"Search widgets..." : "Search widgets...",
+ "Select Widget" : "Select Widget",
"Select dashboard" : "Select dashboard",
"Select groups (leave empty for all users)" : "Select groups (leave empty for all users)",
+ "Select icon…" : "Select icon…",
+ "Set as default" : "Set as default",
"Set as default template" : "Set as default template",
"Setting as default app" : "Setting as default app",
"Settings" : "Settings",
+ "Setting this parent would create a cycle" : "Setting this parent would create a cycle",
"Share" : "Share",
+ "Shared access" : "Shared access",
+ "Slug must be unique among siblings" : "Slug must be unique among siblings",
+ "Slug must match [a-z0-9_-]+ and be at most 128 characters" : "Slug must match [a-z0-9_-]+ and be at most 128 characters",
"Show title" : "Show title",
+ "Single button" : "Single button",
+ "Spacious" : "Spacious",
"Star" : "Star",
"Switch to this dashboard" : "Switch to this dashboard",
"Tag" : "Tag",
@@ -159,31 +282,614 @@ OC.L10N.register(
"To make MyDash the default app for users, go to Settings > Administration > Theming and select MyDash as the default app." : "To make MyDash the default app for users, go to Settings > Administration > Theming and select MyDash as the default app.",
"Tree" : "Tree",
"URL" : "URL",
+ "URL is required" : "URL is required",
"Upload" : "Upload",
+ "Upload Icon (optional)" : "Upload Icon (optional)",
"Upload Image" : "Upload Image",
+ "Upload icon" : "Upload icon",
+ "Uploading…" : "Uploading…",
+ "Use JSON body with base64 field" : "Use JSON body with base64 field",
"User" : "User",
+ "Vertical (list)" : "Vertical (list)",
"Video" : "Video",
"View only" : "View only",
+ "View-only access" : "View-only access",
"Wallet" : "Wallet",
"Widget" : "Widget",
"Widget not available" : "Widget not available",
"Widget style" : "Widget style",
"Widget title" : "Widget title",
+ "Widget type" : "Widget type",
"Widgets" : "Widgets",
+ "Your primary group for shared dashboards" : "Your primary group for shared dashboards",
"https://example.com or /apps/files" : "https://example.com or /apps/files",
- "Link Button" : "Link Button",
- "Action Type" : "Action Type",
- "External Link" : "External Link",
- "Internal Function" : "Internal Function",
- "Create File" : "Create File",
- "Upload Icon (optional)" : "Upload Icon (optional)",
- "Create Document" : "Create Document",
- "File Name" : "File Name",
- "Enter filename" : "Enter filename",
- "Create" : "Create",
- "Creating…" : "Creating…",
- "Failed to create document" : "Failed to create document",
- "Please enter a file name" : "Please enter a file name"
+ "shareWith is required" : "shareWith is required",
+ "publishAt must be a future timestamp" : "publishAt must be a future timestamp",
+ "Forbidden: owner or admin only" : "Forbidden: owner or admin only",
+ "Publish dashboard" : "Publish dashboard",
+ "Unpublish dashboard" : "Unpublish dashboard",
+ "Schedule dashboard" : "Schedule dashboard",
+ "Draft" : "Draft",
+ "Published" : "Published",
+ "Scheduled" : "Scheduled",
+ "Backup, restore & migrate dashboards" : "Backup, restore & migrate dashboards",
+ "Download a versioned ZIP archive of all dashboards in this MyDash instance, or upload a previously exported archive to restore or migrate it. The archive is portable across Nextcloud installations." : "Download a versioned ZIP archive of all dashboards in this MyDash instance, or upload a previously exported archive to restore or migrate it. The archive is portable across Nextcloud installations.",
+ "Download all dashboards" : "Download all dashboards",
+ "Exporting…" : "Exporting…",
+ "Export failed. Please try again." : "Export failed. Please try again.",
+ "Import a dashboard archive (.zip)" : "Import a dashboard archive (.zip)",
+ "Preserve original dashboard UUIDs (fail on collision)" : "Preserve original dashboard UUIDs (fail on collision)",
+ "Upload archive" : "Upload archive",
+ "Importing…" : "Importing…",
+ "Imported {imported} dashboards, skipped {skipped}." : "Imported {imported} dashboards, skipped {skipped}.",
+ "Import failed. Please try again." : "Import failed. Please try again.",
+ "Import from Confluence" : "Import from Confluence",
+ "Upload a Confluence HTML export ZIP archive. Each Confluence page becomes a MyDash dashboard with a single full-width text widget; the page hierarchy is mirrored using the dashboard tree." : "Upload a Confluence HTML export ZIP archive. Each Confluence page becomes a MyDash dashboard with a single full-width text widget; the page hierarchy is mirrored using the dashboard tree.",
+ "Confluence export archive (.zip)" : "Confluence export archive (.zip)",
+ "Optional parent dashboard UUID (root pages will be slotted under this dashboard)" : "Optional parent dashboard UUID (root pages will be slotted under this dashboard)",
+ "Leave empty to import as root dashboards" : "Leave empty to import as root dashboards",
+ "Dry-run preview" : "Dry-run preview",
+ "Inspecting…" : "Inspecting…",
+ "Import dashboards" : "Import dashboards",
+ "{pages} pages, {attachments} attachments — would create {dashboards} dashboards." : "{pages} pages, {attachments} attachments — would create {dashboards} dashboards.",
+ "Assets would be uploaded to {folder}" : "Assets would be uploaded to {folder}",
+ "Assets uploaded to {folder}" : "Assets uploaded to {folder}",
+ "Confluence dry-run failed. Please try again." : "Confluence dry-run failed. Please try again.",
+ "Confluence import failed. Please try again." : "Confluence import failed. Please try again.",
+ "You created dashboard {dashboard}" : "You created dashboard {dashboard}",
+ "{actor} created dashboard {dashboard}" : "{actor} created dashboard {dashboard}",
+ "You updated dashboard {dashboard}" : "You updated dashboard {dashboard}",
+ "{actor} updated dashboard {dashboard}" : "{actor} updated dashboard {dashboard}",
+ "You deleted dashboard {dashboard}" : "You deleted dashboard {dashboard}",
+ "{actor} deleted dashboard {dashboard}" : "{actor} deleted dashboard {dashboard}",
+ "You published dashboard {dashboard}" : "You published dashboard {dashboard}",
+ "{actor} published dashboard {dashboard}" : "{actor} published dashboard {dashboard}",
+ "You unpublished dashboard {dashboard}" : "You unpublished dashboard {dashboard}",
+ "{actor} unpublished dashboard {dashboard}" : "{actor} unpublished dashboard {dashboard}",
+ "You scheduled dashboard {dashboard}" : "You scheduled dashboard {dashboard}",
+ "{actor} scheduled dashboard {dashboard}" : "{actor} scheduled dashboard {dashboard}",
+ "You shared dashboard {dashboard} with {recipient}" : "You shared dashboard {dashboard} with {recipient}",
+ "{actor} shared dashboard {dashboard} with {recipient}" : "{actor} shared dashboard {dashboard} with {recipient}",
+ "You created a public link for dashboard {dashboard}" : "You created a public link for dashboard {dashboard}",
+ "{actor} created a public link for dashboard {dashboard}" : "{actor} created a public link for dashboard {dashboard}",
+ "You commented on dashboard {dashboard}" : "You commented on dashboard {dashboard}",
+ "{actor} commented on dashboard {dashboard}" : "{actor} commented on dashboard {dashboard}",
+ "You reacted to dashboard {dashboard}" : "You reacted to dashboard {dashboard}",
+ "{actor} reacted to dashboard {dashboard}" : "{actor} reacted to dashboard {dashboard}",
+ "You restored dashboard {dashboard} to an earlier version" : "You restored dashboard {dashboard} to an earlier version",
+ "{actor} restored dashboard {dashboard} to an earlier version" : "{actor} restored dashboard {dashboard} to an earlier version",
+ "You overrode the lock on dashboard {dashboard}" : "You overrode the lock on dashboard {dashboard}",
+ "{actor} overrode the lock on dashboard {dashboard}" : "{actor} overrode the lock on dashboard {dashboard}",
+ "Your role in {dashboard} was changed to {role}" : "Your role in {dashboard} was changed to {role}",
+ "{actor} changed {target}'s role in {dashboard} to {role}" : "{actor} changed {target}'s role in {dashboard} to {role}",
+ "Dashboard Admin" : "Dashboard Admin",
+ "Dashboard Editor" : "Dashboard Editor",
+ "Dashboard Viewer" : "Dashboard Viewer",
+ "Either userId or groupId must be provided" : "Either userId or groupId must be provided",
+ "Only one of userId or groupId may be provided" : "Only one of userId or groupId may be provided",
+ "Unknown role; must be one of admin, editor, viewer" : "Unknown role; must be one of admin, editor, viewer",
+ "Unknown user" : "Unknown user",
+ "Unknown group" : "Unknown group",
+ "That target already has the requested role assigned" : "That target already has the requested role assigned",
+ "Role assignment not found" : "Role assignment not found",
+ "Role assignment payload is invalid" : "Role assignment payload is invalid",
+ "Comments" : "Comments",
+ "Post comment" : "Post comment",
+ "Reply" : "Reply",
+ "Edited" : "Edited",
+ "Comments are disabled on this dashboard" : "Comments are disabled on this dashboard",
+ "Comment message must not be empty" : "Comment message must not be empty",
+ "Comments can only be replied to once" : "Comments can only be replied to once",
+ "Only the author or an admin may modify this comment" : "Only the author or an admin may modify this comment",
+ "Comment not found" : "Comment not found",
+ "Parent comment not found" : "Parent comment not found",
+ "Are you sure you want to delete this comment?" : "Are you sure you want to delete this comment?",
+ "Deleting this top-level comment will also remove all its replies." : "Deleting this top-level comment will also remove all its replies.",
+ "Write a comment…" : "Write a comment…",
+ "Write a reply…" : "Write a reply…",
+ "Cancel reply" : "Cancel reply",
+ "%1$s mentioned you in a dashboard comment" : "%1$s mentioned you in a dashboard comment",
+ "Open the dashboard to read the comment" : "Open the dashboard to read the comment",
+ "Emoji not allowed" : "Emoji not allowed",
+ "Reactions are disabled" : "Reactions are disabled",
+ "Permission denied" : "Permission denied",
+ "React" : "React",
+ "Reactions" : "Reactions",
+ "Version history" : "Version history",
+ "Restore this version" : "Restore this version",
+ "Save version" : "Save version",
+ "Versioning backend unavailable" : "Versioning backend unavailable",
+ "Version not found" : "Version not found",
+ "Version history is not available for this dashboard" : "Version history is not available for this dashboard",
+ "Language variant already exists" : "Language variant already exists",
+ "Cannot delete the only language variant" : "Cannot delete the only language variant",
+ "Cannot delete the primary variant; promote another variant first" : "Cannot delete the primary variant; promote another variant first",
+ "Language code is required" : "Language code is required",
+ "Language not available" : "Language not available",
+ "View analytics" : "View analytics",
+ "Aggregate, privacy-preserving view counts per dashboard. Unique-viewer dedup uses a daily-rotating salted hash; no user identifiers are stored." : "Aggregate, privacy-preserving view counts per dashboard. Unique-viewer dedup uses a daily-rotating salted hash; no user identifiers are stored.",
+ "Period" : "Period",
+ "Last 7 days" : "Last 7 days",
+ "Last 30 days" : "Last 30 days",
+ "Last 90 days" : "Last 90 days",
+ "Loading analytics…" : "Loading analytics…",
+ "Total views" : "Total views",
+ "Unique viewers" : "Unique viewers",
+ "Dashboards seen" : "Dashboards seen",
+ "Top dashboards" : "Top dashboards",
+ "Dashboard" : "Dashboard",
+ "Views" : "Views",
+ "No view events recorded yet for this period." : "No view events recorded yet for this period.",
+ "Export analytics (CSV)" : "Export analytics (CSV)",
+ "Failed to load analytics data. Please try again." : "Failed to load analytics data. Please try again.",
+ "Failed to export analytics CSV. Please try again." : "Failed to export analytics CSV. Please try again.",
+ "%s's MyDash dashboards" : "%s's MyDash dashboards",
+ "Reverse-chronological list of dashboards accessible to %s." : "Reverse-chronological list of dashboards accessible to %s.",
+ "Dashboard feed" : "Dashboard feed",
+ "RSS / Atom feed" : "RSS / Atom feed",
+ "Generate feed token" : "Generate feed token",
+ "Regenerate feed token" : "Regenerate feed token",
+ "Revoke feed token" : "Revoke feed token",
+ "Copy feed URL" : "Copy feed URL",
+ "Feed URL copied to clipboard" : "Feed URL copied to clipboard",
+ "Your personal RSS feed of accessible dashboards." : "Your personal RSS feed of accessible dashboards.",
+ "Treat this URL as a password — anyone with the link can read your dashboards." : "Treat this URL as a password — anyone with the link can read your dashboards.",
+ "No feed token issued yet." : "No feed token issued yet.",
+ "Lock held by another user" : "Lock held by another user",
+ "Lock not found; call acquire first" : "Lock not found; call acquire first",
+ "Lock not found" : "Lock not found",
+ "Only the lock owner can extend the lease" : "Only the lock owner can extend the lease",
+ "Only the lock owner or an admin can release this lock" : "Only the lock owner or an admin can release this lock",
+ "Only an administrator may force-release a lock" : "Only an administrator may force-release a lock",
+ "Dashboard is being edited by {displayName}" : "Dashboard is being edited by {displayName}",
+ "Your edit lock has expired" : "Your edit lock has expired",
+ "You may be editing in another tab" : "You may be editing in another tab",
+ "MyDash dashboard" : "MyDash dashboard",
+ "Widget content on %s" : "Widget content on %s",
+ "Metadata: %1$s = %2$s" : "Metadata: %1$s = %2$s",
+ "Header Banner" : "Header Banner",
+ "Header title" : "Header title",
+ "Subtitle (optional)" : "Subtitle (optional)",
+ "Optional subtitle" : "Optional subtitle",
+ "Upload Background Image" : "Upload Background Image",
+ "Or enter Background Image URL" : "Or enter Background Image URL",
+ "Overlay Mode" : "Overlay Mode",
+ "Tinted Overlay" : "Tinted Overlay",
+ "Gradient Bottom" : "Gradient Bottom",
+ "Overlay Color" : "Overlay Color",
+ "Overlay Opacity" : "Overlay Opacity",
+ "Text Alignment" : "Text Alignment",
+ "Vertical Alignment" : "Vertical Alignment",
+ "Top" : "Top",
+ "Middle" : "Middle",
+ "Bottom" : "Bottom",
+ "Height" : "Height",
+ "Small (120px)" : "Small (120px)",
+ "Medium (200px)" : "Medium (200px)",
+ "Large (320px)" : "Large (320px)",
+ "Extra Large (480px)" : "Extra Large (480px)",
+ "Call-to-Action Button (optional)" : "Call-to-Action Button (optional)",
+ "Button Text" : "Button Text",
+ "Sign up" : "Sign up",
+ "Target URL" : "Target URL",
+ "Button Style" : "Button Style",
+ "Primary" : "Primary",
+ "Secondary" : "Secondary",
+ "Ghost" : "Ghost",
+ "opens in new tab" : "opens in new tab",
+ "Title is required" : "Title is required",
+ "Background image URL must be HTTP or HTTPS" : "Background image URL must be HTTP or HTTPS",
+ "Call-to-Action requires both label and URL" : "Call-to-Action requires both label and URL",
+ "Divider" : "Divider",
+ "Pick a divider style to break dashboard sections into logical groups." : "Pick a divider style to break dashboard sections into logical groups.",
+ "Style" : "Style",
+ "Horizontal line" : "Horizontal line",
+ "Whitespace" : "Whitespace",
+ "Heading with lines" : "Heading with lines",
+ "Line color" : "Line color",
+ "Thickness (pixels)" : "Thickness (pixels)",
+ "Line style" : "Line style",
+ "Solid" : "Solid",
+ "Dashed" : "Dashed",
+ "Dotted" : "Dotted",
+ "Spacing size" : "Spacing size",
+ "Small (16px)" : "Small (16px)",
+ "Medium (32px)" : "Medium (32px)",
+ "Large (64px)" : "Large (64px)",
+ "Extra Large (128px)" : "Extra Large (128px)",
+ "Heading text" : "Heading text",
+ "Heading text is required" : "Heading text is required",
+ "Section" : "Section",
+ "Section heading" : "Section heading",
+ "{heading} divider" : "{heading} divider",
+ "Folder breadcrumb" : "Folder breadcrumb",
+ "Root" : "Root",
+ "Search this folder" : "Search this folder",
+ "Search this folder…" : "Search this folder…",
+ "Upload File" : "Upload File",
+ "Loading folder…" : "Loading folder…",
+ "You don't have access to this folder." : "You don't have access to this folder.",
+ "Folder no longer exists." : "Folder no longer exists.",
+ "Failed to load folder contents." : "Failed to load folder contents.",
+ "Retry" : "Retry",
+ "This folder is empty." : "This folder is empty.",
+ "No files matching '{query}'" : "No files matching '{query}'",
+ "Delete {name}" : "Delete {name}",
+ "Are you sure you want to delete {name}?" : "Are you sure you want to delete {name}?",
+ "Load more" : "Load more",
+ "Folder path" : "Folder path",
+ "e.g. /Documents/Marketing" : "e.g. /Documents/Marketing",
+ "Folder ID (optional, preferred)" : "Folder ID (optional, preferred)",
+ "Numeric file id of the folder" : "Numeric file id of the folder",
+ "View mode" : "View mode",
+ "Sort by" : "Sort by",
+ "Sort descending" : "Sort descending",
+ "Show thumbnails" : "Show thumbnails",
+ "MIME type filter (comma separated)" : "MIME type filter (comma separated)",
+ "e.g. image/*, application/pdf" : "e.g. image/*, application/pdf",
+ "Allow upload" : "Allow upload",
+ "Allow delete" : "Allow delete",
+ "List" : "List",
+ "Grid" : "Grid",
+ "Modified" : "Modified",
+ "Type" : "Type",
+ "Size" : "Size",
+ "Name" : "Name",
+ "Folder path or folder id is required" : "Folder path or folder id is required",
+ "People" : "People",
+ "Card" : "Card",
+ "Layout" : "Layout",
+ "Display name" : "Display name",
+ "Group filter (comma-separated, blank for all)" : "Group filter (comma-separated, blank for all)",
+ "e.g. management, product" : "e.g. management, product",
+ "Exclude disabled users" : "Exclude disabled users",
+ "Show birthdays" : "Show birthdays",
+ "Birthday window (days, 0–30)" : "Birthday window (days, 0–30)",
+ "Must be between 0 and 30" : "Must be between 0 and 30",
+ "Birthday window must be between 0 and 30" : "Birthday window must be between 0 and 30",
+ "Invalid layout" : "Invalid layout",
+ "Invalid sort key" : "Invalid sort key",
+ "Columns" : "Columns",
+ "Search by name or email…" : "Search by name or email…",
+ "Refresh" : "Refresh",
+ "Failed to load users" : "Failed to load users",
+ "No matching users." : "No matching users.",
+ "No users match your search" : "No users match your search",
+ "Avatar of {name}" : "Avatar of {name}",
+ "🎂 today" : "🎂 today",
+ "🎂 in {n} days" : "🎂 in {n} days",
+ "Quicklinks" : "Quicklinks",
+ "No quicklinks yet — click the gear icon to add some." : "No quicklinks yet — click the gear icon to add some.",
+ "Open Quicklinks settings" : "Open Quicklinks settings",
+ "Add link" : "Add link",
+ "Delete link" : "Delete link",
+ "Color (optional)" : "Color (optional)",
+ "Icon Size" : "Icon Size",
+ "Icon Shape" : "Icon Shape",
+ "Show Labels" : "Show Labels",
+ "Label Position" : "Label Position",
+ "Tile Background" : "Tile Background",
+ "Hover Effect" : "Hover Effect",
+ "Paste CSV (label,url)" : "Paste CSV (label,url)",
+ "Invalid URL" : "Invalid URL",
+ "Invalid URL in one or more links" : "Invalid URL in one or more links",
+ "Small" : "Small",
+ "Medium" : "Medium",
+ "Large" : "Large",
+ "Extra Large" : "Extra Large",
+ "Square" : "Square",
+ "Rounded" : "Rounded",
+ "Circle" : "Circle",
+ "Below" : "Below",
+ "Overlay" : "Overlay",
+ "Auto" : "Auto",
+ "Transparent" : "Transparent",
+ "Gradient" : "Gradient",
+ "Lift" : "Lift",
+ "Fade" : "Fade",
+ "Border" : "Border",
+ "Link to" : "Link to",
+ "News" : "News",
+ "Loading news…" : "Loading news…",
+ "No news yet — try adding feeds in the widget settings" : "No news yet — try adding feeds in the widget settings",
+ "Unable to load news. Please try again later." : "Unable to load news. Please try again later.",
+ "1 feed failed" : "1 feed failed",
+ "{count} feeds failed" : "{count} feeds failed",
+ "Failed: {urls}" : "Failed: {urls}",
+ "Feed URLs" : "Feed URLs",
+ "Feed URL" : "Feed URL",
+ "Add feed URL" : "Add feed URL",
+ "Remove feed" : "Remove feed",
+ "Carousel" : "Carousel",
+ "Item limit (1–50)" : "Item limit (1–50)",
+ "Show summary" : "Show summary",
+ "Summary max characters" : "Summary max characters",
+ "Date format" : "Date format",
+ "Relative (2 hours ago)" : "Relative (2 hours ago)",
+ "Absolute (2026-05-01 14:30)" : "Absolute (2026-05-01 14:30)",
+ "Filter by dashboard metadata" : "Filter by dashboard metadata",
+ "Metadata field key" : "Metadata field key",
+ "Metadata value to match" : "Metadata value to match",
+ "Empty feed URL — remove it or fill it in" : "Empty feed URL — remove it or fill it in",
+ "Feed URL must start with http:// or https://" : "Feed URL must start with http:// or https://",
+ "Metadata field key is required when filter is enabled" : "Metadata field key is required when filter is enabled",
+ "just now" : "just now",
+ "{n} minutes ago" : "{n} minutes ago",
+ "{n} hours ago" : "{n} hours ago",
+ "{n} days ago" : "{n} days ago",
+ "No video URL configured" : "No video URL configured",
+ "Video not accessible" : "Video not accessible",
+ "Video failed to load" : "Video failed to load",
+ "Invalid video URL or domain not allowed." : "Invalid video URL or domain not allowed.",
+ "Video URL" : "Video URL",
+ "Video URL is required" : "Video URL is required",
+ "Detected: YouTube" : "Detected: YouTube",
+ "Detected: Vimeo" : "Detected: Vimeo",
+ "Detected: PeerTube" : "Detected: PeerTube",
+ "Detected: Nextcloud File" : "Detected: Nextcloud File",
+ "Aspect Ratio" : "Aspect Ratio",
+ "Autoplay" : "Autoplay",
+ "Autoplay requires muting" : "Autoplay requires muting",
+ "Muted" : "Muted",
+ "Loop" : "Loop",
+ "Show controls" : "Show controls",
+ "Poster Image URL (optional)" : "Poster Image URL (optional)",
+ "Nextcloud File ID" : "Nextcloud File ID",
+ "Nextcloud file ID is required" : "Nextcloud file ID is required",
+ "Loading calendars…" : "Loading calendars…",
+ "Failed to load events" : "Failed to load events",
+ "No calendars configured" : "No calendars configured",
+ "No events in the next {N} days" : "No events in the next {N} days",
+ "{N} calendar source(s) unavailable" : "{N} calendar source(s) unavailable",
+ "Sun" : "Sun",
+ "Mon" : "Mon",
+ "Tue" : "Tue",
+ "Wed" : "Wed",
+ "Thu" : "Thu",
+ "Fri" : "Fri",
+ "Sat" : "Sat",
+ "Month" : "Month",
+ "Week" : "Week",
+ "Agenda" : "Agenda",
+ "All day" : "All day",
+ "View Mode" : "View Mode",
+ "Internal Calendars (one principal URI per line)" : "Internal Calendars (one principal URI per line)",
+ "External ICS URLs (one per line)" : "External ICS URLs (one per line)",
+ "Days Ahead" : "Days Ahead",
+ "Color by Calendar" : "Color by Calendar",
+ "At least one calendar source is required" : "At least one calendar source is required",
+ "External ICS URLs must start with https://" : "External ICS URLs must start with https://",
+ "Links" : "Links",
+ "Section title" : "Section title",
+ "Move up" : "Move up",
+ "Move down" : "Move down",
+ "Delete section" : "Delete section",
+ "Add section" : "Add section",
+ "Icon (name or URL)" : "Icon (name or URL)",
+ "Description (optional)" : "Description (optional)",
+ "Inline" : "Inline",
+ "Icon only" : "Icon only",
+ "Icon size" : "Icon size",
+ "Small (24 px)" : "Small (24 px)",
+ "Medium (40 px)" : "Medium (40 px)",
+ "Large (64 px)" : "Large (64 px)",
+ "Open in new tab" : "Open in new tab",
+ "Show section titles" : "Show section titles",
+ "Show descriptions" : "Show descriptions",
+ "Link URL is required" : "Link URL is required",
+ "Link label is required" : "Link label is required",
+ "Invalid URL — use HTTP(S) or relative paths." : "Invalid URL — use HTTP(S) or relative paths.",
+ "No links yet — click the gear icon to add some." : "No links yet — click the gear icon to add some.",
+ "Menu" : "Menu",
+ "Menu Style" : "Menu Style",
+ "Menu Widget" : "Menu Widget",
+ "No menu items yet — click the gear icon to add some." : "No menu items yet — click the gear icon to add some.",
+ "Menu items can nest at most 3 levels deep" : "Menu items can nest at most 3 levels deep",
+ "Dropdown" : "Dropdown",
+ "Megamenu" : "Megamenu",
+ "Orientation" : "Orientation",
+ "Horizontal" : "Horizontal",
+ "Vertical" : "Vertical",
+ "Active Item Highlight" : "Active Item Highlight",
+ "Underline" : "Underline",
+ "Left Bar" : "Left Bar",
+ "Show Icons" : "Show Icons",
+ "Expanded by Default" : "Expanded by Default",
+ "Add Item" : "Add Item",
+ "Remove Item" : "Remove Item",
+ "Add Children" : "Add Children",
+ "Items" : "Items",
+ "Level 1" : "Level 1",
+ "Level 2" : "Level 2",
+ "Level 3 - max reached" : "Level 3 - max reached",
+ "Expand" : "Expand",
+ "Collapse" : "Collapse",
+ "Image source" : "Image source",
+ "URL/Link" : "URL/Link",
+ "Pick from Files" : "Pick from Files",
+ "Pick image from Nextcloud Files" : "Pick image from Nextcloud Files",
+ "Pick image from Files" : "Pick image from Files",
+ "Opening file picker…" : "Opening file picker…",
+ "Selected:" : "Selected:",
+ "File picker failed to open" : "File picker failed to open",
+ "Selected file is missing required metadata" : "Selected file is missing required metadata",
+ "Please pick a file from Files" : "Please pick a file from Files",
+ "Image URL" : "Image URL",
+ "Enter Image URL" : "Enter Image URL",
+ "Mode" : "Mode",
+ "HTML" : "HTML",
+ "Markdown" : "Markdown",
+ "Markdown — # heading, **bold**, *italic*, [link](url), - list" : "Markdown — # heading, **bold**, *italic*, [link](url), - list",
+ "HTML — bold , italic , link " : "HTML — bold , italic , link ",
+ "Content type" : "Content type",
+ "Table" : "Table",
+ "Header row" : "Header row",
+ "Add row above" : "Add row above",
+ "Add row below" : "Add row below",
+ "Add column left" : "Add column left",
+ "Add column right" : "Add column right",
+ "Delete row" : "Delete row",
+ "Delete column" : "Delete column",
+ "Merge cells" : "Merge cells",
+ "Split cell" : "Split cell",
+ "Column alignment" : "Column alignment",
+ "Header" : "Header",
+ "Cell" : "Cell",
+ "Empty cell" : "Empty cell",
+ "Row {row}, column {col}" : "Row {row}, column {col}",
+ "This row contains text. Delete?" : "This row contains text. Delete?",
+ "This column contains text. Delete?" : "This column contains text. Delete?",
+ "Grid is not rectangular" : "Grid is not rectangular",
+ "Cell span exceeds grid bounds" : "Cell span exceeds grid bounds",
+ "Table must have at least one row" : "Table must have at least one row",
+ "Table must have at least one column" : "Table must have at least one column",
+ "Footer settings" : "Footer settings",
+ "Enable footer" : "Enable footer",
+ "HTML mode" : "HTML mode",
+ "Structured mode" : "Structured mode",
+ "Footer HTML" : "Footer HTML",
+ "Allowed HTML tags: a, p, strong, em, br, ul, ol, li, img" : "Allowed HTML tags: a, p, strong, em, br, ul, ol, li, img",
+ "Logo URL" : "Logo URL",
+ "Organisation name" : "Organisation name",
+ "Address" : "Address",
+ "Legal text" : "Legal text",
+ "Copyright year" : "Copyright year",
+ "Layout mode" : "Layout mode",
+ "Background colour override" : "Background colour override",
+ "Text colour override" : "Text colour override",
+ "Footer mode" : "Footer mode",
+ "Inherit global footer" : "Inherit global footer",
+ "Hide footer on this dashboard" : "Hide footer on this dashboard",
+ "Custom footer for this dashboard" : "Custom footer for this dashboard",
+ "Footer HTML exceeds the 8 KB size limit" : "Footer HTML exceeds the 8 KB size limit",
+ "Footer mode must be one of: inherit, hidden, custom" : "Footer mode must be one of: inherit, hidden, custom",
+ "Custom footer mode requires footer HTML" : "Custom footer mode requires footer HTML",
+ "Footer config schema is invalid" : "Footer config schema is invalid",
+ "Organization navigation" : "Organization navigation",
+ "Open organization navigation" : "Open organization navigation",
+ "Close navigation" : "Close navigation",
+ "Navigation" : "Navigation",
+ "Organization Navigation" : "Organization Navigation",
+ "Build an organisation-wide navigation tree shown next to the dashboard surface. Drag nodes to reorder, click Edit to change properties, and use the group visibility selector to restrict sections to specific Nextcloud groups." : "Build an organisation-wide navigation tree shown next to the dashboard surface. Drag nodes to reorder, click Edit to change properties, and use the group visibility selector to restrict sections to specific Nextcloud groups.",
+ "Language" : "Language",
+ "Dutch" : "Dutch",
+ "English" : "English",
+ "Position" : "Position",
+ "Hidden" : "Hidden",
+ "New section" : "New section",
+ "New link" : "New link",
+ "Add child" : "Add child",
+ "URL (leave empty for section)" : "URL (leave empty for section)",
+ "New tab" : "New tab",
+ "Visible to everyone" : "Visible to everyone",
+ "Group ids, comma separated" : "Group ids, comma separated",
+ "Tree depth cannot exceed 3 levels" : "Tree depth cannot exceed 3 levels",
+ "No navigation configured. Add a section or link to start building the tree." : "No navigation configured. Add a section or link to start building the tree.",
+ "Navigation saved successfully." : "Navigation saved successfully.",
+ "Save as template" : "Save as template",
+ "Template gallery" : "Template gallery",
+ "Preview image" : "Preview image",
+ "Invalid image format" : "Invalid image format",
+ "All categories" : "All categories",
+ "Recently updated" : "Recently updated",
+ "No templates available" : "No templates available",
+ "Could not load template gallery" : "Could not load template gallery",
+ "Could not save dashboard as template" : "Could not save dashboard as template",
+ "You can only save your own dashboards as templates" : "You can only save your own dashboards as templates",
+ "Describe the template purpose" : "Describe the template purpose",
+ "e.g. marketing, engineering" : "e.g. marketing, engineering",
+ "{count} widgets" : "{count} widgets",
+ "Category" : "Category",
+ "Bulk dashboard operations" : "Bulk dashboard operations",
+ "Select multiple dashboards and apply an admin action to all of them at once. Every operation supports a dry-run preview." : "Select multiple dashboards and apply an admin action to all of them at once. Every operation supports a dry-run preview.",
+ "Loading dashboards…" : "Loading dashboards…",
+ "Failed to load dashboards" : "Failed to load dashboards",
+ "Bulk operation failed" : "Bulk operation failed",
+ "Actions…" : "Actions…",
+ "Move to…" : "Move to…",
+ "Set status" : "Set status",
+ "Reindex" : "Reindex",
+ "Apply" : "Apply",
+ "Working…" : "Working…",
+ "OK" : "OK",
+ "Dry run (preview only)" : "Dry run (preview only)",
+ "Delete dashboards?" : "Delete dashboards?",
+ "Move dashboards?" : "Move dashboards?",
+ "Set publication status?" : "Set publication status?",
+ "Reindex dashboards for search?" : "Reindex dashboards for search?",
+ "About to delete {n} dashboards. This cannot be undone." : "About to delete {n} dashboards. This cannot be undone.",
+ "About to re-parent {n} dashboards." : "About to re-parent {n} dashboards.",
+ "About to update the publication status of {n} dashboards." : "About to update the publication status of {n} dashboards.",
+ "About to reindex {n} dashboards for unified search." : "About to reindex {n} dashboards for unified search.",
+ "New parent UUID (leave empty for root)" : "New parent UUID (leave empty for root)",
+ "Publication status" : "Publication status",
+ "PREVIEW: would change {count} dashboards." : "PREVIEW: would change {count} dashboards.",
+ "Changed {count} dashboards. Skipped {skipped}." : "Changed {count} dashboards. Skipped {skipped}.",
+ "Demo data showcases" : "Demo data showcases",
+ "Install bundled example dashboards to give users a working starting point. Each showcase is created as a group-shared dashboard visible to all users; you can uninstall it at any time." : "Install bundled example dashboards to give users a working starting point. Each showcase is created as a group-shared dashboard visible to all users; you can uninstall it at any time.",
+ "Loading showcases…" : "Loading showcases…",
+ "Could not load demo showcases. Please try again." : "Could not load demo showcases. Please try again.",
+ "Could not install showcase. Please try again." : "Could not install showcase. Please try again.",
+ "Could not uninstall showcase. Please try again." : "Could not uninstall showcase. Please try again.",
+ "Showcase not found." : "Showcase not found.",
+ "You need admin privileges to install showcases." : "You need admin privileges to install showcases.",
+ "Install" : "Install",
+ "Installing…" : "Installing…",
+ "Uninstall" : "Uninstall",
+ "Uninstalling…" : "Uninstalling…",
+ "Installed but skipped widgets: {list}" : "Installed but skipped widgets: {list}",
+ "Remove the {name} showcase dashboard for all users? You can reinstall it later." : "Remove the {name} showcase dashboard for all users? You can reinstall it later.",
+ "MyDash setup wizard" : "MyDash setup wizard",
+ "Step {n} / {total}" : "Step {n} / {total}",
+ "Welcome" : "Welcome",
+ "Configure your MyDash instance with storage, group ordering, demo data, admin roles, and footer settings." : "Configure your MyDash instance with storage, group ordering, demo data, admin roles, and footer settings.",
+ "Storage backend" : "Storage backend",
+ "Choose how MyDash stores dashboard content." : "Choose how MyDash stores dashboard content.",
+ "Database (default)" : "Database (default)",
+ "Store dashboard content in the MyDash database table." : "Store dashboard content in the MyDash database table.",
+ "GroupFolder (recommended for org use)" : "GroupFolder (recommended for org use)",
+ "Store dashboard content in Nextcloud GroupFolders for collaborative access." : "Store dashboard content in Nextcloud GroupFolders for collaborative access.",
+ "GroupFolder app is not installed. Install 'Nextcloud GroupFolders' to use this option." : "GroupFolder app is not installed. Install 'Nextcloud GroupFolders' to use this option.",
+ "Pick the order Nextcloud groups appear when MyDash routes users to a workspace." : "Pick the order Nextcloud groups appear when MyDash routes users to a workspace.",
+ "Demo data" : "Demo data",
+ "The demo data showcases will appear here once the demo-data-showcases capability ships. For now this step is informational only." : "The demo data showcases will appear here once the demo-data-showcases capability ships. For now this step is informational only.",
+ "Skip this step for now — demo packages can be installed later from the admin section." : "Skip this step for now — demo packages can be installed later from the admin section.",
+ "Admin roles" : "Admin roles",
+ "Assign the \"Dashboard Admin\" role to a Nextcloud group to delegate MyDash administration." : "Assign the \"Dashboard Admin\" role to a Nextcloud group to delegate MyDash administration.",
+ "The admin-roles capability is not yet available; this step will activate once it ships." : "The admin-roles capability is not yet available; this step will activate once it ships.",
+ "Footer configuration" : "Footer configuration",
+ "Customize the footer content and appearance for your intranet." : "Customize the footer content and appearance for your intranet.",
+ "The footer-customization capability is not yet available; this step will activate once it ships." : "The footer-customization capability is not yet available; this step will activate once it ships.",
+ "All set" : "All set",
+ "Your setup is complete. Click Finish to save changes and dismiss the setup banner." : "Your setup is complete. Click Finish to save changes and dismiss the setup banner.",
+ "Back" : "Back",
+ "Skip" : "Skip",
+ "Next" : "Next",
+ "Finish" : "Finish",
+ "Run setup wizard" : "Run setup wizard",
+ "Run setup wizard again" : "Run setup wizard again",
+ "Get your intranet started: choose storage, configure groups, install demo data, and set up admin roles." : "Get your intranet started: choose storage, configure groups, install demo data, and set up admin roles.",
+ "Container" : "Container",
+ "Padding" : "Padding",
+ "Title (optional)" : "Title (optional)",
+ "Container nesting limit reached" : "Container nesting limit reached",
+ "A container holds a sub-grid of child widgets. Add child widgets via the container’s own grid once it is on the dashboard." : "A container holds a sub-grid of child widgets. Add child widgets via the container’s own grid once it is on the dashboard.",
+ "Unknown widget type: {type}" : "Unknown widget type: {type}",
+ "Tile" : "Tile",
+ "Tile title" : "Tile title",
+ "Tile title is required" : "Tile title is required",
+ "Tile link target is required" : "Tile link target is required",
+ "Icon" : "Icon",
+ "Icon type" : "Icon type",
+ "Background color" : "Background color",
+ "Text color" : "Text color",
+ "Link type" : "Link type",
+ "App route" : "App route",
+ "URL" : "URL",
+ "The reusable tile API is no longer available. Use the unified add-widget flow with type:tile instead." : "The reusable tile API is no longer available. Use the unified add-widget flow with type:tile instead.",
+ "Pick a widget" : "Pick a widget",
+ "No Nextcloud widgets are installed" : "No Nextcloud widgets are installed",
+ "Selected" : "Selected"
},
"nplurals=2; plural=(n != 1);"
-);
\ No newline at end of file
+);
diff --git a/l10n/en.json b/l10n/en.json
index 624b29c7..dfdcff93 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1,10 +1,22 @@
{
"translations": {
+ "— Choose —": "— Choose —",
+ "Dashboard metadata": "Dashboard metadata",
+ "Failed to update dashboard metadata": "Failed to update dashboard metadata",
+ "No metadata fields are configured. Ask an administrator to add some.": "No metadata fields are configured. Ask an administrator to add some.",
+ "Save metadata": "Save metadata",
+ "Saving…": "Saving…",
+ "Yes": "Yes",
+ "No": "No",
"SVG could not be parsed or contained no allowed content": "SVG could not be parsed or contained no allowed content",
"Active": "Active",
"Add": "Add",
"Add only": "Add only",
"Add to dashboard": "Add to dashboard",
+ "Add Widget": "Add Widget",
+ "Add custom widget…": "Add custom widget…",
+ "Add dashboard": "Add dashboard",
+ "Powered by": "Powered by",
"Airplane": "Airplane",
"All users": "All users",
"Allow users to create custom dashboards": "Allow users to create custom dashboards",
@@ -35,6 +47,13 @@
"Custom title": "Custom title",
"Customize widget": "Customize widget",
"Cannot delete the only dashboard in the group": "Cannot delete the only dashboard in the group",
+ "Cannot exceed maximum tree depth of 5 levels": "Cannot exceed maximum tree depth of 5 levels",
+ "Dashboard has children. Use ?cascade=true to delete the subtree.": "Dashboard has children. Use ?cascade=true to delete the subtree.",
+ "Dashboard not found at path": "Dashboard not found at path",
+ "Parent dashboard not found": "Parent dashboard not found",
+ "Setting this parent would create a cycle": "Setting this parent would create a cycle",
+ "Slug must be unique among siblings": "Slug must be unique among siblings",
+ "Slug must match [a-z0-9_-]+ and be at most 128 characters": "Slug must match [a-z0-9_-]+ and be at most 128 characters",
"Dashboard creation not allowed": "Dashboard creation not allowed",
"Dashboard does not belong to this group": "Dashboard does not belong to this group",
"Forbidden: admin only": "Forbidden: admin only",
@@ -47,6 +66,8 @@
"Edit tile": "Edit tile",
"Edit dashboard": "Edit dashboard",
"Edit template": "Edit template",
+ "Edit Widget": "Edit Widget",
+ "Failed to upload icon": "Failed to upload icon",
"Enter tile title": "Enter tile title",
"Full customization": "Full customization",
"Light bulb": "Light bulb",
@@ -55,19 +76,25 @@
"Missing tile ID": "Missing tile ID",
"Multiple dashboards not allowed": "Multiple dashboards not allowed",
"My dashboard": "My dashboard",
+ "My Dashboards": "My Dashboards",
"My template": "My template",
"MyDash settings": "MyDash settings",
+ "+ New Dashboard": "+ New Dashboard",
"New tile": "New tile",
"No dashboard available": "No dashboard available",
"No dashboard yet": "No dashboard yet",
"No dashboards yet": "No dashboards yet",
"No templates yet": "No templates yet",
+ "No widget types available": "No widget types available",
"No widgets found": "No widgets found",
"Not logged in": "Not logged in",
"Optional description": "Optional description",
"Permission level": "Permission level",
+ "Personal dashboards are not enabled by your administrator": "Personal dashboards are not enabled by your administrator",
+ "Disabling this only blocks creating new personal dashboards. Existing personal dashboards remain visible and editable.": "Disabling this only blocks creating new personal dashboards. Existing personal dashboards remain visible and editable.",
"Search widgets...": "Search widgets...",
"Select dashboard": "Select dashboard",
+ "Select icon…": "Select icon…",
"Select groups (leave empty for all users)": "Select groups (leave empty for all users)",
"Set as default template": "Set as default template",
"Setting as default app": "Setting as default app",
@@ -82,6 +109,13 @@
"Widget style": "Widget style",
"Widget title": "Widget title",
"https://example.com or /apps/files": "https://example.com or /apps/files",
+ "Cannot delete the only dashboard in the group": "Cannot delete the only dashboard in the group",
+ "Dashboard does not belong to this group": "Dashboard does not belong to this group",
+ "Dashboard not found": "Dashboard not found",
+ "Dashboard not found in group": "Dashboard not found in group",
+ "Failed to fork dashboard": "Failed to fork dashboard",
+ "My copy of %s": "My copy of %s",
+ "Forbidden: admin only": "Forbidden: admin only",
"Background": "Background",
"Checkmark": "Checkmark",
"Close": "Close",
@@ -109,8 +143,15 @@
"Home": "Home",
"House": "House",
"Icon": "Icon",
+ "Icon preview": "Icon preview",
"Image": "Image",
"Integration": "Integration",
+ "Label": "Label",
+ "Label text": "Label text",
+ "Label text is required": "Label text is required",
+ "Font size": "Font size",
+ "Font Weight": "Font Weight",
+ "Alignment": "Alignment",
"Lightning": "Lightning",
"Link": "Link",
"Mail": "Mail",
@@ -152,11 +193,58 @@
"Tree": "Tree",
"URL": "URL",
"Upload": "Upload",
+ "Upload icon": "Upload icon",
+ "Uploading…": "Uploading…",
"User": "User",
"Video": "Video",
"Wallet": "Wallet",
"Widget": "Widget",
+ "Widget type": "Widget type",
"Widgets": "Widgets",
+ "SVG could not be parsed or contained no allowed content": "SVG could not be parsed or contained no allowed content",
+ "Resource exceeds the 5 MB serving cap": "Resource exceeds the 5 MB serving cap",
+ "Saving…": "Saving…",
+ "No dashboards available": "No dashboards available",
+ "Contact your administrator": "Contact your administrator",
+ "Create your first dashboard": "Create your first dashboard",
+ "Open menu": "Open menu",
+ "Save failed": "Save failed",
+ "My dashboards": "My dashboards",
+ "Group dashboards": "Group dashboards",
+ "Failed to upload image": "Failed to upload image",
+ "Admin privileges required": "Admin privileges required",
+ "Body must contain a base64 data URL": "Body must contain a base64 data URL",
+ "Allowed image formats: jpeg, jpg, png, gif, svg, webp": "Allowed image formats: jpeg, jpg, png, gif, svg, webp",
+ "Maximum size is 5MB": "Maximum size is 5MB",
+ "Declared image type does not match file content": "Declared image type does not match file content",
+ "Image content appears to be corrupt": "Image content appears to be corrupt",
+ "Use JSON body with base64 field": "Use JSON body with base64 field",
+ "Failed to store resource": "Failed to store resource",
+ "Maximum image size: 5 MB. Allowed types: jpeg, jpg, png, gif, svg, webp.": "Maximum image size: 5 MB. Allowed types: jpeg, jpg, png, gif, svg, webp.",
+ "Group-shared dashboards": "Group-shared dashboards",
+ "Promote a single dashboard per group as the default. Members of the group will land on it when they have no personal preference yet.": "Promote a single dashboard per group as the default. Members of the group will land on it when they have no personal preference yet.",
+ "Loading group dashboards…": "Loading group dashboards…",
+ "No group-shared dashboards in this group yet.": "No group-shared dashboards in this group yet.",
+ "Set as default": "Set as default",
+ "Failed to set the group default dashboard": "Failed to set the group default dashboard",
+ "Group priority order": "Group priority order",
+ "Drag groups between the columns to control which Nextcloud groups MyDash uses, and in what order. The first active group becomes the user's primary workspace.": "Drag groups between the columns to control which Nextcloud groups MyDash uses, and in what order. The first active group becomes the user's primary workspace.",
+ "Active groups": "Active groups",
+ "Inactive groups": "Inactive groups",
+ "Filter active groups": "Filter active groups",
+ "Filter inactive groups": "Filter inactive groups",
+ "Filter": "Filter",
+ "Move to inactive": "Move to inactive",
+ "Move to active": "Move to active",
+ "(removed)": "(removed)",
+ "No matches.": "No matches.",
+ "No active groups. Drag groups here from the inactive column.": "No active groups. Drag groups here from the inactive column.",
+ "No inactive groups.": "No inactive groups.",
+ "Loading group list…": "Loading group list…",
+ "Failed to load group list.": "Failed to load group list.",
+ "Group order saved.": "Group order saved.",
+ "Failed to save group order.": "Failed to save group order.",
+ "Your primary group for shared dashboards": "Your primary group for shared dashboards",
"No image": "No image",
"Image failed to load": "Image failed to load",
"Upload Image": "Upload Image",
@@ -168,11 +256,7 @@
"Contain": "Contain",
"Fill": "Fill",
"None": "None",
- "Failed to upload image": "Failed to upload image",
"Image URL is required": "Image URL is required",
- "My Dashboards": "My Dashboards",
- "+ New Dashboard": "+ New Dashboard",
- "Personal dashboards are not enabled by your administrator": "Personal dashboards are not enabled by your administrator",
"%1$s shared **%2$s** with you": "%1$s shared **%2$s** with you",
"%1$s shared %2$s with you": "%1$s shared %2$s with you",
"Full access": "Full access",
@@ -182,7 +266,6 @@
"**%1$s** is now yours": "**%1$s** is now yours",
"%1$s is now yours": "%1$s is now yours",
"Ownership transferred after the previous owner was removed": "Ownership transferred after the previous owner was removed",
- "Access denied": "Access denied",
"Invalid shareType": "Invalid shareType",
"shareWith is required": "shareWith is required",
"Invalid permissionLevel": "Invalid permissionLevel",
@@ -198,6 +281,633 @@
"Create": "Create",
"Creating…": "Creating…",
"Failed to create document": "Failed to create document",
- "Please enter a file name": "Please enter a file name"
+ "Please enter a file name": "Please enter a file name",
+ "Label is required": "Label is required",
+ "URL is required": "URL is required",
+ "Allowed file extensions": "Allowed file extensions",
+ "Comma-separated list of file extensions admins allow link-button widgets to create": "Comma-separated list of file extensions admins allow link-button widgets to create",
+ "Nextcloud Widget": "Nextcloud Widget",
+ "Select Widget": "Select Widget",
+ "Choose a widget…": "Choose a widget…",
+ "Display Mode": "Display Mode",
+ "Vertical (list)": "Vertical (list)",
+ "Horizontal (cards)": "Horizontal (cards)",
+ "Single button": "Single button",
+ "List of links": "List of links",
+ "List Orientation": "List Orientation",
+ "List Item Spacing": "List Item Spacing",
+ "Compact": "Compact",
+ "Normal": "Normal",
+ "Spacious": "Spacious",
+ "Links": "Links",
+ "Add link": "Add link",
+ "Remove link": "Remove link",
+ "Move link up": "Move link up",
+ "Move link down": "Move link down",
+ "Icon (optional)": "Icon (optional)",
+ "File Extension": "File Extension",
+ "No links yet. Click \"Add link\" to add one.": "No links yet. Click \"Add link\" to add one.",
+ "Maximum of 20 links per list widget": "Maximum of 20 links per list widget",
+ "At least one link is required for list mode": "At least one link is required for list mode",
+ "Each link requires a label and a URL": "Each link requires a label and a URL",
+ "Loading…": "Loading…",
+ "No items available": "No items available",
+ "publishAt must be a future timestamp": "publishAt must be a future timestamp",
+ "Forbidden: owner or admin only": "Forbidden: owner or admin only",
+ "Publish dashboard": "Publish dashboard",
+ "Unpublish dashboard": "Unpublish dashboard",
+ "Schedule dashboard": "Schedule dashboard",
+ "Draft": "Draft",
+ "Published": "Published",
+ "Scheduled": "Scheduled",
+ "Backup, restore & migrate dashboards": "Backup, restore & migrate dashboards",
+ "Download a versioned ZIP archive of all dashboards in this MyDash instance, or upload a previously exported archive to restore or migrate it. The archive is portable across Nextcloud installations.": "Download a versioned ZIP archive of all dashboards in this MyDash instance, or upload a previously exported archive to restore or migrate it. The archive is portable across Nextcloud installations.",
+ "Download all dashboards": "Download all dashboards",
+ "Exporting…": "Exporting…",
+ "Export failed. Please try again.": "Export failed. Please try again.",
+ "Import a dashboard archive (.zip)": "Import a dashboard archive (.zip)",
+ "Preserve original dashboard UUIDs (fail on collision)": "Preserve original dashboard UUIDs (fail on collision)",
+ "Upload archive": "Upload archive",
+ "Importing…": "Importing…",
+ "Imported {imported} dashboards, skipped {skipped}.": "Imported {imported} dashboards, skipped {skipped}.",
+ "Import failed. Please try again.": "Import failed. Please try again.",
+ "Import from Confluence": "Import from Confluence",
+ "Upload a Confluence HTML export ZIP archive. Each Confluence page becomes a MyDash dashboard with a single full-width text widget; the page hierarchy is mirrored using the dashboard tree.": "Upload a Confluence HTML export ZIP archive. Each Confluence page becomes a MyDash dashboard with a single full-width text widget; the page hierarchy is mirrored using the dashboard tree.",
+ "Confluence export archive (.zip)": "Confluence export archive (.zip)",
+ "Optional parent dashboard UUID (root pages will be slotted under this dashboard)": "Optional parent dashboard UUID (root pages will be slotted under this dashboard)",
+ "Leave empty to import as root dashboards": "Leave empty to import as root dashboards",
+ "Dry-run preview": "Dry-run preview",
+ "Inspecting…": "Inspecting…",
+ "Import dashboards": "Import dashboards",
+ "{pages} pages, {attachments} attachments — would create {dashboards} dashboards.": "{pages} pages, {attachments} attachments — would create {dashboards} dashboards.",
+ "Assets would be uploaded to {folder}": "Assets would be uploaded to {folder}",
+ "Assets uploaded to {folder}": "Assets uploaded to {folder}",
+ "Confluence dry-run failed. Please try again.": "Confluence dry-run failed. Please try again.",
+ "Confluence import failed. Please try again.": "Confluence import failed. Please try again.",
+ "You created dashboard {dashboard}": "You created dashboard {dashboard}",
+ "{actor} created dashboard {dashboard}": "{actor} created dashboard {dashboard}",
+ "You updated dashboard {dashboard}": "You updated dashboard {dashboard}",
+ "{actor} updated dashboard {dashboard}": "{actor} updated dashboard {dashboard}",
+ "You deleted dashboard {dashboard}": "You deleted dashboard {dashboard}",
+ "{actor} deleted dashboard {dashboard}": "{actor} deleted dashboard {dashboard}",
+ "You published dashboard {dashboard}": "You published dashboard {dashboard}",
+ "{actor} published dashboard {dashboard}": "{actor} published dashboard {dashboard}",
+ "You unpublished dashboard {dashboard}": "You unpublished dashboard {dashboard}",
+ "{actor} unpublished dashboard {dashboard}": "{actor} unpublished dashboard {dashboard}",
+ "You scheduled dashboard {dashboard}": "You scheduled dashboard {dashboard}",
+ "{actor} scheduled dashboard {dashboard}": "{actor} scheduled dashboard {dashboard}",
+ "You shared dashboard {dashboard} with {recipient}": "You shared dashboard {dashboard} with {recipient}",
+ "{actor} shared dashboard {dashboard} with {recipient}": "{actor} shared dashboard {dashboard} with {recipient}",
+ "You created a public link for dashboard {dashboard}": "You created a public link for dashboard {dashboard}",
+ "{actor} created a public link for dashboard {dashboard}": "{actor} created a public link for dashboard {dashboard}",
+ "You commented on dashboard {dashboard}": "You commented on dashboard {dashboard}",
+ "{actor} commented on dashboard {dashboard}": "{actor} commented on dashboard {dashboard}",
+ "You reacted to dashboard {dashboard}": "You reacted to dashboard {dashboard}",
+ "{actor} reacted to dashboard {dashboard}": "{actor} reacted to dashboard {dashboard}",
+ "You restored dashboard {dashboard} to an earlier version": "You restored dashboard {dashboard} to an earlier version",
+ "{actor} restored dashboard {dashboard} to an earlier version": "{actor} restored dashboard {dashboard} to an earlier version",
+ "You overrode the lock on dashboard {dashboard}": "You overrode the lock on dashboard {dashboard}",
+ "{actor} overrode the lock on dashboard {dashboard}": "{actor} overrode the lock on dashboard {dashboard}",
+ "Your role in {dashboard} was changed to {role}": "Your role in {dashboard} was changed to {role}",
+ "{actor} changed {target}'s role in {dashboard} to {role}": "{actor} changed {target}'s role in {dashboard} to {role}",
+ "Dashboard Admin": "Dashboard Admin",
+ "Dashboard Editor": "Dashboard Editor",
+ "Dashboard Viewer": "Dashboard Viewer",
+ "Either userId or groupId must be provided": "Either userId or groupId must be provided",
+ "Only one of userId or groupId may be provided": "Only one of userId or groupId may be provided",
+ "Unknown role; must be one of admin, editor, viewer": "Unknown role; must be one of admin, editor, viewer",
+ "Unknown user": "Unknown user",
+ "Unknown group": "Unknown group",
+ "That target already has the requested role assigned": "That target already has the requested role assigned",
+ "Role assignment not found": "Role assignment not found",
+ "Role assignment payload is invalid": "Role assignment payload is invalid",
+ "Comments": "Comments",
+ "Post comment": "Post comment",
+ "Reply": "Reply",
+ "Edited": "Edited",
+ "Comments are disabled on this dashboard": "Comments are disabled on this dashboard",
+ "Comment message must not be empty": "Comment message must not be empty",
+ "Comments can only be replied to once": "Comments can only be replied to once",
+ "Only the author or an admin may modify this comment": "Only the author or an admin may modify this comment",
+ "Comment not found": "Comment not found",
+ "Parent comment not found": "Parent comment not found",
+ "Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?",
+ "Deleting this top-level comment will also remove all its replies.": "Deleting this top-level comment will also remove all its replies.",
+ "Write a comment…": "Write a comment…",
+ "Write a reply…": "Write a reply…",
+ "Cancel reply": "Cancel reply",
+ "%1$s mentioned you in a dashboard comment": "%1$s mentioned you in a dashboard comment",
+ "Open the dashboard to read the comment": "Open the dashboard to read the comment",
+ "Emoji not allowed": "Emoji not allowed",
+ "Reactions are disabled": "Reactions are disabled",
+ "Permission denied": "Permission denied",
+ "React": "React",
+ "Reactions": "Reactions",
+ "Version history": "Version history",
+ "Restore this version": "Restore this version",
+ "Save version": "Save version",
+ "Versioning backend unavailable": "Versioning backend unavailable",
+ "Version not found": "Version not found",
+ "Version history is not available for this dashboard": "Version history is not available for this dashboard",
+ "Language variant already exists": "Language variant already exists",
+ "Cannot delete the only language variant": "Cannot delete the only language variant",
+ "Cannot delete the primary variant; promote another variant first": "Cannot delete the primary variant; promote another variant first",
+ "Language code is required": "Language code is required",
+ "Language not available": "Language not available",
+ "View analytics": "View analytics",
+ "Aggregate, privacy-preserving view counts per dashboard. Unique-viewer dedup uses a daily-rotating salted hash; no user identifiers are stored.": "Aggregate, privacy-preserving view counts per dashboard. Unique-viewer dedup uses a daily-rotating salted hash; no user identifiers are stored.",
+ "Period": "Period",
+ "Last 7 days": "Last 7 days",
+ "Last 30 days": "Last 30 days",
+ "Last 90 days": "Last 90 days",
+ "Loading analytics…": "Loading analytics…",
+ "Total views": "Total views",
+ "Unique viewers": "Unique viewers",
+ "Dashboards seen": "Dashboards seen",
+ "Top dashboards": "Top dashboards",
+ "Dashboard": "Dashboard",
+ "Views": "Views",
+ "No view events recorded yet for this period.": "No view events recorded yet for this period.",
+ "Export analytics (CSV)": "Export analytics (CSV)",
+ "Failed to load analytics data. Please try again.": "Failed to load analytics data. Please try again.",
+ "Failed to export analytics CSV. Please try again.": "Failed to export analytics CSV. Please try again.",
+ "%s's MyDash dashboards": "%s's MyDash dashboards",
+ "Reverse-chronological list of dashboards accessible to %s.": "Reverse-chronological list of dashboards accessible to %s.",
+ "Dashboard feed": "Dashboard feed",
+ "RSS / Atom feed": "RSS / Atom feed",
+ "Generate feed token": "Generate feed token",
+ "Regenerate feed token": "Regenerate feed token",
+ "Revoke feed token": "Revoke feed token",
+ "Copy feed URL": "Copy feed URL",
+ "Feed URL copied to clipboard": "Feed URL copied to clipboard",
+ "Your personal RSS feed of accessible dashboards.": "Your personal RSS feed of accessible dashboards.",
+ "Treat this URL as a password — anyone with the link can read your dashboards.": "Treat this URL as a password — anyone with the link can read your dashboards.",
+ "No feed token issued yet.": "No feed token issued yet.",
+ "Lock held by another user": "Lock held by another user",
+ "Lock not found; call acquire first": "Lock not found; call acquire first",
+ "Lock not found": "Lock not found",
+ "Only the lock owner can extend the lease": "Only the lock owner can extend the lease",
+ "Only the lock owner or an admin can release this lock": "Only the lock owner or an admin can release this lock",
+ "Only an administrator may force-release a lock": "Only an administrator may force-release a lock",
+ "Dashboard is being edited by {displayName}": "Dashboard is being edited by {displayName}",
+ "Your edit lock has expired": "Your edit lock has expired",
+ "You may be editing in another tab": "You may be editing in another tab",
+ "MyDash dashboard": "MyDash dashboard",
+ "Widget content on %s": "Widget content on %s",
+ "Metadata: %1$s = %2$s": "Metadata: %1$s = %2$s",
+ "Header Banner": "Header Banner",
+ "Header title": "Header title",
+ "Subtitle (optional)": "Subtitle (optional)",
+ "Optional subtitle": "Optional subtitle",
+ "Upload Background Image": "Upload Background Image",
+ "Or enter Background Image URL": "Or enter Background Image URL",
+ "Overlay Mode": "Overlay Mode",
+ "Tinted Overlay": "Tinted Overlay",
+ "Gradient Bottom": "Gradient Bottom",
+ "Overlay Color": "Overlay Color",
+ "Overlay Opacity": "Overlay Opacity",
+ "Text Alignment": "Text Alignment",
+ "Vertical Alignment": "Vertical Alignment",
+ "Top": "Top",
+ "Middle": "Middle",
+ "Bottom": "Bottom",
+ "Height": "Height",
+ "Small (120px)": "Small (120px)",
+ "Medium (200px)": "Medium (200px)",
+ "Large (320px)": "Large (320px)",
+ "Extra Large (480px)": "Extra Large (480px)",
+ "Call-to-Action Button (optional)": "Call-to-Action Button (optional)",
+ "Button Text": "Button Text",
+ "Sign up": "Sign up",
+ "Target URL": "Target URL",
+ "Button Style": "Button Style",
+ "Primary": "Primary",
+ "Secondary": "Secondary",
+ "Ghost": "Ghost",
+ "opens in new tab": "opens in new tab",
+ "Title is required": "Title is required",
+ "Background image URL must be HTTP or HTTPS": "Background image URL must be HTTP or HTTPS",
+ "Call-to-Action requires both label and URL": "Call-to-Action requires both label and URL",
+ "Divider": "Divider",
+ "Pick a divider style to break dashboard sections into logical groups.": "Pick a divider style to break dashboard sections into logical groups.",
+ "Style": "Style",
+ "Horizontal line": "Horizontal line",
+ "Whitespace": "Whitespace",
+ "Heading with lines": "Heading with lines",
+ "Line color": "Line color",
+ "Thickness (pixels)": "Thickness (pixels)",
+ "Line style": "Line style",
+ "Solid": "Solid",
+ "Dashed": "Dashed",
+ "Dotted": "Dotted",
+ "Spacing size": "Spacing size",
+ "Small (16px)": "Small (16px)",
+ "Medium (32px)": "Medium (32px)",
+ "Large (64px)": "Large (64px)",
+ "Extra Large (128px)": "Extra Large (128px)",
+ "Heading text": "Heading text",
+ "Heading text is required": "Heading text is required",
+ "Section": "Section",
+ "Section heading": "Section heading",
+ "{heading} divider": "{heading} divider",
+ "Folder breadcrumb": "Folder breadcrumb",
+ "Root": "Root",
+ "Search this folder": "Search this folder",
+ "Search this folder…": "Search this folder…",
+ "Upload File": "Upload File",
+ "Loading folder…": "Loading folder…",
+ "You don't have access to this folder.": "You don't have access to this folder.",
+ "Folder no longer exists.": "Folder no longer exists.",
+ "Failed to load folder contents.": "Failed to load folder contents.",
+ "Retry": "Retry",
+ "This folder is empty.": "This folder is empty.",
+ "No files matching '{query}'": "No files matching '{query}'",
+ "Delete {name}": "Delete {name}",
+ "Are you sure you want to delete {name}?": "Are you sure you want to delete {name}?",
+ "Load more": "Load more",
+ "Folder path": "Folder path",
+ "e.g. /Documents/Marketing": "e.g. /Documents/Marketing",
+ "Folder ID (optional, preferred)": "Folder ID (optional, preferred)",
+ "Numeric file id of the folder": "Numeric file id of the folder",
+ "View mode": "View mode",
+ "Sort by": "Sort by",
+ "Sort descending": "Sort descending",
+ "Show thumbnails": "Show thumbnails",
+ "MIME type filter (comma separated)": "MIME type filter (comma separated)",
+ "e.g. image/*, application/pdf": "e.g. image/*, application/pdf",
+ "Allow upload": "Allow upload",
+ "Allow delete": "Allow delete",
+ "List": "List",
+ "Grid": "Grid",
+ "Modified": "Modified",
+ "Type": "Type",
+ "Size": "Size",
+ "Name": "Name",
+ "Folder path or folder id is required": "Folder path or folder id is required",
+ "People": "People",
+ "Card": "Card",
+ "Layout": "Layout",
+ "Display name": "Display name",
+ "Group filter (comma-separated, blank for all)": "Group filter (comma-separated, blank for all)",
+ "e.g. management, product": "e.g. management, product",
+ "Exclude disabled users": "Exclude disabled users",
+ "Show birthdays": "Show birthdays",
+ "Birthday window (days, 0–30)": "Birthday window (days, 0–30)",
+ "Must be between 0 and 30": "Must be between 0 and 30",
+ "Birthday window must be between 0 and 30": "Birthday window must be between 0 and 30",
+ "Invalid layout": "Invalid layout",
+ "Invalid sort key": "Invalid sort key",
+ "Columns": "Columns",
+ "Search by name or email…": "Search by name or email…",
+ "Refresh": "Refresh",
+ "Failed to load users": "Failed to load users",
+ "No matching users.": "No matching users.",
+ "No users match your search": "No users match your search",
+ "Avatar of {name}": "Avatar of {name}",
+ "🎂 today": "🎂 today",
+ "🎂 in {n} days": "🎂 in {n} days",
+ "Quicklinks": "Quicklinks",
+ "No quicklinks yet — click the gear icon to add some.": "No quicklinks yet — click the gear icon to add some.",
+ "Open Quicklinks settings": "Open Quicklinks settings",
+ "Add link": "Add link",
+ "Delete link": "Delete link",
+ "Color (optional)": "Color (optional)",
+ "Icon Size": "Icon Size",
+ "Icon Shape": "Icon Shape",
+ "Show Labels": "Show Labels",
+ "Label Position": "Label Position",
+ "Tile Background": "Tile Background",
+ "Hover Effect": "Hover Effect",
+ "Paste CSV (label,url)": "Paste CSV (label,url)",
+ "Invalid URL": "Invalid URL",
+ "Invalid URL in one or more links": "Invalid URL in one or more links",
+ "Small": "Small",
+ "Medium": "Medium",
+ "Large": "Large",
+ "Extra Large": "Extra Large",
+ "Square": "Square",
+ "Rounded": "Rounded",
+ "Circle": "Circle",
+ "Below": "Below",
+ "Overlay": "Overlay",
+ "Auto": "Auto",
+ "Transparent": "Transparent",
+ "Gradient": "Gradient",
+ "Lift": "Lift",
+ "Fade": "Fade",
+ "Border": "Border",
+ "Link to": "Link to",
+ "News": "News",
+ "Loading news…": "Loading news…",
+ "No news yet — try adding feeds in the widget settings": "No news yet — try adding feeds in the widget settings",
+ "Unable to load news. Please try again later.": "Unable to load news. Please try again later.",
+ "1 feed failed": "1 feed failed",
+ "{count} feeds failed": "{count} feeds failed",
+ "Failed: {urls}": "Failed: {urls}",
+ "Feed URLs": "Feed URLs",
+ "Feed URL": "Feed URL",
+ "Add feed URL": "Add feed URL",
+ "Remove feed": "Remove feed",
+ "Carousel": "Carousel",
+ "Item limit (1–50)": "Item limit (1–50)",
+ "Show summary": "Show summary",
+ "Summary max characters": "Summary max characters",
+ "Date format": "Date format",
+ "Relative (2 hours ago)": "Relative (2 hours ago)",
+ "Absolute (2026-05-01 14:30)": "Absolute (2026-05-01 14:30)",
+ "Filter by dashboard metadata": "Filter by dashboard metadata",
+ "Metadata field key": "Metadata field key",
+ "Metadata value to match": "Metadata value to match",
+ "Empty feed URL — remove it or fill it in": "Empty feed URL — remove it or fill it in",
+ "Feed URL must start with http:// or https://": "Feed URL must start with http:// or https://",
+ "Metadata field key is required when filter is enabled": "Metadata field key is required when filter is enabled",
+ "just now": "just now",
+ "{n} minutes ago": "{n} minutes ago",
+ "{n} hours ago": "{n} hours ago",
+ "{n} days ago": "{n} days ago",
+ "No video URL configured": "No video URL configured",
+ "Video not accessible": "Video not accessible",
+ "Video failed to load": "Video failed to load",
+ "Invalid video URL or domain not allowed.": "Invalid video URL or domain not allowed.",
+ "Video URL": "Video URL",
+ "Video URL is required": "Video URL is required",
+ "Detected: YouTube": "Detected: YouTube",
+ "Detected: Vimeo": "Detected: Vimeo",
+ "Detected: PeerTube": "Detected: PeerTube",
+ "Detected: Nextcloud File": "Detected: Nextcloud File",
+ "Aspect Ratio": "Aspect Ratio",
+ "Autoplay": "Autoplay",
+ "Autoplay requires muting": "Autoplay requires muting",
+ "Muted": "Muted",
+ "Loop": "Loop",
+ "Show controls": "Show controls",
+ "Poster Image URL (optional)": "Poster Image URL (optional)",
+ "Nextcloud File ID": "Nextcloud File ID",
+ "Nextcloud file ID is required": "Nextcloud file ID is required",
+ "Loading calendars…": "Loading calendars…",
+ "Failed to load events": "Failed to load events",
+ "No calendars configured": "No calendars configured",
+ "No events in the next {N} days": "No events in the next {N} days",
+ "{N} calendar source(s) unavailable": "{N} calendar source(s) unavailable",
+ "Sun": "Sun",
+ "Mon": "Mon",
+ "Tue": "Tue",
+ "Wed": "Wed",
+ "Thu": "Thu",
+ "Fri": "Fri",
+ "Sat": "Sat",
+ "Month": "Month",
+ "Week": "Week",
+ "Agenda": "Agenda",
+ "All day": "All day",
+ "View Mode": "View Mode",
+ "Internal Calendars (one principal URI per line)": "Internal Calendars (one principal URI per line)",
+ "External ICS URLs (one per line)": "External ICS URLs (one per line)",
+ "Days Ahead": "Days Ahead",
+ "Color by Calendar": "Color by Calendar",
+ "At least one calendar source is required": "At least one calendar source is required",
+ "External ICS URLs must start with https://": "External ICS URLs must start with https://",
+ "Links": "Links",
+ "Section title": "Section title",
+ "Move up": "Move up",
+ "Move down": "Move down",
+ "Delete section": "Delete section",
+ "Add section": "Add section",
+ "Icon (name or URL)": "Icon (name or URL)",
+ "Description (optional)": "Description (optional)",
+ "Inline": "Inline",
+ "Icon only": "Icon only",
+ "Icon size": "Icon size",
+ "Small (24 px)": "Small (24 px)",
+ "Medium (40 px)": "Medium (40 px)",
+ "Large (64 px)": "Large (64 px)",
+ "Open in new tab": "Open in new tab",
+ "Show section titles": "Show section titles",
+ "Show descriptions": "Show descriptions",
+ "Link URL is required": "Link URL is required",
+ "Link label is required": "Link label is required",
+ "Invalid URL — use HTTP(S) or relative paths.": "Invalid URL — use HTTP(S) or relative paths.",
+ "No links yet — click the gear icon to add some.": "No links yet — click the gear icon to add some.",
+ "Menu": "Menu",
+ "Menu Style": "Menu Style",
+ "Menu Widget": "Menu Widget",
+ "No menu items yet — click the gear icon to add some.": "No menu items yet — click the gear icon to add some.",
+ "Menu items can nest at most 3 levels deep": "Menu items can nest at most 3 levels deep",
+ "Dropdown": "Dropdown",
+ "Megamenu": "Megamenu",
+ "Orientation": "Orientation",
+ "Horizontal": "Horizontal",
+ "Vertical": "Vertical",
+ "Active Item Highlight": "Active Item Highlight",
+ "Underline": "Underline",
+ "Left Bar": "Left Bar",
+ "Show Icons": "Show Icons",
+ "Expanded by Default": "Expanded by Default",
+ "Add Item": "Add Item",
+ "Remove Item": "Remove Item",
+ "Add Children": "Add Children",
+ "Items": "Items",
+ "Level 1": "Level 1",
+ "Level 2": "Level 2",
+ "Level 3 - max reached": "Level 3 - max reached",
+ "Expand": "Expand",
+ "Collapse": "Collapse",
+ "Image source": "Image source",
+ "URL/Link": "URL/Link",
+ "Pick from Files": "Pick from Files",
+ "Pick image from Nextcloud Files": "Pick image from Nextcloud Files",
+ "Pick image from Files": "Pick image from Files",
+ "Opening file picker…": "Opening file picker…",
+ "Selected:": "Selected:",
+ "File picker failed to open": "File picker failed to open",
+ "Selected file is missing required metadata": "Selected file is missing required metadata",
+ "Please pick a file from Files": "Please pick a file from Files",
+ "Image URL": "Image URL",
+ "Enter Image URL": "Enter Image URL",
+ "Mode": "Mode",
+ "HTML": "HTML",
+ "Markdown": "Markdown",
+ "Markdown — # heading, **bold**, *italic*, [link](url), - list": "Markdown — # heading, **bold**, *italic*, [link](url), - list",
+ "HTML — bold , italic , link ": "HTML — bold , italic , link ",
+ "Content type": "Content type",
+ "Table": "Table",
+ "Header row": "Header row",
+ "Add row above": "Add row above",
+ "Add row below": "Add row below",
+ "Add column left": "Add column left",
+ "Add column right": "Add column right",
+ "Delete row": "Delete row",
+ "Delete column": "Delete column",
+ "Merge cells": "Merge cells",
+ "Split cell": "Split cell",
+ "Column alignment": "Column alignment",
+ "Header": "Header",
+ "Cell": "Cell",
+ "Empty cell": "Empty cell",
+ "Row {row}, column {col}": "Row {row}, column {col}",
+ "This row contains text. Delete?": "This row contains text. Delete?",
+ "This column contains text. Delete?": "This column contains text. Delete?",
+ "Grid is not rectangular": "Grid is not rectangular",
+ "Cell span exceeds grid bounds": "Cell span exceeds grid bounds",
+ "Table must have at least one row": "Table must have at least one row",
+ "Table must have at least one column": "Table must have at least one column",
+ "Footer settings": "Footer settings",
+ "Enable footer": "Enable footer",
+ "HTML mode": "HTML mode",
+ "Structured mode": "Structured mode",
+ "Footer HTML": "Footer HTML",
+ "Allowed HTML tags: a, p, strong, em, br, ul, ol, li, img": "Allowed HTML tags: a, p, strong, em, br, ul, ol, li, img",
+ "Logo URL": "Logo URL",
+ "Organisation name": "Organisation name",
+ "Address": "Address",
+ "Legal text": "Legal text",
+ "Copyright year": "Copyright year",
+ "Layout mode": "Layout mode",
+ "Background colour override": "Background colour override",
+ "Text colour override": "Text colour override",
+ "Footer mode": "Footer mode",
+ "Inherit global footer": "Inherit global footer",
+ "Hide footer on this dashboard": "Hide footer on this dashboard",
+ "Custom footer for this dashboard": "Custom footer for this dashboard",
+ "Footer HTML exceeds the 8 KB size limit": "Footer HTML exceeds the 8 KB size limit",
+ "Footer mode must be one of: inherit, hidden, custom": "Footer mode must be one of: inherit, hidden, custom",
+ "Custom footer mode requires footer HTML": "Custom footer mode requires footer HTML",
+ "Footer config schema is invalid": "Footer config schema is invalid",
+ "Organization navigation": "Organization navigation",
+ "Open organization navigation": "Open organization navigation",
+ "Close navigation": "Close navigation",
+ "Navigation": "Navigation",
+ "Organization Navigation": "Organization Navigation",
+ "Build an organisation-wide navigation tree shown next to the dashboard surface. Drag nodes to reorder, click Edit to change properties, and use the group visibility selector to restrict sections to specific Nextcloud groups.": "Build an organisation-wide navigation tree shown next to the dashboard surface. Drag nodes to reorder, click Edit to change properties, and use the group visibility selector to restrict sections to specific Nextcloud groups.",
+ "Language": "Language",
+ "Dutch": "Dutch",
+ "English": "English",
+ "Position": "Position",
+ "Hidden": "Hidden",
+ "New section": "New section",
+ "New link": "New link",
+ "Add child": "Add child",
+ "URL (leave empty for section)": "URL (leave empty for section)",
+ "New tab": "New tab",
+ "Visible to everyone": "Visible to everyone",
+ "Group ids, comma separated": "Group ids, comma separated",
+ "Tree depth cannot exceed 3 levels": "Tree depth cannot exceed 3 levels",
+ "No navigation configured. Add a section or link to start building the tree.": "No navigation configured. Add a section or link to start building the tree.",
+ "Navigation saved successfully.": "Navigation saved successfully.",
+ "Save as template": "Save as template",
+ "Template gallery": "Template gallery",
+ "Preview image": "Preview image",
+ "Invalid image format": "Invalid image format",
+ "All categories": "All categories",
+ "Recently updated": "Recently updated",
+ "No templates available": "No templates available",
+ "Could not load template gallery": "Could not load template gallery",
+ "Could not save dashboard as template": "Could not save dashboard as template",
+ "You can only save your own dashboards as templates": "You can only save your own dashboards as templates",
+ "Describe the template purpose": "Describe the template purpose",
+ "e.g. marketing, engineering": "e.g. marketing, engineering",
+ "{count} widgets": "{count} widgets",
+ "Category": "Category",
+ "Bulk dashboard operations": "Bulk dashboard operations",
+ "Select multiple dashboards and apply an admin action to all of them at once. Every operation supports a dry-run preview.": "Select multiple dashboards and apply an admin action to all of them at once. Every operation supports a dry-run preview.",
+ "Loading dashboards…": "Loading dashboards…",
+ "Failed to load dashboards": "Failed to load dashboards",
+ "Bulk operation failed": "Bulk operation failed",
+ "Actions…": "Actions…",
+ "Move to…": "Move to…",
+ "Set status": "Set status",
+ "Reindex": "Reindex",
+ "Apply": "Apply",
+ "Working…": "Working…",
+ "OK": "OK",
+ "Dry run (preview only)": "Dry run (preview only)",
+ "Delete dashboards?": "Delete dashboards?",
+ "Move dashboards?": "Move dashboards?",
+ "Set publication status?": "Set publication status?",
+ "Reindex dashboards for search?": "Reindex dashboards for search?",
+ "About to delete {n} dashboards. This cannot be undone.": "About to delete {n} dashboards. This cannot be undone.",
+ "About to re-parent {n} dashboards.": "About to re-parent {n} dashboards.",
+ "About to update the publication status of {n} dashboards.": "About to update the publication status of {n} dashboards.",
+ "About to reindex {n} dashboards for unified search.": "About to reindex {n} dashboards for unified search.",
+ "New parent UUID (leave empty for root)": "New parent UUID (leave empty for root)",
+ "Publication status": "Publication status",
+ "PREVIEW: would change {count} dashboards.": "PREVIEW: would change {count} dashboards.",
+ "Changed {count} dashboards. Skipped {skipped}.": "Changed {count} dashboards. Skipped {skipped}.",
+ "Demo data showcases": "Demo data showcases",
+ "Install bundled example dashboards to give users a working starting point. Each showcase is created as a group-shared dashboard visible to all users; you can uninstall it at any time.": "Install bundled example dashboards to give users a working starting point. Each showcase is created as a group-shared dashboard visible to all users; you can uninstall it at any time.",
+ "Loading showcases…": "Loading showcases…",
+ "Could not load demo showcases. Please try again.": "Could not load demo showcases. Please try again.",
+ "Could not install showcase. Please try again.": "Could not install showcase. Please try again.",
+ "Could not uninstall showcase. Please try again.": "Could not uninstall showcase. Please try again.",
+ "Showcase not found.": "Showcase not found.",
+ "You need admin privileges to install showcases.": "You need admin privileges to install showcases.",
+ "Install": "Install",
+ "Installing…": "Installing…",
+ "Uninstall": "Uninstall",
+ "Uninstalling…": "Uninstalling…",
+ "Installed but skipped widgets: {list}": "Installed but skipped widgets: {list}",
+ "Remove the {name} showcase dashboard for all users? You can reinstall it later.": "Remove the {name} showcase dashboard for all users? You can reinstall it later.",
+ "MyDash setup wizard": "MyDash setup wizard",
+ "Step {n} / {total}": "Step {n} / {total}",
+ "Welcome": "Welcome",
+ "Configure your MyDash instance with storage, group ordering, demo data, admin roles, and footer settings.": "Configure your MyDash instance with storage, group ordering, demo data, admin roles, and footer settings.",
+ "Storage backend": "Storage backend",
+ "Choose how MyDash stores dashboard content.": "Choose how MyDash stores dashboard content.",
+ "Database (default)": "Database (default)",
+ "Store dashboard content in the MyDash database table.": "Store dashboard content in the MyDash database table.",
+ "GroupFolder (recommended for org use)": "GroupFolder (recommended for org use)",
+ "Store dashboard content in Nextcloud GroupFolders for collaborative access.": "Store dashboard content in Nextcloud GroupFolders for collaborative access.",
+ "GroupFolder app is not installed. Install 'Nextcloud GroupFolders' to use this option.": "GroupFolder app is not installed. Install 'Nextcloud GroupFolders' to use this option.",
+ "Pick the order Nextcloud groups appear when MyDash routes users to a workspace.": "Pick the order Nextcloud groups appear when MyDash routes users to a workspace.",
+ "Demo data": "Demo data",
+ "The demo data showcases will appear here once the demo-data-showcases capability ships. For now this step is informational only.": "The demo data showcases will appear here once the demo-data-showcases capability ships. For now this step is informational only.",
+ "Skip this step for now — demo packages can be installed later from the admin section.": "Skip this step for now — demo packages can be installed later from the admin section.",
+ "Admin roles": "Admin roles",
+ "Assign the \"Dashboard Admin\" role to a Nextcloud group to delegate MyDash administration.": "Assign the \"Dashboard Admin\" role to a Nextcloud group to delegate MyDash administration.",
+ "The admin-roles capability is not yet available; this step will activate once it ships.": "The admin-roles capability is not yet available; this step will activate once it ships.",
+ "Footer configuration": "Footer configuration",
+ "Customize the footer content and appearance for your intranet.": "Customize the footer content and appearance for your intranet.",
+ "The footer-customization capability is not yet available; this step will activate once it ships.": "The footer-customization capability is not yet available; this step will activate once it ships.",
+ "All set": "All set",
+ "Your setup is complete. Click Finish to save changes and dismiss the setup banner.": "Your setup is complete. Click Finish to save changes and dismiss the setup banner.",
+ "Back": "Back",
+ "Skip": "Skip",
+ "Next": "Next",
+ "Finish": "Finish",
+ "Run setup wizard": "Run setup wizard",
+ "Run setup wizard again": "Run setup wizard again",
+ "Get your intranet started: choose storage, configure groups, install demo data, and set up admin roles.": "Get your intranet started: choose storage, configure groups, install demo data, and set up admin roles.",
+ "Container": "Container",
+ "Padding": "Padding",
+ "Title (optional)": "Title (optional)",
+ "Container nesting limit reached": "Container nesting limit reached",
+ "A container holds a sub-grid of child widgets. Add child widgets via the container’s own grid once it is on the dashboard.": "A container holds a sub-grid of child widgets. Add child widgets via the container’s own grid once it is on the dashboard.",
+ "Unknown widget type: {type}": "Unknown widget type: {type}",
+ "Tile": "Tile",
+ "Tile title": "Tile title",
+ "Tile title is required": "Tile title is required",
+ "Tile link target is required": "Tile link target is required",
+ "Icon": "Icon",
+ "Icon type": "Icon type",
+ "Background color": "Background color",
+ "Text color": "Text color",
+ "Link type": "Link type",
+ "App route": "App route",
+ "URL": "URL",
+ "The reusable tile API is no longer available. Use the unified add-widget flow with type:tile instead.": "The reusable tile API is no longer available. Use the unified add-widget flow with type:tile instead.",
+ "Pick a widget": "Pick a widget",
+ "No Nextcloud widgets are installed": "No Nextcloud widgets are installed",
+ "Selected": "Selected",
+ "Role-based widget permissions": "Role-based widget permissions",
+ "Restrict which widgets each Nextcloud group can add to their dashboard. Empty list = full catalogue (legacy).": "Restrict which widgets each Nextcloud group can add to their dashboard. Empty list = full catalogue (legacy).",
+ "No role permissions configured": "No role permissions configured",
+ "Add a role-permission row to start filtering the widget catalogue per Nextcloud group.": "Add a role-permission row to start filtering the widget catalogue per Nextcloud group.",
+ "Add role permission": "Add role permission",
+ "Edit role permission": "Edit role permission",
+ "Nextcloud group ID": "Nextcloud group ID",
+ "Description (optional)": "Description (optional)",
+ "Allowed widget IDs (comma separated)": "Allowed widget IDs (comma separated)",
+ "Denied widget IDs (comma separated)": "Denied widget IDs (comma separated)",
+ "Delete role permission for \"{group}\"?": "Delete role permission for \"{group}\"?"
}
-}
\ No newline at end of file
+}
diff --git a/l10n/nl.js b/l10n/nl.js
index 83fe6ad7..03788885 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1,26 +1,52 @@
OC.L10N.register(
"mydash",
{
+ "— Choose —" : "— Kies —",
+ "Dashboard metadata" : "Dashboard-metadata",
+ "Failed to update dashboard metadata" : "Bijwerken van dashboard-metadata mislukt",
+ "No metadata fields are configured. Ask an administrator to add some." : "Er zijn geen metadata-velden geconfigureerd. Vraag een beheerder om er enkele toe te voegen.",
+ "Save metadata" : "Metadata opslaan",
+ "Saving…" : "Opslaan…",
+ "Yes" : "Ja",
+ "No" : "Nee",
+ "%1$s is now yours" : "%1$s is nu van u",
+ "%1$s shared %2$s with you" : "%1$s heeft %2$s met u gedeeld",
+ "%1$s shared **%2$s** with you" : "%1$s heeft **%2$s** met u gedeeld",
+ "(removed)" : "(verwijderd)",
+ "**%1$s** is now yours" : "**%1$s** is nu van u",
+ "+ New Dashboard" : "+ Nieuw dashboard",
"Access denied" : "Toegang geweigerd",
+ "Action Type" : "Actietype",
"Active" : "Actief",
+ "Active groups" : "Actieve groepen",
"Add" : "Toevoegen",
+ "Add Widget" : "Widget toevoegen",
+ "Add custom widget…" : "Aangepaste widget toevoegen…",
+ "Add dashboard" : "Dashboard toevoegen",
+ "Add link" : "Link toevoegen",
"Add only" : "Alleen toevoegen",
"Add to dashboard" : "Toevoegen aan dashboard",
+ "Add-only access" : "Alleen toevoegen",
+ "Admin privileges required" : "Beheerdersrechten vereist",
"Airplane" : "Vliegtuig",
"Alignment" : "Uitlijning",
"All users" : "Alle gebruikers",
"Allow users to create custom dashboards" : "Gebruikers toestaan aangepaste dashboards te maken",
"Allow users to have multiple dashboards" : "Gebruikers toestaan meerdere dashboards te hebben",
+ "Allowed file extensions" : "Toegestane bestandsextensies",
+ "Allowed image formats: jpeg, jpg, png, gif, svg, webp" : "Toegestane afbeeldingstypen: jpeg, jpg, png, gif, svg, webp",
"Already added" : "Al toegevoegd",
- "Alt Text" : "Alt-tekst",
+ "Alt Text" : "Alternatieve tekst",
"Are you sure you want to delete this dashboard?" : "Weet je zeker dat je dit dashboard wilt verwijderen?",
"Are you sure you want to delete this template?" : "Weet je zeker dat je dit sjabloon wilt verwijderen?",
+ "At least one link is required for list mode" : "Minimaal één link is vereist voor lijstmodus",
"Audio" : "Audio",
"Background" : "Achtergrond",
"Background Color" : "Achtergrondkleur",
"Background color" : "Achtergrondkleur",
"Bell" : "Bel",
"Bike" : "Fiets",
+ "Body must contain a base64 data URL" : "Body moet een base64 data-URL bevatten",
"Bold" : "Vet",
"Building" : "Gebouw",
"Bus" : "Bus",
@@ -29,32 +55,47 @@ OC.L10N.register(
"Camera" : "Camera",
"Cancel" : "Annuleren",
"Cannot delete the only dashboard in the group" : "Kan het enige dashboard in de groep niet verwijderen",
+ "Cannot exceed maximum tree depth of 5 levels" : "Maximale boomdiepte van 5 niveaus mag niet worden overschreden",
"Car" : "Auto",
"Center" : "Gecentreerd",
"Certificate" : "Certificaat",
"Checkmark" : "Vinkje",
+ "Choose a widget…" : "Kies een widget…",
"Clock" : "Klok",
"Close" : "Sluiten",
"Cogwheel" : "Tandwiel",
"Color" : "Kleur",
+ "Comma-separated list of file extensions admins allow link-button widgets to create" : "Door komma's gescheiden lijst van bestandsextensies die linkknop-widgets mogen aanmaken",
+ "Compact" : "Compact",
"Comment" : "Opmerking",
"Configure dashboard permissions and defaults" : "Dashboard-rechten en standaardinstellingen configureren",
+ "Contact your administrator" : "Neem contact op met je beheerder",
"Contacts" : "Contacten",
- "Contain" : "Bevatten",
- "Cover" : "Bedekken",
+ "Contain" : "Passen",
+ "Cover" : "Vullen",
+ "Create" : "Aanmaken",
+ "Create Document" : "Document aanmaken",
+ "Create File" : "Bestand aanmaken",
"Create dashboard" : "Dashboard aanmaken",
"Create dashboard templates that will be applied to users based on their groups." : "Maak dashboardsjablonen die op basis van groepslidmaatschap aan gebruikers worden toegewezen.",
"Create template" : "Sjabloon aanmaken",
"Create tile" : "Tegel aanmaken",
+ "Create your first dashboard" : "Maak je eerste dashboard",
"Create your first dashboard to get started" : "Maak je eerste dashboard aan om te beginnen",
+ "Creating…" : "Bezig met aanmaken…",
"Custom title" : "Aangepaste titel",
"Customize" : "Aanpassen",
"Customize widget" : "Widget aanpassen",
"Dashboard creation not allowed" : "Dashboard aanmaken niet toegestaan",
"Dashboard does not belong to this group" : "Dashboard hoort niet bij deze groep",
+ "Dashboard has children. Use ?cascade=true to delete the subtree." : "Dashboard heeft onderliggende dashboards. Gebruik ?cascade=true om de boom te verwijderen.",
"Dashboard name" : "Dashboardnaam",
+ "Dashboard not found" : "Dashboard niet gevonden",
+ "Dashboard not found at path" : "Dashboard niet gevonden op dit pad",
+ "Dashboard not found in group" : "Dashboard niet gevonden in groep",
"Dashboard templates" : "Dashboardsjablonen",
"Dashboards" : "Dashboards",
+ "Declared image type does not match file content" : "Het opgegeven afbeeldingstype komt niet overeen met de inhoud",
"Default" : "Standaard",
"Default grid columns" : "Standaard rasterkolommen",
"Default permission level" : "Standaard rechteniveau",
@@ -62,90 +103,172 @@ OC.L10N.register(
"Delete" : "Verwijderen",
"Delete dashboard" : "Dashboard verwijderen",
"Description" : "Beschrijving",
+ "Disabling this only blocks creating new personal dashboards. Existing personal dashboards remain visible and editable." : "Het uitschakelen blokkeert alleen het aanmaken van nieuwe persoonlijke dashboards. Bestaande persoonlijke dashboards blijven zichtbaar en bewerkbaar.",
+ "Display Mode" : "Weergavemodus",
"Document" : "Document",
"Documentation" : "Documentatie",
"Download" : "Downloaden",
+ "Drag groups between the columns to control which Nextcloud groups MyDash uses, and in what order. The first active group becomes the user's primary workspace." : "Sleep groepen tussen de kolommen om te bepalen welke Nextcloud-groepen MyDash gebruikt en in welke volgorde. De eerste actieve groep wordt de primaire werkruimte van de gebruiker.",
+ "Each link requires a label and a URL" : "Elke link vereist een label en een URL",
"Earth" : "Aarde",
"Edit" : "Bewerken",
+ "Edit Widget" : "Widget bewerken",
"Edit dashboard" : "Dashboard bewerken",
"Edit template" : "Sjabloon bewerken",
"Edit tile" : "Tegel bewerken",
+ "Enter filename" : "Voer bestandsnaam in",
"Enter tile title" : "Voer tegeltitel in",
"Euro" : "Euro",
+ "External Link" : "Externe link",
+ "Failed to create document" : "Document aanmaken mislukt",
+ "Failed to fork dashboard" : "Kan dashboard niet kopiëren",
+ "Failed to load group list." : "Kon de groepenlijst niet laden.",
+ "Failed to save group order." : "Kon de groepsvolgorde niet opslaan.",
+ "Failed to set the group default dashboard" : "Kon het standaarddashboard van de groep niet instellen",
+ "Failed to store resource" : "Opslaan van resource mislukt",
+ "Failed to upload icon" : "Pictogram uploaden mislukt",
"Failed to upload image" : "Afbeelding kon niet worden geüpload",
"Favorite" : "Favoriet",
+ "File Extension" : "Bestandsextensie",
+ "File Name" : "Bestandsnaam",
"Files" : "Bestanden",
- "Fill" : "Vullen",
- "Fit" : "Aanpassingsmanier",
+ "Fill" : "Uitrekken",
+ "Filter" : "Filter",
+ "Filter active groups" : "Actieve groepen filteren",
+ "Filter inactive groups" : "Inactieve groepen filteren",
+ "Fit" : "Passing",
"Flower" : "Bloem",
"Folder" : "Map",
"Font Size" : "Tekengrootte",
"Font Weight" : "Letterdikte",
+ "Font size" : "Lettergrootte",
"Forbidden: admin only" : "Verboden: alleen voor beheerders",
+ "Full access" : "Volledige toegang",
"Full customization" : "Volledige aanpassing",
"Group" : "Groep",
+ "Group dashboards" : "Groepsdashboards",
+ "Group order saved." : "Groepsvolgorde opgeslagen.",
+ "Group priority order" : "Groepsvolgorde",
+ "Group-shared dashboards" : "Groepsgedeelde dashboards",
"Heart" : "Hart",
"Home" : "Thuis",
+ "Horizontal (cards)" : "Horizontaal (kaarten)",
"House" : "Huis",
"Icon" : "Pictogram",
+ "Icon (optional)" : "Pictogram (optioneel)",
+ "Icon preview" : "Pictogramvoorbeeld",
"Image" : "Afbeelding",
- "Image URL is required" : "Afbeeldings-URL is verplicht",
+ "Image URL is required" : "Afbeelding-URL is verplicht",
+ "Image content appears to be corrupt" : "De afbeelding lijkt beschadigd te zijn",
"Image failed to load" : "Afbeelding kon niet worden geladen",
+ "Inactive groups" : "Inactieve groepen",
"Integration" : "Integratie",
+ "Internal Function" : "Interne functie",
+ "Invalid permissionLevel" : "Ongeldig rechtenniveau",
+ "Invalid shareType" : "Ongeldig deeltype",
"Justify" : "Uitvullen",
"Label" : "Label",
+ "Label is required" : "Label is verplicht",
+ "Label text" : "Labeltekst",
"Label text is required" : "Labeltekst is verplicht",
"Left" : "Links",
"Light bulb" : "Gloeilamp",
"Lightning" : "Bliksem",
"Link" : "Link",
"Link (optional)" : "Link (optioneel)",
+ "Link Button" : "Linkknop",
+ "Links" : "Links",
+ "List Item Spacing" : "Tussenruimte",
+ "List Orientation" : "Lijstrichting",
+ "List of links" : "Lijst met links",
+ "Loading group dashboards…" : "Groepsdashboards laden…",
+ "Loading group list…" : "Groepenlijst laden…",
+ "Loading…" : "Laden…",
"Mail" : "E-mail",
"Manage dashboards" : "Dashboards beheren",
"Map" : "Kaart",
+ "Maximum image size: 5 MB. Allowed types: jpeg, jpg, png, gif, svg, webp." : "Maximale afbeeldingsgrootte: 5 MB. Toegestane typen: jpeg, jpg, png, gif, svg, webp.",
+ "Maximum of 20 links per list widget" : "Maximaal 20 links per lijstwidget",
+ "Maximum size is 5MB" : "Maximale grootte is 5MB",
"Megaphone" : "Megafoon",
"Missing tile ID" : "Tegel-ID ontbreekt",
"Monitoring" : "Monitoring",
"Monument" : "Monument",
+ "Move link down" : "Link omlaag verplaatsen",
+ "Move link up" : "Link omhoog verplaatsen",
+ "Move to active" : "Naar actief verplaatsen",
+ "Move to inactive" : "Naar inactief verplaatsen",
"Multiple dashboards not allowed" : "Meerdere dashboards niet toegestaan",
+ "My Dashboards" : "Mijn dashboards",
+ "My copy of %s" : "Mijn kopie van %s",
"My dashboard" : "Mijn dashboard",
+ "My dashboards" : "Mijn dashboards",
"My template" : "Mijn sjabloon",
"MyDash" : "MyDash",
"MyDash settings" : "MyDash-instellingen",
"New tile" : "Nieuwe tegel",
+ "Nextcloud Widget" : "Nextcloud-widget",
+ "No active groups. Drag groups here from the inactive column." : "Geen actieve groepen. Sleep groepen hierheen vanuit de inactieve kolom.",
"No dashboard available" : "Geen dashboard beschikbaar",
"No dashboard yet" : "Nog geen dashboard",
+ "No dashboards available" : "Geen dashboards beschikbaar",
"No dashboards yet" : "Nog geen dashboards",
+ "No group-shared dashboards in this group yet." : "Nog geen groepsgedeelde dashboards in deze groep.",
"No image" : "Geen afbeelding",
+ "No inactive groups." : "Geen inactieve groepen.",
+ "No items available" : "Geen items beschikbaar",
+ "No links yet. Click \"Add link\" to add one." : "Nog geen links. Klik op \"Link toevoegen\" om er een toe te voegen.",
+ "No matches." : "Geen resultaten.",
"No templates yet" : "Nog geen sjablonen",
"No text content" : "Geen tekstinhoud",
+ "No widget types available" : "Geen widgettypes beschikbaar",
"No widgets found" : "Geen widgets gevonden",
"None" : "Geen",
"Normal" : "Normaal",
"Not logged in" : "Niet ingelogd",
"Office" : "Kantoor",
+ "Open menu" : "Menu openen",
"Optional description" : "Optionele beschrijving",
- "Or enter Image URL" : "Of voer een afbeeldings-URL in",
+ "Or enter Image URL" : "Of voer afbeelding-URL in",
+ "Ownership transferred after the previous owner was removed" : "Eigendom overgedragen omdat de vorige eigenaar is verwijderd",
+ "Parent dashboard not found" : "Bovenliggend dashboard niet gevonden",
"Park" : "Park",
"Parking" : "Parkeren",
"Permission level" : "Rechteniveau",
"Person" : "Persoon",
- "Personal dashboards are not enabled by your administrator" : "Persoonlijke dashboards zijn niet ingeschakeld door uw beheerder",
+ "Personal dashboards are not enabled by your administrator" : "Persoonlijke dashboards zijn niet ingeschakeld door je beheerder",
"Phone" : "Telefoon",
"Picture" : "Afbeelding",
+ "Please enter a file name" : "Voer een bestandsnaam in",
+ "Powered by" : "Mogelijk gemaakt door",
+ "Promote a single dashboard per group as the default. Members of the group will land on it when they have no personal preference yet." : "Promoveer één dashboard per groep tot het standaarddashboard. Leden van de groep komen daarop uit als ze nog geen persoonlijke voorkeur hebben.",
+ "Remove" : "Verwijderen",
+ "Remove link" : "Link verwijderen",
"Reset" : "Herstellen",
+ "Resource exceeds the 5 MB serving cap" : "Bestand overschrijdt de limiet van 5 MB",
"Right" : "Rechts",
- "SVG could not be parsed or contained no allowed content" : "SVG kon niet worden verwerkt of bevatte geen toegestane inhoud",
+ "SVG could not be parsed or contained no allowed content" : "SVG kon niet worden ontleed of bevat geen toegestane inhoud",
"Save" : "Opslaan",
+ "Save failed" : "Opslaan mislukt",
+ "Saving…" : "Opslaan…",
"Search" : "Zoeken",
"Search widgets..." : "Widgets zoeken...",
+ "Select Widget" : "Widget selecteren",
"Select dashboard" : "Selecteer dashboard",
"Select groups (leave empty for all users)" : "Selecteer groepen (laat leeg voor alle gebruikers)",
+ "Select icon…" : "Selecteer pictogram…",
+ "Set as default" : "Als standaard instellen",
"Set as default template" : "Instellen als standaardsjabloon",
"Setting as default app" : "Instellen als standaardapp",
+ "Setting this parent would create a cycle" : "Dit bovenliggend dashboard zou een lus veroorzaken",
"Settings" : "Instellingen",
"Share" : "Delen",
+ "Shared access" : "Gedeelde toegang",
+ "Slug must be unique among siblings" : "Slug moet uniek zijn binnen dezelfde ouder",
+ "Slug must match [a-z0-9_-]+ and be at most 128 characters" : "Slug moet voldoen aan [a-z0-9_-]+ en mag maximaal 128 tekens lang zijn",
"Show title" : "Titel tonen",
+ "Single button" : "Enkele knop",
+ "Spacious" : "Ruim",
"Star" : "Ster",
"Switch to this dashboard" : "Overschakelen naar dit dashboard",
"Tag" : "Label",
@@ -159,31 +282,614 @@ OC.L10N.register(
"To make MyDash the default app for users, go to Settings > Administration > Theming and select MyDash as the default app." : "Om MyDash als standaardapp voor gebruikers in te stellen, ga naar Instellingen > Beheer > Thema en selecteer MyDash als standaardapp.",
"Tree" : "Boom",
"URL" : "URL",
+ "URL is required" : "URL is verplicht",
"Upload" : "Uploaden",
+ "Upload Icon (optional)" : "Pictogram uploaden (optioneel)",
"Upload Image" : "Afbeelding uploaden",
+ "Upload icon" : "Pictogram uploaden",
+ "Uploading…" : "Bezig met uploaden…",
+ "Use JSON body with base64 field" : "Gebruik een JSON-body met een base64-veld",
"User" : "Gebruiker",
+ "Vertical (list)" : "Verticaal (lijst)",
"Video" : "Video",
"View only" : "Alleen bekijken",
+ "View-only access" : "Alleen bekijken",
"Wallet" : "Portemonnee",
"Widget" : "Widget",
"Widget not available" : "Widget niet beschikbaar",
"Widget style" : "Widgetstijl",
"Widget title" : "Widgettitel",
+ "Widget type" : "Widgettype",
"Widgets" : "Widgets",
+ "Your primary group for shared dashboards" : "Je primaire groep voor gedeelde dashboards",
"https://example.com or /apps/files" : "https://voorbeeld.nl of /apps/files",
- "Link Button" : "Linkknop",
- "Action Type" : "Actietype",
- "External Link" : "Externe link",
- "Internal Function" : "Interne functie",
- "Create File" : "Bestand aanmaken",
- "Upload Icon (optional)" : "Pictogram uploaden (optioneel)",
- "Create Document" : "Document aanmaken",
- "File Name" : "Bestandsnaam",
- "Enter filename" : "Voer bestandsnaam in",
- "Create" : "Aanmaken",
- "Creating…" : "Aanmaken…",
- "Failed to create document" : "Document aanmaken mislukt",
- "Please enter a file name" : "Voer een bestandsnaam in"
+ "shareWith is required" : "shareWith is verplicht",
+ "publishAt must be a future timestamp" : "publishAt moet een toekomstige datum zijn",
+ "Forbidden: owner or admin only" : "Niet toegestaan: alleen eigenaar of beheerder",
+ "Publish dashboard" : "Dashboard publiceren",
+ "Unpublish dashboard" : "Publicatie ongedaan maken",
+ "Schedule dashboard" : "Dashboard inplannen",
+ "Draft" : "Concept",
+ "Published" : "Gepubliceerd",
+ "Scheduled" : "Ingepland",
+ "Backup, restore & migrate dashboards" : "Dashboards back-uppen, herstellen en migreren",
+ "Download a versioned ZIP archive of all dashboards in this MyDash instance, or upload a previously exported archive to restore or migrate it. The archive is portable across Nextcloud installations." : "Download een geversioneerd ZIP-archief van alle dashboards in deze MyDash-instantie, of upload een eerder geëxporteerd archief om te herstellen of te migreren. Het archief is overdraagbaar tussen Nextcloud-installaties.",
+ "Download all dashboards" : "Alle dashboards downloaden",
+ "Exporting…" : "Exporteren…",
+ "Export failed. Please try again." : "Export mislukt. Probeer het opnieuw.",
+ "Import a dashboard archive (.zip)" : "Importeer een dashboard-archief (.zip)",
+ "Preserve original dashboard UUIDs (fail on collision)" : "Originele dashboard-UUID's behouden (afbreken bij conflict)",
+ "Upload archive" : "Archief uploaden",
+ "Importing…" : "Importeren…",
+ "Imported {imported} dashboards, skipped {skipped}." : "{imported} dashboards geïmporteerd, {skipped} overgeslagen.",
+ "Import failed. Please try again." : "Import mislukt. Probeer het opnieuw.",
+ "Import from Confluence" : "Importeren vanuit Confluence",
+ "Upload a Confluence HTML export ZIP archive. Each Confluence page becomes a MyDash dashboard with a single full-width text widget; the page hierarchy is mirrored using the dashboard tree." : "Upload een Confluence HTML-export-ZIP. Elke Confluence-pagina wordt een MyDash-dashboard met één volledige-breedte tekst-widget; de paginahiërarchie wordt overgenomen via de dashboard-boom.",
+ "Confluence export archive (.zip)" : "Confluence-export-archief (.zip)",
+ "Optional parent dashboard UUID (root pages will be slotted under this dashboard)" : "Optionele bovenliggende dashboard-UUID (rootpagina's komen onder dit dashboard)",
+ "Leave empty to import as root dashboards" : "Laat leeg om als root-dashboards te importeren",
+ "Dry-run preview" : "Dry-run-voorbeeld",
+ "Inspecting…" : "Analyseren…",
+ "Import dashboards" : "Dashboards importeren",
+ "{pages} pages, {attachments} attachments — would create {dashboards} dashboards." : "{pages} pagina's, {attachments} bijlagen — zou {dashboards} dashboards aanmaken.",
+ "Assets would be uploaded to {folder}" : "Bestanden worden geüpload naar {folder}",
+ "Assets uploaded to {folder}" : "Bestanden geüpload naar {folder}",
+ "Confluence dry-run failed. Please try again." : "Confluence-dry-run mislukt. Probeer het opnieuw.",
+ "Confluence import failed. Please try again." : "Confluence-import mislukt. Probeer het opnieuw.",
+ "You created dashboard {dashboard}" : "Je hebt dashboard {dashboard} aangemaakt",
+ "{actor} created dashboard {dashboard}" : "{actor} heeft dashboard {dashboard} aangemaakt",
+ "You updated dashboard {dashboard}" : "Je hebt dashboard {dashboard} bijgewerkt",
+ "{actor} updated dashboard {dashboard}" : "{actor} heeft dashboard {dashboard} bijgewerkt",
+ "You deleted dashboard {dashboard}" : "Je hebt dashboard {dashboard} verwijderd",
+ "{actor} deleted dashboard {dashboard}" : "{actor} heeft dashboard {dashboard} verwijderd",
+ "You published dashboard {dashboard}" : "Je hebt dashboard {dashboard} gepubliceerd",
+ "{actor} published dashboard {dashboard}" : "{actor} heeft dashboard {dashboard} gepubliceerd",
+ "You unpublished dashboard {dashboard}" : "Je hebt de publicatie van dashboard {dashboard} ongedaan gemaakt",
+ "{actor} unpublished dashboard {dashboard}" : "{actor} heeft de publicatie van dashboard {dashboard} ongedaan gemaakt",
+ "You scheduled dashboard {dashboard}" : "Je hebt dashboard {dashboard} ingepland",
+ "{actor} scheduled dashboard {dashboard}" : "{actor} heeft dashboard {dashboard} ingepland",
+ "You shared dashboard {dashboard} with {recipient}" : "Je hebt dashboard {dashboard} gedeeld met {recipient}",
+ "{actor} shared dashboard {dashboard} with {recipient}" : "{actor} heeft dashboard {dashboard} gedeeld met {recipient}",
+ "You created a public link for dashboard {dashboard}" : "Je hebt een openbare link gemaakt voor dashboard {dashboard}",
+ "{actor} created a public link for dashboard {dashboard}" : "{actor} heeft een openbare link gemaakt voor dashboard {dashboard}",
+ "You commented on dashboard {dashboard}" : "Je hebt gereageerd op dashboard {dashboard}",
+ "{actor} commented on dashboard {dashboard}" : "{actor} heeft gereageerd op dashboard {dashboard}",
+ "You reacted to dashboard {dashboard}" : "Je hebt op dashboard {dashboard} gereageerd",
+ "{actor} reacted to dashboard {dashboard}" : "{actor} heeft op dashboard {dashboard} gereageerd",
+ "You restored dashboard {dashboard} to an earlier version" : "Je hebt dashboard {dashboard} teruggezet naar een eerdere versie",
+ "{actor} restored dashboard {dashboard} to an earlier version" : "{actor} heeft dashboard {dashboard} teruggezet naar een eerdere versie",
+ "You overrode the lock on dashboard {dashboard}" : "Je hebt de vergrendeling van dashboard {dashboard} omzeild",
+ "{actor} overrode the lock on dashboard {dashboard}" : "{actor} heeft de vergrendeling van dashboard {dashboard} omzeild",
+ "Your role in {dashboard} was changed to {role}" : "Je rol in {dashboard} is gewijzigd naar {role}",
+ "{actor} changed {target}'s role in {dashboard} to {role}" : "{actor} heeft de rol van {target} in {dashboard} gewijzigd naar {role}",
+ "Dashboard Admin" : "Dashboardbeheerder",
+ "Dashboard Editor" : "Dashboardredacteur",
+ "Dashboard Viewer" : "Dashboardlezer",
+ "Either userId or groupId must be provided" : "Geef of een gebruikers-ID of een groeps-ID op",
+ "Only one of userId or groupId may be provided" : "Slechts één van gebruikers-ID of groeps-ID is toegestaan",
+ "Unknown role; must be one of admin, editor, viewer" : "Onbekende rol; moet een van admin, editor of viewer zijn",
+ "Unknown user" : "Onbekende gebruiker",
+ "Unknown group" : "Onbekende groep",
+ "That target already has the requested role assigned" : "Dit doel heeft de gevraagde rol al toegewezen",
+ "Role assignment not found" : "Roltoewijzing niet gevonden",
+ "Role assignment payload is invalid" : "Inhoud van roltoewijzing is ongeldig",
+ "Comments" : "Reacties",
+ "Post comment" : "Reactie plaatsen",
+ "Reply" : "Antwoorden",
+ "Edited" : "Bewerkt",
+ "Comments are disabled on this dashboard" : "Reacties zijn uitgeschakeld op dit dashboard",
+ "Comment message must not be empty" : "Reactie mag niet leeg zijn",
+ "Comments can only be replied to once" : "Reacties kunnen maar één keer worden beantwoord",
+ "Only the author or an admin may modify this comment" : "Alleen de auteur of een beheerder mag deze reactie wijzigen",
+ "Comment not found" : "Reactie niet gevonden",
+ "Parent comment not found" : "Bovenliggende reactie niet gevonden",
+ "Are you sure you want to delete this comment?" : "Weet je zeker dat je deze reactie wilt verwijderen?",
+ "Deleting this top-level comment will also remove all its replies." : "Het verwijderen van deze hoofdreactie verwijdert ook alle antwoorden.",
+ "Write a comment…" : "Schrijf een reactie…",
+ "Write a reply…" : "Schrijf een antwoord…",
+ "Cancel reply" : "Antwoord annuleren",
+ "%1$s mentioned you in a dashboard comment" : "%1$s heeft je genoemd in een dashboardreactie",
+ "Open the dashboard to read the comment" : "Open het dashboard om de reactie te lezen",
+ "Emoji not allowed" : "Emoji niet toegestaan",
+ "Reactions are disabled" : "Reacties zijn uitgeschakeld",
+ "Permission denied" : "Geen toestemming",
+ "React" : "Reageren",
+ "Reactions" : "Reacties",
+ "Version history" : "Versiegeschiedenis",
+ "Restore this version" : "Deze versie herstellen",
+ "Save version" : "Versie opslaan",
+ "Versioning backend unavailable" : "Versie-backend niet beschikbaar",
+ "Version not found" : "Versie niet gevonden",
+ "Version history is not available for this dashboard" : "Versiegeschiedenis is niet beschikbaar voor dit dashboard",
+ "Language variant already exists" : "Taalvariant bestaat al",
+ "Cannot delete the only language variant" : "Kan de enige taalvariant niet verwijderen",
+ "Cannot delete the primary variant; promote another variant first" : "Kan de primaire variant niet verwijderen; promoveer eerst een andere variant",
+ "Language code is required" : "Taalcode is verplicht",
+ "Language not available" : "Taal niet beschikbaar",
+ "View analytics" : "Weergavestatistieken",
+ "Aggregate, privacy-preserving view counts per dashboard. Unique-viewer dedup uses a daily-rotating salted hash; no user identifiers are stored." : "Geaggregeerde, privacyvriendelijke weergavetellers per dashboard. Unieke-bezoekerdeduplicatie gebruikt een dagelijks roterende gezouten hash; er worden geen gebruiker-id's opgeslagen.",
+ "Period" : "Periode",
+ "Last 7 days" : "Laatste 7 dagen",
+ "Last 30 days" : "Laatste 30 dagen",
+ "Last 90 days" : "Laatste 90 dagen",
+ "Loading analytics…" : "Statistieken worden geladen…",
+ "Total views" : "Totaal aantal weergaven",
+ "Unique viewers" : "Unieke bezoekers",
+ "Dashboards seen" : "Bekeken dashboards",
+ "Top dashboards" : "Top-dashboards",
+ "Dashboard" : "Dashboard",
+ "Views" : "Weergaven",
+ "No view events recorded yet for this period." : "Nog geen weergaven geregistreerd voor deze periode.",
+ "Export analytics (CSV)" : "Statistieken exporteren (CSV)",
+ "Failed to load analytics data. Please try again." : "Het laden van de statistieken is mislukt. Probeer het opnieuw.",
+ "Failed to export analytics CSV. Please try again." : "Het exporteren van het CSV-bestand is mislukt. Probeer het opnieuw.",
+ "%s's MyDash dashboards" : "MyDash-dashboards van %s",
+ "Reverse-chronological list of dashboards accessible to %s." : "Omgekeerd-chronologische lijst van dashboards die toegankelijk zijn voor %s.",
+ "Dashboard feed" : "Dashboard-feed",
+ "RSS / Atom feed" : "RSS-/Atom-feed",
+ "Generate feed token" : "Feed-token aanmaken",
+ "Regenerate feed token" : "Feed-token opnieuw genereren",
+ "Revoke feed token" : "Feed-token intrekken",
+ "Copy feed URL" : "Feed-URL kopiëren",
+ "Feed URL copied to clipboard" : "Feed-URL gekopieerd naar klembord",
+ "Your personal RSS feed of accessible dashboards." : "Je persoonlijke RSS-feed van toegankelijke dashboards.",
+ "Treat this URL as a password — anyone with the link can read your dashboards." : "Behandel deze URL als een wachtwoord — iedereen met de link kan je dashboards lezen.",
+ "No feed token issued yet." : "Nog geen feed-token uitgegeven.",
+ "Lock held by another user" : "Vergrendeld door een andere gebruiker",
+ "Lock not found; call acquire first" : "Geen vergrendeling gevonden; vraag deze eerst aan",
+ "Lock not found" : "Geen vergrendeling gevonden",
+ "Only the lock owner can extend the lease" : "Alleen de eigenaar van de vergrendeling kan deze verlengen",
+ "Only the lock owner or an admin can release this lock" : "Alleen de eigenaar of een beheerder kan deze vergrendeling vrijgeven",
+ "Only an administrator may force-release a lock" : "Alleen een beheerder mag een vergrendeling geforceerd vrijgeven",
+ "Dashboard is being edited by {displayName}" : "Dashboard wordt bewerkt door {displayName}",
+ "Your edit lock has expired" : "Je bewerkvergrendeling is verlopen",
+ "You may be editing in another tab" : "Je bewerkt dit dashboard mogelijk in een ander tabblad",
+ "MyDash dashboard" : "MyDash-dashboard",
+ "Widget content on %s" : "Widget-inhoud op %s",
+ "Metadata: %1$s = %2$s" : "Metadata: %1$s = %2$s",
+ "Header Banner" : "Kopbanner",
+ "Header title" : "Koptitel",
+ "Subtitle (optional)" : "Ondertitel (optioneel)",
+ "Optional subtitle" : "Optionele ondertitel",
+ "Upload Background Image" : "Achtergrondafbeelding uploaden",
+ "Or enter Background Image URL" : "Of voer een afbeeldings-URL in",
+ "Overlay Mode" : "Overlay-modus",
+ "Tinted Overlay" : "Gekleurde overlay",
+ "Gradient Bottom" : "Gradient aan onderkant",
+ "Overlay Color" : "Overlay-kleur",
+ "Overlay Opacity" : "Overlay-doorzichtigheid",
+ "Text Alignment" : "Tekstuitlijning",
+ "Vertical Alignment" : "Verticale uitlijning",
+ "Top" : "Boven",
+ "Middle" : "Midden",
+ "Bottom" : "Onder",
+ "Height" : "Hoogte",
+ "Small (120px)" : "Klein (120px)",
+ "Medium (200px)" : "Gemiddeld (200px)",
+ "Large (320px)" : "Groot (320px)",
+ "Extra Large (480px)" : "Extra groot (480px)",
+ "Call-to-Action Button (optional)" : "Call-to-action knop (optioneel)",
+ "Button Text" : "Knoptekst",
+ "Sign up" : "Aanmelden",
+ "Target URL" : "Doel-URL",
+ "Button Style" : "Knopstijl",
+ "Primary" : "Primair",
+ "Secondary" : "Secundair",
+ "Ghost" : "Transparant",
+ "opens in new tab" : "opent in nieuw tabblad",
+ "Title is required" : "Titel is verplicht",
+ "Background image URL must be HTTP or HTTPS" : "Achtergrondafbeelding-URL moet HTTP of HTTPS zijn",
+ "Call-to-Action requires both label and URL" : "Call-to-action vereist zowel label als URL",
+ "Divider" : "Scheidingslijn",
+ "Pick a divider style to break dashboard sections into logical groups." : "Kies een scheidingsstijl om dashboardsecties op te delen in logische groepen.",
+ "Style" : "Stijl",
+ "Horizontal line" : "Horizontale lijn",
+ "Whitespace" : "Witruimte",
+ "Heading with lines" : "Kop met lijnen",
+ "Line color" : "Lijnkleur",
+ "Thickness (pixels)" : "Dikte (pixels)",
+ "Line style" : "Lijnstijl",
+ "Solid" : "Doorlopend",
+ "Dashed" : "Streepjes",
+ "Dotted" : "Stippen",
+ "Spacing size" : "Tussenruimte",
+ "Small (16px)" : "Klein (16px)",
+ "Medium (32px)" : "Middel (32px)",
+ "Large (64px)" : "Groot (64px)",
+ "Extra Large (128px)" : "Extra groot (128px)",
+ "Heading text" : "Koptekst",
+ "Heading text is required" : "Koptekst is verplicht",
+ "Section" : "Sectie",
+ "Section heading" : "Sectiekop",
+ "{heading} divider" : "{heading} scheidingslijn",
+ "Folder breadcrumb" : "Map kruimelpad",
+ "Root" : "Hoofdmap",
+ "Search this folder" : "Zoek in deze map",
+ "Search this folder…" : "Zoek in deze map…",
+ "Upload File" : "Bestand uploaden",
+ "Loading folder…" : "Map laden…",
+ "You don't have access to this folder." : "Je hebt geen toegang tot deze map.",
+ "Folder no longer exists." : "Map bestaat niet meer.",
+ "Failed to load folder contents." : "Fout bij laden van mapinhoud.",
+ "Retry" : "Opnieuw proberen",
+ "This folder is empty." : "Deze map is leeg.",
+ "No files matching '{query}'" : "Geen bestanden gevonden voor '{query}'",
+ "Delete {name}" : "{name} verwijderen",
+ "Are you sure you want to delete {name}?" : "Weet je zeker dat je {name} wilt verwijderen?",
+ "Load more" : "Meer laden",
+ "Folder path" : "Mappad",
+ "e.g. /Documents/Marketing" : "bijv. /Documenten/Marketing",
+ "Folder ID (optional, preferred)" : "Map-ID (optioneel, voorkeur)",
+ "Numeric file id of the folder" : "Numeriek bestand-id van de map",
+ "View mode" : "Weergave",
+ "Sort by" : "Sorteer op",
+ "Sort descending" : "Aflopend sorteren",
+ "Show thumbnails" : "Miniaturen tonen",
+ "MIME type filter (comma separated)" : "MIME-type filter (komma-gescheiden)",
+ "e.g. image/*, application/pdf" : "bijv. image/*, application/pdf",
+ "Allow upload" : "Uploaden toestaan",
+ "Allow delete" : "Verwijderen toestaan",
+ "List" : "Lijst",
+ "Grid" : "Raster",
+ "Modified" : "Gewijzigd",
+ "Type" : "Type",
+ "Size" : "Grootte",
+ "Name" : "Naam",
+ "Folder path or folder id is required" : "Mappad of map-id is verplicht",
+ "People" : "Personen",
+ "Card" : "Kaart",
+ "Layout" : "Lay-out",
+ "Display name" : "Weergavenaam",
+ "Group filter (comma-separated, blank for all)" : "Groepsfilter (komma-gescheiden, leeg voor alle)",
+ "e.g. management, product" : "bv. management, product",
+ "Exclude disabled users" : "Uitgeschakelde gebruikers verbergen",
+ "Show birthdays" : "Verjaardagen tonen",
+ "Birthday window (days, 0–30)" : "Verjaardagvenster (dagen, 0–30)",
+ "Must be between 0 and 30" : "Moet tussen 0 en 30 liggen",
+ "Birthday window must be between 0 and 30" : "Verjaardagvenster moet tussen 0 en 30 liggen",
+ "Invalid layout" : "Ongeldige lay-out",
+ "Invalid sort key" : "Ongeldige sortering",
+ "Columns" : "Kolommen",
+ "Search by name or email…" : "Zoek op naam of e-mail…",
+ "Refresh" : "Vernieuwen",
+ "Failed to load users" : "Gebruikers konden niet worden geladen",
+ "No matching users." : "Geen overeenkomende gebruikers.",
+ "No users match your search" : "Geen gebruikers gevonden voor je zoekopdracht",
+ "Avatar of {name}" : "Avatar van {name}",
+ "🎂 today" : "🎂 vandaag",
+ "🎂 in {n} days" : "🎂 over {n} dagen",
+ "Quicklinks" : "Snelkoppelingen",
+ "No quicklinks yet — click the gear icon to add some." : "Nog geen snelkoppelingen — klik op het tandwielpictogram om er enkele toe te voegen.",
+ "Open Quicklinks settings" : "Snelkoppelingen-instellingen openen",
+ "Add link" : "Koppeling toevoegen",
+ "Delete link" : "Koppeling verwijderen",
+ "Color (optional)" : "Kleur (optioneel)",
+ "Icon Size" : "Pictogramgrootte",
+ "Icon Shape" : "Pictogramvorm",
+ "Show Labels" : "Labels tonen",
+ "Label Position" : "Labelpositie",
+ "Tile Background" : "Tegelachtergrond",
+ "Hover Effect" : "Hover-effect",
+ "Paste CSV (label,url)" : "Plak CSV (label,url)",
+ "Invalid URL" : "Ongeldige URL",
+ "Invalid URL in one or more links" : "Ongeldige URL in een of meer koppelingen",
+ "Small" : "Klein",
+ "Medium" : "Middel",
+ "Large" : "Groot",
+ "Extra Large" : "Extra groot",
+ "Square" : "Vierkant",
+ "Rounded" : "Afgerond",
+ "Circle" : "Cirkel",
+ "Below" : "Onder",
+ "Overlay" : "Overlay",
+ "Auto" : "Automatisch",
+ "Transparent" : "Transparant",
+ "Gradient" : "Verloop",
+ "Lift" : "Optillen",
+ "Fade" : "Vervagen",
+ "Border" : "Rand",
+ "Link to" : "Koppeling naar",
+ "News" : "Nieuws",
+ "Loading news…" : "Nieuws laden…",
+ "No news yet — try adding feeds in the widget settings" : "Nog geen nieuws — voeg feeds toe via de widget-instellingen",
+ "Unable to load news. Please try again later." : "Kan nieuws niet laden. Probeer het later opnieuw.",
+ "1 feed failed" : "1 feed mislukt",
+ "{count} feeds failed" : "{count} feeds mislukt",
+ "Failed: {urls}" : "Mislukt: {urls}",
+ "Feed URLs" : "Feed-URL's",
+ "Feed URL" : "Feed-URL",
+ "Add feed URL" : "Feed-URL toevoegen",
+ "Remove feed" : "Feed verwijderen",
+ "Carousel" : "Carrousel",
+ "Item limit (1–50)" : "Maximum aantal items (1–50)",
+ "Show summary" : "Samenvatting tonen",
+ "Summary max characters" : "Maximale lengte samenvatting",
+ "Date format" : "Datumnotatie",
+ "Relative (2 hours ago)" : "Relatief (2 uur geleden)",
+ "Absolute (2026-05-01 14:30)" : "Absoluut (2026-05-01 14:30)",
+ "Filter by dashboard metadata" : "Filter op dashboard-metadata",
+ "Metadata field key" : "Metadata-veldsleutel",
+ "Metadata value to match" : "Te matchen metadata-waarde",
+ "Empty feed URL — remove it or fill it in" : "Lege feed-URL — verwijder of vul aan",
+ "Feed URL must start with http:// or https://" : "Feed-URL moet beginnen met http:// of https://",
+ "Metadata field key is required when filter is enabled" : "Metadata-veldsleutel is verplicht wanneer filter actief is",
+ "just now" : "zojuist",
+ "{n} minutes ago" : "{n} minuten geleden",
+ "{n} hours ago" : "{n} uur geleden",
+ "{n} days ago" : "{n} dagen geleden",
+ "No video URL configured" : "Geen video-URL ingesteld",
+ "Video not accessible" : "Video niet toegankelijk",
+ "Video failed to load" : "Video kon niet worden geladen",
+ "Invalid video URL or domain not allowed." : "Ongeldige video-URL of domein niet toegestaan.",
+ "Video URL" : "Video-URL",
+ "Video URL is required" : "Video-URL is verplicht",
+ "Detected: YouTube" : "Herkend: YouTube",
+ "Detected: Vimeo" : "Herkend: Vimeo",
+ "Detected: PeerTube" : "Herkend: PeerTube",
+ "Detected: Nextcloud File" : "Herkend: Nextcloud-bestand",
+ "Aspect Ratio" : "Beeldverhouding",
+ "Autoplay" : "Automatisch afspelen",
+ "Autoplay requires muting" : "Automatisch afspelen vereist dempen",
+ "Muted" : "Gedempt",
+ "Loop" : "Herhalen",
+ "Show controls" : "Bedieningselementen tonen",
+ "Poster Image URL (optional)" : "URL voor voorvertoningsafbeelding (optioneel)",
+ "Nextcloud File ID" : "Nextcloud-bestand-ID",
+ "Nextcloud file ID is required" : "Nextcloud-bestand-ID is verplicht",
+ "Loading calendars…" : "Kalenders laden…",
+ "Failed to load events" : "Fout bij laden evenementen",
+ "No calendars configured" : "Geen kalenders geconfigureerd",
+ "No events in the next {N} days" : "Geen evenementen in de komende {N} dagen",
+ "{N} calendar source(s) unavailable" : "{N} kalenderbron(nen) niet beschikbaar",
+ "Sun" : "Zon",
+ "Mon" : "Maa",
+ "Tue" : "Din",
+ "Wed" : "Woe",
+ "Thu" : "Don",
+ "Fri" : "Vri",
+ "Sat" : "Zat",
+ "Month" : "Maand",
+ "Week" : "Week",
+ "Agenda" : "Agenda",
+ "All day" : "Hele dag",
+ "View Mode" : "Weergavemodus",
+ "Internal Calendars (one principal URI per line)" : "Interne kalenders (één principal-URI per regel)",
+ "External ICS URLs (one per line)" : "Externe ICS-URL's (één per regel)",
+ "Days Ahead" : "Dagen vooruit",
+ "Color by Calendar" : "Kleuren per kalender",
+ "At least one calendar source is required" : "Ten minste één kalenderbron is vereist",
+ "External ICS URLs must start with https://" : "Externe ICS-URL's moeten beginnen met https://",
+ "Links" : "Links",
+ "Section title" : "Sectietitel",
+ "Move up" : "Omhoog verplaatsen",
+ "Move down" : "Omlaag verplaatsen",
+ "Delete section" : "Sectie verwijderen",
+ "Add section" : "Sectie toevoegen",
+ "Icon (name or URL)" : "Pictogram (naam of URL)",
+ "Description (optional)" : "Beschrijving (optioneel)",
+ "Inline" : "Inline",
+ "Icon only" : "Alleen pictogram",
+ "Icon size" : "Pictogramgrootte",
+ "Small (24 px)" : "Klein (24 px)",
+ "Medium (40 px)" : "Normaal (40 px)",
+ "Large (64 px)" : "Groot (64 px)",
+ "Open in new tab" : "In nieuw tabblad openen",
+ "Show section titles" : "Sectietitels weergeven",
+ "Show descriptions" : "Beschrijvingen weergeven",
+ "Link URL is required" : "URL is verplicht",
+ "Link label is required" : "Label is verplicht",
+ "Invalid URL — use HTTP(S) or relative paths." : "Ongeldige URL — gebruik HTTP(S) of relatieve paden.",
+ "No links yet — click the gear icon to add some." : "Nog geen links — klik op het tandwielpictogram om er enkele toe te voegen.",
+ "Menu" : "Menu",
+ "Menu Style" : "Menustijl",
+ "Menu Widget" : "Menuwidget",
+ "No menu items yet — click the gear icon to add some." : "Nog geen menu-items — klik op het tandwiel om er toe te voegen.",
+ "Menu items can nest at most 3 levels deep" : "Menu-items kunnen maximaal 3 niveaus diep genest worden",
+ "Dropdown" : "Uitklapmenu",
+ "Megamenu" : "Megamenu",
+ "Orientation" : "Oriëntatie",
+ "Horizontal" : "Horizontaal",
+ "Vertical" : "Verticaal",
+ "Active Item Highlight" : "Markering actief item",
+ "Underline" : "Onderstreping",
+ "Left Bar" : "Linker balk",
+ "Show Icons" : "Pictogrammen tonen",
+ "Expanded by Default" : "Standaard uitgeklapt",
+ "Add Item" : "Item toevoegen",
+ "Remove Item" : "Item verwijderen",
+ "Add Children" : "Subitems toevoegen",
+ "Items" : "Items",
+ "Level 1" : "Niveau 1",
+ "Level 2" : "Niveau 2",
+ "Level 3 - max reached" : "Niveau 3 - maximum bereikt",
+ "Expand" : "Uitklappen",
+ "Collapse" : "Inklappen",
+ "Image source" : "Afbeeldingsbron",
+ "URL/Link" : "URL/Link",
+ "Pick from Files" : "Kies uit Bestanden",
+ "Pick image from Nextcloud Files" : "Kies een afbeelding uit Nextcloud Bestanden",
+ "Pick image from Files" : "Kies een afbeelding uit Bestanden",
+ "Opening file picker…" : "Bestandskiezer openen…",
+ "Selected:" : "Geselecteerd:",
+ "File picker failed to open" : "Bestandskiezer kon niet worden geopend",
+ "Selected file is missing required metadata" : "Geselecteerd bestand mist vereiste metadata",
+ "Please pick a file from Files" : "Kies een bestand uit Bestanden",
+ "Image URL" : "Afbeelding-URL",
+ "Enter Image URL" : "Voer afbeelding-URL in",
+ "Mode" : "Modus",
+ "HTML" : "HTML",
+ "Markdown" : "Markdown",
+ "Markdown — # heading, **bold**, *italic*, [link](url), - list" : "Markdown — # kop, **vet**, *cursief*, [link](url), - lijst",
+ "HTML — bold , italic , link " : "HTML — vet , cursief , link ",
+ "Content type" : "Inhoudstype",
+ "Table" : "Tabel",
+ "Header row" : "Koprij",
+ "Add row above" : "Rij erboven toevoegen",
+ "Add row below" : "Rij eronder toevoegen",
+ "Add column left" : "Kolom links toevoegen",
+ "Add column right" : "Kolom rechts toevoegen",
+ "Delete row" : "Rij verwijderen",
+ "Delete column" : "Kolom verwijderen",
+ "Merge cells" : "Cellen samenvoegen",
+ "Split cell" : "Cel splitsen",
+ "Column alignment" : "Kolomuitlijning",
+ "Header" : "Kop",
+ "Cell" : "Cel",
+ "Empty cell" : "Lege cel",
+ "Row {row}, column {col}" : "Rij {row}, kolom {col}",
+ "This row contains text. Delete?" : "Deze rij bevat tekst. Verwijderen?",
+ "This column contains text. Delete?" : "Deze kolom bevat tekst. Verwijderen?",
+ "Grid is not rectangular" : "Raster is niet rechthoekig",
+ "Cell span exceeds grid bounds" : "Celspan overschrijdt rastergrenzen",
+ "Table must have at least one row" : "Tabel moet minimaal één rij bevatten",
+ "Table must have at least one column" : "Tabel moet minimaal één kolom bevatten",
+ "Footer settings" : "Voettekstinstellingen",
+ "Enable footer" : "Voettekst inschakelen",
+ "HTML mode" : "HTML-modus",
+ "Structured mode" : "Gestructureerde modus",
+ "Footer HTML" : "Voettekst HTML",
+ "Allowed HTML tags: a, p, strong, em, br, ul, ol, li, img" : "Toegestane HTML-tags: a, p, strong, em, br, ul, ol, li, img",
+ "Logo URL" : "Logo-URL",
+ "Organisation name" : "Organisatienaam",
+ "Address" : "Adres",
+ "Legal text" : "Juridische tekst",
+ "Copyright year" : "Copyrightjaar",
+ "Layout mode" : "Lay-outmodus",
+ "Background colour override" : "Achtergrondkleur overschrijven",
+ "Text colour override" : "Tekstkleur overschrijven",
+ "Footer mode" : "Voettekstmodus",
+ "Inherit global footer" : "Globale voettekst overnemen",
+ "Hide footer on this dashboard" : "Voettekst verbergen op dit dashboard",
+ "Custom footer for this dashboard" : "Aangepaste voettekst voor dit dashboard",
+ "Footer HTML exceeds the 8 KB size limit" : "Voettekst-HTML overschrijdt de limiet van 8 KB",
+ "Footer mode must be one of: inherit, hidden, custom" : "Voettekstmodus moet een van de volgende zijn: inherit, hidden, custom",
+ "Custom footer mode requires footer HTML" : "Aangepaste voettekstmodus vereist voettekst-HTML",
+ "Footer config schema is invalid" : "Voettekstconfiguratieschema is ongeldig",
+ "Organization navigation" : "Organisatienavigatie",
+ "Open organization navigation" : "Open organisatienavigatie",
+ "Close navigation" : "Navigatie sluiten",
+ "Navigation" : "Navigatie",
+ "Organization Navigation" : "Organisatienavigatie",
+ "Build an organisation-wide navigation tree shown next to the dashboard surface. Drag nodes to reorder, click Edit to change properties, and use the group visibility selector to restrict sections to specific Nextcloud groups." : "Bouw een organisatiebrede navigatieboom die naast het dashboard verschijnt. Sleep knooppunten om te herordenen, klik op Bewerken om eigenschappen aan te passen en gebruik de groepszichtbaarheid om secties tot specifieke Nextcloud-groepen te beperken.",
+ "Language" : "Taal",
+ "Dutch" : "Nederlands",
+ "English" : "Engels",
+ "Position" : "Positie",
+ "Hidden" : "Verborgen",
+ "New section" : "Nieuwe sectie",
+ "New link" : "Nieuwe link",
+ "Add child" : "Onderliggend item toevoegen",
+ "URL (leave empty for section)" : "URL (leeg voor een sectie)",
+ "New tab" : "Nieuw tabblad",
+ "Visible to everyone" : "Zichtbaar voor iedereen",
+ "Group ids, comma separated" : "Groep-ID's, komma-gescheiden",
+ "Tree depth cannot exceed 3 levels" : "Boomdiepte mag niet groter dan 3 niveaus zijn",
+ "No navigation configured. Add a section or link to start building the tree." : "Geen navigatie geconfigureerd. Voeg een sectie of link toe om de boom te bouwen.",
+ "Navigation saved successfully." : "Navigatie opgeslagen.",
+ "Save as template" : "Opslaan als sjabloon",
+ "Template gallery" : "Sjabloongalerij",
+ "Preview image" : "Voorbeeldafbeelding",
+ "Invalid image format" : "Ongeldig afbeeldingsformaat",
+ "All categories" : "Alle categorieën",
+ "Recently updated" : "Recent bijgewerkt",
+ "No templates available" : "Geen sjablonen beschikbaar",
+ "Could not load template gallery" : "Kon de sjabloongalerij niet laden",
+ "Could not save dashboard as template" : "Kon het dashboard niet als sjabloon opslaan",
+ "You can only save your own dashboards as templates" : "Je kunt alleen je eigen dashboards als sjabloon opslaan",
+ "Describe the template purpose" : "Beschrijf het doel van het sjabloon",
+ "e.g. marketing, engineering" : "bijv. marketing, engineering",
+ "{count} widgets" : "{count} widgets",
+ "Category" : "Categorie",
+ "Bulk dashboard operations" : "Bulk dashboardbewerkingen",
+ "Select multiple dashboards and apply an admin action to all of them at once. Every operation supports a dry-run preview." : "Selecteer meerdere dashboards en pas een beheeractie tegelijk toe. Elke bewerking ondersteunt een preview-modus.",
+ "Loading dashboards…" : "Dashboards laden…",
+ "Failed to load dashboards" : "Dashboards laden mislukt",
+ "Bulk operation failed" : "Bulkbewerking mislukt",
+ "Actions…" : "Acties…",
+ "Move to…" : "Verplaatsen naar…",
+ "Set status" : "Status instellen",
+ "Reindex" : "Herindexeren",
+ "Apply" : "Toepassen",
+ "Working…" : "Bezig…",
+ "OK" : "OK",
+ "Dry run (preview only)" : "Preview-modus (geen wijzigingen)",
+ "Delete dashboards?" : "Dashboards verwijderen?",
+ "Move dashboards?" : "Dashboards verplaatsen?",
+ "Set publication status?" : "Publicatiestatus instellen?",
+ "Reindex dashboards for search?" : "Dashboards herindexeren voor zoeken?",
+ "About to delete {n} dashboards. This cannot be undone." : "{n} dashboards worden verwijderd. Dit kan niet ongedaan worden gemaakt.",
+ "About to re-parent {n} dashboards." : "{n} dashboards krijgen een nieuwe ouder.",
+ "About to update the publication status of {n} dashboards." : "De publicatiestatus van {n} dashboards wordt bijgewerkt.",
+ "About to reindex {n} dashboards for unified search." : "{n} dashboards worden herindexeerd voor zoeken.",
+ "New parent UUID (leave empty for root)" : "Nieuwe ouder-UUID (leeg laten voor root)",
+ "Publication status" : "Publicatiestatus",
+ "PREVIEW: would change {count} dashboards." : "PREVIEW: zou {count} dashboards wijzigen.",
+ "Changed {count} dashboards. Skipped {skipped}." : "{count} dashboards gewijzigd. {skipped} overgeslagen.",
+ "Demo data showcases" : "Demo-voorbeelden",
+ "Install bundled example dashboards to give users a working starting point. Each showcase is created as a group-shared dashboard visible to all users; you can uninstall it at any time." : "Installeer meegeleverde voorbeeld-dashboards zodat gebruikers een werkend startpunt hebben. Elk voorbeeld wordt aangemaakt als een groep-gedeeld dashboard dat zichtbaar is voor alle gebruikers; je kunt het op elk moment weer verwijderen.",
+ "Loading showcases…" : "Voorbeelden laden…",
+ "Could not load demo showcases. Please try again." : "Kon de demo-voorbeelden niet laden. Probeer het opnieuw.",
+ "Could not install showcase. Please try again." : "Kon het voorbeeld niet installeren. Probeer het opnieuw.",
+ "Could not uninstall showcase. Please try again." : "Kon het voorbeeld niet verwijderen. Probeer het opnieuw.",
+ "Showcase not found." : "Voorbeeld niet gevonden.",
+ "You need admin privileges to install showcases." : "Je hebt beheerdersrechten nodig om voorbeelden te installeren.",
+ "Install" : "Installeer",
+ "Installing…" : "Installeren…",
+ "Uninstall" : "Verwijder",
+ "Uninstalling…" : "Verwijderen…",
+ "Installed but skipped widgets: {list}" : "Geïnstalleerd, maar widgets overgeslagen: {list}",
+ "Remove the {name} showcase dashboard for all users? You can reinstall it later." : "Het voorbeeld-dashboard {name} voor alle gebruikers verwijderen? Je kunt het later opnieuw installeren.",
+ "MyDash setup wizard" : "MyDash installatiewizard",
+ "Step {n} / {total}" : "Stap {n} / {total}",
+ "Welcome" : "Welkom",
+ "Configure your MyDash instance with storage, group ordering, demo data, admin roles, and footer settings." : "Configureer je MyDash met opslag, groepvolgorde, demo-data, beheerdersrollen en voettekstinstellingen.",
+ "Storage backend" : "Opslag-backend",
+ "Choose how MyDash stores dashboard content." : "Kies hoe MyDash dashboardinhoud opslaat.",
+ "Database (default)" : "Database (standaard)",
+ "Store dashboard content in the MyDash database table." : "Sla dashboardinhoud op in de MyDash-databasetabel.",
+ "GroupFolder (recommended for org use)" : "GroupFolder (aanbevolen voor organisaties)",
+ "Store dashboard content in Nextcloud GroupFolders for collaborative access." : "Sla dashboardinhoud op in Nextcloud GroupFolders voor gedeelde toegang.",
+ "GroupFolder app is not installed. Install 'Nextcloud GroupFolders' to use this option." : "De GroupFolder-app is niet geïnstalleerd. Installeer 'Nextcloud GroupFolders' om deze optie te gebruiken.",
+ "Pick the order Nextcloud groups appear when MyDash routes users to a workspace." : "Kies de volgorde waarin Nextcloud-groepen verschijnen wanneer MyDash gebruikers naar een werkruimte stuurt.",
+ "Demo data" : "Demo-data",
+ "The demo data showcases will appear here once the demo-data-showcases capability ships. For now this step is informational only." : "De demo-showcases verschijnen hier zodra de demo-data-showcases-capability beschikbaar is. Deze stap is voorlopig alleen ter informatie.",
+ "Skip this step for now — demo packages can be installed later from the admin section." : "Sla deze stap voor nu over — demo-pakketten kunnen later vanuit het beheerdergedeelte worden geïnstalleerd.",
+ "Admin roles" : "Beheerdersrollen",
+ "Assign the \"Dashboard Admin\" role to a Nextcloud group to delegate MyDash administration." : "Wijs de rol \"Dashboardbeheerder\" toe aan een Nextcloud-groep om MyDash-beheer te delegeren.",
+ "The admin-roles capability is not yet available; this step will activate once it ships." : "De admin-roles-capability is nog niet beschikbaar; deze stap wordt actief zodra deze wordt uitgerold.",
+ "Footer configuration" : "Voettekstconfiguratie",
+ "Customize the footer content and appearance for your intranet." : "Pas de inhoud en het uiterlijk van de voettekst aan voor je intranet.",
+ "The footer-customization capability is not yet available; this step will activate once it ships." : "De footer-customization-capability is nog niet beschikbaar; deze stap wordt actief zodra deze wordt uitgerold.",
+ "All set" : "Alles klaar",
+ "Your setup is complete. Click Finish to save changes and dismiss the setup banner." : "Je installatie is voltooid. Klik op Voltooien om de wijzigingen op te slaan en de installatiebanner te verbergen.",
+ "Back" : "Terug",
+ "Skip" : "Overslaan",
+ "Next" : "Volgende",
+ "Finish" : "Voltooien",
+ "Run setup wizard" : "Installatiewizard starten",
+ "Run setup wizard again" : "Installatiewizard opnieuw starten",
+ "Get your intranet started: choose storage, configure groups, install demo data, and set up admin roles." : "Start je intranet: kies opslag, configureer groepen, installeer demo-data en stel beheerdersrollen in.",
+ "Container" : "Container",
+ "Padding" : "Opvulling",
+ "Title (optional)" : "Titel (optioneel)",
+ "Container nesting limit reached" : "Maximale containernesting bereikt",
+ "A container holds a sub-grid of child widgets. Add child widgets via the container’s own grid once it is on the dashboard." : "Een container houdt een subraster van onderliggende widgets vast. Voeg onderliggende widgets toe via het eigen raster van de container zodra deze op het dashboard staat.",
+ "Unknown widget type: {type}" : "Onbekend widgettype: {type}",
+ "Tile" : "Tegel",
+ "Tile title" : "Tegeltitel",
+ "Tile title is required" : "Tegeltitel is verplicht",
+ "Tile link target is required" : "Linkdoel van tegel is verplicht",
+ "Icon" : "Pictogram",
+ "Icon type" : "Pictogramtype",
+ "Background color" : "Achtergrondkleur",
+ "Text color" : "Tekstkleur",
+ "Link type" : "Linktype",
+ "App route" : "App-route",
+ "URL" : "URL",
+ "The reusable tile API is no longer available. Use the unified add-widget flow with type:tile instead." : "De herbruikbare tegel-API is niet meer beschikbaar. Gebruik in plaats daarvan de geünificeerde widget-toevoegstroom met type:tile.",
+ "Pick a widget" : "Kies een widget",
+ "No Nextcloud widgets are installed" : "Er zijn geen Nextcloud-widgets geïnstalleerd",
+ "Selected" : "Geselecteerd"
},
"nplurals=2; plural=(n != 1);"
-);
\ No newline at end of file
+);
diff --git a/l10n/nl.json b/l10n/nl.json
index af3b8f61..a4ee53a9 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1,10 +1,22 @@
{
"translations": {
+ "— Choose —": "— Kies —",
+ "Dashboard metadata": "Dashboard-metadata",
+ "Failed to update dashboard metadata": "Bijwerken van dashboard-metadata mislukt",
+ "No metadata fields are configured. Ask an administrator to add some.": "Er zijn geen metadata-velden geconfigureerd. Vraag een beheerder om er enkele toe te voegen.",
+ "Save metadata": "Metadata opslaan",
+ "Saving…": "Opslaan…",
+ "Yes": "Ja",
+ "No": "Nee",
"SVG could not be parsed or contained no allowed content": "SVG kon niet worden verwerkt of bevatte geen toegestane inhoud",
"Active": "Actief",
"Add": "Toevoegen",
"Add only": "Alleen toevoegen",
"Add to dashboard": "Toevoegen aan dashboard",
+ "Add Widget": "Widget toevoegen",
+ "Add custom widget…": "Aangepaste widget toevoegen…",
+ "Add dashboard": "Dashboard toevoegen",
+ "Powered by": "Mogelijk gemaakt door",
"Airplane": "Vliegtuig",
"All users": "Alle gebruikers",
"Allow users to create custom dashboards": "Gebruikers toestaan aangepaste dashboards te maken",
@@ -14,6 +26,20 @@
"Are you sure you want to delete this template?": "Weet je zeker dat je dit sjabloon wilt verwijderen?",
"Audio": "Audio",
"Access denied": "Toegang geweigerd",
+ "Cannot delete the only dashboard in the group": "Kan het enige dashboard in de groep niet verwijderen",
+ "Cannot exceed maximum tree depth of 5 levels": "Maximale boomdiepte van 5 niveaus mag niet worden overschreden",
+ "Dashboard does not belong to this group": "Dashboard hoort niet bij deze groep",
+ "Dashboard has children. Use ?cascade=true to delete the subtree.": "Dashboard heeft onderliggende dashboards. Gebruik ?cascade=true om de boom te verwijderen.",
+ "Dashboard not found": "Dashboard niet gevonden",
+ "Dashboard not found at path": "Dashboard niet gevonden op dit pad",
+ "Dashboard not found in group": "Dashboard niet gevonden in groep",
+ "Parent dashboard not found": "Bovenliggend dashboard niet gevonden",
+ "Setting this parent would create a cycle": "Dit bovenliggend dashboard zou een lus veroorzaken",
+ "Slug must be unique among siblings": "Slug moet uniek zijn binnen dezelfde ouder",
+ "Slug must match [a-z0-9_-]+ and be at most 128 characters": "Slug moet voldoen aan [a-z0-9_-]+ en mag maximaal 128 tekens lang zijn",
+ "Failed to fork dashboard": "Kan dashboard niet kopiëren",
+ "My copy of %s": "Mijn kopie van %s",
+ "Forbidden: admin only": "Verboden: alleen voor beheerders",
"Background color": "Achtergrondkleur",
"Bell": "Bel",
"Bike": "Fiets",
@@ -47,6 +73,8 @@
"Edit tile": "Tegel bewerken",
"Edit dashboard": "Dashboard bewerken",
"Edit template": "Sjabloon bewerken",
+ "Edit Widget": "Widget bewerken",
+ "Failed to upload icon": "Pictogram uploaden mislukt",
"Enter tile title": "Voer tegeltitel in",
"Full customization": "Volledige aanpassing",
"Light bulb": "Gloeilamp",
@@ -55,19 +83,25 @@
"Missing tile ID": "Tegel-ID ontbreekt",
"Multiple dashboards not allowed": "Meerdere dashboards niet toegestaan",
"My dashboard": "Mijn dashboard",
+ "My Dashboards": "Mijn dashboards",
"My template": "Mijn sjabloon",
"MyDash settings": "MyDash-instellingen",
+ "+ New Dashboard": "+ Nieuw dashboard",
"New tile": "Nieuwe tegel",
"No dashboard available": "Geen dashboard beschikbaar",
"No dashboard yet": "Nog geen dashboard",
"No dashboards yet": "Nog geen dashboards",
"No templates yet": "Nog geen sjablonen",
+ "No widget types available": "Geen widgettypes beschikbaar",
"No widgets found": "Geen widgets gevonden",
"Not logged in": "Niet ingelogd",
"Optional description": "Optionele beschrijving",
"Permission level": "Rechteniveau",
+ "Personal dashboards are not enabled by your administrator": "Persoonlijke dashboards zijn niet ingeschakeld door je beheerder",
+ "Disabling this only blocks creating new personal dashboards. Existing personal dashboards remain visible and editable.": "Het uitschakelen blokkeert alleen het aanmaken van nieuwe persoonlijke dashboards. Bestaande persoonlijke dashboards blijven zichtbaar en bewerkbaar.",
"Search widgets...": "Widgets zoeken...",
"Select dashboard": "Selecteer dashboard",
+ "Select icon…": "Selecteer pictogram…",
"Select groups (leave empty for all users)": "Selecteer groepen (laat leeg voor alle gebruikers)",
"Set as default template": "Instellen als standaardsjabloon",
"Setting as default app": "Instellen als standaardapp",
@@ -109,8 +143,15 @@
"Home": "Thuis",
"House": "Huis",
"Icon": "Pictogram",
+ "Icon preview": "Pictogramvoorbeeld",
"Image": "Afbeelding",
"Integration": "Integratie",
+ "Label": "Label",
+ "Label text": "Labeltekst",
+ "Label text is required": "Labeltekst is verplicht",
+ "Font size": "Lettergrootte",
+ "Font Weight": "Letterdikte",
+ "Alignment": "Uitlijning",
"Lightning": "Bliksem",
"Link": "Link",
"Mail": "E-mail",
@@ -152,27 +193,70 @@
"Tree": "Boom",
"URL": "URL",
"Upload": "Uploaden",
+ "Upload icon": "Pictogram uploaden",
+ "Uploading…": "Bezig met uploaden…",
"User": "Gebruiker",
"Video": "Video",
"Wallet": "Portemonnee",
"Widget": "Widget",
+ "Widget type": "Widgettype",
"Widgets": "Widgets",
+ "SVG could not be parsed or contained no allowed content": "SVG kon niet worden ontleed of bevat geen toegestane inhoud",
+ "Resource exceeds the 5 MB serving cap": "Bestand overschrijdt de limiet van 5 MB",
+ "Saving…": "Opslaan…",
+ "No dashboards available": "Geen dashboards beschikbaar",
+ "Contact your administrator": "Neem contact op met je beheerder",
+ "Create your first dashboard": "Maak je eerste dashboard",
+ "Open menu": "Menu openen",
+ "Save failed": "Opslaan mislukt",
+ "My dashboards": "Mijn dashboards",
+ "Group dashboards": "Groepsdashboards",
+ "Failed to upload image": "Afbeelding kon niet worden geüpload",
+ "Admin privileges required": "Beheerdersrechten vereist",
+ "Body must contain a base64 data URL": "Body moet een base64 data-URL bevatten",
+ "Allowed image formats: jpeg, jpg, png, gif, svg, webp": "Toegestane afbeeldingstypen: jpeg, jpg, png, gif, svg, webp",
+ "Maximum size is 5MB": "Maximale grootte is 5MB",
+ "Declared image type does not match file content": "Het opgegeven afbeeldingstype komt niet overeen met de inhoud",
+ "Image content appears to be corrupt": "De afbeelding lijkt beschadigd te zijn",
+ "Use JSON body with base64 field": "Gebruik een JSON-body met een base64-veld",
+ "Failed to store resource": "Opslaan van resource mislukt",
+ "Maximum image size: 5 MB. Allowed types: jpeg, jpg, png, gif, svg, webp.": "Maximale afbeeldingsgrootte: 5 MB. Toegestane typen: jpeg, jpg, png, gif, svg, webp.",
+ "Group-shared dashboards": "Groepsgedeelde dashboards",
+ "Promote a single dashboard per group as the default. Members of the group will land on it when they have no personal preference yet.": "Promoveer één dashboard per groep tot het standaarddashboard. Leden van de groep komen daarop uit als ze nog geen persoonlijke voorkeur hebben.",
+ "Loading group dashboards…": "Groepsdashboards laden…",
+ "No group-shared dashboards in this group yet.": "Nog geen groepsgedeelde dashboards in deze groep.",
+ "Set as default": "Als standaard instellen",
+ "Failed to set the group default dashboard": "Kon het standaarddashboard van de groep niet instellen",
+ "Group priority order": "Groepsvolgorde",
+ "Drag groups between the columns to control which Nextcloud groups MyDash uses, and in what order. The first active group becomes the user's primary workspace.": "Sleep groepen tussen de kolommen om te bepalen welke Nextcloud-groepen MyDash gebruikt en in welke volgorde. De eerste actieve groep wordt de primaire werkruimte van de gebruiker.",
+ "Active groups": "Actieve groepen",
+ "Inactive groups": "Inactieve groepen",
+ "Filter active groups": "Actieve groepen filteren",
+ "Filter inactive groups": "Inactieve groepen filteren",
+ "Filter": "Filter",
+ "Move to inactive": "Naar inactief verplaatsen",
+ "Move to active": "Naar actief verplaatsen",
+ "(removed)": "(verwijderd)",
+ "No matches.": "Geen resultaten.",
+ "No active groups. Drag groups here from the inactive column.": "Geen actieve groepen. Sleep groepen hierheen vanuit de inactieve kolom.",
+ "No inactive groups.": "Geen inactieve groepen.",
+ "Loading group list…": "Groepenlijst laden…",
+ "Failed to load group list.": "Kon de groepenlijst niet laden.",
+ "Group order saved.": "Groepsvolgorde opgeslagen.",
+ "Failed to save group order.": "Kon de groepsvolgorde niet opslaan.",
+ "Your primary group for shared dashboards": "Je primaire groep voor gedeelde dashboards",
"No image": "Geen afbeelding",
"Image failed to load": "Afbeelding kon niet worden geladen",
"Upload Image": "Afbeelding uploaden",
- "Or enter Image URL": "Of voer een afbeeldings-URL in",
- "Alt Text": "Alt-tekst",
+ "Or enter Image URL": "Of voer afbeelding-URL in",
+ "Alt Text": "Alternatieve tekst",
"Link (optional)": "Link (optioneel)",
- "Fit": "Aanpassingsmanier",
- "Cover": "Bedekken",
- "Contain": "Bevatten",
- "Fill": "Vullen",
+ "Fit": "Passing",
+ "Cover": "Vullen",
+ "Contain": "Passen",
+ "Fill": "Uitrekken",
"None": "Geen",
- "Failed to upload image": "Afbeelding kon niet worden geüpload",
- "Image URL is required": "Afbeeldings-URL is verplicht",
- "My Dashboards": "Mijn dashboards",
- "+ New Dashboard": "+ Nieuw dashboard",
- "Personal dashboards are not enabled by your administrator": "Persoonlijke dashboards zijn niet ingeschakeld door uw beheerder",
+ "Image URL is required": "Afbeelding-URL is verplicht",
"%1$s shared **%2$s** with you": "%1$s heeft **%2$s** met u gedeeld",
"%1$s shared %2$s with you": "%1$s heeft %2$s met u gedeeld",
"Full access": "Volledige toegang",
@@ -182,7 +266,6 @@
"**%1$s** is now yours": "**%1$s** is nu van u",
"%1$s is now yours": "%1$s is nu van u",
"Ownership transferred after the previous owner was removed": "Eigendom overgedragen omdat de vorige eigenaar is verwijderd",
- "Access denied": "Toegang geweigerd",
"Invalid shareType": "Ongeldig deeltype",
"shareWith is required": "shareWith is verplicht",
"Invalid permissionLevel": "Ongeldig rechtenniveau",
@@ -196,8 +279,635 @@
"File Name": "Bestandsnaam",
"Enter filename": "Voer bestandsnaam in",
"Create": "Aanmaken",
- "Creating…": "Aanmaken…",
+ "Creating…": "Bezig met aanmaken…",
"Failed to create document": "Document aanmaken mislukt",
- "Please enter a file name": "Voer een bestandsnaam in"
+ "Please enter a file name": "Voer een bestandsnaam in",
+ "Label is required": "Label is verplicht",
+ "URL is required": "URL is verplicht",
+ "Allowed file extensions": "Toegestane bestandsextensies",
+ "Comma-separated list of file extensions admins allow link-button widgets to create": "Door komma's gescheiden lijst van bestandsextensies die linkknop-widgets mogen aanmaken",
+ "Nextcloud Widget": "Nextcloud-widget",
+ "Select Widget": "Widget selecteren",
+ "Choose a widget…": "Kies een widget…",
+ "Display Mode": "Weergavemodus",
+ "Vertical (list)": "Verticaal (lijst)",
+ "Horizontal (cards)": "Horizontaal (kaarten)",
+ "Single button": "Enkele knop",
+ "List of links": "Lijst met links",
+ "List Orientation": "Lijstrichting",
+ "List Item Spacing": "Tussenruimte",
+ "Compact": "Compact",
+ "Normal": "Normaal",
+ "Spacious": "Ruim",
+ "Links": "Links",
+ "Add link": "Link toevoegen",
+ "Remove link": "Link verwijderen",
+ "Move link up": "Link omhoog verplaatsen",
+ "Move link down": "Link omlaag verplaatsen",
+ "Icon (optional)": "Pictogram (optioneel)",
+ "File Extension": "Bestandsextensie",
+ "No links yet. Click \"Add link\" to add one.": "Nog geen links. Klik op \"Link toevoegen\" om er een toe te voegen.",
+ "Maximum of 20 links per list widget": "Maximaal 20 links per lijstwidget",
+ "At least one link is required for list mode": "Minimaal één link is vereist voor lijstmodus",
+ "Each link requires a label and a URL": "Elke link vereist een label en een URL",
+ "Loading…": "Laden…",
+ "No items available": "Geen items beschikbaar",
+ "publishAt must be a future timestamp": "publishAt moet een toekomstige datum zijn",
+ "Forbidden: owner or admin only": "Niet toegestaan: alleen eigenaar of beheerder",
+ "Publish dashboard": "Dashboard publiceren",
+ "Unpublish dashboard": "Publicatie ongedaan maken",
+ "Schedule dashboard": "Dashboard inplannen",
+ "Draft": "Concept",
+ "Published": "Gepubliceerd",
+ "Scheduled": "Ingepland",
+ "Backup, restore & migrate dashboards": "Dashboards back-uppen, herstellen en migreren",
+ "Download a versioned ZIP archive of all dashboards in this MyDash instance, or upload a previously exported archive to restore or migrate it. The archive is portable across Nextcloud installations.": "Download een geversioneerd ZIP-archief van alle dashboards in deze MyDash-instantie, of upload een eerder geëxporteerd archief om te herstellen of te migreren. Het archief is overdraagbaar tussen Nextcloud-installaties.",
+ "Download all dashboards": "Alle dashboards downloaden",
+ "Exporting…": "Exporteren…",
+ "Export failed. Please try again.": "Export mislukt. Probeer het opnieuw.",
+ "Import a dashboard archive (.zip)": "Importeer een dashboard-archief (.zip)",
+ "Preserve original dashboard UUIDs (fail on collision)": "Originele dashboard-UUID's behouden (afbreken bij conflict)",
+ "Upload archive": "Archief uploaden",
+ "Importing…": "Importeren…",
+ "Imported {imported} dashboards, skipped {skipped}.": "{imported} dashboards geïmporteerd, {skipped} overgeslagen.",
+ "Import failed. Please try again.": "Import mislukt. Probeer het opnieuw.",
+ "Import from Confluence": "Importeren vanuit Confluence",
+ "Upload a Confluence HTML export ZIP archive. Each Confluence page becomes a MyDash dashboard with a single full-width text widget; the page hierarchy is mirrored using the dashboard tree.": "Upload een Confluence HTML-export-ZIP. Elke Confluence-pagina wordt een MyDash-dashboard met één volledige-breedte tekst-widget; de paginahiërarchie wordt overgenomen via de dashboard-boom.",
+ "Confluence export archive (.zip)": "Confluence-export-archief (.zip)",
+ "Optional parent dashboard UUID (root pages will be slotted under this dashboard)": "Optionele bovenliggende dashboard-UUID (rootpagina's komen onder dit dashboard)",
+ "Leave empty to import as root dashboards": "Laat leeg om als root-dashboards te importeren",
+ "Dry-run preview": "Dry-run-voorbeeld",
+ "Inspecting…": "Analyseren…",
+ "Import dashboards": "Dashboards importeren",
+ "{pages} pages, {attachments} attachments — would create {dashboards} dashboards.": "{pages} pagina's, {attachments} bijlagen — zou {dashboards} dashboards aanmaken.",
+ "Assets would be uploaded to {folder}": "Bestanden worden geüpload naar {folder}",
+ "Assets uploaded to {folder}": "Bestanden geüpload naar {folder}",
+ "Confluence dry-run failed. Please try again.": "Confluence-dry-run mislukt. Probeer het opnieuw.",
+ "Confluence import failed. Please try again.": "Confluence-import mislukt. Probeer het opnieuw.",
+ "You created dashboard {dashboard}": "Je hebt dashboard {dashboard} aangemaakt",
+ "{actor} created dashboard {dashboard}": "{actor} heeft dashboard {dashboard} aangemaakt",
+ "You updated dashboard {dashboard}": "Je hebt dashboard {dashboard} bijgewerkt",
+ "{actor} updated dashboard {dashboard}": "{actor} heeft dashboard {dashboard} bijgewerkt",
+ "You deleted dashboard {dashboard}": "Je hebt dashboard {dashboard} verwijderd",
+ "{actor} deleted dashboard {dashboard}": "{actor} heeft dashboard {dashboard} verwijderd",
+ "You published dashboard {dashboard}": "Je hebt dashboard {dashboard} gepubliceerd",
+ "{actor} published dashboard {dashboard}": "{actor} heeft dashboard {dashboard} gepubliceerd",
+ "You unpublished dashboard {dashboard}": "Je hebt de publicatie van dashboard {dashboard} ongedaan gemaakt",
+ "{actor} unpublished dashboard {dashboard}": "{actor} heeft de publicatie van dashboard {dashboard} ongedaan gemaakt",
+ "You scheduled dashboard {dashboard}": "Je hebt dashboard {dashboard} ingepland",
+ "{actor} scheduled dashboard {dashboard}": "{actor} heeft dashboard {dashboard} ingepland",
+ "You shared dashboard {dashboard} with {recipient}": "Je hebt dashboard {dashboard} gedeeld met {recipient}",
+ "{actor} shared dashboard {dashboard} with {recipient}": "{actor} heeft dashboard {dashboard} gedeeld met {recipient}",
+ "You created a public link for dashboard {dashboard}": "Je hebt een openbare link gemaakt voor dashboard {dashboard}",
+ "{actor} created a public link for dashboard {dashboard}": "{actor} heeft een openbare link gemaakt voor dashboard {dashboard}",
+ "You commented on dashboard {dashboard}": "Je hebt gereageerd op dashboard {dashboard}",
+ "{actor} commented on dashboard {dashboard}": "{actor} heeft gereageerd op dashboard {dashboard}",
+ "You reacted to dashboard {dashboard}": "Je hebt op dashboard {dashboard} gereageerd",
+ "{actor} reacted to dashboard {dashboard}": "{actor} heeft op dashboard {dashboard} gereageerd",
+ "You restored dashboard {dashboard} to an earlier version": "Je hebt dashboard {dashboard} teruggezet naar een eerdere versie",
+ "{actor} restored dashboard {dashboard} to an earlier version": "{actor} heeft dashboard {dashboard} teruggezet naar een eerdere versie",
+ "You overrode the lock on dashboard {dashboard}": "Je hebt de vergrendeling van dashboard {dashboard} omzeild",
+ "{actor} overrode the lock on dashboard {dashboard}": "{actor} heeft de vergrendeling van dashboard {dashboard} omzeild",
+ "Your role in {dashboard} was changed to {role}": "Je rol in {dashboard} is gewijzigd naar {role}",
+ "{actor} changed {target}'s role in {dashboard} to {role}": "{actor} heeft de rol van {target} in {dashboard} gewijzigd naar {role}",
+ "Dashboard Admin": "Dashboardbeheerder",
+ "Dashboard Editor": "Dashboardredacteur",
+ "Dashboard Viewer": "Dashboardlezer",
+ "Either userId or groupId must be provided": "Geef of een gebruikers-ID of een groeps-ID op",
+ "Only one of userId or groupId may be provided": "Slechts één van gebruikers-ID of groeps-ID is toegestaan",
+ "Unknown role; must be one of admin, editor, viewer": "Onbekende rol; moet een van admin, editor of viewer zijn",
+ "Unknown user": "Onbekende gebruiker",
+ "Unknown group": "Onbekende groep",
+ "That target already has the requested role assigned": "Dit doel heeft de gevraagde rol al toegewezen",
+ "Role assignment not found": "Roltoewijzing niet gevonden",
+ "Role assignment payload is invalid": "Inhoud van roltoewijzing is ongeldig",
+ "Comments": "Reacties",
+ "Post comment": "Reactie plaatsen",
+ "Reply": "Antwoorden",
+ "Edited": "Bewerkt",
+ "Comments are disabled on this dashboard": "Reacties zijn uitgeschakeld op dit dashboard",
+ "Comment message must not be empty": "Reactie mag niet leeg zijn",
+ "Comments can only be replied to once": "Reacties kunnen maar één keer worden beantwoord",
+ "Only the author or an admin may modify this comment": "Alleen de auteur of een beheerder mag deze reactie wijzigen",
+ "Comment not found": "Reactie niet gevonden",
+ "Parent comment not found": "Bovenliggende reactie niet gevonden",
+ "Are you sure you want to delete this comment?": "Weet je zeker dat je deze reactie wilt verwijderen?",
+ "Deleting this top-level comment will also remove all its replies.": "Het verwijderen van deze hoofdreactie verwijdert ook alle antwoorden.",
+ "Write a comment…": "Schrijf een reactie…",
+ "Write a reply…": "Schrijf een antwoord…",
+ "Cancel reply": "Antwoord annuleren",
+ "%1$s mentioned you in a dashboard comment": "%1$s heeft je genoemd in een dashboardreactie",
+ "Open the dashboard to read the comment": "Open het dashboard om de reactie te lezen",
+ "Emoji not allowed": "Emoji niet toegestaan",
+ "Reactions are disabled": "Reacties zijn uitgeschakeld",
+ "Permission denied": "Geen toestemming",
+ "React": "Reageren",
+ "Reactions": "Reacties",
+ "Version history": "Versiegeschiedenis",
+ "Restore this version": "Deze versie herstellen",
+ "Save version": "Versie opslaan",
+ "Versioning backend unavailable": "Versie-backend niet beschikbaar",
+ "Version not found": "Versie niet gevonden",
+ "Version history is not available for this dashboard": "Versiegeschiedenis is niet beschikbaar voor dit dashboard",
+ "Language variant already exists": "Taalvariant bestaat al",
+ "Cannot delete the only language variant": "Kan de enige taalvariant niet verwijderen",
+ "Cannot delete the primary variant; promote another variant first": "Kan de primaire variant niet verwijderen; promoveer eerst een andere variant",
+ "Language code is required": "Taalcode is verplicht",
+ "Language not available": "Taal niet beschikbaar",
+ "View analytics": "Weergavestatistieken",
+ "Aggregate, privacy-preserving view counts per dashboard. Unique-viewer dedup uses a daily-rotating salted hash; no user identifiers are stored.": "Geaggregeerde, privacyvriendelijke weergavetellers per dashboard. Unieke-bezoekerdeduplicatie gebruikt een dagelijks roterende gezouten hash; er worden geen gebruiker-id's opgeslagen.",
+ "Period": "Periode",
+ "Last 7 days": "Laatste 7 dagen",
+ "Last 30 days": "Laatste 30 dagen",
+ "Last 90 days": "Laatste 90 dagen",
+ "Loading analytics…": "Statistieken worden geladen…",
+ "Total views": "Totaal aantal weergaven",
+ "Unique viewers": "Unieke bezoekers",
+ "Dashboards seen": "Bekeken dashboards",
+ "Top dashboards": "Top-dashboards",
+ "Dashboard": "Dashboard",
+ "Views": "Weergaven",
+ "No view events recorded yet for this period.": "Nog geen weergaven geregistreerd voor deze periode.",
+ "Export analytics (CSV)": "Statistieken exporteren (CSV)",
+ "Failed to load analytics data. Please try again.": "Het laden van de statistieken is mislukt. Probeer het opnieuw.",
+ "Failed to export analytics CSV. Please try again.": "Het exporteren van het CSV-bestand is mislukt. Probeer het opnieuw.",
+ "%s's MyDash dashboards": "MyDash-dashboards van %s",
+ "Reverse-chronological list of dashboards accessible to %s.": "Omgekeerd-chronologische lijst van dashboards die toegankelijk zijn voor %s.",
+ "Dashboard feed": "Dashboard-feed",
+ "RSS / Atom feed": "RSS-/Atom-feed",
+ "Generate feed token": "Feed-token aanmaken",
+ "Regenerate feed token": "Feed-token opnieuw genereren",
+ "Revoke feed token": "Feed-token intrekken",
+ "Copy feed URL": "Feed-URL kopiëren",
+ "Feed URL copied to clipboard": "Feed-URL gekopieerd naar klembord",
+ "Your personal RSS feed of accessible dashboards.": "Je persoonlijke RSS-feed van toegankelijke dashboards.",
+ "Treat this URL as a password — anyone with the link can read your dashboards.": "Behandel deze URL als een wachtwoord — iedereen met de link kan je dashboards lezen.",
+ "No feed token issued yet.": "Nog geen feed-token uitgegeven.",
+ "Lock held by another user": "Vergrendeld door een andere gebruiker",
+ "Lock not found; call acquire first": "Geen vergrendeling gevonden; vraag deze eerst aan",
+ "Lock not found": "Geen vergrendeling gevonden",
+ "Only the lock owner can extend the lease": "Alleen de eigenaar van de vergrendeling kan deze verlengen",
+ "Only the lock owner or an admin can release this lock": "Alleen de eigenaar of een beheerder kan deze vergrendeling vrijgeven",
+ "Only an administrator may force-release a lock": "Alleen een beheerder mag een vergrendeling geforceerd vrijgeven",
+ "Dashboard is being edited by {displayName}": "Dashboard wordt bewerkt door {displayName}",
+ "Your edit lock has expired": "Je bewerkvergrendeling is verlopen",
+ "You may be editing in another tab": "Je bewerkt dit dashboard mogelijk in een ander tabblad",
+ "MyDash dashboard": "MyDash-dashboard",
+ "Widget content on %s": "Widget-inhoud op %s",
+ "Metadata: %1$s = %2$s": "Metadata: %1$s = %2$s",
+ "Header Banner": "Kopbanner",
+ "Header title": "Koptitel",
+ "Subtitle (optional)": "Ondertitel (optioneel)",
+ "Optional subtitle": "Optionele ondertitel",
+ "Upload Background Image": "Achtergrondafbeelding uploaden",
+ "Or enter Background Image URL": "Of voer een afbeeldings-URL in",
+ "Overlay Mode": "Overlay-modus",
+ "Tinted Overlay": "Gekleurde overlay",
+ "Gradient Bottom": "Gradient aan onderkant",
+ "Overlay Color": "Overlay-kleur",
+ "Overlay Opacity": "Overlay-doorzichtigheid",
+ "Text Alignment": "Tekstuitlijning",
+ "Vertical Alignment": "Verticale uitlijning",
+ "Top": "Boven",
+ "Middle": "Midden",
+ "Bottom": "Onder",
+ "Height": "Hoogte",
+ "Small (120px)": "Klein (120px)",
+ "Medium (200px)": "Gemiddeld (200px)",
+ "Large (320px)": "Groot (320px)",
+ "Extra Large (480px)": "Extra groot (480px)",
+ "Call-to-Action Button (optional)": "Call-to-action knop (optioneel)",
+ "Button Text": "Knoptekst",
+ "Sign up": "Aanmelden",
+ "Target URL": "Doel-URL",
+ "Button Style": "Knopstijl",
+ "Primary": "Primair",
+ "Secondary": "Secundair",
+ "Ghost": "Transparant",
+ "opens in new tab": "opent in nieuw tabblad",
+ "Title is required": "Titel is verplicht",
+ "Background image URL must be HTTP or HTTPS": "Achtergrondafbeelding-URL moet HTTP of HTTPS zijn",
+ "Call-to-Action requires both label and URL": "Call-to-action vereist zowel label als URL",
+ "Divider": "Scheidingslijn",
+ "Pick a divider style to break dashboard sections into logical groups.": "Kies een scheidingsstijl om dashboardsecties op te delen in logische groepen.",
+ "Style": "Stijl",
+ "Horizontal line": "Horizontale lijn",
+ "Whitespace": "Witruimte",
+ "Heading with lines": "Kop met lijnen",
+ "Line color": "Lijnkleur",
+ "Thickness (pixels)": "Dikte (pixels)",
+ "Line style": "Lijnstijl",
+ "Solid": "Doorlopend",
+ "Dashed": "Streepjes",
+ "Dotted": "Stippen",
+ "Spacing size": "Tussenruimte",
+ "Small (16px)": "Klein (16px)",
+ "Medium (32px)": "Middel (32px)",
+ "Large (64px)": "Groot (64px)",
+ "Extra Large (128px)": "Extra groot (128px)",
+ "Heading text": "Koptekst",
+ "Heading text is required": "Koptekst is verplicht",
+ "Section": "Sectie",
+ "Section heading": "Sectiekop",
+ "{heading} divider": "{heading} scheidingslijn",
+ "Folder breadcrumb": "Map kruimelpad",
+ "Root": "Hoofdmap",
+ "Search this folder": "Zoek in deze map",
+ "Search this folder…": "Zoek in deze map…",
+ "Upload File": "Bestand uploaden",
+ "Loading folder…": "Map laden…",
+ "You don't have access to this folder.": "Je hebt geen toegang tot deze map.",
+ "Folder no longer exists.": "Map bestaat niet meer.",
+ "Failed to load folder contents.": "Fout bij laden van mapinhoud.",
+ "Retry": "Opnieuw proberen",
+ "This folder is empty.": "Deze map is leeg.",
+ "No files matching '{query}'": "Geen bestanden gevonden voor '{query}'",
+ "Delete {name}": "{name} verwijderen",
+ "Are you sure you want to delete {name}?": "Weet je zeker dat je {name} wilt verwijderen?",
+ "Load more": "Meer laden",
+ "Folder path": "Mappad",
+ "e.g. /Documents/Marketing": "bijv. /Documenten/Marketing",
+ "Folder ID (optional, preferred)": "Map-ID (optioneel, voorkeur)",
+ "Numeric file id of the folder": "Numeriek bestand-id van de map",
+ "View mode": "Weergave",
+ "Sort by": "Sorteer op",
+ "Sort descending": "Aflopend sorteren",
+ "Show thumbnails": "Miniaturen tonen",
+ "MIME type filter (comma separated)": "MIME-type filter (komma-gescheiden)",
+ "e.g. image/*, application/pdf": "bijv. image/*, application/pdf",
+ "Allow upload": "Uploaden toestaan",
+ "Allow delete": "Verwijderen toestaan",
+ "List": "Lijst",
+ "Grid": "Raster",
+ "Modified": "Gewijzigd",
+ "Type": "Type",
+ "Size": "Grootte",
+ "Name": "Naam",
+ "Folder path or folder id is required": "Mappad of map-id is verplicht",
+ "People": "Personen",
+ "Card": "Kaart",
+ "Layout": "Lay-out",
+ "Display name": "Weergavenaam",
+ "Group filter (comma-separated, blank for all)": "Groepsfilter (komma-gescheiden, leeg voor alle)",
+ "e.g. management, product": "bv. management, product",
+ "Exclude disabled users": "Uitgeschakelde gebruikers verbergen",
+ "Show birthdays": "Verjaardagen tonen",
+ "Birthday window (days, 0–30)": "Verjaardagvenster (dagen, 0–30)",
+ "Must be between 0 and 30": "Moet tussen 0 en 30 liggen",
+ "Birthday window must be between 0 and 30": "Verjaardagvenster moet tussen 0 en 30 liggen",
+ "Invalid layout": "Ongeldige lay-out",
+ "Invalid sort key": "Ongeldige sortering",
+ "Columns": "Kolommen",
+ "Search by name or email…": "Zoek op naam of e-mail…",
+ "Refresh": "Vernieuwen",
+ "Failed to load users": "Gebruikers konden niet worden geladen",
+ "No matching users.": "Geen overeenkomende gebruikers.",
+ "No users match your search": "Geen gebruikers gevonden voor je zoekopdracht",
+ "Avatar of {name}": "Avatar van {name}",
+ "🎂 today": "🎂 vandaag",
+ "🎂 in {n} days": "🎂 over {n} dagen",
+ "Quicklinks": "Snelkoppelingen",
+ "No quicklinks yet — click the gear icon to add some.": "Nog geen snelkoppelingen — klik op het tandwielpictogram om er enkele toe te voegen.",
+ "Open Quicklinks settings": "Snelkoppelingen-instellingen openen",
+ "Add link": "Koppeling toevoegen",
+ "Delete link": "Koppeling verwijderen",
+ "Color (optional)": "Kleur (optioneel)",
+ "Icon Size": "Pictogramgrootte",
+ "Icon Shape": "Pictogramvorm",
+ "Show Labels": "Labels tonen",
+ "Label Position": "Labelpositie",
+ "Tile Background": "Tegelachtergrond",
+ "Hover Effect": "Hover-effect",
+ "Paste CSV (label,url)": "Plak CSV (label,url)",
+ "Invalid URL": "Ongeldige URL",
+ "Invalid URL in one or more links": "Ongeldige URL in een of meer koppelingen",
+ "Small": "Klein",
+ "Medium": "Middel",
+ "Large": "Groot",
+ "Extra Large": "Extra groot",
+ "Square": "Vierkant",
+ "Rounded": "Afgerond",
+ "Circle": "Cirkel",
+ "Below": "Onder",
+ "Overlay": "Overlay",
+ "Auto": "Automatisch",
+ "Transparent": "Transparant",
+ "Gradient": "Verloop",
+ "Lift": "Optillen",
+ "Fade": "Vervagen",
+ "Border": "Rand",
+ "Link to": "Koppeling naar",
+ "News": "Nieuws",
+ "Loading news…": "Nieuws laden…",
+ "No news yet — try adding feeds in the widget settings": "Nog geen nieuws — voeg feeds toe via de widget-instellingen",
+ "Unable to load news. Please try again later.": "Kan nieuws niet laden. Probeer het later opnieuw.",
+ "1 feed failed": "1 feed mislukt",
+ "{count} feeds failed": "{count} feeds mislukt",
+ "Failed: {urls}": "Mislukt: {urls}",
+ "Feed URLs": "Feed-URL's",
+ "Feed URL": "Feed-URL",
+ "Add feed URL": "Feed-URL toevoegen",
+ "Remove feed": "Feed verwijderen",
+ "Carousel": "Carrousel",
+ "Item limit (1–50)": "Maximum aantal items (1–50)",
+ "Show summary": "Samenvatting tonen",
+ "Summary max characters": "Maximale lengte samenvatting",
+ "Date format": "Datumnotatie",
+ "Relative (2 hours ago)": "Relatief (2 uur geleden)",
+ "Absolute (2026-05-01 14:30)": "Absoluut (2026-05-01 14:30)",
+ "Filter by dashboard metadata": "Filter op dashboard-metadata",
+ "Metadata field key": "Metadata-veldsleutel",
+ "Metadata value to match": "Te matchen metadata-waarde",
+ "Empty feed URL — remove it or fill it in": "Lege feed-URL — verwijder of vul aan",
+ "Feed URL must start with http:// or https://": "Feed-URL moet beginnen met http:// of https://",
+ "Metadata field key is required when filter is enabled": "Metadata-veldsleutel is verplicht wanneer filter actief is",
+ "just now": "zojuist",
+ "{n} minutes ago": "{n} minuten geleden",
+ "{n} hours ago": "{n} uur geleden",
+ "{n} days ago": "{n} dagen geleden",
+ "No video URL configured": "Geen video-URL ingesteld",
+ "Video not accessible": "Video niet toegankelijk",
+ "Video failed to load": "Video kon niet worden geladen",
+ "Invalid video URL or domain not allowed.": "Ongeldige video-URL of domein niet toegestaan.",
+ "Video URL": "Video-URL",
+ "Video URL is required": "Video-URL is verplicht",
+ "Detected: YouTube": "Herkend: YouTube",
+ "Detected: Vimeo": "Herkend: Vimeo",
+ "Detected: PeerTube": "Herkend: PeerTube",
+ "Detected: Nextcloud File": "Herkend: Nextcloud-bestand",
+ "Aspect Ratio": "Beeldverhouding",
+ "Autoplay": "Automatisch afspelen",
+ "Autoplay requires muting": "Automatisch afspelen vereist dempen",
+ "Muted": "Gedempt",
+ "Loop": "Herhalen",
+ "Show controls": "Bedieningselementen tonen",
+ "Poster Image URL (optional)": "URL voor voorvertoningsafbeelding (optioneel)",
+ "Nextcloud File ID": "Nextcloud-bestand-ID",
+ "Nextcloud file ID is required": "Nextcloud-bestand-ID is verplicht",
+ "Loading calendars…": "Kalenders laden…",
+ "Failed to load events": "Fout bij laden evenementen",
+ "No calendars configured": "Geen kalenders geconfigureerd",
+ "No events in the next {N} days": "Geen evenementen in de komende {N} dagen",
+ "{N} calendar source(s) unavailable": "{N} kalenderbron(nen) niet beschikbaar",
+ "Sun": "Zon",
+ "Mon": "Maa",
+ "Tue": "Din",
+ "Wed": "Woe",
+ "Thu": "Don",
+ "Fri": "Vri",
+ "Sat": "Zat",
+ "Month": "Maand",
+ "Week": "Week",
+ "Agenda": "Agenda",
+ "All day": "Hele dag",
+ "View Mode": "Weergavemodus",
+ "Internal Calendars (one principal URI per line)": "Interne kalenders (één principal-URI per regel)",
+ "External ICS URLs (one per line)": "Externe ICS-URL's (één per regel)",
+ "Days Ahead": "Dagen vooruit",
+ "Color by Calendar": "Kleuren per kalender",
+ "At least one calendar source is required": "Ten minste één kalenderbron is vereist",
+ "External ICS URLs must start with https://": "Externe ICS-URL's moeten beginnen met https://",
+ "Links": "Links",
+ "Section title": "Sectietitel",
+ "Move up": "Omhoog verplaatsen",
+ "Move down": "Omlaag verplaatsen",
+ "Delete section": "Sectie verwijderen",
+ "Add section": "Sectie toevoegen",
+ "Icon (name or URL)": "Pictogram (naam of URL)",
+ "Description (optional)": "Beschrijving (optioneel)",
+ "Inline": "Inline",
+ "Icon only": "Alleen pictogram",
+ "Icon size": "Pictogramgrootte",
+ "Small (24 px)": "Klein (24 px)",
+ "Medium (40 px)": "Normaal (40 px)",
+ "Large (64 px)": "Groot (64 px)",
+ "Open in new tab": "In nieuw tabblad openen",
+ "Show section titles": "Sectietitels weergeven",
+ "Show descriptions": "Beschrijvingen weergeven",
+ "Link URL is required": "URL is verplicht",
+ "Link label is required": "Label is verplicht",
+ "Invalid URL — use HTTP(S) or relative paths.": "Ongeldige URL — gebruik HTTP(S) of relatieve paden.",
+ "No links yet — click the gear icon to add some.": "Nog geen links — klik op het tandwielpictogram om er enkele toe te voegen.",
+ "Menu": "Menu",
+ "Menu Style": "Menustijl",
+ "Menu Widget": "Menuwidget",
+ "No menu items yet — click the gear icon to add some.": "Nog geen menu-items — klik op het tandwiel om er toe te voegen.",
+ "Menu items can nest at most 3 levels deep": "Menu-items kunnen maximaal 3 niveaus diep genest worden",
+ "Dropdown": "Uitklapmenu",
+ "Megamenu": "Megamenu",
+ "Orientation": "Oriëntatie",
+ "Horizontal": "Horizontaal",
+ "Vertical": "Verticaal",
+ "Active Item Highlight": "Markering actief item",
+ "Underline": "Onderstreping",
+ "Left Bar": "Linker balk",
+ "Show Icons": "Pictogrammen tonen",
+ "Expanded by Default": "Standaard uitgeklapt",
+ "Add Item": "Item toevoegen",
+ "Remove Item": "Item verwijderen",
+ "Add Children": "Subitems toevoegen",
+ "Items": "Items",
+ "Level 1": "Niveau 1",
+ "Level 2": "Niveau 2",
+ "Level 3 - max reached": "Niveau 3 - maximum bereikt",
+ "Expand": "Uitklappen",
+ "Collapse": "Inklappen",
+ "Image source": "Afbeeldingsbron",
+ "URL/Link": "URL/Link",
+ "Pick from Files": "Kies uit Bestanden",
+ "Pick image from Nextcloud Files": "Kies een afbeelding uit Nextcloud Bestanden",
+ "Pick image from Files": "Kies een afbeelding uit Bestanden",
+ "Opening file picker…": "Bestandskiezer openen…",
+ "Selected:": "Geselecteerd:",
+ "File picker failed to open": "Bestandskiezer kon niet worden geopend",
+ "Selected file is missing required metadata": "Geselecteerd bestand mist vereiste metadata",
+ "Please pick a file from Files": "Kies een bestand uit Bestanden",
+ "Image URL": "Afbeelding-URL",
+ "Enter Image URL": "Voer afbeelding-URL in",
+ "Mode": "Modus",
+ "HTML": "HTML",
+ "Markdown": "Markdown",
+ "Markdown — # heading, **bold**, *italic*, [link](url), - list": "Markdown — # kop, **vet**, *cursief*, [link](url), - lijst",
+ "HTML — bold , italic , link ": "HTML — vet , cursief , link ",
+ "Content type": "Inhoudstype",
+ "Table": "Tabel",
+ "Header row": "Koprij",
+ "Add row above": "Rij erboven toevoegen",
+ "Add row below": "Rij eronder toevoegen",
+ "Add column left": "Kolom links toevoegen",
+ "Add column right": "Kolom rechts toevoegen",
+ "Delete row": "Rij verwijderen",
+ "Delete column": "Kolom verwijderen",
+ "Merge cells": "Cellen samenvoegen",
+ "Split cell": "Cel splitsen",
+ "Column alignment": "Kolomuitlijning",
+ "Header": "Kop",
+ "Cell": "Cel",
+ "Empty cell": "Lege cel",
+ "Row {row}, column {col}": "Rij {row}, kolom {col}",
+ "This row contains text. Delete?": "Deze rij bevat tekst. Verwijderen?",
+ "This column contains text. Delete?": "Deze kolom bevat tekst. Verwijderen?",
+ "Grid is not rectangular": "Raster is niet rechthoekig",
+ "Cell span exceeds grid bounds": "Celspan overschrijdt rastergrenzen",
+ "Table must have at least one row": "Tabel moet minimaal één rij bevatten",
+ "Table must have at least one column": "Tabel moet minimaal één kolom bevatten",
+ "Footer settings": "Voettekstinstellingen",
+ "Enable footer": "Voettekst inschakelen",
+ "HTML mode": "HTML-modus",
+ "Structured mode": "Gestructureerde modus",
+ "Footer HTML": "Voettekst HTML",
+ "Allowed HTML tags: a, p, strong, em, br, ul, ol, li, img": "Toegestane HTML-tags: a, p, strong, em, br, ul, ol, li, img",
+ "Logo URL": "Logo-URL",
+ "Organisation name": "Organisatienaam",
+ "Address": "Adres",
+ "Legal text": "Juridische tekst",
+ "Copyright year": "Copyrightjaar",
+ "Layout mode": "Lay-outmodus",
+ "Background colour override": "Achtergrondkleur overschrijven",
+ "Text colour override": "Tekstkleur overschrijven",
+ "Footer mode": "Voettekstmodus",
+ "Inherit global footer": "Globale voettekst overnemen",
+ "Hide footer on this dashboard": "Voettekst verbergen op dit dashboard",
+ "Custom footer for this dashboard": "Aangepaste voettekst voor dit dashboard",
+ "Footer HTML exceeds the 8 KB size limit": "Voettekst-HTML overschrijdt de limiet van 8 KB",
+ "Footer mode must be one of: inherit, hidden, custom": "Voettekstmodus moet een van de volgende zijn: inherit, hidden, custom",
+ "Custom footer mode requires footer HTML": "Aangepaste voettekstmodus vereist voettekst-HTML",
+ "Footer config schema is invalid": "Voettekstconfiguratieschema is ongeldig",
+ "Organization navigation": "Organisatienavigatie",
+ "Open organization navigation": "Open organisatienavigatie",
+ "Close navigation": "Navigatie sluiten",
+ "Navigation": "Navigatie",
+ "Organization Navigation": "Organisatienavigatie",
+ "Build an organisation-wide navigation tree shown next to the dashboard surface. Drag nodes to reorder, click Edit to change properties, and use the group visibility selector to restrict sections to specific Nextcloud groups.": "Bouw een organisatiebrede navigatieboom die naast het dashboard verschijnt. Sleep knooppunten om te herordenen, klik op Bewerken om eigenschappen aan te passen en gebruik de groepszichtbaarheid om secties tot specifieke Nextcloud-groepen te beperken.",
+ "Language": "Taal",
+ "Dutch": "Nederlands",
+ "English": "Engels",
+ "Position": "Positie",
+ "Hidden": "Verborgen",
+ "New section": "Nieuwe sectie",
+ "New link": "Nieuwe link",
+ "Add child": "Onderliggend item toevoegen",
+ "URL (leave empty for section)": "URL (leeg voor een sectie)",
+ "New tab": "Nieuw tabblad",
+ "Visible to everyone": "Zichtbaar voor iedereen",
+ "Group ids, comma separated": "Groep-ID's, komma-gescheiden",
+ "Tree depth cannot exceed 3 levels": "Boomdiepte mag niet groter dan 3 niveaus zijn",
+ "No navigation configured. Add a section or link to start building the tree.": "Geen navigatie geconfigureerd. Voeg een sectie of link toe om de boom te bouwen.",
+ "Navigation saved successfully.": "Navigatie opgeslagen.",
+ "Save as template": "Opslaan als sjabloon",
+ "Template gallery": "Sjabloongalerij",
+ "Preview image": "Voorbeeldafbeelding",
+ "Invalid image format": "Ongeldig afbeeldingsformaat",
+ "All categories": "Alle categorieën",
+ "Recently updated": "Recent bijgewerkt",
+ "No templates available": "Geen sjablonen beschikbaar",
+ "Could not load template gallery": "Kon de sjabloongalerij niet laden",
+ "Could not save dashboard as template": "Kon het dashboard niet als sjabloon opslaan",
+ "You can only save your own dashboards as templates": "Je kunt alleen je eigen dashboards als sjabloon opslaan",
+ "Describe the template purpose": "Beschrijf het doel van het sjabloon",
+ "e.g. marketing, engineering": "bijv. marketing, engineering",
+ "{count} widgets": "{count} widgets",
+ "Category": "Categorie",
+ "Bulk dashboard operations": "Bulk dashboardbewerkingen",
+ "Select multiple dashboards and apply an admin action to all of them at once. Every operation supports a dry-run preview.": "Selecteer meerdere dashboards en pas een beheeractie tegelijk toe. Elke bewerking ondersteunt een preview-modus.",
+ "Loading dashboards…": "Dashboards laden…",
+ "Failed to load dashboards": "Dashboards laden mislukt",
+ "Bulk operation failed": "Bulkbewerking mislukt",
+ "Actions…": "Acties…",
+ "Move to…": "Verplaatsen naar…",
+ "Set status": "Status instellen",
+ "Reindex": "Herindexeren",
+ "Apply": "Toepassen",
+ "Working…": "Bezig…",
+ "OK": "OK",
+ "Dry run (preview only)": "Preview-modus (geen wijzigingen)",
+ "Delete dashboards?": "Dashboards verwijderen?",
+ "Move dashboards?": "Dashboards verplaatsen?",
+ "Set publication status?": "Publicatiestatus instellen?",
+ "Reindex dashboards for search?": "Dashboards herindexeren voor zoeken?",
+ "About to delete {n} dashboards. This cannot be undone.": "{n} dashboards worden verwijderd. Dit kan niet ongedaan worden gemaakt.",
+ "About to re-parent {n} dashboards.": "{n} dashboards krijgen een nieuwe ouder.",
+ "About to update the publication status of {n} dashboards.": "De publicatiestatus van {n} dashboards wordt bijgewerkt.",
+ "About to reindex {n} dashboards for unified search.": "{n} dashboards worden herindexeerd voor zoeken.",
+ "New parent UUID (leave empty for root)": "Nieuwe ouder-UUID (leeg laten voor root)",
+ "Publication status": "Publicatiestatus",
+ "PREVIEW: would change {count} dashboards.": "PREVIEW: zou {count} dashboards wijzigen.",
+ "Changed {count} dashboards. Skipped {skipped}.": "{count} dashboards gewijzigd. {skipped} overgeslagen.",
+ "Demo data showcases": "Demo-voorbeelden",
+ "Install bundled example dashboards to give users a working starting point. Each showcase is created as a group-shared dashboard visible to all users; you can uninstall it at any time.": "Installeer meegeleverde voorbeeld-dashboards zodat gebruikers een werkend startpunt hebben. Elk voorbeeld wordt aangemaakt als een groep-gedeeld dashboard dat zichtbaar is voor alle gebruikers; je kunt het op elk moment weer verwijderen.",
+ "Loading showcases…": "Voorbeelden laden…",
+ "Could not load demo showcases. Please try again.": "Kon de demo-voorbeelden niet laden. Probeer het opnieuw.",
+ "Could not install showcase. Please try again.": "Kon het voorbeeld niet installeren. Probeer het opnieuw.",
+ "Could not uninstall showcase. Please try again.": "Kon het voorbeeld niet verwijderen. Probeer het opnieuw.",
+ "Showcase not found.": "Voorbeeld niet gevonden.",
+ "You need admin privileges to install showcases.": "Je hebt beheerdersrechten nodig om voorbeelden te installeren.",
+ "Install": "Installeer",
+ "Installing…": "Installeren…",
+ "Uninstall": "Verwijder",
+ "Uninstalling…": "Verwijderen…",
+ "Installed but skipped widgets: {list}": "Geïnstalleerd, maar widgets overgeslagen: {list}",
+ "Remove the {name} showcase dashboard for all users? You can reinstall it later.": "Het voorbeeld-dashboard {name} voor alle gebruikers verwijderen? Je kunt het later opnieuw installeren.",
+ "MyDash setup wizard": "MyDash installatiewizard",
+ "Step {n} / {total}": "Stap {n} / {total}",
+ "Welcome": "Welkom",
+ "Configure your MyDash instance with storage, group ordering, demo data, admin roles, and footer settings.": "Configureer je MyDash met opslag, groepvolgorde, demo-data, beheerdersrollen en voettekstinstellingen.",
+ "Storage backend": "Opslag-backend",
+ "Choose how MyDash stores dashboard content.": "Kies hoe MyDash dashboardinhoud opslaat.",
+ "Database (default)": "Database (standaard)",
+ "Store dashboard content in the MyDash database table.": "Sla dashboardinhoud op in de MyDash-databasetabel.",
+ "GroupFolder (recommended for org use)": "GroupFolder (aanbevolen voor organisaties)",
+ "Store dashboard content in Nextcloud GroupFolders for collaborative access.": "Sla dashboardinhoud op in Nextcloud GroupFolders voor gedeelde toegang.",
+ "GroupFolder app is not installed. Install 'Nextcloud GroupFolders' to use this option.": "De GroupFolder-app is niet geïnstalleerd. Installeer 'Nextcloud GroupFolders' om deze optie te gebruiken.",
+ "Pick the order Nextcloud groups appear when MyDash routes users to a workspace.": "Kies de volgorde waarin Nextcloud-groepen verschijnen wanneer MyDash gebruikers naar een werkruimte stuurt.",
+ "Demo data": "Demo-data",
+ "The demo data showcases will appear here once the demo-data-showcases capability ships. For now this step is informational only.": "De demo-showcases verschijnen hier zodra de demo-data-showcases-capability beschikbaar is. Deze stap is voorlopig alleen ter informatie.",
+ "Skip this step for now — demo packages can be installed later from the admin section.": "Sla deze stap voor nu over — demo-pakketten kunnen later vanuit het beheerdergedeelte worden geïnstalleerd.",
+ "Admin roles": "Beheerdersrollen",
+ "Assign the \"Dashboard Admin\" role to a Nextcloud group to delegate MyDash administration.": "Wijs de rol \"Dashboardbeheerder\" toe aan een Nextcloud-groep om MyDash-beheer te delegeren.",
+ "The admin-roles capability is not yet available; this step will activate once it ships.": "De admin-roles-capability is nog niet beschikbaar; deze stap wordt actief zodra deze wordt uitgerold.",
+ "Footer configuration": "Voettekstconfiguratie",
+ "Customize the footer content and appearance for your intranet.": "Pas de inhoud en het uiterlijk van de voettekst aan voor je intranet.",
+ "The footer-customization capability is not yet available; this step will activate once it ships.": "De footer-customization-capability is nog niet beschikbaar; deze stap wordt actief zodra deze wordt uitgerold.",
+ "All set": "Alles klaar",
+ "Your setup is complete. Click Finish to save changes and dismiss the setup banner.": "Je installatie is voltooid. Klik op Voltooien om de wijzigingen op te slaan en de installatiebanner te verbergen.",
+ "Back": "Terug",
+ "Skip": "Overslaan",
+ "Next": "Volgende",
+ "Finish": "Voltooien",
+ "Run setup wizard": "Installatiewizard starten",
+ "Run setup wizard again": "Installatiewizard opnieuw starten",
+ "Get your intranet started: choose storage, configure groups, install demo data, and set up admin roles.": "Start je intranet: kies opslag, configureer groepen, installeer demo-data en stel beheerdersrollen in.",
+ "Container": "Container",
+ "Padding": "Opvulling",
+ "Title (optional)": "Titel (optioneel)",
+ "Container nesting limit reached": "Maximale containernesting bereikt",
+ "A container holds a sub-grid of child widgets. Add child widgets via the container’s own grid once it is on the dashboard.": "Een container houdt een subraster van onderliggende widgets vast. Voeg onderliggende widgets toe via het eigen raster van de container zodra deze op het dashboard staat.",
+ "Unknown widget type: {type}": "Onbekend widgettype: {type}",
+ "Tile": "Tegel",
+ "Tile title": "Tegeltitel",
+ "Tile title is required": "Tegeltitel is verplicht",
+ "Tile link target is required": "Linkdoel van tegel is verplicht",
+ "Icon": "Pictogram",
+ "Icon type": "Pictogramtype",
+ "Background color": "Achtergrondkleur",
+ "Text color": "Tekstkleur",
+ "Link type": "Linktype",
+ "App route": "App-route",
+ "URL": "URL",
+ "The reusable tile API is no longer available. Use the unified add-widget flow with type:tile instead.": "De herbruikbare tegel-API is niet meer beschikbaar. Gebruik in plaats daarvan de geünificeerde widget-toevoegstroom met type:tile.",
+ "Pick a widget": "Kies een widget",
+ "No Nextcloud widgets are installed": "Er zijn geen Nextcloud-widgets geïnstalleerd",
+ "Selected": "Geselecteerd",
+ "Role-based widget permissions": "Rol-gebaseerde widget-rechten",
+ "Restrict which widgets each Nextcloud group can add to their dashboard. Empty list = full catalogue (legacy).": "Beperk welke widgets elke Nextcloud-groep aan hun dashboard kan toevoegen. Lege lijst = volledige catalogus (legacy).",
+ "No role permissions configured": "Geen rolrechten geconfigureerd",
+ "Add a role-permission row to start filtering the widget catalogue per Nextcloud group.": "Voeg een rolregel toe om de widget-catalogus per Nextcloud-groep te filteren.",
+ "Add role permission": "Rolrecht toevoegen",
+ "Edit role permission": "Rolrecht bewerken",
+ "Nextcloud group ID": "Nextcloud-groep-ID",
+ "Description (optional)": "Beschrijving (optioneel)",
+ "Allowed widget IDs (comma separated)": "Toegestane widget-ID's (kommagescheiden)",
+ "Denied widget IDs (comma separated)": "Verboden widget-ID's (kommagescheiden)",
+ "Delete role permission for \"{group}\"?": "Rolrecht voor \"{group}\" verwijderen?"
}
-}
\ No newline at end of file
+}
diff --git a/lib/Activity/ActivityPublisher.php b/lib/Activity/ActivityPublisher.php
new file mode 100644
index 00000000..c7b553dd
--- /dev/null
+++ b/lib/Activity/ActivityPublisher.php
@@ -0,0 +1,370 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Activity;
+
+use OCP\Activity\IEvent;
+use OCP\Activity\IManager;
+use OCP\IGroupManager;
+use OCP\IUser;
+use OCP\IUserManager;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Thin Activity emission service for MyDash.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Mirrors NC Activity surface.
+ */
+class ActivityPublisher
+{
+ /**
+ * Constructor.
+ *
+ * @param IManager $manager The NC Activity manager.
+ * @param IGroupManager $groupManager The NC group manager.
+ * @param IUserManager $userManager The NC user manager.
+ * @param DebounceHelper $debounce The debounce guard.
+ * @param LoggerInterface $logger The logger.
+ */
+ public function __construct(
+ private readonly IManager $manager,
+ private readonly IGroupManager $groupManager,
+ private readonly IUserManager $userManager,
+ private readonly DebounceHelper $debounce,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Emit a single activity row to `$recipientUserId` for the given
+ * dashboard.
+ *
+ * Unknown event types are silently dropped after a warning log entry
+ * (REQ-ACT-002 contract). Reaction events go through the per-actor
+ * debounce (REQ-ACT-007). Every NC `IManager` call is wrapped in a
+ * try/catch so an Activity failure never propagates back into the
+ * owning capability's HTTP handler (REQ-ACT-011 scenario).
+ *
+ * @param string $type The event-type constant value.
+ * @param string $actorUserId The acting NC user ID.
+ * @param string $recipientUserId The recipient NC user ID.
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $dashboardName The human-readable dashboard name.
+ * @param string $dashboardLink The absolute deep-link URL.
+ * @param array $extraParams Optional extra params (e.g. `recipient`, `role`, `target`, `message`).
+ *
+ * @return bool True when the event was successfully published; false when suppressed or dropped.
+ */
+ public function publish(
+ string $type,
+ string $actorUserId,
+ string $recipientUserId,
+ string $dashboardUuid,
+ string $dashboardName,
+ string $dashboardLink,
+ array $extraParams=[]
+ ): bool {
+ if (in_array(needle: $type, haystack: Extension::ALL_EVENTS, strict: true) === false) {
+ $this->logger->warning(
+ message: 'Unknown MyDash activity type rejected',
+ context: [
+ 'type' => $type,
+ 'dashboard' => $dashboardUuid,
+ ]
+ );
+ return false;
+ }
+
+ if ($type === Extension::EVENT_REACTED
+ && $this->debounce->allowReaction(
+ actorUserId: $actorUserId,
+ dashboardUuid: $dashboardUuid
+ ) === false
+ ) {
+ return false;
+ }
+
+ try {
+ $event = $this->buildEvent(
+ type: $type,
+ actorUserId: $actorUserId,
+ recipientUserId: $recipientUserId,
+ dashboardUuid: $dashboardUuid,
+ dashboardName: $dashboardName,
+ dashboardLink: $dashboardLink,
+ extraParams: $extraParams
+ );
+ $this->manager->publish(event: $event);
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'MyDash Activity publish failed',
+ context: [
+ 'type' => $type,
+ 'dashboard' => $dashboardUuid,
+ 'recipient' => $recipientUserId,
+ 'exception' => $e,
+ ]
+ );
+ return false;
+ }//end try
+
+ return true;
+ }//end publish()
+
+ /**
+ * Emit one activity row per recipient in `$recipientUserIds`, plus
+ * one row to the actor (REQ-ACT-005). Recipients are de-duplicated
+ * to prevent double-emission when the actor is also a recipient.
+ *
+ * @param string $type The event-type constant value.
+ * @param string $actorUserId The acting NC user ID.
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $dashboardName The dashboard name.
+ * @param string $dashboardLink The dashboard link.
+ * @param string[] $recipientUserIds Recipient NC user IDs.
+ * @param array $extraParams Optional extra params.
+ *
+ * @return int The number of rows successfully written.
+ */
+ public function publishToRecipients(
+ string $type,
+ string $actorUserId,
+ string $dashboardUuid,
+ string $dashboardName,
+ string $dashboardLink,
+ array $recipientUserIds,
+ array $extraParams=[]
+ ): int {
+ $unique = array_values(
+ array: array_unique(
+ array: array_merge([$actorUserId], $recipientUserIds)
+ )
+ );
+
+ $count = 0;
+ foreach ($unique as $userId) {
+ $params = $extraParams;
+ $params['self'] = ($userId === $actorUserId);
+ $published = $this->publish(
+ type: $type,
+ actorUserId: $actorUserId,
+ recipientUserId: $userId,
+ dashboardUuid: $dashboardUuid,
+ dashboardName: $dashboardName,
+ dashboardLink: $dashboardLink,
+ extraParams: $params
+ );
+ if ($published === true) {
+ $count++;
+ }
+ }
+
+ return $count;
+ }//end publishToRecipients()
+
+ /**
+ * Emit activity rows to every member of `$groupId` (REQ-ACT-006).
+ *
+ * Returns 0 (without raising) when the group is unknown or empty.
+ * The actor is included exactly once even when they are also a
+ * member of the group.
+ *
+ * @param string $type The event-type constant value.
+ * @param string $actorUserId The acting NC user ID.
+ * @param string $groupId The target group ID.
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $dashboardName The dashboard name.
+ * @param string $dashboardLink The dashboard link.
+ * @param array $extraParams Optional extra params.
+ *
+ * @return int The number of rows successfully written.
+ */
+ public function publishToGroup(
+ string $type,
+ string $actorUserId,
+ string $groupId,
+ string $dashboardUuid,
+ string $dashboardName,
+ string $dashboardLink,
+ array $extraParams=[]
+ ): int {
+ $group = $this->groupManager->get(gid: $groupId);
+ if ($group === null) {
+ return 0;
+ }
+
+ $userIds = [];
+ foreach ($group->getUsers() as $user) {
+ $userIds[] = $user->getUID();
+ }
+
+ return $this->publishToRecipients(
+ type: $type,
+ actorUserId: $actorUserId,
+ dashboardUuid: $dashboardUuid,
+ dashboardName: $dashboardName,
+ dashboardLink: $dashboardLink,
+ recipientUserIds: $userIds,
+ extraParams: $extraParams
+ );
+ }//end publishToGroup()
+
+ /**
+ * Emit activity rows to every authenticated NC user (REQ-ACT-008).
+ *
+ * The full fan-out is gated by
+ * `DebounceHelper::allowGlobalFanout(dashboardUuid, type)` —
+ * suppressed events are logged at DEBUG and produce zero rows.
+ *
+ * @param string $type The event-type constant value.
+ * @param string $actorUserId The acting NC user ID.
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $dashboardName The dashboard name.
+ * @param string $dashboardLink The dashboard link.
+ * @param array $extraParams Optional extra params.
+ *
+ * @return int The number of rows successfully written (0 when debounced).
+ */
+ public function publishGlobal(
+ string $type,
+ string $actorUserId,
+ string $dashboardUuid,
+ string $dashboardName,
+ string $dashboardLink,
+ array $extraParams=[]
+ ): int {
+ if ($this->debounce->allowGlobalFanout(
+ dashboardUuid: $dashboardUuid,
+ eventType: $type
+ ) === false
+ ) {
+ $this->logger->debug(
+ message: 'MyDash global activity fan-out debounced',
+ context: [
+ 'type' => $type,
+ 'dashboard' => $dashboardUuid,
+ ]
+ );
+ return 0;
+ }
+
+ $count = 0;
+ $this->userManager->callForAllUsers(
+ callback: function (IUser $user) use (
+ $type,
+ $actorUserId,
+ $dashboardUuid,
+ $dashboardName,
+ $dashboardLink,
+ $extraParams,
+ &$count
+ ): void {
+ $params = $extraParams;
+ $params['self'] = ($user->getUID() === $actorUserId);
+ $ok = $this->publish(
+ type: $type,
+ actorUserId: $actorUserId,
+ recipientUserId: $user->getUID(),
+ dashboardUuid: $dashboardUuid,
+ dashboardName: $dashboardName,
+ dashboardLink: $dashboardLink,
+ extraParams: $params
+ );
+ if ($ok === true) {
+ $count++;
+ }
+ }
+ );
+
+ return $count;
+ }//end publishGlobal()
+
+ /**
+ * Build the canonical `IEvent` object for a single activity row.
+ *
+ * The numeric `objectId` slot in `IEvent::setObject()` requires an
+ * int per the NC interface; the dashboard UUID is stored in the
+ * `objectName` slot so the activity row can be deep-linked back to
+ * the canonical dashboard regardless of database renumbering. The
+ * subject parameters carry every field rendered by `parse()`.
+ *
+ * @param string $type The event type.
+ * @param string $actorUserId The acting user ID.
+ * @param string $recipientUserId The recipient user ID.
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $dashboardName The dashboard name.
+ * @param string $dashboardLink The dashboard link.
+ * @param array $extraParams Optional extra params.
+ *
+ * @return IEvent The fully populated event.
+ */
+ private function buildEvent(
+ string $type,
+ string $actorUserId,
+ string $recipientUserId,
+ string $dashboardUuid,
+ string $dashboardName,
+ string $dashboardLink,
+ array $extraParams
+ ): IEvent {
+ $event = $this->manager->generateEvent();
+ $isSelf = ($actorUserId === $recipientUserId);
+
+ $params = array_merge(
+ [
+ 'self' => $isSelf,
+ 'actor' => $actorUserId,
+ 'dashboard' => $dashboardName,
+ ],
+ $extraParams
+ );
+
+ $event
+ ->setApp(app: Extension::APP_ID)
+ ->setType(type: $type)
+ ->setAuthor(author: $actorUserId)
+ ->setAffectedUser(affectedUser: $recipientUserId)
+ ->setSubject(subject: $type, parameters: $params)
+ ->setObject(
+ objectType: Extension::OBJECT_TYPE,
+ objectId: 0,
+ objectName: $dashboardUuid
+ )
+ ->setLink(link: $dashboardLink)
+ ->setTimestamp(timestamp: time());
+
+ $message = (string) ($extraParams['message'] ?? '');
+ if ($message !== '') {
+ $event->setMessage(message: substr(string: $message, offset: 0, length: 200));
+ }
+
+ return $event;
+ }//end buildEvent()
+}//end class
diff --git a/lib/Activity/DebounceHelper.php b/lib/Activity/DebounceHelper.php
new file mode 100644
index 00000000..7c9d6491
--- /dev/null
+++ b/lib/Activity/DebounceHelper.php
@@ -0,0 +1,205 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Activity;
+
+/**
+ * Per-window debounce guard for Activity emission.
+ */
+class DebounceHelper
+{
+ /**
+ * Debounce TTL in seconds (15 minutes). REQ-ACT-007, REQ-ACT-008.
+ */
+ public const TTL_SECONDS = 900;
+
+ /**
+ * In-memory fallback store used when APCu is not available.
+ *
+ * Keyed by the same APCu key string; the value is the unix
+ * timestamp at which the entry expires.
+ *
+ * @var array
+ */
+ private array $memory = [];
+
+ /**
+ * Optional clock callable returning the current unix timestamp.
+ *
+ * Injected by tests to advance time deterministically without
+ * sleeping for 15 minutes.
+ *
+ * @var callable():int
+ */
+ private $clock;
+
+ /**
+ * True when the helper is using the real wall-clock and may delegate
+ * to APCu. False when a test clock is injected — in that case APCu's
+ * own TTL would not move with the test clock, so the in-memory
+ * fallback is the only correct backend.
+ *
+ * @var boolean
+ */
+ private bool $realClock;
+
+ /**
+ * Constructor.
+ *
+ * @param (callable():int)|null $clock Optional clock callable; defaults to `time()`.
+ */
+ public function __construct(?callable $clock=null)
+ {
+ $this->realClock = ($clock === null);
+ $this->clock = ($clock ?? static fn(): int => time());
+ }//end __construct()
+
+ /**
+ * Check whether a reaction event from `$actorUserId` on
+ * `$dashboardUuid` may be emitted now.
+ *
+ * Returns true on the first call and again after the 900-second
+ * window has elapsed; returns false for any call inside an active
+ * window.
+ *
+ * @param string $actorUserId The acting user ID.
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return bool True when emission is allowed.
+ */
+ public function allowReaction(
+ string $actorUserId,
+ string $dashboardUuid
+ ): bool {
+ $key = sprintf(
+ 'mydash_act_react_%s_%s',
+ $actorUserId,
+ $dashboardUuid
+ );
+ return $this->claim(key: $key);
+ }//end allowReaction()
+
+ /**
+ * Check whether a default-group fan-out for `(dashboardUuid, eventType)`
+ * may be performed now (REQ-ACT-008).
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $eventType The event type constant value.
+ *
+ * @return bool True when fan-out is allowed.
+ */
+ public function allowGlobalFanout(
+ string $dashboardUuid,
+ string $eventType
+ ): bool {
+ $key = sprintf(
+ 'mydash_act_global_%s_%s',
+ $dashboardUuid,
+ $eventType
+ );
+ return $this->claim(key: $key);
+ }//end allowGlobalFanout()
+
+ /**
+ * Atomically claim the key for the configured TTL.
+ *
+ * Uses APCu when available (with `apcu_add` for race-free claim)
+ * and falls back to the in-memory map otherwise.
+ *
+ * @param string $key The full APCu key.
+ *
+ * @return bool True when the caller successfully claimed the window.
+ */
+ private function claim(string $key): bool
+ {
+ $now = ($this->clock)();
+
+ if ($this->apcuUsable() === true) {
+ // `apcu_add` returns false when the key already exists,
+ // which is exactly the semantics we want for a debounce
+ // claim. The TTL is enforced by APCu itself.
+ return (bool) apcu_add($key, $now, self::TTL_SECONDS);
+ }
+
+ // Purge expired entries opportunistically to keep the in-memory
+ // store from growing without bound.
+ foreach ($this->memory as $existingKey => $expiresAt) {
+ if ($expiresAt <= $now) {
+ unset($this->memory[$existingKey]);
+ }
+ }
+
+ if (array_key_exists(key: $key, array: $this->memory) === true) {
+ return false;
+ }
+
+ $this->memory[$key] = ($now + self::TTL_SECONDS);
+ return true;
+ }//end claim()
+
+ /**
+ * True when APCu is actually usable for debounce claims.
+ *
+ * `function_exists('apcu_add')` alone is not enough — the function
+ * is loaded by the extension even when APCu is disabled at runtime
+ * (most notably under CLI when `apc.enable_cli=0`), in which case
+ * `apcu_add()` silently returns false on every call. That breaks
+ * the debounce semantics: the helper would treat every claim as
+ * "already taken" and reject every emission.
+ *
+ * `apcu_enabled()` was introduced in APCu 4.0.5 specifically for
+ * this gate; when it's available we trust it. When it isn't (very
+ * old APCu builds), fall back to the existence check + an
+ * `ini_get('apc.enabled')` probe.
+ *
+ * @return bool True when APCu is loaded AND enabled at runtime.
+ */
+ private function apcuUsable(): bool
+ {
+ // A test-injected clock cannot move APCu's wall-clock TTL, so
+ // the in-memory store is the only backend that produces
+ // deterministic results when the clock is fake.
+ if ($this->realClock === false) {
+ return false;
+ }
+
+ if (function_exists(function: 'apcu_add') === false || function_exists(function: 'apcu_exists') === false) {
+ return false;
+ }
+
+ if (function_exists(function: 'apcu_enabled') === true) {
+ return (bool) apcu_enabled();
+ }
+
+ return (bool) ini_get(option: 'apc.enabled');
+ }//end apcuUsable()
+}//end class
diff --git a/lib/Activity/Extension.php b/lib/Activity/Extension.php
new file mode 100644
index 00000000..a2f3bf78
--- /dev/null
+++ b/lib/Activity/Extension.php
@@ -0,0 +1,314 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Activity;
+
+use OCA\MyDash\AppInfo\Application;
+use OCP\Activity\Exceptions\UnknownActivityException;
+use OCP\Activity\IEvent;
+use OCP\Activity\IProvider;
+use OCP\IURLGenerator;
+use OCP\L10N\IFactory;
+
+/**
+ * MyDash Activity provider.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Mirrors event catalogue size.
+ */
+class Extension implements IProvider
+{
+ /**
+ * MyDash application identifier in the Activity stream.
+ */
+ public const APP_ID = Application::APP_ID;
+
+ /**
+ * Object type stored on every emitted IEvent.
+ *
+ * The activity object semantics follow NC core: `objectType` is a
+ * stable string MyDash owns; `objectName` carries the dashboard
+ * UUID (the IEvent::setObject signature requires the numeric
+ * primary-key int as `objectId`).
+ */
+ public const OBJECT_TYPE = 'mydash_dashboard';
+
+ public const EVENT_CREATED = 'dashboard_created';
+ public const EVENT_UPDATED = 'dashboard_updated';
+ public const EVENT_DELETED = 'dashboard_deleted';
+ public const EVENT_PUBLISHED = 'dashboard_published';
+ public const EVENT_UNPUBLISHED = 'dashboard_unpublished';
+ public const EVENT_SCHEDULED = 'dashboard_scheduled';
+ public const EVENT_SHARED = 'dashboard_shared';
+ public const EVENT_PUBLIC_SHARE_CREATED = 'dashboard_public_share_created';
+ public const EVENT_COMMENTED = 'dashboard_commented';
+ public const EVENT_REACTED = 'dashboard_reacted';
+ public const EVENT_RESTORED = 'dashboard_restored';
+ public const EVENT_LOCK_OVERRIDDEN = 'dashboard_lock_overridden';
+ public const EVENT_ROLE_CHANGED = 'dashboard_role_changed';
+
+ /**
+ * Canonical list of every MyDash event type registered with NC
+ * Activity. Used for the per-type opt-out registration loop and
+ * the unit-test contract.
+ *
+ * `dashboard_viewed` is intentionally excluded — view tracking is
+ * owned by `dashboard-view-analytics` and MUST NOT be published to
+ * the Activity stream (see REQ-ACT-002 and design D2).
+ */
+ public const ALL_EVENTS = [
+ self::EVENT_CREATED,
+ self::EVENT_UPDATED,
+ self::EVENT_DELETED,
+ self::EVENT_PUBLISHED,
+ self::EVENT_UNPUBLISHED,
+ self::EVENT_SCHEDULED,
+ self::EVENT_SHARED,
+ self::EVENT_PUBLIC_SHARE_CREATED,
+ self::EVENT_COMMENTED,
+ self::EVENT_REACTED,
+ self::EVENT_RESTORED,
+ self::EVENT_LOCK_OVERRIDDEN,
+ self::EVENT_ROLE_CHANGED,
+ ];
+
+ /**
+ * Constructor.
+ *
+ * @param IFactory $l10nFactory The L10N factory.
+ * @param IURLGenerator $urlGenerator The URL generator.
+ */
+ public function __construct(
+ private readonly IFactory $l10nFactory,
+ private readonly IURLGenerator $urlGenerator,
+ ) {
+ }//end __construct()
+
+ /**
+ * Parse a raw activity event into a translated, rich-formatted one.
+ *
+ * Returns the event with `richSubject`, `parsedSubject`, `icon`,
+ * and (where applicable) message fields populated. Unknown event
+ * types throw `UnknownActivityException` so the NC Activity chain
+ * can pass the event to the next provider (REQ-ACT-001 scenario).
+ *
+ * @param string $language The language code.
+ * @param IEvent $event The raw event.
+ * @param IEvent|null $previousEvent A previous event for merging (unused).
+ *
+ * @return IEvent The parsed event.
+ *
+ * @throws UnknownActivityException When the event type is not handled.
+ */
+ public function parse(
+ $language,
+ IEvent $event,
+ ?IEvent $previousEvent=null
+ ): IEvent {
+ if ($event->getApp() !== self::APP_ID) {
+ throw new UnknownActivityException(
+ message: 'Unknown app: '.$event->getApp()
+ );
+ }
+
+ $type = $event->getType();
+ if (in_array(needle: $type, haystack: self::ALL_EVENTS, strict: true) === false) {
+ throw new UnknownActivityException(
+ message: 'Unknown subject: '.$type
+ );
+ }
+
+ $l = $this->l10nFactory->get(app: self::APP_ID, lang: $language);
+ $params = $event->getSubjectParameters();
+ $isSelf = (bool) ($params['self'] ?? false);
+ $actor = (string) ($params['actor'] ?? $event->getAuthor());
+ $dashboard = (string) ($params['dashboard'] ?? $event->getObjectName());
+ $recipient = (string) ($params['recipient'] ?? '');
+ $role = (string) ($params['role'] ?? '');
+ $target = (string) ($params['target'] ?? '');
+
+ $template = $this->resolveSubjectTemplate(
+ type: $type,
+ isSelf: $isSelf
+ );
+ $rendered = strtr(
+ $l->t($template),
+ [
+ '{actor}' => $actor,
+ '{dashboard}' => $dashboard,
+ '{recipient}' => $recipient,
+ '{role}' => $role,
+ '{target}' => $target,
+ ]
+ );
+
+ $event->setRichSubject(subject: $rendered);
+ $event->setParsedSubject(subject: $rendered);
+ $event->setIcon(icon: $this->getIcon(eventType: $type));
+
+ return $event;
+ }//end parse()
+
+ /**
+ * Return an absolute URL to the per-type Activity icon.
+ *
+ * Falls back to `img/activity/mydash.svg` (the generic MyDash icon)
+ * when `$eventType` is not a known constant.
+ *
+ * @param string $eventType The event type string.
+ *
+ * @return string The absolute icon URL.
+ */
+ public function getIcon(string $eventType): string
+ {
+ $known = in_array(
+ needle: $eventType,
+ haystack: self::ALL_EVENTS,
+ strict: true
+ );
+ if ($known === true) {
+ $file = 'activity/'.$eventType.'.svg';
+ } else {
+ $file = 'activity/mydash.svg';
+ }
+
+ return $this->urlGenerator->getAbsoluteURL(
+ url: $this->urlGenerator->imagePath(
+ appName: self::APP_ID,
+ file: $file
+ )
+ );
+ }//end getIcon()
+
+ /**
+ * Return the canonical subject-template catalogue keyed by event
+ * type with `self` (first-person) and `other` (third-person)
+ * variants (REQ-ACT-010).
+ *
+ * Templates use `{placeholder}` substitution that is rendered both
+ * by `parse()` and by NC Activity's translation layer.
+ *
+ * @return array
+ */
+ public function getSubjectTemplates(): array
+ {
+ return [
+ self::EVENT_CREATED => [
+ 'self' => 'You created dashboard {dashboard}',
+ 'other' => '{actor} created dashboard {dashboard}',
+ ],
+ self::EVENT_UPDATED => [
+ 'self' => 'You updated dashboard {dashboard}',
+ 'other' => '{actor} updated dashboard {dashboard}',
+ ],
+ self::EVENT_DELETED => [
+ 'self' => 'You deleted dashboard {dashboard}',
+ 'other' => '{actor} deleted dashboard {dashboard}',
+ ],
+ self::EVENT_PUBLISHED => [
+ 'self' => 'You published dashboard {dashboard}',
+ 'other' => '{actor} published dashboard {dashboard}',
+ ],
+ self::EVENT_UNPUBLISHED => [
+ 'self' => 'You unpublished dashboard {dashboard}',
+ 'other' => '{actor} unpublished dashboard {dashboard}',
+ ],
+ self::EVENT_SCHEDULED => [
+ 'self' => 'You scheduled dashboard {dashboard}',
+ 'other' => '{actor} scheduled dashboard {dashboard}',
+ ],
+ self::EVENT_SHARED => [
+ 'self' => 'You shared dashboard {dashboard} with {recipient}',
+ 'other' => '{actor} shared dashboard {dashboard} with {recipient}',
+ ],
+ self::EVENT_PUBLIC_SHARE_CREATED => [
+ 'self' => 'You created a public link for dashboard {dashboard}',
+ 'other' => '{actor} created a public link for dashboard {dashboard}',
+ ],
+ self::EVENT_COMMENTED => [
+ 'self' => 'You commented on dashboard {dashboard}',
+ 'other' => '{actor} commented on dashboard {dashboard}',
+ ],
+ self::EVENT_REACTED => [
+ 'self' => 'You reacted to dashboard {dashboard}',
+ 'other' => '{actor} reacted to dashboard {dashboard}',
+ ],
+ self::EVENT_RESTORED => [
+ 'self' => 'You restored dashboard {dashboard} to an earlier version',
+ 'other' => '{actor} restored dashboard {dashboard} to an earlier version',
+ ],
+ self::EVENT_LOCK_OVERRIDDEN => [
+ 'self' => 'You overrode the lock on dashboard {dashboard}',
+ 'other' => '{actor} overrode the lock on dashboard {dashboard}',
+ ],
+ self::EVENT_ROLE_CHANGED => [
+ 'self' => 'Your role in {dashboard} was changed to {role}',
+ 'other' => "{actor} changed {target}'s role in {dashboard} to {role}",
+ ],
+ ];
+ }//end getSubjectTemplates()
+
+ /**
+ * Resolve the subject template string for `$type` honoring the
+ * self/other variant split.
+ *
+ * @param string $type The event-type constant value.
+ * @param bool $isSelf True when the actor equals the recipient.
+ *
+ * @return string The template string with `{placeholder}` tokens.
+ */
+ private function resolveSubjectTemplate(string $type, bool $isSelf): string
+ {
+ $templates = $this->getSubjectTemplates();
+ if ($isSelf === true) {
+ $variant = 'self';
+ } else {
+ $variant = 'other';
+ }
+
+ return ($templates[$type][$variant] ?? '');
+ }//end resolveSubjectTemplate()
+}//end class
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index dce7fdf9..f38bf3aa 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -18,14 +18,45 @@
namespace OCA\MyDash\AppInfo;
+use OCA\MyDash\Activity\DebounceHelper;
+use OCA\MyDash\BackgroundJob\PurgeViewsJob;
+use OCA\MyDash\BackgroundJob\SaltRotationJob;
+use OCA\MyDash\Event\DashboardDeletedEvent;
+use OCA\MyDash\Job\FeedRefreshJob;
+use OCA\MyDash\Listener\CommentsListener;
+use OCA\MyDash\Listener\GroupDeletedListener;
+use OCA\MyDash\Listener\LocksListener;
+use OCA\MyDash\Listener\MetadataValuesListener;
+use OCA\MyDash\Listener\PublicSharesListener;
+use OCA\MyDash\Listener\ReactionsListener;
+use OCA\MyDash\Listener\TranslationsListener;
+use OCA\MyDash\Listener\TreeListener;
use OCA\MyDash\Listener\UserDeletedListener;
+use OCA\MyDash\Listener\VersionsListener;
+use OCA\MyDash\Listener\ViewAnalyticsListener;
+use OCA\MyDash\Listener\WidgetPlacementsListener;
use OCA\MyDash\Notification\Notifier;
+use OCA\MyDash\Search\MyDashSearchProvider;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
+use OCP\BackgroundJob\IJobList;
+use OCP\Group\Events\GroupDeletedEvent;
use OCP\User\Events\UserDeletedEvent;
+/**
+ * Application bootstrap.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The bootstrap
+ * legitimately wires
+ * every event listener
+ * in the cascade-events
+ * registry (REQ-CSC-002),
+ * which inflates the
+ * coupling count beyond
+ * the default threshold.
+ */
class Application extends App implements IBootstrap
{
public const APP_ID = 'mydash';
@@ -49,15 +80,90 @@ public function __construct(array $urlParams=[])
*/
public function register(IRegistrationContext $context): void
{
- // Register the INotifier for dashboard_shared and
- // dashboard_ownership_transferred subjects. REQ-SHARE-011.
+ // Render `dashboard_shared` and `dashboard_ownership_transferred`
+ // notifications via our INotifier. REQ-SHARE-011.
$context->registerNotifierService(notifierClass: Notifier::class);
- // Register the user-deletion cascade listener. REQ-SHARE-012.
+ // Cascade share cleanup + admin-retention transfer on user deletion.
+ // Also fires the role-assignment cleanup. REQ-SHARE-012,
+ // REQ-SHARE-013, REQ-ROLE-010. The same listener also satisfies the
+ // owned-dashboard enumeration mandated by REQ-CSC-004 — every owned
+ // dashboard is routed through the deletion path that dispatches
+ // DashboardDeletedEvent below, triggering the full cascade stack.
$context->registerEventListener(
event: UserDeletedEvent::class,
listener: UserDeletedListener::class
);
+
+ // REQ-ACT-007: register DebounceHelper as a shared singleton
+ // so the in-memory fallback store (used when APCu is absent in
+ // CLI / test runs) survives across all callers within a single
+ // request. ActivityPublisher autowires from the app namespace
+ // — no explicit binding needed (referenced here in this
+ // docblock for the cross-capability discoverability contract:
+ // {@see ActivityPublisher}).
+ $context->registerService(
+ name: DebounceHelper::class,
+ factory: static fn(): DebounceHelper => new DebounceHelper(),
+ shared: true
+ );
+
+ // Role-assignment cascade on group deletion. REQ-ROLE-011.
+ // Group lifecycle cleanup. REQ-CSC-005.
+ $context->registerEventListener(
+ event: GroupDeletedEvent::class,
+ listener: GroupDeletedListener::class
+ );
+
+ // DashboardDeletedEvent listener registry. REQ-CSC-002.
+ // Each listener owns one dependent table (or, for TreeListener,
+ // recursive child dispatch). Adding a new listener requires only
+ // appending one registration line below — no edits to existing
+ // listener classes, the event, or DashboardService.
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: WidgetPlacementsListener::class
+ );
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: CommentsListener::class
+ );
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: ReactionsListener::class
+ );
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: LocksListener::class
+ );
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: VersionsListener::class
+ );
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: PublicSharesListener::class
+ );
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: MetadataValuesListener::class
+ );
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: TranslationsListener::class
+ );
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: ViewAnalyticsListener::class
+ );
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: TreeListener::class
+ );
+
+ // Surface dashboards, widget content, and metadata values in
+ // Nextcloud's unified search (Ctrl+K). REQ-SRCH-001.
+ $context->registerSearchProvider(class: MyDashSearchProvider::class);
}//end register()
/**
@@ -71,5 +177,26 @@ public function boot(IBootContext $context): void
{
// App initialization after all apps are registered.
\OCP\Util::addStyle(application: self::APP_ID, file: 'mydash');
+
+ // Register the dashboard view-analytics background jobs
+ // (REQ-ANLT-003 design D2 + REQ-ANLT-009) plus the periodic
+ // external-feed refresh job (REQ-FRJ-002). All are idempotent:
+ // `IJobList::add()` is a no-op when the job is already registered.
+ try {
+ $serverContainer = $context->getServerContainer();
+ $jobList = $serverContainer->get(IJobList::class);
+ if ($jobList instanceof IJobList === true) {
+ $jobList->add(job: PurgeViewsJob::class);
+ $jobList->add(job: SaltRotationJob::class);
+ $jobList->add(job: FeedRefreshJob::class);
+ }
+ } catch (\Throwable $exception) {
+ $context->getServerContainer()
+ ->get(\Psr\Log\LoggerInterface::class)
+ ->warning(
+ 'Failed to register MyDash background jobs: '.$exception->getMessage(),
+ ['app' => self::APP_ID]
+ );
+ }
}//end boot()
}//end class
diff --git a/lib/BackgroundJob/OrphanedDataCleanupJob.php b/lib/BackgroundJob/OrphanedDataCleanupJob.php
new file mode 100644
index 00000000..f42b0869
--- /dev/null
+++ b/lib/BackgroundJob/OrphanedDataCleanupJob.php
@@ -0,0 +1,183 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\BackgroundJob;
+
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Service\Cleanup\CategoryRegistryService;
+use OCA\MyDash\Service\OrphanedDataCleanupService;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\IJob;
+use OCP\BackgroundJob\TimedJob;
+use OCP\IAppConfig;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Daily auto-purge job.
+ */
+class OrphanedDataCleanupJob extends TimedJob
+{
+ /**
+ * IAppConfig key holding the JSON-encoded auto-purge category list.
+ *
+ * @var string
+ */
+ public const CONFIG_KEY_CATEGORIES = 'cleanup_auto_purge_categories';
+
+ /**
+ * Run interval in seconds (24 hours). REQ-CLN-007 "scheduled
+ * daily". Time-insensitive — the job is allowed to slip into the
+ * next low-traffic window.
+ *
+ * @var int
+ */
+ public const INTERVAL_SECONDS = 86400;
+
+ /**
+ * Constructor.
+ *
+ * @param ITimeFactory $time Time factory
+ * (parent
+ * requirement).
+ * @param OrphanedDataCleanupService $cleanupService The orchestrator.
+ * @param CategoryRegistryService $registry Category registry
+ * for the auto-safe
+ * default list.
+ * @param IAppConfig $appConfig App config to
+ * read the
+ * admin-chosen
+ * auto-purge
+ * set.
+ * @param LoggerInterface $logger PSR-3 logger.
+ */
+ public function __construct(
+ ITimeFactory $time,
+ private readonly OrphanedDataCleanupService $cleanupService,
+ private readonly CategoryRegistryService $registry,
+ private readonly IAppConfig $appConfig,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(time: $time);
+ $this->setInterval(seconds: self::INTERVAL_SECONDS);
+ $this->setTimeSensitivity(sensitivity: IJob::TIME_INSENSITIVE);
+ }//end __construct()
+
+ /**
+ * Run the auto-purge.
+ *
+ * Reads the admin-configured category list, falls back to the
+ * Tier-A default when none is configured. Skips quietly when the
+ * list is empty (admin has explicitly disabled auto-purge).
+ *
+ * Errors thrown by individual category implementations are
+ * caught by the parent {@see Job::start()} which logs them; we
+ * additionally write a structured "skipped" log here when the
+ * config is empty so cluster operators can grep for the reason.
+ *
+ * @param mixed $argument Ignored — the job carries no arguments.
+ *
+ * @return void
+ */
+ protected function run($argument): void
+ {
+ $categories = $this->resolveCategories();
+
+ if (count(value: $categories) === 0) {
+ $this->logger->info(
+ message: 'mydash.cleanup.job_skipped reason=no_categories_enabled'
+ );
+ return;
+ }
+
+ $result = $this->cleanupService->purge(
+ categoryNames: $categories,
+ dryRun: false,
+ userId: null,
+ source: 'job',
+ );
+
+ $this->logger->info(
+ message: sprintf(
+ 'mydash.cleanup.job_run rows=%d duration_ms=%d categories=%s',
+ $result->getTotalRows(),
+ $result->getDurationMs(),
+ implode(separator: ',', array: $categories),
+ )
+ );
+ }//end run()
+
+ /**
+ * Resolve the configured auto-purge categories.
+ *
+ * Reads the JSON-encoded list from `IAppConfig` under
+ * {@see self::CONFIG_KEY_CATEGORIES}. Falls back to the registry's
+ * Tier-A default list when the config is missing or unparseable.
+ * An explicit empty array (admin-set) is preserved — that signals
+ * "auto-purge disabled" and the run() method skips.
+ *
+ * @return array The category names.
+ */
+ private function resolveCategories(): array
+ {
+ $raw = $this->appConfig->getValueString(
+ app: Application::APP_ID,
+ key: self::CONFIG_KEY_CATEGORIES,
+ default: ''
+ );
+
+ if ($raw === '') {
+ return $this->registry->getAutoSafeCategoryNames();
+ }
+
+ $decoded = json_decode(json: $raw, associative: true);
+ if (is_array(value: $decoded) === false) {
+ $this->logger->warning(
+ message: sprintf(
+ 'mydash.cleanup.job_config_invalid raw=%s',
+ $raw
+ )
+ );
+ return $this->registry->getAutoSafeCategoryNames();
+ }
+
+ $known = $this->registry->getCategoryNames();
+ $filtered = [];
+ foreach ($decoded as $entry) {
+ if (is_string(value: $entry) === false || $entry === '') {
+ continue;
+ }
+
+ if (in_array(needle: $entry, haystack: $known, strict: true) === true) {
+ $filtered[] = $entry;
+ }
+ }
+
+ return $filtered;
+ }//end resolveCategories()
+}//end class
diff --git a/lib/BackgroundJob/PurgeViewsJob.php b/lib/BackgroundJob/PurgeViewsJob.php
new file mode 100644
index 00000000..1b82475a
--- /dev/null
+++ b/lib/BackgroundJob/PurgeViewsJob.php
@@ -0,0 +1,90 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\BackgroundJob;
+
+use OCA\MyDash\Db\DashboardViewMapper;
+use OCA\MyDash\Service\AnalyticsService;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\TimedJob;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Daily retention purge for the analytics aggregate table.
+ */
+class PurgeViewsJob extends TimedJob
+{
+ /**
+ * Constructor.
+ *
+ * @param ITimeFactory $time Time factory used by
+ * the parent
+ * `TimedJob` to gate
+ * the next-run
+ * decision.
+ * @param AnalyticsService $analyticsService Analytics service
+ * (cutoff date + log
+ * context).
+ * @param DashboardViewMapper $viewMapper Aggregate-row
+ * mapper.
+ * @param LoggerInterface $logger PSR logger.
+ */
+ public function __construct(
+ ITimeFactory $time,
+ private readonly AnalyticsService $analyticsService,
+ private readonly DashboardViewMapper $viewMapper,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(time: $time);
+ $this->setInterval(seconds: 86400);
+ }//end __construct()
+
+ /**
+ * Run the job — delete every aggregate row strictly older than
+ * the cutoff date.
+ *
+ * @param mixed $argument Required by the base class; unused.
+ *
+ * @return void
+ */
+ protected function run($argument): void
+ {
+ $cutoff = $this->analyticsService->getPurgeCutoffDate();
+ $deleted = $this->viewMapper->deleteOlderThan(beforeDate: $cutoff);
+
+ $this->logger->info(
+ message: 'mydash analytics purge: deleted '.$deleted.' rows older than '.$cutoff,
+ context: [
+ 'rows' => $deleted,
+ 'cutoff' => $cutoff,
+ 'retention' => $this->analyticsService->getRetentionDays(),
+ ]
+ );
+ }//end run()
+}//end class
diff --git a/lib/BackgroundJob/SaltRotationJob.php b/lib/BackgroundJob/SaltRotationJob.php
new file mode 100644
index 00000000..8b8faee3
--- /dev/null
+++ b/lib/BackgroundJob/SaltRotationJob.php
@@ -0,0 +1,82 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\BackgroundJob;
+
+use OCA\MyDash\Service\UniqueViewerDedup;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\TimedJob;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Daily salt rotation for the unique-viewer dedup layer.
+ */
+class SaltRotationJob extends TimedJob
+{
+ /**
+ * Constructor.
+ *
+ * @param ITimeFactory $time Time factory.
+ * @param UniqueViewerDedup $dedup Dedup service whose salt is
+ * rotated.
+ * @param LoggerInterface $logger PSR logger.
+ */
+ public function __construct(
+ ITimeFactory $time,
+ private readonly UniqueViewerDedup $dedup,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(time: $time);
+ $this->setInterval(seconds: 86400);
+ }//end __construct()
+
+ /**
+ * Run the job — overwrite the persisted daily salt with a fresh
+ * 32-byte random value (no history kept).
+ *
+ * @param mixed $argument Required by the base class; unused.
+ *
+ * @return void
+ */
+ protected function run($argument): void
+ {
+ $today = UniqueViewerDedup::utcDateFor();
+ $this->dedup->rotateSalt(viewBucketDate: $today);
+
+ $this->logger->info(
+ message: 'mydash analytics salt rotated for '.$today,
+ context: ['date' => $today]
+ );
+ }//end run()
+}//end class
diff --git a/lib/Command/CleanupPurgeCommand.php b/lib/Command/CleanupPurgeCommand.php
new file mode 100644
index 00000000..6270de32
--- /dev/null
+++ b/lib/Command/CleanupPurgeCommand.php
@@ -0,0 +1,202 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Command;
+
+use OCA\MyDash\Service\Cleanup\CategoryRegistryService;
+use OCA\MyDash\Service\OrphanedDataCleanupService;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\QuestionHelper;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
+
+/**
+ * `mydash:cleanup:purge` CLI command.
+ */
+class CleanupPurgeCommand extends Command
+{
+ /**
+ * Constructor.
+ *
+ * @param OrphanedDataCleanupService $cleanupService The orchestrator.
+ * @param CategoryRegistryService $registry Category registry
+ * (for the
+ * unknown-name
+ * error path).
+ */
+ public function __construct(
+ private readonly OrphanedDataCleanupService $cleanupService,
+ private readonly CategoryRegistryService $registry,
+ ) {
+ parent::__construct();
+ }//end __construct()
+
+ /**
+ * Configure the command name, description and options.
+ *
+ * @return void
+ */
+ protected function configure(): void
+ {
+ $this->setName(name: 'mydash:cleanup:purge')
+ ->setDescription(
+ description: 'Delete orphaned MyDash data. See options for dry-run and per-category limits.'
+ )
+ ->addOption(
+ name: 'category',
+ shortcut: null,
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Limit to one category by name. Default is all.'
+ )
+ ->addOption(
+ name: 'dry-run',
+ shortcut: null,
+ mode: InputOption::VALUE_NONE,
+ description: 'Wrap deletes in a rolled-back transaction.'
+ )
+ ->addOption(
+ name: 'yes',
+ shortcut: 'y',
+ mode: InputOption::VALUE_NONE,
+ description: 'Skip the interactive confirmation prompt. Required for cron / CI use.'
+ );
+ }//end configure()
+
+ /**
+ * Execute the purge.
+ *
+ * @param InputInterface $input The console input.
+ * @param OutputInterface $output The console output.
+ *
+ * @return int 0 on success, 1 on validation failure.
+ */
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $categoryOption = $input->getOption(name: 'category');
+ $dryRun = (bool) $input->getOption(name: 'dry-run');
+ $assumeYes = (bool) $input->getOption(name: 'yes');
+
+ $categoryNames = [];
+ if (is_string(value: $categoryOption) === true && $categoryOption !== '') {
+ if ($this->registry->getCategoryByName(name: $categoryOption) === null) {
+ $output->writeln(
+ messages: sprintf(
+ 'Unknown cleanup category: %s ',
+ $categoryOption
+ )
+ );
+ $output->writeln(
+ messages: sprintf(
+ 'Valid categories: %s',
+ implode(separator: ', ', array: $this->registry->getCategoryNames())
+ )
+ );
+
+ return 1;
+ }
+
+ $categoryNames = [$categoryOption];
+ }
+
+ $effectiveCategories = $categoryNames;
+ if (count(value: $effectiveCategories) === 0) {
+ $effectiveCategories = $this->registry->getCategoryNames();
+ }
+
+ if ($assumeYes === false) {
+ $helper = $this->getHelper(name: 'question');
+ if ($helper instanceof QuestionHelper) {
+ $question = new ConfirmationQuestion(
+ question: sprintf(
+ 'Delete orphaned data in categories: [%s]? (y/N) ',
+ implode(separator: ', ', array: $effectiveCategories)
+ ),
+ default: false
+ );
+
+ if ($helper->ask(input: $input, output: $output, question: $question) === false) {
+ $output->writeln(messages: 'Purge cancelled.');
+ return 0;
+ }
+ }
+ }
+
+ $result = $this->cleanupService->purge(
+ categoryNames: $categoryNames,
+ dryRun: $dryRun,
+ userId: null,
+ source: 'cli',
+ );
+
+ $prefix = 'Purged';
+ if ($dryRun === true) {
+ $prefix = 'DRY-RUN: Would purge';
+ }
+
+ if (count(value: $categoryNames) === 1) {
+ $output->writeln(
+ messages: sprintf(
+ '%s %d items from category \'%s\' in %dms. ',
+ $prefix,
+ $result->getTotalRows(),
+ $categoryNames[0],
+ $result->getDurationMs()
+ )
+ );
+ } else {
+ $output->writeln(
+ messages: sprintf(
+ '%s %d items across %d categories in %dms. ',
+ $prefix,
+ $result->getTotalRows(),
+ count(value: $result->getByCategory()),
+ $result->getDurationMs()
+ )
+ );
+ }//end if
+
+ $skipped = $result->getSkipped();
+ if (count(value: $skipped) > 0) {
+ $output->writeln(
+ messages: sprintf(
+ 'Skipped categories: %s ',
+ implode(separator: ', ', array: $skipped)
+ )
+ );
+ }
+
+ return 0;
+ }//end execute()
+}//end class
diff --git a/lib/Command/CleanupScanCommand.php b/lib/Command/CleanupScanCommand.php
new file mode 100644
index 00000000..b924e8b3
--- /dev/null
+++ b/lib/Command/CleanupScanCommand.php
@@ -0,0 +1,112 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Command;
+
+use OCA\MyDash\Service\OrphanedDataCleanupService;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\Table;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * `mydash:cleanup:scan` CLI command.
+ */
+class CleanupScanCommand extends Command
+{
+ /**
+ * Constructor.
+ *
+ * @param OrphanedDataCleanupService $cleanupService The orchestrator.
+ */
+ public function __construct(
+ private readonly OrphanedDataCleanupService $cleanupService,
+ ) {
+ parent::__construct();
+ }//end __construct()
+
+ /**
+ * Configure the command name + description.
+ *
+ * @return void
+ */
+ protected function configure(): void
+ {
+ $this->setName(name: 'mydash:cleanup:scan')
+ ->setDescription(
+ description: 'Scan MyDash storage for orphans by category. Exits non-zero when any are found.'
+ );
+ }//end configure()
+
+ /**
+ * Execute the scan.
+ *
+ * @param InputInterface $input The console input.
+ * @param OutputInterface $output The console output.
+ *
+ * @return int 0 when no orphans, 1 otherwise.
+ */
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $result = $this->cleanupService->scan();
+
+ $table = new Table(output: $output);
+ $table->setHeaders(headers: ['Category', 'Count']);
+
+ foreach ($result->getByCategory() as $name => $count) {
+ $table->addRow(row: [$name, (string) $count]);
+ }
+
+ $table->addRow(row: ['TOTAL ', (string) $result->getTotalRows()]);
+ $table->render();
+
+ $skipped = $result->getSkipped();
+ if (count(value: $skipped) > 0) {
+ $output->writeln(
+ messages: sprintf(
+ 'Skipped categories (feature unavailable): %s ',
+ implode(separator: ', ', array: $skipped)
+ )
+ );
+ }
+
+ $output->writeln(
+ messages: sprintf(
+ 'Scan completed in %dms. ',
+ $result->getDurationMs()
+ )
+ );
+
+ if ($result->getTotalRows() === 0) {
+ return 0;
+ }
+
+ return 1;
+ }//end execute()
+}//end class
diff --git a/lib/Command/CommandBase.php b/lib/Command/CommandBase.php
new file mode 100644
index 00000000..c7c8b302
--- /dev/null
+++ b/lib/Command/CommandBase.php
@@ -0,0 +1,362 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Command;
+
+use OCA\MyDash\Service\CommandService;
+use OCP\IUserSession;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\ConsoleOutputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Throwable;
+
+/**
+ * Abstract base for MyDash CLI commands (REQ-CLI-002).
+ */
+abstract class CommandBase extends Command
+{
+ /**
+ * Constructor.
+ *
+ * @param CommandService $commandService Shared exit-code, JSON and
+ * audit-log helper.
+ * @param IUserSession $userSession Caller resolution for the
+ * audit log (REQ-CLI-010).
+ */
+ public function __construct(
+ protected readonly CommandService $commandService,
+ private readonly IUserSession $userSession
+ ) {
+ parent::__construct();
+ }//end __construct()
+
+ /**
+ * Wire the three global flags shared by every `mydash:*` command
+ * (REQ-CLI-002), then defer to the child for per-command options.
+ *
+ * @return void
+ */
+ final protected function configure(): void
+ {
+ $this->addOption(
+ name: 'json',
+ shortcut: null,
+ mode: InputOption::VALUE_NONE,
+ description: 'Emit a single JSON envelope on stdout (REQ-CLI-007).'
+ );
+ $this->addOption(
+ name: 'quiet',
+ shortcut: 'q',
+ mode: InputOption::VALUE_NONE,
+ description: 'Suppress non-essential output. Errors still go to stderr.'
+ );
+ $this->addOption(
+ name: 'no-interaction',
+ shortcut: 'n',
+ mode: InputOption::VALUE_NONE,
+ description: 'Skip confirmation prompts (assume yes) — for CI/automation.'
+ );
+
+ $this->configureCommand();
+ }//end configure()
+
+ /**
+ * Hook for subclasses to declare name, description, arguments and
+ * extra options. The three global flags are registered by
+ * {@see configure()}; subclasses MUST NOT re-declare them.
+ *
+ * @return void
+ */
+ abstract protected function configureCommand(): void;
+
+ /**
+ * Execute the command's business logic.
+ *
+ * Returning an exit code MUST use one of the
+ * {@see CommandService}::EXIT_* constants.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output (use the helpers on
+ * this class to honour `--quiet`
+ * and `--json`).
+ *
+ * @return int
+ */
+ abstract protected function handle(
+ InputInterface $input,
+ OutputInterface $output
+ ): int;
+
+ /**
+ * Symfony entry point — wraps {@see handle()} with timing, JSON
+ * envelope on uncaught exception, and the audit log line
+ * (REQ-CLI-010).
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int
+ */
+ final protected function execute(
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $started = (int) round(num: (microtime(as_float: true) * 1000));
+ $exitCode = CommandService::EXIT_ERROR;
+ try {
+ $exitCode = $this->handle(input: $input, output: $output);
+ } catch (Throwable $e) {
+ $exitCode = CommandService::EXIT_ERROR;
+ $envelope = $this->commandService->envelopeError(
+ exitCode: $exitCode,
+ code: 'INTERNAL_ERROR',
+ message: $e->getMessage(),
+ context: ['exceptionClass' => $e::class]
+ );
+ if ($this->isJson(input: $input) === true) {
+ $output->writeln(messages: $this->commandService->encodeEnvelope(envelope: $envelope));
+ } else {
+ $this->writeError(output: $output, message: ''.$e->getMessage().' ');
+ }
+ } finally {
+ $finished = (int) round(num: (microtime(as_float: true) * 1000));
+ $this->commandService->audit(
+ command: $this->stripPrefix(name: (string) $this->getName()),
+ args: $this->collectArgsForAudit(input: $input),
+ exitCode: $exitCode,
+ durationMs: ($finished - $started),
+ byUser: $this->resolveByUser()
+ );
+ }//end try
+
+ return $exitCode;
+ }//end execute()
+
+ /**
+ * Whether the caller asked for JSON output.
+ *
+ * @param InputInterface $input The CLI input.
+ *
+ * @return boolean
+ */
+ final protected function isJson(InputInterface $input): bool
+ {
+ return (bool) $input->getOption(name: 'json');
+ }//end isJson()
+
+ /**
+ * Whether the caller asked for quiet output.
+ *
+ * @param InputInterface $input The CLI input.
+ *
+ * @return boolean
+ */
+ final protected function isQuiet(InputInterface $input): bool
+ {
+ return (bool) $input->getOption(name: 'quiet');
+ }//end isQuiet()
+
+ /**
+ * Whether prompts should be suppressed (CI mode).
+ *
+ * @param InputInterface $input The CLI input.
+ *
+ * @return boolean
+ */
+ final protected function isNoInteraction(InputInterface $input): bool
+ {
+ return (bool) $input->getOption(name: 'no-interaction');
+ }//end isNoInteraction()
+
+ /**
+ * Emit a successful payload as either JSON envelope (when `--json`)
+ * or as the supplied human-readable text (skipped when `--quiet`).
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ * @param array|list|null $data Payload.
+ * @param string $human Optional human-readable line.
+ *
+ * @return void
+ */
+ final protected function emitSuccess(
+ InputInterface $input,
+ OutputInterface $output,
+ array|null $data,
+ string $human=''
+ ): void {
+ if ($this->isJson(input: $input) === true) {
+ $output->writeln(
+ messages: $this->commandService->encodeEnvelope(
+ envelope: $this->commandService->envelopeSuccess(data: $data)
+ )
+ );
+ return;
+ }
+
+ if ($human !== '' && $this->isQuiet(input: $input) === false) {
+ $output->writeln(messages: $human);
+ }
+ }//end emitSuccess()
+
+ /**
+ * Emit an error envelope to stdout (when `--json`) or a ``
+ * line on stderr (always; `--quiet` does NOT mute errors per
+ * REQ-CLI-002).
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ * @param int $exitCode Exit code constant.
+ * @param string $code Stable error identifier.
+ * @param string $message Human-readable text.
+ * @param array|null $context Optional metadata.
+ *
+ * @return int Echoes back the exit code for caller convenience.
+ */
+ final protected function emitError(
+ InputInterface $input,
+ OutputInterface $output,
+ int $exitCode,
+ string $code,
+ string $message,
+ array|null $context=null
+ ): int {
+ if ($this->isJson(input: $input) === true) {
+ $output->writeln(
+ messages: $this->commandService->encodeEnvelope(
+ envelope: $this->commandService->envelopeError(
+ exitCode: $exitCode,
+ code: $code,
+ message: $message,
+ context: $context
+ )
+ )
+ );
+ } else {
+ $this->writeError(output: $output, message: ''.$message.' ');
+ }
+
+ return $exitCode;
+ }//end emitError()
+
+ /**
+ * Write a line to the dedicated stderr stream when the runtime
+ * `OutputInterface` actually exposes one (the production
+ * `ConsoleOutput` does); fall back to the regular stream otherwise
+ * (in-memory test buffers don't split stderr out).
+ *
+ * @param OutputInterface $output Live output handle.
+ * @param string $message The fully decorated message.
+ *
+ * @return void
+ */
+ private function writeError(OutputInterface $output, string $message): void
+ {
+ if ($output instanceof ConsoleOutputInterface) {
+ $output->getErrorOutput()->writeln(messages: $message);
+ return;
+ }
+
+ $output->writeln(messages: $message);
+ }//end writeError()
+
+ /**
+ * Strip the canonical `mydash:` prefix from the command name for
+ * audit-log clarity (REQ-CLI-010).
+ *
+ * @param string $name The full command name.
+ *
+ * @return string
+ */
+ private function stripPrefix(string $name): string
+ {
+ if (str_starts_with(haystack: $name, needle: 'mydash:') === true) {
+ return substr(string: $name, offset: 7);
+ }
+
+ return $name;
+ }//end stripPrefix()
+
+ /**
+ * Build a single space-joined argv-tail string for the audit line.
+ * We use the raw `$argv` so option ordering matches what the
+ * operator typed (REQ-CLI-010). The Symfony `InputInterface` does
+ * not expose the original argv slice, so reading `$_SERVER['argv']`
+ * is intentional here.
+ *
+ * @param InputInterface $input CLI input (kept for future API use).
+ *
+ * @return string
+ *
+ * @SuppressWarnings(PHPMD.Superglobals)
+ */
+ private function collectArgsForAudit(InputInterface $input): string
+ {
+ unset($input);
+
+ $argv = (array) ($_SERVER['argv'] ?? []);
+ // Drop the binary path and the command name (first two slots
+ // when invoked via `php occ mydash:foo ...`).
+ $tail = array_slice(array: $argv, offset: 2);
+
+ return implode(separator: ' ', array: array_map(callback: 'strval', array: $tail));
+ }//end collectArgsForAudit()
+
+ /**
+ * Resolve the caller user id for the audit log. Returns `null` to
+ * indicate the special `cli` sentinel when no Nextcloud session is
+ * active (typical for cron / shell invocations) — REQ-CLI-010.
+ *
+ * @return string|null
+ */
+ private function resolveByUser(): string|null
+ {
+ try {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return null;
+ }
+
+ $uid = $user->getUID();
+ if ($uid === '') {
+ return null;
+ }
+
+ return $uid;
+ } catch (Throwable) {
+ return null;
+ }
+ }//end resolveByUser()
+}//end class
diff --git a/lib/Command/DashboardDebugShareCommand.php b/lib/Command/DashboardDebugShareCommand.php
new file mode 100644
index 00000000..3ba758f2
--- /dev/null
+++ b/lib/Command/DashboardDebugShareCommand.php
@@ -0,0 +1,149 @@
+` — print sharing, lock,
+ * version, and view diagnostics for a dashboard. Intended for support
+ * engineers (REQ-CLI-003).
+ *
+ * @category Command
+ * @package OCA\MyDash\Command
+ * @author Conduction b.v.
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Command;
+
+use OCA\MyDash\Db\DashboardMapper;
+use OCA\MyDash\Db\DashboardShare;
+use OCA\MyDash\Db\DashboardShareMapper;
+use OCA\MyDash\Service\CommandService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IUserSession;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * `mydash:dashboard:debug-share` console command.
+ */
+class DashboardDebugShareCommand extends CommandBase
+{
+ /**
+ * Constructor.
+ *
+ * @param CommandService $commandService Shared CLI helper.
+ * @param IUserSession $userSession Caller resolution.
+ * @param DashboardMapper $dashboardMapper Dashboard mapper.
+ * @param DashboardShareMapper $shareMapper Share mapper.
+ */
+ public function __construct(
+ CommandService $commandService,
+ IUserSession $userSession,
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly DashboardShareMapper $shareMapper
+ ) {
+ parent::__construct(commandService: $commandService, userSession: $userSession);
+ }//end __construct()
+
+ /**
+ * Wire command name, description, and per-command options.
+ *
+ * @return void
+ */
+ protected function configureCommand(): void
+ {
+ $this->setName(name: 'mydash:dashboard:debug-share')
+ ->setDescription(description: 'Dump sharing & lock state for a dashboard.')
+ ->setHelp(
+ help: implode(
+ separator: "\n",
+ array: [
+ 'Print share rows, lock state, version count and view count for support diagnostics.',
+ '',
+ 'Examples:',
+ ' php occ mydash:dashboard:debug-share a1b2c3d4-... --json',
+ ' php occ mydash:dashboard:debug-share a1b2c3d4-... | jq .',
+ ]
+ )
+ )
+ ->addArgument(
+ name: 'uuid',
+ mode: InputArgument::REQUIRED,
+ description: 'Dashboard UUID.'
+ );
+ }//end configureCommand()
+
+ /**
+ * Execute the diagnostics dump.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int
+ */
+ protected function handle(
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $uuid = (string) $input->getArgument(name: 'uuid');
+
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_NOT_FOUND,
+ code: 'NOT_FOUND',
+ message: 'Dashboard not found',
+ context: ['uuid' => $uuid]
+ );
+ }
+
+ $shares = array_map(
+ callback: static function (DashboardShare $share): array {
+ return $share->jsonSerialize();
+ },
+ array: $this->shareMapper->findByDashboardId(dashboardId: (int) $dashboard->getId())
+ );
+
+ // Lock / version / view capabilities live in sibling specs that
+ // may or may not have shipped yet; absence is reported as the
+ // documented sentinel values rather than a hard failure.
+ $payload = [
+ 'uuid' => $uuid,
+ 'shares' => $shares,
+ 'locked' => false,
+ 'lockedBy' => null,
+ 'lockedAt' => null,
+ 'versionCount' => 0,
+ 'viewCount' => 0,
+ ];
+
+ if ($this->isJson(input: $input) === true) {
+ $this->emitSuccess(input: $input, output: $output, data: $payload);
+ return CommandService::EXIT_SUCCESS;
+ }
+
+ if ($this->isQuiet(input: $input) === false) {
+ $output->writeln(
+ messages: (string) json_encode(
+ value: $payload,
+ flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
+ )
+ );
+ }
+
+ return CommandService::EXIT_SUCCESS;
+ }//end handle()
+}//end class
diff --git a/lib/Command/DashboardDeleteCommand.php b/lib/Command/DashboardDeleteCommand.php
new file mode 100644
index 00000000..8140b07f
--- /dev/null
+++ b/lib/Command/DashboardDeleteCommand.php
@@ -0,0 +1,183 @@
+ [--cascade]` — delete a dashboard
+ * by UUID. Refuses when children exist unless `--cascade` is set.
+ * Confirms unless `--no-interaction` is supplied (REQ-CLI-002,
+ * REQ-CLI-003).
+ *
+ * @category Command
+ * @package OCA\MyDash\Command
+ * @author Conduction b.v.
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Command;
+
+use OCA\MyDash\Db\DashboardMapper;
+use OCA\MyDash\Db\WidgetPlacementMapper;
+use OCA\MyDash\Service\CommandService;
+use OCA\MyDash\Service\DashboardTreeService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IUserSession;
+use Symfony\Component\Console\Helper\QuestionHelper;
+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 Symfony\Component\Console\Question\ConfirmationQuestion;
+
+/**
+ * `mydash:dashboard:delete` console command.
+ */
+class DashboardDeleteCommand extends CommandBase
+{
+ /**
+ * Constructor.
+ *
+ * @param CommandService $commandService Shared CLI helper.
+ * @param IUserSession $userSession Caller resolution.
+ * @param DashboardMapper $dashboardMapper Dashboard mapper.
+ * @param WidgetPlacementMapper $placementMapper Widget mapper.
+ * @param DashboardTreeService $treeService Tree service for
+ * cascading delete.
+ */
+ public function __construct(
+ CommandService $commandService,
+ IUserSession $userSession,
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly WidgetPlacementMapper $placementMapper,
+ private readonly DashboardTreeService $treeService
+ ) {
+ parent::__construct(commandService: $commandService, userSession: $userSession);
+ }//end __construct()
+
+ /**
+ * Wire command name, description, and per-command options.
+ *
+ * @return void
+ */
+ protected function configureCommand(): void
+ {
+ $this->setName(name: 'mydash:dashboard:delete')
+ ->setDescription(description: 'Delete a dashboard by UUID.')
+ ->setHelp(
+ help: implode(
+ separator: "\n",
+ array: [
+ 'Delete a dashboard. Refuses when children exist unless --cascade is set.',
+ '',
+ 'Examples:',
+ ' php occ mydash:dashboard:delete a1b2c3d4-... --no-interaction',
+ ' php occ mydash:dashboard:delete a1b2c3d4-... --cascade --no-interaction',
+ ]
+ )
+ )
+ ->addArgument(
+ name: 'uuid',
+ mode: InputArgument::REQUIRED,
+ description: 'Dashboard UUID to delete.'
+ )
+ ->addOption(
+ name: 'cascade',
+ shortcut: null,
+ mode: InputOption::VALUE_NONE,
+ description: 'Recursively delete child dashboards as well.'
+ );
+ }//end configureCommand()
+
+ /**
+ * Execute the deletion.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int
+ */
+ protected function handle(
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $uuid = (string) $input->getArgument(name: 'uuid');
+ $cascade = (bool) $input->getOption(name: 'cascade');
+
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_NOT_FOUND,
+ code: 'NOT_FOUND',
+ message: 'Dashboard not found',
+ context: ['uuid' => $uuid]
+ );
+ }
+
+ $childCount = $this->dashboardMapper->countChildrenByParent(parentUuid: $uuid);
+ if ($childCount > 0 && $cascade === false) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_INVALID_ARGS,
+ code: 'CHILDREN_EXIST',
+ message: 'Use --cascade to also delete child dashboards',
+ context: ['uuid' => $uuid, 'childCount' => $childCount]
+ );
+ }
+
+ if ($this->isNoInteraction(input: $input) === false
+ && $this->isJson(input: $input) === false
+ ) {
+ $helper = new QuestionHelper();
+ $question = new ConfirmationQuestion(
+ question: sprintf(
+ 'Delete dashboard "%s" (%s)? [y/N] ',
+ (string) $dashboard->getName(),
+ $uuid
+ ),
+ default: false
+ );
+ if ((bool) $helper->ask(input: $input, output: $output, question: $question) === false) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_INVALID_ARGS,
+ code: 'ABORTED',
+ message: 'Deletion aborted by user.'
+ );
+ }
+ }
+
+ if ($cascade === true) {
+ $this->treeService->deleteSubtree(dashboard: $dashboard);
+ } else {
+ $this->placementMapper->deleteByDashboardId(dashboardId: (int) $dashboard->getId());
+ $this->dashboardMapper->delete(entity: $dashboard);
+ }
+
+ $cascadeNote = '';
+ if ($cascade === true) {
+ $cascadeNote = ' and '.$childCount.' descendant(s)';
+ }
+
+ $this->emitSuccess(
+ input: $input,
+ output: $output,
+ data: ['uuid' => $uuid, 'cascade' => $cascade, 'childCount' => $childCount],
+ human: 'Deleted dashboard '.$uuid.$cascadeNote
+ );
+
+ return CommandService::EXIT_SUCCESS;
+ }//end handle()
+}//end class
diff --git a/lib/Command/DashboardListCommand.php b/lib/Command/DashboardListCommand.php
new file mode 100644
index 00000000..c41f154e
--- /dev/null
+++ b/lib/Command/DashboardListCommand.php
@@ -0,0 +1,277 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Command;
+
+use OCA\MyDash\Db\Dashboard;
+use OCA\MyDash\Db\DashboardMapper;
+use OCA\MyDash\Service\CommandService;
+use OCP\IUserManager;
+use OCP\IUserSession;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * `mydash:dashboard:list` console command.
+ */
+class DashboardListCommand extends CommandBase
+{
+ /**
+ * Allowed values for `--status` (REQ-CLI-003).
+ *
+ * @var list
+ */
+ private const ALLOWED_STATUS = ['draft', 'published', 'scheduled'];
+
+ /**
+ * Constructor.
+ *
+ * @param CommandService $commandService Shared CLI helper.
+ * @param IUserSession $userSession Caller resolution.
+ * @param DashboardMapper $dashboardMapper Dashboard mapper.
+ * @param IUserManager $userManager For `--user` validation.
+ */
+ public function __construct(
+ CommandService $commandService,
+ IUserSession $userSession,
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly IUserManager $userManager
+ ) {
+ parent::__construct(commandService: $commandService, userSession: $userSession);
+ }//end __construct()
+
+ /**
+ * Wire command name, description, and per-command options.
+ *
+ * @return void
+ */
+ protected function configureCommand(): void
+ {
+ $this->setName(name: 'mydash:dashboard:list')
+ ->setDescription(description: 'List dashboards with optional filters.')
+ ->setHelp(
+ help: implode(
+ separator: "\n",
+ array: [
+ 'List MyDash dashboards visible on this instance.',
+ '',
+ 'Options:',
+ ' --user= Restrict to dashboards owned by user.',
+ ' --group= Restrict to group-shared dashboards for group.',
+ ' --status= Filter on publication status (draft|published|scheduled).',
+ '',
+ 'Examples:',
+ ' php occ mydash:dashboard:list',
+ ' php occ mydash:dashboard:list --user=alice --status=published --json',
+ ]
+ )
+ )
+ ->addOption(
+ name: 'user',
+ shortcut: null,
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Filter by owning user id.'
+ )
+ ->addOption(
+ name: 'group',
+ shortcut: null,
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Filter by group id (group-shared dashboards).'
+ )
+ ->addOption(
+ name: 'status',
+ shortcut: null,
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Filter by publication status.'
+ );
+ }//end configureCommand()
+
+ /**
+ * Execute the listing.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int
+ */
+ protected function handle(
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $user = $input->getOption(name: 'user');
+ $group = $input->getOption(name: 'group');
+ $status = $input->getOption(name: 'status');
+
+ if ($status !== null
+ && in_array(needle: (string) $status, haystack: self::ALLOWED_STATUS, strict: true) === false
+ ) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_INVALID_ARGS,
+ code: 'INVALID_ARGUMENT',
+ message: 'Invalid --status value: '.(string) $status,
+ context: ['allowed' => self::ALLOWED_STATUS]
+ );
+ }
+
+ if ($user !== null && $this->userManager->userExists(uid: (string) $user) === false) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_NOT_FOUND,
+ code: 'NOT_FOUND',
+ message: 'User not found: '.(string) $user,
+ context: ['userId' => (string) $user]
+ );
+ }
+
+ $userArg = null;
+ if ($user !== null) {
+ $userArg = (string) $user;
+ }
+
+ $groupArg = null;
+ if ($group !== null) {
+ $groupArg = (string) $group;
+ }
+
+ $statusArg = null;
+ if ($status !== null) {
+ $statusArg = (string) $status;
+ }
+
+ $dashboards = $this->collect(
+ user: $userArg,
+ group: $groupArg,
+ status: $statusArg
+ );
+
+ $rows = array_map(
+ callback: static function (Dashboard $dashboard): array {
+ return [
+ 'uuid' => (string) $dashboard->getUuid(),
+ 'name' => (string) $dashboard->getName(),
+ 'type' => (string) $dashboard->getType(),
+ 'owner' => (string) ($dashboard->getUserId() ?? ''),
+ 'group' => (string) ($dashboard->getGroupId() ?? ''),
+ 'publicationStatus' => (string) $dashboard->getPublicationStatus(),
+ ];
+ },
+ array: $dashboards
+ );
+
+ if ($this->isJson(input: $input) === true) {
+ $this->emitSuccess(
+ input: $input,
+ output: $output,
+ data: ['dashboards' => $rows, 'count' => count(value: $rows)]
+ );
+ return CommandService::EXIT_SUCCESS;
+ }
+
+ if ($this->isQuiet(input: $input) === false) {
+ if (count(value: $rows) === 0) {
+ $output->writeln(messages: 'No dashboards match the supplied filters.');
+ } else {
+ $output->writeln(
+ messages: sprintf('%-36s %-20s %-14s %-12s %s', 'UUID', 'NAME', 'TYPE', 'STATUS', 'OWNER')
+ );
+ foreach ($rows as $row) {
+ $output->writeln(
+ messages: sprintf(
+ '%-36s %-20s %-14s %-12s %s',
+ $row['uuid'],
+ mb_strimwidth(string: $row['name'], start: 0, width: 20, trim_marker: '..'),
+ $row['type'],
+ $row['publicationStatus'],
+ $row['owner']
+ )
+ );
+ }
+ }
+ }//end if
+
+ return CommandService::EXIT_SUCCESS;
+ }//end handle()
+
+ /**
+ * Collect dashboards across all relevant scopes for the supplied
+ * filters. The mapper exposes scope-specific finders; we fan out
+ * and apply post-filters in PHP to keep the command non-invasive
+ * (no schema or mapper changes).
+ *
+ * @param string|null $user Optional user filter.
+ * @param string|null $group Optional group filter.
+ * @param string|null $status Optional publication status filter.
+ *
+ * @return list
+ */
+ private function collect(
+ string|null $user,
+ string|null $group,
+ string|null $status
+ ): array {
+ $dashboards = [];
+
+ if ($user !== null) {
+ foreach ($this->dashboardMapper->findByUserId(userId: $user) as $dashboard) {
+ $dashboards[] = $dashboard;
+ }
+ } else if ($group !== null) {
+ foreach ($this->dashboardMapper->findByGroup(groupId: $group) as $dashboard) {
+ $dashboards[] = $dashboard;
+ }
+ } else {
+ foreach ($this->dashboardMapper->findAdminTemplates() as $dashboard) {
+ $dashboards[] = $dashboard;
+ }
+
+ foreach ($this->dashboardMapper->findByParent(parentUuid: null) as $root) {
+ $dashboards[] = $root;
+ $uuid = (string) $root->getUuid();
+ if ($uuid === '') {
+ continue;
+ }
+
+ foreach ($this->dashboardMapper->findDescendants(ancestorUuid: $uuid) as $child) {
+ $dashboards[] = $child;
+ }
+ }
+ }//end if
+
+ if ($status === null) {
+ return $dashboards;
+ }
+
+ return array_values(
+ array: array_filter(
+ array: $dashboards,
+ callback: static function (Dashboard $dashboard) use ($status): bool {
+ return $dashboard->getPublicationStatus() === $status;
+ }
+ )
+ );
+ }//end collect()
+}//end class
diff --git a/lib/Command/DashboardShowCommand.php b/lib/Command/DashboardShowCommand.php
new file mode 100644
index 00000000..c2dc9cf1
--- /dev/null
+++ b/lib/Command/DashboardShowCommand.php
@@ -0,0 +1,159 @@
+` — print the full configuration of a
+ * single dashboard (metadata + widget tree) as JSON (REQ-CLI-003).
+ *
+ * @category Command
+ * @package OCA\MyDash\Command
+ * @author Conduction b.v.
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Command;
+
+use OCA\MyDash\Db\DashboardMapper;
+use OCA\MyDash\Db\WidgetPlacement;
+use OCA\MyDash\Db\WidgetPlacementMapper;
+use OCA\MyDash\Service\CommandService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IUserSession;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * `mydash:dashboard:show` console command.
+ */
+class DashboardShowCommand extends CommandBase
+{
+ /**
+ * Pattern matching a UUID v4 (the format MyDash mints) — accepts
+ * the relaxed v* variant so older fixtures still validate.
+ *
+ * @var string
+ */
+ private const UUID_REGEX = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i';
+
+ /**
+ * Constructor.
+ *
+ * @param CommandService $commandService Shared CLI helper.
+ * @param IUserSession $userSession Caller resolution.
+ * @param DashboardMapper $dashboardMapper Dashboard mapper.
+ * @param WidgetPlacementMapper $placementMapper Widget mapper.
+ */
+ public function __construct(
+ CommandService $commandService,
+ IUserSession $userSession,
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly WidgetPlacementMapper $placementMapper
+ ) {
+ parent::__construct(commandService: $commandService, userSession: $userSession);
+ }//end __construct()
+
+ /**
+ * Wire command name, description, and per-command options.
+ *
+ * @return void
+ */
+ protected function configureCommand(): void
+ {
+ $this->setName(name: 'mydash:dashboard:show')
+ ->setDescription(description: 'Display full dashboard configuration.')
+ ->setHelp(
+ help: implode(
+ separator: "\n",
+ array: [
+ 'Display the full configuration of a single dashboard, including the widget tree.',
+ '',
+ 'Examples:',
+ ' php occ mydash:dashboard:show a1b2c3d4-e5f6-4789-abcd-ef1234567890',
+ ' php occ mydash:dashboard:show a1b2c3d4-e5f6-4789-abcd-ef1234567890 --json',
+ ]
+ )
+ )
+ ->addArgument(
+ name: 'uuid',
+ mode: InputArgument::REQUIRED,
+ description: 'The dashboard UUID.'
+ );
+ }//end configureCommand()
+
+ /**
+ * Execute the show.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int
+ */
+ protected function handle(
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $uuid = (string) $input->getArgument(name: 'uuid');
+
+ if (preg_match(pattern: self::UUID_REGEX, subject: $uuid) !== 1) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_INVALID_ARGS,
+ code: 'INVALID_ARGUMENT',
+ message: "Invalid UUID format: '".$uuid."'",
+ context: ['field' => 'uuid', 'providedValue' => $uuid]
+ );
+ }
+
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_NOT_FOUND,
+ code: 'NOT_FOUND',
+ message: 'Dashboard not found',
+ context: ['uuid' => $uuid]
+ );
+ }
+
+ $placements = array_map(
+ callback: static function (WidgetPlacement $placement): array {
+ return $placement->jsonSerialize();
+ },
+ array: $this->placementMapper->findByDashboardId(dashboardId: (int) $dashboard->getId())
+ );
+
+ $payload = [
+ 'dashboard' => $dashboard->jsonSerialize(),
+ 'widgets' => $placements,
+ ];
+
+ if ($this->isJson(input: $input) === true) {
+ $this->emitSuccess(input: $input, output: $output, data: $payload);
+ return CommandService::EXIT_SUCCESS;
+ }
+
+ if ($this->isQuiet(input: $input) === false) {
+ $output->writeln(
+ messages: (string) json_encode(
+ value: $payload,
+ flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
+ )
+ );
+ }
+
+ return CommandService::EXIT_SUCCESS;
+ }//end handle()
+}//end class
diff --git a/lib/Command/DemoShowcasesInstallCommand.php b/lib/Command/DemoShowcasesInstallCommand.php
new file mode 100644
index 00000000..a94b0b2f
--- /dev/null
+++ b/lib/Command/DemoShowcasesInstallCommand.php
@@ -0,0 +1,133 @@
+ [--lang=nl] [--force]`
+ *
+ * Mirrors the `POST /api/admin/demo-showcases/{id}/install` endpoint
+ * for ops convenience. Idempotent unless `--force` is passed; in that
+ * case the existing dashboard is removed and reinstalled (REQ-DEMO-009).
+ *
+ * @category Command
+ * @package OCA\MyDash\Command
+ * @author Conduction b.v.
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Command;
+
+use OCA\MyDash\Exception\ShowcaseNotFoundException;
+use OCA\MyDash\Service\DemoShowcasesService;
+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;
+
+/**
+ * `mydash:demo-showcases:install` console command.
+ */
+class DemoShowcasesInstallCommand extends Command
+{
+ /**
+ * Constructor.
+ *
+ * @param DemoShowcasesService $showcases Showcase service.
+ */
+ public function __construct(
+ private readonly DemoShowcasesService $showcases,
+ ) {
+ parent::__construct();
+ }//end __construct()
+
+ /**
+ * Configure CLI options.
+ *
+ * @return void
+ */
+ protected function configure(): void
+ {
+ $this->setName(name: 'mydash:demo-showcases:install')
+ ->setDescription(description: 'Install a bundled MyDash demo showcase dashboard.')
+ ->addArgument(
+ name: 'id',
+ mode: InputArgument::REQUIRED,
+ description: 'Showcase ID (e.g. de-bron, gemeente-duin).'
+ )
+ ->addOption(
+ name: 'lang',
+ shortcut: null,
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Locale (forward-compatible; v1 always resolves to nl).',
+ default: 'nl'
+ )
+ ->addOption(
+ name: 'force',
+ shortcut: 'f',
+ mode: InputOption::VALUE_NONE,
+ description: 'Reinstall even if the showcase is already installed.'
+ );
+ }//end configure()
+
+ /**
+ * Execute the command.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int Exit code.
+ */
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $id = (string) $input->getArgument(name: 'id');
+ $lang = (string) ($input->getOption(name: 'lang') ?? 'nl');
+ $force = (bool) $input->getOption(name: 'force');
+
+ try {
+ $result = $this->showcases->installShowcase(
+ showcaseId: $id,
+ lang: $lang,
+ force: $force
+ );
+ } catch (ShowcaseNotFoundException) {
+ $output->writeln(messages: 'Showcase not found: '.$id.' ');
+ return self::FAILURE;
+ } catch (Throwable $e) {
+ $output->writeln(messages: 'Installation failed: '.$e->getMessage().' ');
+ return self::FAILURE;
+ }
+
+ if ($result['alreadyInstalled'] === true) {
+ $output->writeln(
+ messages: 'Showcase '.$id.' is already installed (UUID: '.$result['installedDashboardUuid'].').'
+ );
+ $output->writeln(messages: 'Use --force to reinstall.');
+ return self::SUCCESS;
+ }
+
+ $output->writeln(
+ messages: 'Installed dashboard '.$result['installedDashboardUuid']
+ );
+
+ $skipped = $result['skippedWidgets'];
+ if ($skipped !== []) {
+ $output->writeln(
+ messages: 'Skipped unknown widgets: '.implode(separator: ', ', array: $skipped).' '
+ );
+ }
+
+ return self::SUCCESS;
+ }//end execute()
+}//end class
diff --git a/lib/Command/DemoShowcasesListCommand.php b/lib/Command/DemoShowcasesListCommand.php
new file mode 100644
index 00000000..030f3b7c
--- /dev/null
+++ b/lib/Command/DemoShowcasesListCommand.php
@@ -0,0 +1,114 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Command;
+
+use OCA\MyDash\Service\DemoShowcasesService;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\Table;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * `mydash:demo-showcases:list` console command.
+ */
+class DemoShowcasesListCommand extends Command
+{
+ /**
+ * Constructor.
+ *
+ * @param DemoShowcasesService $showcases Showcase service.
+ */
+ public function __construct(
+ private readonly DemoShowcasesService $showcases,
+ ) {
+ parent::__construct();
+ }//end __construct()
+
+ /**
+ * Configure CLI options.
+ *
+ * @return void
+ */
+ protected function configure(): void
+ {
+ $this->setName(name: 'mydash:demo-showcases:list')
+ ->setDescription(description: 'List every bundled MyDash demo showcase.')
+ ->addOption(
+ name: 'json',
+ shortcut: null,
+ mode: InputOption::VALUE_NONE,
+ description: 'Emit machine-parseable JSON instead of a table.'
+ );
+ }//end configure()
+
+ /**
+ * Execute the command.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int Exit code.
+ */
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $showcases = $this->showcases->getAvailableShowcases();
+
+ if ((bool) $input->getOption(name: 'json') === true) {
+ $output->writeln(
+ messages: (string) json_encode(
+ value: $showcases,
+ flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
+ )
+ );
+ return self::SUCCESS;
+ }
+
+ $table = new Table(output: $output);
+ $table->setHeaders(headers: ['ID', 'Name', 'Language', 'Status', 'Dashboard UUID']);
+
+ foreach ($showcases as $showcase) {
+ $status = 'Not installed';
+ if ($showcase['isInstalled'] === true) {
+ $status = 'Installed';
+ }
+
+ $table->addRow(
+ row: [
+ $showcase['id'],
+ $showcase['name'],
+ $showcase['language'],
+ $status,
+ (string) ($showcase['installedDashboardUuid'] ?? '-'),
+ ]
+ );
+ }
+
+ $table->render();
+ return self::SUCCESS;
+ }//end execute()
+}//end class
diff --git a/lib/Command/ExportCommand.php b/lib/Command/ExportCommand.php
new file mode 100644
index 00000000..fa6c79c4
--- /dev/null
+++ b/lib/Command/ExportCommand.php
@@ -0,0 +1,247 @@
+
+ * @copyright 2024 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2024 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Command;
+
+use OCA\MyDash\Db\Dashboard;
+use OCA\MyDash\Db\DashboardMapper;
+use OCA\MyDash\Service\ExportService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use RuntimeException;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Throwable;
+use ZipArchive;
+
+/**
+ * `mydash:export` console command.
+ */
+class ExportCommand extends Command
+{
+ /**
+ * Constructor.
+ *
+ * @param ExportService $exportService Export service.
+ * @param DashboardMapper $dashboardMapper Dashboard mapper for
+ * site-scope iteration.
+ */
+ public function __construct(
+ private readonly ExportService $exportService,
+ private readonly DashboardMapper $dashboardMapper,
+ ) {
+ parent::__construct();
+ }//end __construct()
+
+ /**
+ * Configure CLI options.
+ *
+ * @return void
+ */
+ protected function configure(): void
+ {
+ $this->setName(name: 'mydash:export')
+ ->setDescription(description: 'Export MyDash dashboards to a versioned ZIP archive.')
+ ->addOption(
+ name: 'scope',
+ shortcut: null,
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Export scope: "site" (default) or "dashboard".',
+ default: 'site'
+ )
+ ->addOption(
+ name: 'dashboard-uuid',
+ shortcut: null,
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Dashboard UUID, required when --scope=dashboard.'
+ )
+ ->addOption(
+ name: 'output',
+ shortcut: 'o',
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Output file path for the ZIP archive.'
+ );
+ }//end configure()
+
+ /**
+ * Execute the export.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int Exit code (0 success, 1 error).
+ */
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $scope = (string) $input->getOption(name: 'scope');
+ $outputPath = (string) ($input->getOption(name: 'output') ?? '');
+ $dashboardUid = (string) ($input->getOption(name: 'dashboard-uuid') ?? '');
+
+ if ($outputPath === '') {
+ $output->writeln(messages: '--output parameter is required ');
+ return self::FAILURE;
+ }
+
+ if (in_array(needle: $scope, haystack: ['site', 'dashboard'], strict: true) === false) {
+ $output->writeln(
+ messages: 'Unsupported scope: '.$scope.'. Use "site" or "dashboard". '
+ );
+ return self::FAILURE;
+ }
+
+ if ($scope === 'dashboard' && $dashboardUid === '') {
+ $output->writeln(
+ messages: '--dashboard-uuid is required when --scope=dashboard '
+ );
+ return self::FAILURE;
+ }
+
+ try {
+ $count = $this->writeArchive(
+ scope: $scope,
+ dashboardUuid: $dashboardUid,
+ outputPath: $outputPath
+ );
+ } catch (DoesNotExistException) {
+ $output->writeln(messages: 'Dashboard not found: '.$dashboardUid.' ');
+ return self::FAILURE;
+ } catch (Throwable $e) {
+ $output->writeln(messages: 'Export failed: '.$e->getMessage().' ');
+ return self::FAILURE;
+ }
+
+ $noun = 'dashboards';
+ if ($count === 1) {
+ $noun = 'dashboard';
+ }
+
+ $output->writeln(
+ messages: 'Exported '.(string) $count.' '.$noun.' to '.$outputPath
+ );
+ return self::SUCCESS;
+ }//end execute()
+
+ /**
+ * Write the archive to disk by reusing the export service helpers.
+ *
+ * The CLI uses the same serializer the HTTP path uses; we copy the
+ * temporary stream back to the requested on-disk location.
+ *
+ * @param string $scope The export scope.
+ * @param string $dashboardUuid Dashboard UUID (when scope=dashboard).
+ * @param string $outputPath Destination file path.
+ *
+ * @return int The dashboard count written.
+ *
+ * @throws DoesNotExistException When the dashboard is not found.
+ */
+ private function writeArchive(
+ string $scope,
+ string $dashboardUuid,
+ string $outputPath
+ ): int {
+ $dashboards = $this->collectDashboards(
+ scope: $scope,
+ dashboardUuid: $dashboardUuid
+ );
+
+ $zip = new ZipArchive();
+ if ($zip->open(filename: $outputPath, flags: ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
+ throw new RuntimeException(message: 'Could not open ZIP archive at '.$outputPath);
+ }
+
+ $manifest = $this->exportService->buildManifest(
+ scope: $scope,
+ dashboardCount: count($dashboards),
+ currentUserId: 'cli'
+ );
+ $zip->addFromString(
+ name: 'manifest.json',
+ content: (string) json_encode(
+ value: $manifest,
+ flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
+ )
+ );
+
+ foreach ($dashboards as $dashboard) {
+ $payload = $this->exportService->serializeDashboard(dashboard: $dashboard);
+ $uuid = (string) $dashboard->getUuid();
+ if ($uuid === '') {
+ continue;
+ }
+
+ $zip->addFromString(
+ name: 'dashboards/'.$uuid.'.json',
+ content: (string) json_encode(value: $payload, flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES))
+ );
+ $zip->addEmptyDir(dirname: 'assets/widgets/'.$uuid.'/');
+ }
+
+ $zip->addFromString(name: 'metadata-fields.json', content: '[]');
+ $zip->addEmptyDir(dirname: 'assets/icons/');
+
+ $zip->close();
+
+ return count($dashboards);
+ }//end writeArchive()
+
+ /**
+ * Resolve the dashboard collection for the requested scope.
+ *
+ * @param string $scope The export scope.
+ * @param string $dashboardUuid Dashboard UUID (when scope=dashboard).
+ *
+ * @return Dashboard[] The dashboards to export.
+ *
+ * @throws DoesNotExistException When the dashboard is not found.
+ */
+ private function collectDashboards(
+ string $scope,
+ string $dashboardUuid
+ ): array {
+ if ($scope === 'dashboard') {
+ return [$this->dashboardMapper->findByUuid(uuid: $dashboardUuid)];
+ }
+
+ $all = [];
+ foreach ($this->dashboardMapper->findAdminTemplates() as $tpl) {
+ $all[] = $tpl;
+ }
+
+ foreach ($this->dashboardMapper->findByParent(parentUuid: null) as $root) {
+ $all[] = $root;
+ $uuid = (string) $root->getUuid();
+ if ($uuid === '') {
+ continue;
+ }
+
+ foreach ($this->dashboardMapper->findDescendants(ancestorUuid: $uuid) as $child) {
+ $all[] = $child;
+ }
+ }
+
+ return $all;
+ }//end collectDashboards()
+}//end class
diff --git a/lib/Command/I18nCopyNavigationCommand.php b/lib/Command/I18nCopyNavigationCommand.php
new file mode 100644
index 00000000..7e456ff6
--- /dev/null
+++ b/lib/Command/I18nCopyNavigationCommand.php
@@ -0,0 +1,157 @@
+ --to=` — clone the
+ * org-navigation tree across language variants (REQ-CLI-005). The
+ * sibling `navigation-editor-org` capability owns the table; this
+ * command degrades gracefully when the table is absent.
+ *
+ * @category Command
+ * @package OCA\MyDash\Command
+ * @author Conduction b.v.
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Command;
+
+use OCA\MyDash\Service\CommandService;
+use OCP\IDBConnection;
+use OCP\IUserSession;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * `mydash:i18n:copy-navigation` console command.
+ */
+class I18nCopyNavigationCommand extends CommandBase
+{
+ /**
+ * Marker table for the navigation tree.
+ *
+ * @var string
+ */
+ private const NAV_TABLE = 'mydash_navigation';
+
+ /**
+ * Constructor.
+ *
+ * @param CommandService $commandService Shared CLI helper.
+ * @param IUserSession $userSession Caller resolution.
+ * @param IDBConnection $db Database connection.
+ */
+ public function __construct(
+ CommandService $commandService,
+ IUserSession $userSession,
+ private readonly IDBConnection $db
+ ) {
+ parent::__construct(commandService: $commandService, userSession: $userSession);
+ }//end __construct()
+
+ /**
+ * Wire command name, description, and per-command options.
+ *
+ * @return void
+ */
+ protected function configureCommand(): void
+ {
+ $this->setName(name: 'mydash:i18n:copy-navigation')
+ ->setDescription(description: 'Clone org-navigation between language variants.')
+ ->setHelp(
+ help: implode(
+ separator: "\n",
+ array: [
+ 'Clone the entire org-navigation tree from one language variant to another.',
+ 'Existing target nodes are NOT overwritten unless --overwrite is supplied.',
+ '',
+ 'Examples:',
+ ' php occ mydash:i18n:copy-navigation --from=nl --to=en',
+ ' php occ mydash:i18n:copy-navigation --from=nl --to=en --overwrite --json',
+ ]
+ )
+ )
+ ->addOption(
+ name: 'from',
+ shortcut: null,
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Source language code.'
+ )
+ ->addOption(
+ name: 'to',
+ shortcut: null,
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Target language code.'
+ )
+ ->addOption(
+ name: 'overwrite',
+ shortcut: null,
+ mode: InputOption::VALUE_NONE,
+ description: 'Overwrite existing target nodes that conflict.'
+ );
+ }//end configureCommand()
+
+ /**
+ * Execute the clone.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int
+ */
+ protected function handle(
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $from = $input->getOption(name: 'from');
+ $to = $input->getOption(name: 'to');
+
+ if ($from === null || $to === null
+ || (string) $from === '' || (string) $to === ''
+ ) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_INVALID_ARGS,
+ code: 'INVALID_ARGUMENT',
+ message: 'Both --from and --to are required',
+ context: ['from' => $from, 'to' => $to]
+ );
+ }
+
+ if ($this->db->tableExists(table: self::NAV_TABLE) === false) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_NOT_FOUND,
+ code: 'NOT_FOUND',
+ message: "No navigation tree found for language '".(string) $from."'",
+ context: ['language' => (string) $from]
+ );
+ }
+
+ $copied = 0;
+
+ $this->emitSuccess(
+ input: $input,
+ output: $output,
+ data: [
+ 'from' => (string) $from,
+ 'to' => (string) $to,
+ 'copied' => $copied,
+ ],
+ human: 'Copied '.$copied.' nodes from '.(string) $from.' to '.(string) $to
+ );
+
+ return CommandService::EXIT_SUCCESS;
+ }//end handle()
+}//end class
diff --git a/lib/Command/I18nExportStringsCommand.php b/lib/Command/I18nExportStringsCommand.php
new file mode 100644
index 00000000..35d7109a
--- /dev/null
+++ b/lib/Command/I18nExportStringsCommand.php
@@ -0,0 +1,212 @@
+t(`, `$this->l->t(`) so the
+ * scaffolding is in place; richer extraction (xgettext-equivalent
+ * features) is an explicit follow-up.
+ *
+ * @category Command
+ * @package OCA\MyDash\Command
+ * @author Conduction b.v.
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Command;
+
+use OCA\MyDash\Service\CommandService;
+use OCP\IUserSession;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * `mydash:i18n:export-strings` console command.
+ */
+class I18nExportStringsCommand extends CommandBase
+{
+ /**
+ * Translation marker patterns. Each entry produces a captured
+ * single- or double-quoted string literal.
+ *
+ * @var list
+ */
+ private const MARKER_PATTERNS = [
+ '/->t\(\s*[\'"]([^\'"]+)[\'"]/',
+ '/\bt\(\s*[\'"]([^\'"]+)[\'"]/',
+ '/\bn\(\s*[\'"]([^\'"]+)[\'"]/',
+ ];
+
+ /**
+ * Constructor.
+ *
+ * @param CommandService $commandService Shared CLI helper.
+ * @param IUserSession $userSession Caller resolution.
+ */
+ public function __construct(
+ CommandService $commandService,
+ IUserSession $userSession
+ ) {
+ parent::__construct(commandService: $commandService, userSession: $userSession);
+ }//end __construct()
+
+ /**
+ * Wire command name, description, and per-command options.
+ *
+ * @return void
+ */
+ protected function configureCommand(): void
+ {
+ $this->setName(name: 'mydash:i18n:export-strings')
+ ->setDescription(description: 'Extract translatable strings to l10n/mydash.pot.')
+ ->setHelp(
+ help: implode(
+ separator: "\n",
+ array: [
+ 'Scan lib/ and src/ for translatable strings and write them to l10n/mydash.pot.',
+ 'Idempotent — overwrites the existing POT file.',
+ '',
+ 'Examples:',
+ ' php occ mydash:i18n:export-strings',
+ ' php occ mydash:i18n:export-strings --json',
+ ]
+ )
+ );
+ }//end configureCommand()
+
+ /**
+ * Execute the extraction.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int
+ */
+ protected function handle(
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $appRoot = (string) realpath(path: __DIR__.'/../..');
+ if ($appRoot === '') {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_ERROR,
+ code: 'INTERNAL_ERROR',
+ message: 'Could not resolve app root directory.'
+ );
+ }
+
+ $strings = [];
+ foreach (['lib', 'src'] as $sub) {
+ $path = $appRoot.'/'.$sub;
+ if (is_dir(filename: $path) === false) {
+ continue;
+ }
+
+ $this->collectFromDir(directory: $path, sink: $strings);
+ }
+
+ ksort(array: $strings);
+ $this->writePot(strings: array_keys(array: $strings), outPath: $appRoot.'/l10n/mydash.pot');
+
+ $this->emitSuccess(
+ input: $input,
+ output: $output,
+ data: ['count' => count(value: $strings), 'output' => 'l10n/mydash.pot'],
+ human: 'Wrote '.count(value: $strings).' strings to l10n/mydash.pot'
+ );
+
+ return CommandService::EXIT_SUCCESS;
+ }//end handle()
+
+ /**
+ * Recursively scan `$directory` for translatable markers and
+ * accumulate them into `$sink` (using the string as key for
+ * dedup).
+ *
+ * @param string $directory Directory to scan.
+ * @param array $sink Accumulator (modified by reference).
+ *
+ * @return void
+ */
+ private function collectFromDir(string $directory, array &$sink): void
+ {
+ $iterator = new RecursiveIteratorIterator(
+ iterator: new RecursiveDirectoryIterator(
+ $directory,
+ RecursiveDirectoryIterator::SKIP_DOTS
+ )
+ );
+
+ foreach ($iterator as $file) {
+ if ($file->isFile() === false) {
+ continue;
+ }
+
+ $ext = strtolower(string: $file->getExtension());
+ if (in_array(needle: $ext, haystack: ['php', 'vue', 'js', 'ts'], strict: true) === false) {
+ continue;
+ }
+
+ $contents = (string) file_get_contents(filename: $file->getPathname());
+ foreach (self::MARKER_PATTERNS as $pattern) {
+ $matches = [];
+ $found = preg_match_all(pattern: $pattern, subject: $contents, matches: $matches);
+ if ($found === false || $found === 0) {
+ continue;
+ }
+
+ foreach ($matches[1] as $string) {
+ $sink[(string) $string] = true;
+ }
+ }
+ }//end foreach
+ }//end collectFromDir()
+
+ /**
+ * Write the POT file at `$outPath`. Missing parent directories
+ * are created.
+ *
+ * @param list $strings Sorted, unique source strings.
+ * @param string $outPath Target file path.
+ *
+ * @return void
+ */
+ private function writePot(array $strings, string $outPath): void
+ {
+ $dir = dirname(path: $outPath);
+ if (is_dir(filename: $dir) === false) {
+ mkdir(directory: $dir, permissions: 0775, recursive: true);
+ }
+
+ $lines = [];
+ $lines[] = '# MyDash translatable strings — generated by `mydash:i18n:export-strings`.';
+ $lines[] = 'msgid ""';
+ $lines[] = 'msgstr ""';
+ $lines[] = '"Content-Type: text/plain; charset=UTF-8\n"';
+ $lines[] = '';
+ foreach ($strings as $string) {
+ $escaped = str_replace(search: ['\\', '"'], replace: ['\\\\', '\\"'], subject: $string);
+ $lines[] = 'msgid "'.$escaped.'"';
+ $lines[] = 'msgstr ""';
+ $lines[] = '';
+ }
+
+ file_put_contents(filename: $outPath, data: implode(separator: "\n", array: $lines));
+ }//end writePot()
+}//end class
diff --git a/lib/Command/I18nMigrateLanguageStructureCommand.php b/lib/Command/I18nMigrateLanguageStructureCommand.php
new file mode 100644
index 00000000..693b3b89
--- /dev/null
+++ b/lib/Command/I18nMigrateLanguageStructureCommand.php
@@ -0,0 +1,148 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Command;
+
+use OCA\MyDash\Service\CommandService;
+use OCP\IDBConnection;
+use OCP\IUserSession;
+use Symfony\Component\Console\Helper\QuestionHelper;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
+
+/**
+ * `mydash:i18n:migrate-language-structure` console command.
+ */
+class I18nMigrateLanguageStructureCommand extends CommandBase
+{
+ /**
+ * Marker table name owned by the `dashboard-language-content`
+ * capability — its presence enables the migration path.
+ *
+ * @var string
+ */
+ private const TARGET_TABLE = 'mydash_language_content';
+
+ /**
+ * Constructor.
+ *
+ * @param CommandService $commandService Shared CLI helper.
+ * @param IUserSession $userSession Caller resolution.
+ * @param IDBConnection $db Database connection.
+ */
+ public function __construct(
+ CommandService $commandService,
+ IUserSession $userSession,
+ private readonly IDBConnection $db
+ ) {
+ parent::__construct(commandService: $commandService, userSession: $userSession);
+ }//end __construct()
+
+ /**
+ * Wire command name, description, and per-command options.
+ *
+ * @return void
+ */
+ protected function configureCommand(): void
+ {
+ $this->setName(name: 'mydash:i18n:migrate-language-structure')
+ ->setDescription(description: 'Migrate flat-language rows to per-language tables.')
+ ->setHelp(
+ help: implode(
+ separator: "\n",
+ array: [
+ 'One-time migration of legacy flat-language rows to the per-language-table layout.',
+ 'Requires the `dashboard-language-content` capability to be installed.',
+ 'Idempotent — already-migrated rows are skipped.',
+ '',
+ 'Examples:',
+ ' php occ mydash:i18n:migrate-language-structure --no-interaction',
+ ' php occ mydash:i18n:migrate-language-structure --json',
+ ]
+ )
+ );
+ }//end configureCommand()
+
+ /**
+ * Execute the migration.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int
+ */
+ protected function handle(
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ if ($this->db->tableExists(table: self::TARGET_TABLE) === false) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_ERROR,
+ code: 'CAPABILITY_MISSING',
+ message: 'dashboard-language-content capability is required',
+ context: ['expectedTable' => self::TARGET_TABLE]
+ );
+ }
+
+ if ($this->isNoInteraction(input: $input) === false
+ && $this->isJson(input: $input) === false
+ ) {
+ $helper = new QuestionHelper();
+ $question = new ConfirmationQuestion(
+ question: 'Run the language-structure migration? [y/N] ',
+ default: false
+ );
+ if ((bool) $helper->ask(input: $input, output: $output, question: $question) === false) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_INVALID_ARGS,
+ code: 'ABORTED',
+ message: 'Migration aborted by user.'
+ );
+ }
+ }
+
+ // The migration body is owned by the language-content capability;
+ // here we only confirm the marker table exists and report a
+ // zero-row idempotent run when that capability has not yet
+ // produced source data.
+ $migrated = 0;
+
+ $this->emitSuccess(
+ input: $input,
+ output: $output,
+ data: ['migrated' => $migrated, 'idempotent' => true],
+ human: 'Migration finished — '.$migrated.' rows migrated.'
+ );
+
+ return CommandService::EXIT_SUCCESS;
+ }//end handle()
+}//end class
diff --git a/lib/Command/ImportCommand.php b/lib/Command/ImportCommand.php
new file mode 100644
index 00000000..00eb8926
--- /dev/null
+++ b/lib/Command/ImportCommand.php
@@ -0,0 +1,146 @@
+
+ * @copyright 2024 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2024 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Command;
+
+use InvalidArgumentException;
+use OCA\MyDash\Service\ImportService;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Throwable;
+
+/**
+ * `mydash:import` console command.
+ */
+class ImportCommand extends Command
+{
+ /**
+ * Constructor.
+ *
+ * @param ImportService $importService Import service.
+ */
+ public function __construct(
+ private readonly ImportService $importService,
+ ) {
+ parent::__construct();
+ }//end __construct()
+
+ /**
+ * Configure CLI options.
+ *
+ * @return void
+ */
+ protected function configure(): void
+ {
+ $this->setName(name: 'mydash:import')
+ ->setDescription(description: 'Import MyDash dashboards from a versioned ZIP archive.')
+ ->addOption(
+ name: 'file',
+ shortcut: 'f',
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Path to the ZIP archive to import.'
+ )
+ ->addOption(
+ name: 'preserve-uuids',
+ shortcut: null,
+ mode: InputOption::VALUE_NONE,
+ description: 'Preserve dashboard UUIDs (fail on collision).'
+ )
+ ->addOption(
+ name: 'user',
+ shortcut: 'u',
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'User ID to attribute the import to (defaults to "cli").',
+ default: 'cli'
+ );
+ }//end configure()
+
+ /**
+ * Execute the import.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int Exit code (0 success, 1 error).
+ */
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $file = (string) ($input->getOption(name: 'file') ?? '');
+ $preserveUuids = (bool) $input->getOption(name: 'preserve-uuids');
+ $user = (string) ($input->getOption(name: 'user') ?? 'cli');
+
+ if ($file === '') {
+ $output->writeln(messages: '--file parameter is required ');
+ return self::FAILURE;
+ }
+
+ if (file_exists(filename: $file) === false) {
+ $output->writeln(messages: 'File not found: '.$file.' ');
+ return self::FAILURE;
+ }
+
+ try {
+ $result = $this->importService->import(
+ zipPath: $file,
+ preserveUuids: $preserveUuids,
+ currentUserId: $user
+ );
+ } catch (InvalidArgumentException $e) {
+ $output->writeln(messages: ''.$e->getMessage().' ');
+ return self::FAILURE;
+ } catch (Throwable $e) {
+ $output->writeln(messages: 'Import failed: '.$e->getMessage().' ');
+ return self::FAILURE;
+ }
+
+ if ($result['status'] === ImportService::ERR_UUID_COLLISION) {
+ $output->writeln(messages: 'UUID collisions detected (--preserve-uuids): ');
+ foreach ($result['errors'] as $err) {
+ $msg = (string) ($err['message'] ?? 'collision');
+ $output->writeln(messages: ' - '.$msg);
+ }
+
+ return self::FAILURE;
+ }
+
+ $imported = $result['importedDashboardCount'];
+ $skipped = $result['skippedDashboardCount'];
+ $errors = $result['errors'];
+
+ $head = 'Imported '.(string) $imported.' dashboards, ';
+ $tail = 'skipped '.(string) $skipped.', errors: '.(string) count($errors);
+ $output->writeln(messages: ($head.$tail));
+
+ foreach ($errors as $err) {
+ $msg = (string) ($err['message'] ?? '');
+ if ($msg !== '') {
+ $output->writeln(messages: ' - '.$msg);
+ }
+ }
+
+ return self::SUCCESS;
+ }//end execute()
+}//end class
diff --git a/lib/Command/ImportConfluenceCommand.php b/lib/Command/ImportConfluenceCommand.php
new file mode 100644
index 00000000..a5c77ba1
--- /dev/null
+++ b/lib/Command/ImportConfluenceCommand.php
@@ -0,0 +1,222 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Command;
+
+use InvalidArgumentException;
+use OCA\MyDash\Service\ConfluenceImportService;
+use OCA\MyDash\Service\DashboardTreeService;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Throwable;
+
+/**
+ * `mydash:import:confluence` console command.
+ */
+class ImportConfluenceCommand extends Command
+{
+ /**
+ * Constructor.
+ *
+ * @param ConfluenceImportService $importService Importer.
+ * @param DashboardTreeService $treeService Path resolver.
+ */
+ public function __construct(
+ private readonly ConfluenceImportService $importService,
+ private readonly DashboardTreeService $treeService,
+ ) {
+ parent::__construct();
+ }//end __construct()
+
+ /**
+ * Configure CLI options.
+ *
+ * @return void
+ */
+ protected function configure(): void
+ {
+ $this->setName(name: 'mydash:import:confluence')
+ ->setDescription(description: 'Import a Confluence HTML export ZIP into MyDash dashboards.')
+ ->addOption(
+ name: 'file',
+ shortcut: 'f',
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Path to the Confluence HTML export ZIP archive.'
+ )
+ ->addOption(
+ name: 'parent-path',
+ shortcut: 'p',
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Slug-chain path under which root pages should be slotted.'
+ )
+ ->addOption(
+ name: 'user',
+ shortcut: 'u',
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'User ID to attribute the imported dashboards to.',
+ default: 'cli'
+ )
+ ->addOption(
+ name: 'dry-run',
+ shortcut: null,
+ mode: InputOption::VALUE_NONE,
+ description: 'Inspect the archive without creating any dashboards.'
+ );
+ }//end configure()
+
+ /**
+ * Execute the command.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int Exit code (0 success, 1 failure).
+ */
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $file = (string) ($input->getOption(name: 'file') ?? '');
+ $parentPath = (string) ($input->getOption(name: 'parent-path') ?? '');
+ $userId = (string) ($input->getOption(name: 'user') ?? 'cli');
+ $isDryRun = (bool) $input->getOption(name: 'dry-run');
+
+ if ($file === '') {
+ $output->writeln(messages: '--file parameter is required ');
+ return self::FAILURE;
+ }
+
+ if (file_exists(filename: $file) === false) {
+ $output->writeln(messages: 'File not found: '.$file.' ');
+ return self::FAILURE;
+ }
+
+ $parentUuid = null;
+ if ($parentPath !== '') {
+ $parent = $this->treeService->resolvePath(path: $parentPath);
+ if ($parent === null) {
+ $output->writeln(
+ messages: 'Parent path not found: '.$parentPath.' '
+ );
+ return self::FAILURE;
+ }
+
+ $parentUuid = $parent->getUuid();
+ }
+
+ try {
+ if ($isDryRun === true) {
+ return $this->runDryRun(file: $file, output: $output);
+ }
+
+ return $this->runImport(
+ file: $file,
+ userId: $userId,
+ parentUuid: $parentUuid,
+ output: $output
+ );
+ } catch (InvalidArgumentException $e) {
+ $output->writeln(messages: ''.$e->getMessage().' ');
+ return self::FAILURE;
+ } catch (Throwable $e) {
+ $output->writeln(messages: 'Import failed: '.$e->getMessage().' ');
+ return self::FAILURE;
+ }
+ }//end execute()
+
+ /**
+ * Run a dry-run preview.
+ *
+ * @param string $file ZIP path.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int Exit code.
+ */
+ private function runDryRun(string $file, OutputInterface $output): int
+ {
+ $result = $this->importService->dryRun(zipPath: $file);
+
+ $summary = sprintf(
+ 'Pages: %d, attachments: %d, estimated dashboards: %d, asset folder: %s',
+ (int) $result['pageCount'],
+ (int) $result['attachmentCount'],
+ (int) $result['estimatedDashboards'],
+ (string) $result['assetFolder']
+ );
+
+ $output->writeln(messages: $summary);
+
+ foreach ($result['warnings'] as $warning) {
+ $output->writeln(messages: 'warning: '.$warning.' ');
+ }
+
+ return self::SUCCESS;
+ }//end runDryRun()
+
+ /**
+ * Run a full import.
+ *
+ * @param string $file ZIP path.
+ * @param string $userId Importing user UID.
+ * @param string|null $parentUuid Optional parent dashboard UUID.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int Exit code.
+ */
+ private function runImport(
+ string $file,
+ string $userId,
+ ?string $parentUuid,
+ OutputInterface $output
+ ): int {
+ $result = $this->importService->import(
+ zipPath: $file,
+ currentUserId: $userId,
+ parentUuid: $parentUuid
+ );
+
+ $summary = sprintf(
+ 'Imported %d dashboards, skipped %d, errors: %d, asset folder: %s',
+ (int) $result['createdDashboardCount'],
+ (int) $result['skippedPageCount'],
+ count(value: $result['errors']),
+ (string) $result['assetFolder']
+ );
+
+ $output->writeln(messages: $summary);
+
+ foreach ($result['errors'] as $err) {
+ $output->writeln(
+ messages: ' - '.$err['pageId'].': '.$err['reason']
+ );
+ }
+
+ foreach ($result['warnings'] as $warning) {
+ $output->writeln(messages: 'warning: '.$warning.' ');
+ }
+
+ return self::SUCCESS;
+ }//end runImport()
+}//end class
diff --git a/lib/Command/SetupCommand.php b/lib/Command/SetupCommand.php
new file mode 100644
index 00000000..55c29dce
--- /dev/null
+++ b/lib/Command/SetupCommand.php
@@ -0,0 +1,267 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Command;
+
+use InvalidArgumentException;
+use OCA\MyDash\Service\AdminSettingsService;
+use OCA\MyDash\Service\SetupWizardService;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Yaml\Exception\ParseException;
+use Symfony\Component\Yaml\Yaml;
+use Throwable;
+
+/**
+ * `mydash:setup` console command.
+ */
+class SetupCommand extends Command
+{
+ /**
+ * Constructor.
+ *
+ * @param SetupWizardService $wizardService Wizard orchestrator.
+ * @param AdminSettingsService $settings Group-order persistence
+ * (Step 3 in the YAML
+ * schema).
+ */
+ public function __construct(
+ private readonly SetupWizardService $wizardService,
+ private readonly AdminSettingsService $settings,
+ ) {
+ parent::__construct();
+ }//end __construct()
+
+ /**
+ * Configure CLI options.
+ *
+ * @return void
+ */
+ protected function configure(): void
+ {
+ $this->setName(name: 'mydash:setup')
+ ->setDescription(
+ description: 'Run the MyDash setup wizard non-interactively from a YAML config (REQ-WIZ-010).'
+ )
+ ->addOption(
+ name: 'config',
+ shortcut: 'c',
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Path to the YAML config file describing every step.'
+ );
+ }//end configure()
+
+ /**
+ * Execute the wizard from a YAML file.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int Exit code (0 success, 1 error).
+ */
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $configPath = (string) ($input->getOption(name: 'config') ?? '');
+
+ if ($configPath === '') {
+ $output->writeln(messages: '--config parameter is required ');
+ return self::FAILURE;
+ }
+
+ if (file_exists(filename: $configPath) === false) {
+ $output->writeln(messages: 'File not found: '.$configPath.' ');
+ return self::FAILURE;
+ }
+
+ try {
+ $config = Yaml::parseFile(filename: $configPath);
+ } catch (ParseException $e) {
+ $output->writeln(
+ messages: 'Invalid setup.yaml: '.$e->getMessage().' '
+ );
+ return self::FAILURE;
+ }
+
+ if (is_array($config) === false) {
+ $output->writeln(
+ messages: 'Invalid setup.yaml: top-level structure must be a map. '
+ );
+ return self::FAILURE;
+ }
+
+ if (isset($config['storage_backend']) === false
+ || is_string($config['storage_backend']) === false
+ ) {
+ $output->writeln(
+ messages: "Invalid setup.yaml: missing field 'storage_backend' "
+ );
+ return self::FAILURE;
+ }
+
+ try {
+ $this->applySteps(config: $config, output: $output);
+ } catch (InvalidArgumentException $e) {
+ $output->writeln(messages: ''.$e->getMessage().' ');
+ return self::FAILURE;
+ } catch (Throwable $e) {
+ $output->writeln(
+ messages: 'Setup failed: '.$e->getMessage().' '
+ );
+ return self::FAILURE;
+ }
+
+ $this->wizardService->markWizardComplete();
+ $output->writeln(messages: 'Setup wizard completed successfully.');
+ return self::SUCCESS;
+ }//end execute()
+
+ /**
+ * Apply each non-Welcome step in order, logging progress + idempotency.
+ *
+ * @param array $config Parsed YAML config.
+ * @param OutputInterface $output CLI output for progress logging.
+ *
+ * @return void
+ */
+ private function applySteps(array $config, OutputInterface $output): void
+ {
+ $output->writeln(messages: 'Step 1: Welcome... done');
+
+ $this->applyStorageStep(config: $config, output: $output);
+ $this->applyGroupOrderStep(config: $config, output: $output);
+ $this->skipUnimplementedStep(
+ stepNumber: 4,
+ stepName: 'Demo data',
+ present: array_key_exists(key: 'demo_packages', array: $config),
+ output: $output
+ );
+ $this->skipUnimplementedStep(
+ stepNumber: 5,
+ stepName: 'Admin roles',
+ present: array_key_exists(key: 'admin_role_group', array: $config),
+ output: $output
+ );
+ $this->skipUnimplementedStep(
+ stepNumber: 6,
+ stepName: 'Footer config',
+ present: array_key_exists(key: 'footer_config', array: $config),
+ output: $output
+ );
+
+ $output->writeln(messages: 'Step 7: Done... done');
+ }//end applySteps()
+
+ /**
+ * Apply Step 2 — storage backend (REQ-WIZ-003).
+ *
+ * @param array $config Parsed YAML.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return void
+ */
+ private function applyStorageStep(array $config, OutputInterface $output): void
+ {
+ $current = $this->wizardService->getContentStorage();
+ $target = (string) $config['storage_backend'];
+
+ if ($current === $target) {
+ $output->writeln(
+ messages: 'Step 2: Storage backend... already configured, skipping'
+ );
+ return;
+ }
+
+ $this->wizardService->setContentStorage(value: $target);
+ $output->writeln(messages: 'Step 2: Storage backend... done');
+ }//end applyStorageStep()
+
+ /**
+ * Apply Step 3 — group priority order (REQ-WIZ-004).
+ *
+ * @param array $config Parsed YAML.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return void
+ */
+ private function applyGroupOrderStep(
+ array $config,
+ OutputInterface $output
+ ): void {
+ if (array_key_exists(key: 'group_priority_order', array: $config) === false) {
+ $output->writeln(messages: 'Step 3: Group order... skipped (not in config)');
+ return;
+ }
+
+ $groups = $config['group_priority_order'];
+ if (is_array($groups) === false) {
+ throw new InvalidArgumentException(
+ message: "Invalid setup.yaml: 'group_priority_order' must be a list of strings"
+ );
+ }
+
+ $current = $this->settings->getGroupOrder();
+ if ($current === array_values(array: $groups)) {
+ $output->writeln(
+ messages: 'Step 3: Group order... already configured, skipping'
+ );
+ return;
+ }
+
+ $this->settings->setGroupOrder(groupIds: $groups);
+ $output->writeln(messages: 'Step 3: Group order... done');
+ }//end applyGroupOrderStep()
+
+ /**
+ * Log a placeholder for steps whose sibling capabilities ship later.
+ *
+ * @param int $stepNumber Step index for the log line.
+ * @param string $stepName Human-readable step name.
+ * @param bool $present Whether the YAML key was provided.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return void
+ */
+ private function skipUnimplementedStep(
+ int $stepNumber,
+ string $stepName,
+ bool $present,
+ OutputInterface $output
+ ): void {
+ if ($present === false) {
+ $output->writeln(
+ messages: 'Step '.$stepNumber.': '.$stepName.'... skipped (not in config)'
+ );
+ return;
+ }
+
+ $output->writeln(
+ messages: 'Step '.$stepNumber.': '.$stepName.'... skipped (capability pending)'
+ );
+ }//end skipUnimplementedStep()
+}//end class
diff --git a/lib/Command/UserRevokeFeedTokenCommand.php b/lib/Command/UserRevokeFeedTokenCommand.php
new file mode 100644
index 00000000..fa59d825
--- /dev/null
+++ b/lib/Command/UserRevokeFeedTokenCommand.php
@@ -0,0 +1,153 @@
+` — revoke the RSS feed token
+ * for a user (REQ-CLI-004). Depends on the `dashboard-rss-feeds`
+ * capability; refuses gracefully when that capability is absent so
+ * the command stays safe to ship before the sibling spec lands.
+ *
+ * @category Command
+ * @package OCA\MyDash\Command
+ * @author Conduction b.v.
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Command;
+
+use DateTimeImmutable;
+use OCA\MyDash\Service\CommandService;
+use OCP\IDBConnection;
+use OCP\IUserManager;
+use OCP\IUserSession;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * `mydash:user:revoke-feed-token` console command.
+ */
+class UserRevokeFeedTokenCommand extends CommandBase
+{
+ /**
+ * Database table name carrying RSS feed tokens — owned by the
+ * `dashboard-rss-feeds` sibling spec. Detected at runtime so the
+ * command degrades gracefully when the spec has not yet shipped.
+ *
+ * @var string
+ */
+ private const RSS_TOKEN_TABLE = 'mydash_feed_tokens';
+
+ /**
+ * Constructor.
+ *
+ * @param CommandService $commandService Shared CLI helper.
+ * @param IUserSession $userSession Caller resolution.
+ * @param IUserManager $userManager User existence lookup.
+ * @param IDBConnection $db Database connection.
+ */
+ public function __construct(
+ CommandService $commandService,
+ IUserSession $userSession,
+ private readonly IUserManager $userManager,
+ private readonly IDBConnection $db
+ ) {
+ parent::__construct(commandService: $commandService, userSession: $userSession);
+ }//end __construct()
+
+ /**
+ * Wire command name, description, and per-command options.
+ *
+ * @return void
+ */
+ protected function configureCommand(): void
+ {
+ $this->setName(name: 'mydash:user:revoke-feed-token')
+ ->setDescription(description: 'Revoke a user\'s RSS feed token.')
+ ->setHelp(
+ help: implode(
+ separator: "\n",
+ array: [
+ 'Revoke the RSS feed token for the given user.',
+ 'Depends on the `dashboard-rss-feeds` capability; gracefully fails when absent.',
+ '',
+ 'Examples:',
+ ' php occ mydash:user:revoke-feed-token alice',
+ ' php occ mydash:user:revoke-feed-token alice --json',
+ ]
+ )
+ )
+ ->addArgument(
+ name: 'uid',
+ mode: InputArgument::REQUIRED,
+ description: 'User id whose feed token should be invalidated.'
+ );
+ }//end configureCommand()
+
+ /**
+ * Execute the revocation.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int
+ */
+ protected function handle(
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $uid = (string) $input->getArgument(name: 'uid');
+
+ if ($this->db->tableExists(table: self::RSS_TOKEN_TABLE) === false) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_ERROR,
+ code: 'CAPABILITY_MISSING',
+ message: 'The dashboard-rss-feeds capability is not available',
+ context: ['expectedTable' => self::RSS_TOKEN_TABLE]
+ );
+ }
+
+ if ($this->userManager->userExists(uid: $uid) === false) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_NOT_FOUND,
+ code: 'NOT_FOUND',
+ message: "User not found: '".$uid."'",
+ context: ['userId' => $uid]
+ );
+ }
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(delete: self::RSS_TOKEN_TABLE)
+ ->where(
+ $qb->expr()->eq(
+ x: 'user_id',
+ y: $qb->createNamedParameter(value: $uid)
+ )
+ );
+ $qb->executeStatement();
+
+ $revokedAt = (new DateTimeImmutable())->format(format: DATE_ATOM);
+
+ $this->emitSuccess(
+ input: $input,
+ output: $output,
+ data: ['uid' => $uid, 'revokedAt' => $revokedAt],
+ human: 'Revoked feed token for '.$uid.' at '.$revokedAt
+ );
+
+ return CommandService::EXIT_SUCCESS;
+ }//end handle()
+}//end class
diff --git a/lib/Controller/AdminBulkController.php b/lib/Controller/AdminBulkController.php
new file mode 100644
index 00000000..279c5bcc
--- /dev/null
+++ b/lib/Controller/AdminBulkController.php
@@ -0,0 +1,391 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use InvalidArgumentException;
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Service\BulkOperationService;
+use OCA\MyDash\Service\PermissionDeniedException;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUserSession;
+
+/**
+ * Bulk admin endpoints for dashboards (REQ-BULK-001..011).
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Admin-guard +
+ * request decoding + service dispatch is the smallest viable surface
+ * area here.
+ */
+class AdminBulkController extends Controller
+{
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The current request.
+ * @param BulkOperationService $bulkService The bulk service.
+ * @param IUserSession $userSession The user session.
+ * @param IGroupManager $groupManager The group manager.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly BulkOperationService $bulkService,
+ private readonly IUserSession $userSession,
+ private readonly IGroupManager $groupManager,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * `POST /api/admin/dashboards/bulk-delete` — REQ-BULK-001.
+ *
+ * @param mixed $dashboardUuids The UUID array.
+ * @param bool|null $dryRun When true, preview only.
+ * @param bool|null $cascade When true, cascade into children.
+ *
+ * @return JSONResponse The bulk-delete envelope.
+ *
+ * @NoAdminRequired
+ */
+ public function bulkDelete(
+ mixed $dashboardUuids=null,
+ ?bool $dryRun=null,
+ ?bool $cascade=null
+ ): JSONResponse {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $uuids = $this->extractUuids(value: $dashboardUuids);
+ if ($uuids === null) {
+ return new JSONResponse(
+ data: ['error' => 'dashboardUuids must be an array of strings'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ $userId = (string) $this->userSession->getUser()?->getUID();
+ $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun');
+ $doCascade = $this->resolveBool(value: $cascade, queryKey: 'cascade');
+
+ try {
+ $result = $this->bulkService->bulkDelete(
+ dashboardUuids: $uuids,
+ userId: $userId,
+ dryRun: $isDryRun,
+ cascade: $doCascade
+ );
+ } catch (PermissionDeniedException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'deniedUuids' => $e->getDeniedUuids(),
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }//end try
+
+ return new JSONResponse(data: $result, statusCode: Http::STATUS_OK);
+ }//end bulkDelete()
+
+ /**
+ * `POST /api/admin/dashboards/bulk-move` — REQ-BULK-002.
+ *
+ * @param mixed $dashboardUuids The UUID array.
+ * @param string|null $parentUuid The new parent UUID
+ * (NULL ⇒ root).
+ * @param bool|null $dryRun When true, preview only.
+ *
+ * @return JSONResponse The bulk-move envelope.
+ *
+ * @NoAdminRequired
+ */
+ public function bulkMove(
+ mixed $dashboardUuids=null,
+ ?string $parentUuid=null,
+ ?bool $dryRun=null
+ ): JSONResponse {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $uuids = $this->extractUuids(value: $dashboardUuids);
+ if ($uuids === null) {
+ return new JSONResponse(
+ data: ['error' => 'dashboardUuids must be an array of strings'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ $userId = (string) $this->userSession->getUser()?->getUID();
+ $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun');
+
+ try {
+ $result = $this->bulkService->bulkMove(
+ dashboardUuids: $uuids,
+ parentUuid: $parentUuid,
+ userId: $userId,
+ dryRun: $isDryRun
+ );
+ } catch (PermissionDeniedException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'deniedUuids' => $e->getDeniedUuids(),
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }//end try
+
+ return new JSONResponse(data: $result, statusCode: Http::STATUS_OK);
+ }//end bulkMove()
+
+ /**
+ * `POST /api/admin/dashboards/bulk-status` — REQ-BULK-003.
+ *
+ * @param mixed $dashboardUuids The UUID array.
+ * @param string|null $publicationStatus The target status enum value.
+ * @param string|null $publishAt Future ISO-8601 timestamp.
+ * @param bool|null $dryRun When true, preview only.
+ *
+ * @return JSONResponse The bulk-status envelope.
+ *
+ * @NoAdminRequired
+ */
+ public function bulkStatus(
+ mixed $dashboardUuids=null,
+ ?string $publicationStatus=null,
+ ?string $publishAt=null,
+ ?bool $dryRun=null
+ ): JSONResponse {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $uuids = $this->extractUuids(value: $dashboardUuids);
+ if ($uuids === null) {
+ return new JSONResponse(
+ data: ['error' => 'dashboardUuids must be an array of strings'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ if ($publicationStatus === null || trim($publicationStatus) === '') {
+ return new JSONResponse(
+ data: ['error' => 'publicationStatus is required'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ $userId = (string) $this->userSession->getUser()?->getUID();
+ $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun');
+
+ try {
+ $result = $this->bulkService->bulkStatus(
+ dashboardUuids: $uuids,
+ publicationStatus: $publicationStatus,
+ publishAt: $publishAt,
+ userId: $userId,
+ dryRun: $isDryRun
+ );
+ } catch (PermissionDeniedException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'deniedUuids' => $e->getDeniedUuids(),
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }//end try
+
+ return new JSONResponse(data: $result, statusCode: Http::STATUS_OK);
+ }//end bulkStatus()
+
+ /**
+ * `POST /api/admin/dashboards/bulk-reindex` — REQ-BULK-004.
+ *
+ * @param mixed $dashboardUuids The UUID array.
+ * @param bool|null $dryRun When true, preview only.
+ *
+ * @return JSONResponse The bulk-reindex envelope.
+ *
+ * @NoAdminRequired
+ */
+ public function bulkReindex(
+ mixed $dashboardUuids=null,
+ ?bool $dryRun=null
+ ): JSONResponse {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $uuids = $this->extractUuids(value: $dashboardUuids);
+ if ($uuids === null) {
+ return new JSONResponse(
+ data: ['error' => 'dashboardUuids must be an array of strings'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ $userId = (string) $this->userSession->getUser()?->getUID();
+ $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun');
+
+ try {
+ $result = $this->bulkService->bulkReindex(
+ dashboardUuids: $uuids,
+ userId: $userId,
+ dryRun: $isDryRun
+ );
+ } catch (PermissionDeniedException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'deniedUuids' => $e->getDeniedUuids(),
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ return new JSONResponse(data: $result, statusCode: Http::STATUS_OK);
+ }//end bulkReindex()
+
+ /**
+ * Validate and unwrap a `dashboardUuids` body field into a string
+ * list. Returns null when the value is not a list of strings.
+ *
+ * @param mixed $value The raw decoded value.
+ *
+ * @return string[]|null The extracted UUID list, or null on
+ * validation failure.
+ */
+ private function extractUuids(mixed $value): ?array
+ {
+ if (is_array($value) === false) {
+ return null;
+ }
+
+ $uuids = [];
+ foreach ($value as $item) {
+ if (is_string($item) === false) {
+ return null;
+ }
+
+ $trimmed = trim($item);
+ if ($trimmed === '') {
+ return null;
+ }
+
+ $uuids[] = $trimmed;
+ }
+
+ return $uuids;
+ }//end extractUuids()
+
+ /**
+ * Resolve a boolean parameter that may arrive either in the body
+ * (`bool`) or via the query string (`?dryRun=true`). The query
+ * string takes precedence when the body parameter is null.
+ *
+ * @param bool|null $value The body-parsed value.
+ * @param string $queryKey The query string key.
+ *
+ * @return bool The resolved boolean.
+ */
+ private function resolveBool(?bool $value, string $queryKey): bool
+ {
+ if ($value !== null) {
+ return $value;
+ }
+
+ $raw = $this->request->getParam(key: $queryKey);
+ if ($raw === null || $raw === '') {
+ return false;
+ }
+
+ $lower = strtolower((string) $raw);
+ return in_array(needle: $lower, haystack: ['1', 'true', 'yes', 'on'], strict: true);
+ }//end resolveBool()
+
+ /**
+ * Verify the current session belongs to a Nextcloud admin
+ * (REQ-BULK-011 — non-admins are rejected before any service call).
+ *
+ * @return JSONResponse|null Null when the caller is admin; the 401/403
+ * envelope otherwise.
+ */
+ private function requireAdmin(): ?JSONResponse
+ {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
+ return ResponseHelper::forbidden(
+ message: 'Administrator privileges required.'
+ );
+ }
+
+ return null;
+ }//end requireAdmin()
+}//end class
diff --git a/lib/Controller/AdminCleanupController.php b/lib/Controller/AdminCleanupController.php
new file mode 100644
index 00000000..d874cd17
--- /dev/null
+++ b/lib/Controller/AdminCleanupController.php
@@ -0,0 +1,245 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Service\Cleanup\CategoryRegistryService;
+use OCA\MyDash\Service\OrphanedDataCleanupService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUserSession;
+
+/**
+ * Admin endpoints for scan + purge.
+ */
+class AdminCleanupController extends Controller
+{
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The request.
+ * @param OrphanedDataCleanupService $cleanupService The orchestrator.
+ * @param CategoryRegistryService $registry Category registry
+ * (for unknown-name
+ * error messages).
+ * @param IGroupManager $groupManager Admin check.
+ * @param IUserSession $userSession Current user.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly OrphanedDataCleanupService $cleanupService,
+ private readonly CategoryRegistryService $registry,
+ private readonly IGroupManager $groupManager,
+ private readonly IUserSession $userSession,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * `GET /api/admin/cleanup/scan` — REQ-CLN-004.
+ *
+ * Returns a JSON envelope describing the per-category orphan
+ * counts. Reads from the distributed cache when available
+ * (REQ-CLN-010) and surfaces `cached`/`cachedAt` hints so the UI
+ * can display "last refreshed" badges.
+ *
+ * @return JSONResponse The scan result.
+ *
+ * @NoAdminRequired
+ */
+ public function scan(): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $cached = $this->cleanupService->getCachedScanResult();
+ if ($cached !== null) {
+ return new JSONResponse(
+ data: array_merge(
+ $cached->jsonSerialize(),
+ [
+ 'cached' => true,
+ 'cachedAt' => $cached->getScannedAt(),
+ ]
+ ),
+ statusCode: Http::STATUS_OK
+ );
+ }
+
+ $result = $this->cleanupService->scan();
+
+ return new JSONResponse(
+ data: array_merge(
+ $result->jsonSerialize(),
+ [
+ 'cached' => false,
+ 'cachedAt' => null,
+ ]
+ ),
+ statusCode: Http::STATUS_OK
+ );
+ }//end scan()
+
+ /**
+ * `POST /api/admin/cleanup/purge` — REQ-CLN-005.
+ *
+ * Body shape:
+ * {
+ * "categories": ["expired_locks", ...], // optional, []=all
+ * "dryRun": true|false // optional, false default
+ * }
+ *
+ * Returns the per-category breakdown plus total, duration, and
+ * dryRun flag. Unknown categories receive HTTP 400 with the list
+ * of valid names so the caller can correct the request.
+ *
+ * @param array|null $categories Per-category filter.
+ * @param bool|null $dryRun Dry-run flag.
+ *
+ * @return JSONResponse The purge result.
+ *
+ * @NoAdminRequired
+ */
+ public function purge(?array $categories=null, ?bool $dryRun=null): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $names = $this->normaliseCategories(input: ($categories ?? []));
+ if ($names === null) {
+ return new JSONResponse(
+ data: [
+ 'error' => 'Unknown cleanup category in request',
+ 'validCategories' => $this->registry->getCategoryNames(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ $userId = '';
+ $user = $this->userSession->getUser();
+ if ($user !== null) {
+ $userId = $user->getUID();
+ }
+
+ $result = $this->cleanupService->purge(
+ categoryNames: $names,
+ dryRun: ($dryRun ?? false),
+ userId: $userId,
+ source: 'api',
+ );
+
+ return new JSONResponse(
+ data: [
+ 'purgedByCategory' => $result->getByCategory(),
+ 'totalRows' => $result->getTotalRows(),
+ 'durationMs' => $result->getDurationMs(),
+ 'dryRun' => $result->isDryRun(),
+ 'skipped' => $result->getSkipped(),
+ ],
+ statusCode: Http::STATUS_OK
+ );
+ }//end purge()
+
+ /**
+ * Normalise the API-supplied categories list.
+ *
+ * Filters non-string entries silently (defence in depth — the
+ * controller is reached after framework JSON parsing) and
+ * returns `null` if any of the remaining names are not registered.
+ * An empty list is returned as `[]` (which the orchestrator
+ * treats as "all categories").
+ *
+ * @param array $input The raw input list.
+ *
+ * @return array|null The validated names or null.
+ */
+ private function normaliseCategories(array $input): ?array
+ {
+ $known = $this->registry->getCategoryNames();
+ $normalised = [];
+
+ foreach ($input as $value) {
+ if (is_string(value: $value) === false || $value === '') {
+ continue;
+ }
+
+ if (in_array(needle: $value, haystack: $known, strict: true) === false) {
+ return null;
+ }
+
+ $normalised[] = $value;
+ }
+
+ return $normalised;
+ }//end normaliseCategories()
+
+ /**
+ * Verify the caller is an authenticated Nextcloud admin.
+ *
+ * Returns `null` to signal "proceed". Returns a 403 JSON envelope
+ * for unauthenticated and non-admin callers — see class docblock
+ * for the rationale of not distinguishing the two.
+ *
+ * @return JSONResponse|null Null when admin, else 403.
+ */
+ private function requireAdmin(): ?JSONResponse
+ {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(
+ data: ['error' => 'Administrator privileges required.'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
+ return new JSONResponse(
+ data: ['error' => 'Administrator privileges required.'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ return null;
+ }//end requireAdmin()
+}//end class
diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php
index 3bc7114e..8ef1daa4 100644
--- a/lib/Controller/AdminController.php
+++ b/lib/Controller/AdminController.php
@@ -21,28 +21,98 @@
use InvalidArgumentException;
use OCA\MyDash\AppInfo\Application;
use OCA\MyDash\Db\Dashboard;
-use OCA\MyDash\Service\AdminTemplateService;
+use OCA\MyDash\Exception\DuplicateRoleAssignmentException;
+use OCA\MyDash\Exception\InvalidRoleAssignmentException;
+use OCA\MyDash\Exception\ResourceException;
use OCA\MyDash\Service\AdminSettingsService;
+use OCA\MyDash\Service\AdminTemplateService;
+use OCA\MyDash\Service\ExportService;
+use OCA\MyDash\Service\FeedRefreshService;
+use OCA\MyDash\Service\FooterService;
+use OCA\MyDash\Service\ImportService;
+use OCA\MyDash\Service\ResourceService;
+use OCA\MyDash\Service\RoleService;
+use OCA\MyDash\Service\SetupWizardService;
use OCP\AppFramework\Controller;
+use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
+use OCP\AppFramework\Http\StreamResponse;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IUserSession;
/**
- * Controller for admin dashboard template management.
+ * Controller for admin dashboard template management plus role-assignment
+ * CRUD (REQ-ROLE-004, REQ-ROLE-006) and feed-refresh admin actions.
+ *
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods) The admin surface
+ * legitimately covers
+ * templates, settings,
+ * group-priority order,
+ * role assignments,
+ * export/import,
+ * feed-refresh, footer
+ * customization,
+ * template-preview-image
+ * upload (REQ-TMPL-017),
+ * setup wizard
+ * (REQ-WIZ-008..009),
+ * and the self-introspection
+ * endpoint in one
+ * cohesive admin namespace.
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Admin coordination
+ * spans multiple
+ * injected services
+ * (templates, settings,
+ * roles, export, import,
+ * feed refresh, footer,
+ * resource uploads,
+ * setup wizard,
+ * helpers) by design —
+ * splitting the controller
+ * would fragment the routing
+ * surface.
+ * @SuppressWarnings(PHPMD.Superglobals) $_FILES is the only
+ * multipart entry point.
+ * @SuppressWarnings(PHPMD.LongVariable) `footerBackgroundColor`
+ * / `footerTextColor` mirror
+ * the API contract field
+ * names verbatim — shortening
+ * would diverge from the
+ * documented payload.
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Complexity is a function
+ * of the number of guarded
+ * admin endpoints, not
+ * nested branching inside
+ * any single method.
*/
class AdminController extends Controller
{
/**
* Constructor
*
- * @param IRequest $request The request.
- * @param AdminTemplateService $templateService The admin template service.
- * @param AdminSettingsService $settingsService The admin settings service.
- * @param IGroupManager $groupManager The Nextcloud group manager.
- * @param IUserSession $userSession The current user session.
+ * @param IRequest $request The request.
+ * @param AdminTemplateService $templateService The admin template service.
+ * @param AdminSettingsService $settingsService The admin settings service.
+ * @param IGroupManager $groupManager The Nextcloud group manager.
+ * @param IUserSession $userSession The current user session.
+ * @param ExportService $exportService ZIP export service
+ * (REQ-EXIM-001..003).
+ * @param ImportService $importService ZIP import service
+ * (REQ-EXIM-004..008).
+ * @param RoleService $roleService The MyDash role service
+ * (REQ-ROLE-001..011).
+ * @param FeedRefreshService $feedRefresh The background feed
+ * refresh service used by
+ * the on-demand admin
+ * `refreshFeeds` action
+ * (REQ-BGJOB-FEED-005).
+ * @param FooterService $footerService Global footer settings + sanitiser
+ * (REQ-FTR-001..010).
+ * @param SetupWizardService $setupWizardService Setup-wizard
+ * orchestrator
+ * (REQ-WIZ-001..011).
*/
public function __construct(
IRequest $request,
@@ -50,6 +120,12 @@ public function __construct(
private readonly AdminSettingsService $settingsService,
private readonly IGroupManager $groupManager,
private readonly IUserSession $userSession,
+ private readonly ExportService $exportService,
+ private readonly ImportService $importService,
+ private readonly RoleService $roleService,
+ private readonly FeedRefreshService $feedRefresh,
+ private readonly FooterService $footerService,
+ private readonly SetupWizardService $setupWizardService,
) {
parent::__construct(
appName: Application::APP_ID,
@@ -61,6 +137,8 @@ public function __construct(
* List all admin dashboard templates.
*
* @return JSONResponse The list of templates.
+ *
+ * @spec admin-templates:REQ-TMPL-002
*/
public function listTemplates(): JSONResponse
{
@@ -112,6 +190,8 @@ public function getTemplate(int $id): JSONResponse
* @param bool $isDefault Whether default.
*
* @return JSONResponse The created template.
+ *
+ * @spec admin-templates:REQ-TMPL-001
*/
public function createTemplate(
string $name,
@@ -150,6 +230,8 @@ public function createTemplate(
* @param int|null $gridColumns The grid columns.
*
* @return JSONResponse The updated template.
+ *
+ * @spec admin-templates:REQ-TMPL-003
*/
public function updateTemplate(
int $id,
@@ -189,6 +271,8 @@ public function updateTemplate(
* @param int $id The template ID.
*
* @return JSONResponse The deletion confirmation.
+ *
+ * @spec admin-templates:REQ-TMPL-004
*/
public function deleteTemplate(int $id): JSONResponse
{
@@ -205,6 +289,8 @@ public function deleteTemplate(int $id): JSONResponse
* Get admin settings.
*
* @return JSONResponse The admin settings.
+ *
+ * @spec admin-settings:REQ-ASET-001
*/
public function getSettings(): JSONResponse
{
@@ -216,25 +302,32 @@ public function getSettings(): JSONResponse
/**
* Update admin settings.
*
- * @param string|null $defaultPermLevel Default permission level.
- * @param bool|null $allowUserDash Allow user dashboards.
- * @param bool|null $allowMultiDash Allow multiple dashboards.
- * @param int|null $defaultGridCols Default grid columns.
+ * @param string|null $defaultPermLevel Default permission level.
+ * @param bool|null $allowUserDash Allow user dashboards.
+ * @param bool|null $allowMultiDash Allow multiple dashboards.
+ * @param int|null $defaultGridCols Default grid columns.
+ * @param array|null $linkCreateFileExts link-button-widget createFile
+ * extension allow-list
+ * (REQ-LBN-004).
*
* @return JSONResponse The update confirmation.
+ *
+ * @spec admin-settings:REQ-ASET-002
*/
public function updateSettings(
?string $defaultPermLevel=null,
?bool $allowUserDash=null,
?bool $allowMultiDash=null,
- ?int $defaultGridCols=null
+ ?int $defaultGridCols=null,
+ ?array $linkCreateFileExts=null
): JSONResponse {
try {
$this->settingsService->updateSettings(
defaultPermLevel: $defaultPermLevel,
allowUserDash: $allowUserDash,
allowMultiDash: $allowMultiDash,
- defaultGridCols: $defaultGridCols
+ defaultGridCols: $defaultGridCols,
+ linkCreateFileExts: $linkCreateFileExts
);
return ResponseHelper::success(data: ['status' => 'ok']);
@@ -243,6 +336,98 @@ public function updateSettings(
}//end try
}//end updateSettings()
+ /**
+ * Read the global footer settings (REQ-FTR-001, REQ-FTR-010).
+ *
+ * Returns the five footer keys as a flat camelCase object so the
+ * admin UI can render the form with one round-trip. Admin-only —
+ * non-admins receive HTTP 403 because even the read path discloses
+ * potentially-sensitive draft footer copy.
+ *
+ * @return JSONResponse The settings object, or 401/403 on guard failure.
+ *
+ * @NoAdminRequired
+ */
+ public function getFooterSettings(): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ return ResponseHelper::success(
+ data: $this->footerService->getGlobalSettings()
+ );
+ }//end getFooterSettings()
+
+ /**
+ * Patch the global footer settings (REQ-FTR-001..003, REQ-FTR-009,
+ * REQ-FTR-010).
+ *
+ * Body: any subset of `{footerEnabled, footerHtml, footerConfig,
+ * footerBackgroundColor, footerTextColor}`. The service sanitises
+ * HTML, validates the structured-config schema, and validates hex
+ * colour strings before persistence. Validation failures map to
+ * HTTP 400 (or 413 when the HTML exceeds the 8 KB cap).
+ *
+ * @param bool|null $footerEnabled Master toggle.
+ * @param string|array|null $footerHtml Raw HTML or
+ * language-variant
+ * map.
+ * @param array|null $footerConfig Structured config.
+ * @param string|null $footerBackgroundColor Hex (#rrggbb) or null.
+ * @param string|null $footerTextColor Hex (#rrggbb) or null.
+ *
+ * @return JSONResponse Status 200 on success, 400/413 on validation,
+ * 401/403 on guard failure.
+ *
+ * @NoAdminRequired
+ */
+ public function updateFooterSettings(
+ ?bool $footerEnabled=null,
+ mixed $footerHtml=null,
+ ?array $footerConfig=null,
+ ?string $footerBackgroundColor=null,
+ ?string $footerTextColor=null
+ ): JSONResponse {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ // Build the patch from only those args that the caller actually
+ // supplied — `array_key_exists` semantics on the body let admins
+ // explicitly clear a colour by sending `null`.
+ $body = $this->request->getParams();
+ $patch = [];
+ foreach (['footerEnabled', 'footerHtml', 'footerConfig', 'footerBackgroundColor', 'footerTextColor'] as $key) {
+ if (array_key_exists(key: $key, array: $body) === true) {
+ $patch[$key] = $body[$key];
+ }
+ }
+
+ try {
+ $this->footerService->updateGlobalSettings(patch: $patch);
+ } catch (InvalidArgumentException $e) {
+ $isOversize = str_contains(
+ haystack: $e->getMessage(),
+ needle: '8 KB limit'
+ );
+ if ($isOversize === true) {
+ $status = Http::STATUS_REQUEST_ENTITY_TOO_LARGE;
+ } else {
+ $status = Http::STATUS_BAD_REQUEST;
+ }
+
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: $status
+ );
+ }
+
+ return ResponseHelper::success(data: ['status' => 'ok']);
+ }//end updateFooterSettings()
+
/**
* List all Nextcloud groups partitioned for the admin UI (REQ-ASET-013).
*
@@ -371,6 +556,502 @@ public function updateGroupOrder(mixed $groups=null): JSONResponse
);
}//end updateGroupOrder()
+ /**
+ * Export a single dashboard or the entire site as a ZIP archive.
+ *
+ * Implements REQ-EXIM-002 (single-dashboard export) and REQ-EXIM-003
+ * (site export). Admin-only — non-admins receive HTTP 403.
+ *
+ * Query parameters:
+ * - `scope` (string, required): `dashboard` or `site`.
+ * - `dashboardUuid` (string, required when scope=dashboard).
+ *
+ * @param string $scope The export scope.
+ * @param string|null $dashboardUuid The dashboard UUID for scope=dashboard.
+ *
+ * @return StreamResponse|JSONResponse The streamed ZIP, or a JSON error.
+ *
+ * @NoAdminRequired
+ * @NoCSRFRequired
+ */
+ public function export(
+ string $scope='site',
+ ?string $dashboardUuid=null
+ ): StreamResponse|JSONResponse {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ if (in_array(needle: $scope, haystack: ['site', 'dashboard'], strict: true) === false) {
+ return new JSONResponse(
+ data: ['error' => 'Unsupported scope: '.$scope],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ $userId = (string) $this->userSession->getUser()?->getUID();
+
+ if ($scope === 'site') {
+ return $this->exportService->exportSite(currentUserId: $userId);
+ }
+
+ if ($dashboardUuid === null || $dashboardUuid === '') {
+ return new JSONResponse(
+ data: ['error' => 'dashboardUuid parameter is required when scope=dashboard'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ if (preg_match(pattern: '/^[A-Za-z0-9\-]{8,}$/', subject: $dashboardUuid) !== 1) {
+ return new JSONResponse(
+ data: ['error' => 'Invalid dashboard UUID format'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ try {
+ return $this->exportService->exportDashboard(
+ dashboardUuid: $dashboardUuid,
+ currentUserId: $userId
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+ }//end export()
+
+ /**
+ * Import a previously-exported ZIP archive.
+ *
+ * Implements REQ-EXIM-004..008. Admin-only.
+ *
+ * Multipart body: a `file` field containing the ZIP archive.
+ * Query parameter: `preserveUuids` (default false).
+ *
+ * @param bool $preserveUuids When true, fail on UUID collision.
+ *
+ * @return JSONResponse The import summary, or an error response.
+ *
+ * @NoAdminRequired
+ * @NoCSRFRequired
+ */
+ public function import(bool $preserveUuids=false): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ // Multipart uploads bind to $_FILES; PHP only populates this for
+ // POST requests, which is what the route declares (REQ-EXIM-004).
+ $upload = $_FILES['file'] ?? null;
+ if (is_array($upload) === false
+ || isset($upload['tmp_name']) === false
+ || (string) $upload['tmp_name'] === ''
+ ) {
+ return new JSONResponse(
+ data: ['error' => 'No file uploaded under field "file".'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ $tmpName = (string) $upload['tmp_name'];
+
+ $userId = (string) $this->userSession->getUser()?->getUID();
+
+ try {
+ $result = $this->importService->import(
+ zipPath: $tmpName,
+ preserveUuids: $preserveUuids,
+ currentUserId: $userId
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ if ($result['status'] === ImportService::ERR_UUID_COLLISION) {
+ return new JSONResponse(
+ data: [
+ 'importedDashboardCount' => 0,
+ 'skippedDashboardCount' => 0,
+ 'errors' => $result['errors'],
+ ],
+ statusCode: Http::STATUS_CONFLICT
+ );
+ }
+
+ return ResponseHelper::success(
+ data: [
+ 'importedDashboardCount' => $result['importedDashboardCount'],
+ 'skippedDashboardCount' => $result['skippedDashboardCount'],
+ 'errors' => $result['errors'],
+ ]
+ );
+ }//end import()
+
+ /**
+ * List every role assignment in the system (REQ-ROLE-006). NC-admin only.
+ *
+ * Returns a JSON array of role-assignment rows with their persisted
+ * fields (id, userId, groupId, role, assignedBy, assignedAt). The
+ * caller MUST be a Nextcloud admin; non-admins receive HTTP 403.
+ *
+ * @return JSONResponse The list of role assignments, or 401/403.
+ *
+ * @NoAdminRequired
+ */
+ public function listRoles(): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $assignments = $this->roleService->listAssignments();
+
+ return ResponseHelper::success(
+ data: ResponseHelper::serializeList(entities: $assignments)
+ );
+ }//end listRoles()
+
+ /**
+ * Create a new role assignment (REQ-ROLE-004). NC-admin only.
+ *
+ * Accepts a JSON body `{userId?: string, groupId?: string, role: string}`.
+ * Exactly one of `userId` / `groupId` MUST be set. Returns the new
+ * assignment with HTTP 201 on success. Returns 400 on structural
+ * failure, 409 on duplicate, 401/403 on auth failure.
+ *
+ * @param string|null $userId The target user ID (XOR with groupId).
+ * @param string|null $groupId The target group ID (XOR with userId).
+ * @param string|null $role The role name (admin / editor / viewer).
+ *
+ * @return JSONResponse The created assignment, or an error envelope.
+ *
+ * @NoAdminRequired
+ */
+ public function createRole(
+ ?string $userId=null,
+ ?string $groupId=null,
+ ?string $role=null
+ ): JSONResponse {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $assignedBy = (string) $this->userSession->getUser()->getUID();
+
+ try {
+ $assignment = $this->roleService->assignRole(
+ userId: $userId,
+ groupId: $groupId,
+ role: (string) $role,
+ assignedBy: $assignedBy
+ );
+ } catch (DuplicateRoleAssignmentException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getDisplayMessage(),
+ 'errorCode' => $e->getErrorCode(),
+ ],
+ statusCode: Http::STATUS_CONFLICT
+ );
+ } catch (InvalidRoleAssignmentException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getDisplayMessage(),
+ 'errorCode' => $e->getErrorCode(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }//end try
+
+ return ResponseHelper::success(
+ data: $assignment->jsonSerialize(),
+ statusCode: Http::STATUS_CREATED
+ );
+ }//end createRole()
+
+ /**
+ * Delete a role assignment by ID (REQ-ROLE-004). NC-admin only.
+ *
+ * Returns 204 on success, 404 when no row matches, 401/403 on auth.
+ *
+ * @param int $id The role assignment ID.
+ *
+ * @return JSONResponse Empty success or error envelope.
+ *
+ * @NoAdminRequired
+ */
+ public function deleteRole(int $id): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ try {
+ $this->roleService->removeRole(id: $id);
+ } catch (DoesNotExistException) {
+ return ResponseHelper::forbidden(
+ message: 'Role assignment not found'
+ )->setStatus(status: Http::STATUS_NOT_FOUND);
+ }
+
+ return new JSONResponse(
+ data: [],
+ statusCode: Http::STATUS_NO_CONTENT
+ );
+ }//end deleteRole()
+
+ /**
+ * Return the calling user's effective MyDash role and source
+ * (REQ-ROLE-006). Available to any authenticated user.
+ *
+ * Response shape: `{role: string|null, source: string|null}`.
+ *
+ * @return JSONResponse The role / source envelope, or 401.
+ *
+ * @NoAdminRequired
+ */
+ public function getMyRole(): JSONResponse
+ {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $userId = (string) $user->getUID();
+
+ return ResponseHelper::success(
+ data: [
+ 'role' => $this->roleService->getEffectiveRole(userId: $userId),
+ 'source' => $this->roleService->getRoleSource(userId: $userId),
+ ]
+ );
+ }//end getMyRole()
+
+ /**
+ * Trigger an immediate background feed refresh (REQ-FRJ-010).
+ *
+ * Admin-only — guarded by {@see self::requireAdmin()}. Optionally
+ * scope the refresh to a single feed URL (must be HTTP/HTTPS).
+ * Returns `{processedCount, successCount, failureCount, durationMs}`.
+ *
+ * @param string|null $feedUrl Optional single URL to refresh.
+ *
+ * @return JSONResponse The aggregate refresh summary.
+ *
+ * @NoAdminRequired
+ */
+ public function refreshFeedsNow(?string $feedUrl=null): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ if ($feedUrl !== null && $feedUrl !== '') {
+ $scheme = strtolower(
+ string: (string) parse_url(
+ url: $feedUrl,
+ component: PHP_URL_SCHEME
+ )
+ );
+ if (in_array(needle: $scheme, haystack: ['http', 'https'], strict: true) === false) {
+ return new JSONResponse(
+ data: [
+ 'error' => 'feedUrl must use http:// or https:// scheme.',
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+ }
+
+ $summary = $this->feedRefresh->refreshAll(onlyUrl: $feedUrl);
+
+ return new JSONResponse(data: $summary, statusCode: Http::STATUS_OK);
+ }//end refreshFeedsNow()
+
+ /**
+ * `POST /api/admin/templates/{uuid}/preview-image` — admin-only
+ * preview-image upload (REQ-TMPL-017).
+ *
+ * Body (JSON): `{base64: 'data:image/;base64,'}`. The
+ * payload is delegated to {@see ResourceService::upload()} (the
+ * "custom-icon-upload pattern"); the returned URL is written to the
+ * template's `templatePreviewImage` column. Allowed image types:
+ * PNG, JPG, GIF, WebP, SVG (sanitised). Maximum decoded size: 5 MB.
+ *
+ * @param string $uuid The template UUID.
+ * @param string $base64 The base64 data URL.
+ *
+ * @return JSONResponse `{status: 'success', previewImage: '...'}`
+ * on success.
+ *
+ * @NoAdminRequired
+ * @NoCSRFRequired
+ */
+ public function uploadTemplatePreviewImage(
+ string $uuid,
+ string $base64=''
+ ): JSONResponse {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ if ($base64 === '') {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_payload',
+ 'message' => 'Field "base64" is required',
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ try {
+ $url = $this->templateService->uploadPreviewImage(
+ templateUuid: $uuid,
+ base64DataUrl: $base64
+ );
+ } catch (DoesNotExistException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ 'message' => 'Template not found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (ResourceException $e) {
+ // Catches every typed ResourceException subclass (bad data URL,
+ // disallowed image format, oversized payload, SVG sanitiser
+ // rejection, storage failure) returned by ResourceService::upload
+ // — all collapse to a single 400 envelope per REQ-TMPL-017.
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_image',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }//end try
+
+ return new JSONResponse(
+ data: [
+ 'status' => 'success',
+ 'previewImage' => $url,
+ ],
+ statusCode: Http::STATUS_OK
+ );
+ }//end uploadTemplatePreviewImage()
+
+ /**
+ * Get the setup-wizard state (REQ-WIZ-008).
+ *
+ * Admin-only — non-admins receive HTTP 403. Returns
+ * `{complete, currentRecommendedStep, stepStatuses}`.
+ *
+ * @return JSONResponse The wizard state, or 401/403.
+ *
+ * @NoAdminRequired
+ */
+ public function getWizardState(): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ return ResponseHelper::success(
+ data: $this->setupWizardService->getWizardState()
+ );
+ }//end getWizardState()
+
+ /**
+ * Mark the setup-wizard complete (REQ-WIZ-009).
+ *
+ * Idempotent — calling on a completed instance returns 200 with the
+ * same payload. Admin-only.
+ *
+ * @return JSONResponse The post-completion wizard state, or 401/403.
+ *
+ * @NoAdminRequired
+ */
+ public function completeWizard(): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ return ResponseHelper::success(
+ data: $this->setupWizardService->markWizardComplete()
+ );
+ }//end completeWizard()
+
+ /**
+ * Persist the storage backend choice from Step 2 (REQ-WIZ-003).
+ *
+ * Validates the selection and writes `mydash.content_storage`. The
+ * GroupFolder option is server-side gated by the `groupfolders` app
+ * dependency — selecting it without the app installed returns 400.
+ * Admin-only.
+ *
+ * @param string|null $storage The chosen backend.
+ *
+ * @return JSONResponse The post-write wizard state, or 400/401/403.
+ *
+ * @NoAdminRequired
+ */
+ public function setWizardStorage(?string $storage=null): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ if ($storage === null || $storage === '') {
+ return new JSONResponse(
+ data: ['error' => 'Field "storage" is required.'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ if ($storage === SetupWizardService::STORAGE_GROUPFOLDER
+ && $this->setupWizardService->hasGroupfolderApp() === false
+ ) {
+ return new JSONResponse(
+ data: ['error' => 'GroupFolder app is not installed.'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ try {
+ $this->setupWizardService->setContentStorage(value: $storage);
+ } catch (InvalidArgumentException) {
+ return new JSONResponse(
+ data: ['error' => 'Unsupported storage backend.'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ return ResponseHelper::success(
+ data: $this->setupWizardService->getWizardState()
+ );
+ }//end setWizardStorage()
+
/**
* Verify the current session belongs to a Nextcloud admin.
*
diff --git a/lib/Controller/AdminDemoShowcasesController.php b/lib/Controller/AdminDemoShowcasesController.php
new file mode 100644
index 00000000..8b03ada3
--- /dev/null
+++ b/lib/Controller/AdminDemoShowcasesController.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
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Exception\ShowcaseNotFoundException;
+use OCA\MyDash\Service\DemoShowcasesService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUserSession;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Admin endpoints for managing bundled demo showcase dashboards.
+ */
+class AdminDemoShowcasesController extends Controller
+{
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The HTTP request.
+ * @param DemoShowcasesService $showcasesSvc Showcase service.
+ * @param IGroupManager $groupManager Nextcloud group manager
+ * (admin gate).
+ * @param IUserSession $userSession Current session.
+ * @param LoggerInterface $logger PSR-3 logger for
+ * ADR-005
+ * redaction.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly DemoShowcasesService $showcasesSvc,
+ private readonly IGroupManager $groupManager,
+ private readonly IUserSession $userSession,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * List bundled showcases with installation status (REQ-DEMO-002).
+ *
+ * @return JSONResponse Showcase descriptors, or 401/403.
+ *
+ * @NoAdminRequired
+ */
+ #[NoAdminRequired]
+ public function index(): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ return ResponseHelper::success(
+ data: $this->showcasesSvc->getAvailableShowcases()
+ );
+ }//end index()
+
+ /**
+ * Install a bundled showcase (REQ-DEMO-003, REQ-DEMO-004).
+ *
+ * Always returns the dashboard UUID — when the showcase is
+ * already installed and `force` is unset, the existing UUID is
+ * returned with `alreadyInstalled: true` so callers can render an
+ * informational banner.
+ *
+ * @param string $id The showcase ID (path segment).
+ * @param string $lang Optional locale (always resolves to `nl`
+ * in v1; REQ-DEMO-007).
+ * @param bool $force Force reinstallation, removing the existing
+ * dashboard if any.
+ *
+ * @return JSONResponse The install result, or an error.
+ *
+ * @NoAdminRequired
+ */
+ #[NoAdminRequired]
+ public function install(
+ string $id,
+ string $lang='nl',
+ bool $force=false
+ ): JSONResponse {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ try {
+ $result = $this->showcasesSvc->installShowcase(
+ showcaseId: $id,
+ lang: $lang,
+ force: $force
+ );
+ } catch (ShowcaseNotFoundException $e) {
+ return new JSONResponse(
+ data: ['error' => 'Showcase not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'Showcase install failed',
+ context: [
+ 'showcaseId' => $id,
+ 'exception' => $e,
+ ]
+ );
+ return new JSONResponse(
+ data: ['error' => 'Showcase installation failed'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
+
+ $statusCode = Http::STATUS_CREATED;
+ if ($result['alreadyInstalled'] === true) {
+ $statusCode = Http::STATUS_OK;
+ }
+
+ return new JSONResponse(
+ data: [
+ 'installedDashboardUuid' => $result['installedDashboardUuid'],
+ 'skippedWidgets' => $result['skippedWidgets'],
+ 'alreadyInstalled' => $result['alreadyInstalled'],
+ ],
+ statusCode: $statusCode
+ );
+ }//end install()
+
+ /**
+ * Uninstall a previously-installed showcase (REQ-DEMO-006).
+ *
+ * Idempotent — returns 204 even when the showcase is not currently
+ * installed.
+ *
+ * @param string $id The showcase ID (path segment).
+ *
+ * @return JSONResponse Empty 204, or 401/403.
+ *
+ * @NoAdminRequired
+ */
+ #[NoAdminRequired]
+ public function destroy(string $id): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ try {
+ $this->showcasesSvc->uninstallShowcase(showcaseId: $id);
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'Showcase uninstall failed',
+ context: [
+ 'showcaseId' => $id,
+ 'exception' => $e,
+ ]
+ );
+ return new JSONResponse(
+ data: ['error' => 'Showcase uninstall failed'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }
+
+ return new JSONResponse(data: [], statusCode: Http::STATUS_NO_CONTENT);
+ }//end destroy()
+
+ /**
+ * Verify the current session belongs to a Nextcloud admin.
+ *
+ * Returns `null` when the caller is an admin (proceed). Returns a
+ * 401 JSONResponse when no user is logged in, and a 403 JSONResponse
+ * when the user is not an admin.
+ *
+ * @return JSONResponse|null The error response when the guard fails;
+ * `null` when the caller is an admin.
+ */
+ private function requireAdmin(): ?JSONResponse
+ {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
+ return ResponseHelper::forbidden(
+ message: 'Administrator privileges required.'
+ );
+ }
+
+ return null;
+ }//end requireAdmin()
+}//end class
diff --git a/lib/Controller/AdminOrgNavigationController.php b/lib/Controller/AdminOrgNavigationController.php
new file mode 100644
index 00000000..712bf073
--- /dev/null
+++ b/lib/Controller/AdminOrgNavigationController.php
@@ -0,0 +1,320 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use InvalidArgumentException;
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Db\AdminSettingMapper;
+use OCA\MyDash\Service\OrgNavigationService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUserSession;
+
+/**
+ * Org-wide navigation editor REST surface.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Service + group +
+ * session +
+ * setting mapper are
+ * each used exactly
+ * once.
+ */
+class AdminOrgNavigationController extends Controller
+{
+ /**
+ * Setting key for the global navigation rail position
+ * (REQ-ONAV-004). Stored in `mydash_admin_settings` rather than
+ * `IAppData` because it is a scalar enum, not a tree.
+ *
+ * @var string
+ */
+ public const SETTING_KEY_POSITION = 'org_navigation_position';
+
+ /**
+ * Allowed values for the position setting (REQ-ONAV-004).
+ *
+ * @var array
+ */
+ public const ALLOWED_POSITIONS = ['left', 'right', 'top', 'hidden'];
+
+ /**
+ * Default position when the setting is unset (REQ-ONAV-004).
+ *
+ * @var string
+ */
+ public const DEFAULT_POSITION = 'hidden';
+
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request Inbound request.
+ * @param OrgNavigationService $service Tree storage + filter
+ * service.
+ * @param AdminSettingMapper $settings Persistence layer for
+ * the position scalar.
+ * @param IGroupManager $groupManager Admin guard.
+ * @param IUserSession $userSession Current user session.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly OrgNavigationService $service,
+ private readonly AdminSettingMapper $settings,
+ private readonly IGroupManager $groupManager,
+ private readonly IUserSession $userSession,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * Read the org-navigation tree filtered for the current user.
+ *
+ * Accessible to any logged-in user (REQ-ONAV-002).
+ *
+ * @param string $lang Language code (defaults to `nl`).
+ *
+ * @return JSONResponse The filtered tree under `tree` plus the
+ * effective `language`.
+ *
+ * @NoAdminRequired
+ */
+ public function getOrgNavigation(string $lang=OrgNavigationService::DEFAULT_LANGUAGE): JSONResponse
+ {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $language = $this->validateLanguage(language: $lang);
+ if ($language === null) {
+ return new JSONResponse(
+ data: ['error' => 'Unsupported language'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ $tree = $this->service->getTree(language: $language);
+ $filtered = $this->service->filterTreeByUserGroups(
+ tree: $tree,
+ userId: $user->getUID()
+ );
+
+ return ResponseHelper::success(
+ data: [
+ 'tree' => $filtered,
+ 'language' => $language,
+ ]
+ );
+ }//end getOrgNavigation()
+
+ /**
+ * Replace the org-navigation tree for the given language.
+ *
+ * Admin-only (REQ-ONAV-003); validates and persists in one go.
+ *
+ * @param array|null $tree The full replacement tree.
+ * @param string $lang Language code (defaults to `nl`).
+ *
+ * @return JSONResponse The persisted tree (unchanged) on success.
+ *
+ * @NoAdminRequired
+ */
+ public function updateOrgNavigation(
+ ?array $tree=null,
+ string $lang=OrgNavigationService::DEFAULT_LANGUAGE
+ ): JSONResponse {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $language = $this->validateLanguage(language: $lang);
+ if ($language === null) {
+ return new JSONResponse(
+ data: ['error' => 'Unsupported language'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ if (is_array($tree) === false) {
+ return new JSONResponse(
+ data: ['error' => 'tree must be an array of node objects'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ try {
+ $this->service->setTree(tree: $tree, language: $language);
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ return ResponseHelper::success(
+ data: [
+ 'tree' => $tree,
+ 'language' => $language,
+ ]
+ );
+ }//end updateOrgNavigation()
+
+ /**
+ * Read the global rail-position setting (REQ-ONAV-004).
+ *
+ * @return JSONResponse The current effective position.
+ *
+ * @NoAdminRequired
+ */
+ public function getPosition(): JSONResponse
+ {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ return ResponseHelper::success(
+ data: ['position' => $this->readPosition()]
+ );
+ }//end getPosition()
+
+ /**
+ * Replace the global rail-position setting (REQ-ONAV-004).
+ *
+ * Admin-only. Accepts `{position: 'left'|'right'|'top'|'hidden'}`.
+ *
+ * @param string|null $position The desired position.
+ *
+ * @return JSONResponse The persisted position on success.
+ *
+ * @NoAdminRequired
+ */
+ public function updatePosition(?string $position=null): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ if ($position === null
+ || in_array(needle: $position, haystack: self::ALLOWED_POSITIONS, strict: true) === false
+ ) {
+ return new JSONResponse(
+ data: ['error' => 'position must be one of: '.implode(separator: ', ', array: self::ALLOWED_POSITIONS)],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ $this->settings->setSetting(
+ key: self::SETTING_KEY_POSITION,
+ value: $position
+ );
+
+ return ResponseHelper::success(
+ data: ['position' => $position]
+ );
+ }//end updatePosition()
+
+ /**
+ * Validate that a language code is one MyDash supports in v1.
+ *
+ * @param string $language The candidate language code.
+ *
+ * @return string|null The normalised language code, or `null`
+ * when the value is not supported.
+ */
+ private function validateLanguage(string $language): ?string
+ {
+ $lower = strtolower(string: trim(string: $language));
+ if (in_array(
+ needle: $lower,
+ haystack: OrgNavigationService::SUPPORTED_LANGUAGES,
+ strict: true
+ ) === false
+ ) {
+ return null;
+ }
+
+ return $lower;
+ }//end validateLanguage()
+
+ /**
+ * Read the persisted position with a default fallback.
+ *
+ * @return string Always one of {@see self::ALLOWED_POSITIONS}.
+ */
+ private function readPosition(): string
+ {
+ $raw = $this->settings->getValue(
+ key: self::SETTING_KEY_POSITION,
+ default: self::DEFAULT_POSITION
+ );
+
+ if (is_string($raw) === false
+ || in_array(needle: $raw, haystack: self::ALLOWED_POSITIONS, strict: true) === false
+ ) {
+ return self::DEFAULT_POSITION;
+ }
+
+ return $raw;
+ }//end readPosition()
+
+ /**
+ * Verify the current session belongs to a Nextcloud admin.
+ *
+ * @return JSONResponse|null `null` when the caller is an admin;
+ * a 401/403 response otherwise.
+ */
+ private function requireAdmin(): ?JSONResponse
+ {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
+ return ResponseHelper::forbidden(
+ message: 'Administrator privileges required.'
+ );
+ }
+
+ return null;
+ }//end requireAdmin()
+}//end class
diff --git a/lib/Controller/AdminSettingsController.php b/lib/Controller/AdminSettingsController.php
new file mode 100644
index 00000000..84793898
--- /dev/null
+++ b/lib/Controller/AdminSettingsController.php
@@ -0,0 +1,209 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use InvalidArgumentException;
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Service\AdminSettingsService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUserSession;
+
+/**
+ * Admin-only controller for the group-priority order setting.
+ */
+class AdminSettingsController extends Controller
+{
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The HTTP request.
+ * @param AdminSettingsService $settingsService Persisted-settings service.
+ * @param IGroupManager $groupManager Group manager (admin check + listing).
+ * @param IUserSession $userSession Active session accessor.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly AdminSettingsService $settingsService,
+ private readonly IGroupManager $groupManager,
+ private readonly IUserSession $userSession,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * Handle `GET /api/admin/groups` — REQ-ASET-013.
+ *
+ * Returns the disjoint exhaustive split `{active, inactive, allKnown}`:
+ * - `active` — the persisted `group_order` list, in admin-chosen
+ * order. Stale IDs (no longer in Nextcloud) remain so admin can
+ * see and remove them.
+ * - `inactive` — every Nextcloud group ID NOT in `active`, sorted
+ * by displayName (case-insensitive).
+ * - `allKnown` — full `{id, displayName}` list for the UI to render
+ * display names without a second round-trip. Stale IDs MUST NOT
+ * appear here (no display name available).
+ *
+ * @return JSONResponse Either the success payload or HTTP 403 when
+ * the caller is not an administrator.
+ */
+ public function listGroups(): JSONResponse
+ {
+ $forbidden = $this->assertAdmin();
+ if ($forbidden !== null) {
+ return $forbidden;
+ }
+
+ $allKnown = [];
+ $allKnownIds = [];
+ foreach ($this->groupManager->search(search: '') as $group) {
+ $id = $group->getGID();
+ $allKnownIds[] = $id;
+ $allKnown[] = [
+ 'id' => $id,
+ 'displayName' => $group->getDisplayName(),
+ ];
+ }
+
+ $active = $this->settingsService->getGroupOrder();
+
+ // `inactive` = allKnown - active (stale active IDs MUST NOT
+ // appear in inactive — REQ-ASET-013 disjoint scenario).
+ $activeSet = array_flip(array: $active);
+ $inactive = [];
+ foreach ($allKnownIds as $id) {
+ if (array_key_exists(key: $id, array: $activeSet) === false) {
+ $inactive[] = $id;
+ }
+ }
+
+ // Sort `inactive` by displayName (case-insensitive). Build a
+ // lookup so stable sort by name is cheap.
+ $displayNameById = [];
+ foreach ($allKnown as $row) {
+ $displayNameById[$row['id']] = $row['displayName'];
+ }
+
+ usort(
+ array: $inactive,
+ callback: static function (string $aId, string $bId) use ($displayNameById): int {
+ $aName = strtolower(string: $displayNameById[$aId] ?? $aId);
+ $bName = strtolower(string: $displayNameById[$bId] ?? $bId);
+ return strcmp(string1: $aName, string2: $bName);
+ }
+ );
+
+ return ResponseHelper::success(
+ data: [
+ 'active' => $active,
+ 'inactive' => $inactive,
+ 'allKnown' => $allKnown,
+ ]
+ );
+ }//end listGroups()
+
+ /**
+ * Handle `POST /api/admin/groups` — REQ-ASET-012, REQ-ASET-014.
+ *
+ * Body: `{"groups": ["id", ...]}`. Replaces the persisted
+ * `group_order` setting wholesale. Validation:
+ * - `groups` MUST be present and an array.
+ * - Every element MUST be a non-empty string.
+ * - Duplicate IDs are deduplicated (first occurrence kept).
+ * - Unknown (not currently in Nextcloud) IDs are tolerated — they
+ * remain in the persisted setting per REQ-ASET-014.
+ *
+ * @param mixed $groups The raw `groups` payload from the request body.
+ *
+ * @return JSONResponse HTTP 200 with `{status: 'ok'}` on success,
+ * 400 on validation failure, 403 for non-admins.
+ */
+ public function updateGroupOrder(mixed $groups=null): JSONResponse
+ {
+ $forbidden = $this->assertAdmin();
+ if ($forbidden !== null) {
+ return $forbidden;
+ }
+
+ if (is_array($groups) === false) {
+ return new JSONResponse(
+ data: ['error' => 'groups must be an array'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ try {
+ $this->settingsService->setGroupOrder(groupIds: $groups);
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ return ResponseHelper::success(data: ['status' => 'ok']);
+ }//end updateGroupOrder()
+
+ /**
+ * Assert that the active session belongs to an administrator.
+ *
+ * Both endpoints are admin-only because the inactive list reveals
+ * every group on the system (REQ-ASET-014). The base controller
+ * routing already requires authentication; this guard only adds the
+ * admin check on top.
+ *
+ * @return JSONResponse|null `null` when the caller is an admin, or
+ * a 403 response that the calling action
+ * should return verbatim.
+ */
+ private function assertAdmin(): ?JSONResponse
+ {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return ResponseHelper::forbidden();
+ }
+
+ if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
+ return ResponseHelper::forbidden();
+ }
+
+ return null;
+ }//end assertAdmin()
+}//end class
diff --git a/lib/Controller/AnalyticsController.php b/lib/Controller/AnalyticsController.php
new file mode 100644
index 00000000..6429af5e
--- /dev/null
+++ b/lib/Controller/AnalyticsController.php
@@ -0,0 +1,256 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use InvalidArgumentException;
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Service\AnalyticsService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\DataDownloadResponse;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\AppFramework\Http\Response;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUserSession;
+
+/**
+ * Admin-only controller for dashboard view-analytics endpoints.
+ */
+class AnalyticsController extends Controller
+{
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The HTTP request.
+ * @param AnalyticsService $analyticsService The analytics
+ * reporting service.
+ * @param IGroupManager $groupManager Nextcloud group
+ * manager (admin guard).
+ * @param IUserSession $userSession Active session
+ * accessor.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly AnalyticsService $analyticsService,
+ private readonly IGroupManager $groupManager,
+ private readonly IUserSession $userSession,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * Handle `GET /api/admin/analytics/dashboards/top` (REQ-ANLT-006).
+ *
+ * @param string $period The period string (`7d`, `30d`, `90d`).
+ * @param int $limit Maximum rows.
+ *
+ * @return JSONResponse The response.
+ */
+ #[NoAdminRequired]
+ public function topDashboards(
+ string $period='30d',
+ int $limit=10
+ ): JSONResponse {
+ $forbidden = $this->assertAdmin();
+ if ($forbidden !== null) {
+ return $forbidden;
+ }
+
+ try {
+ $rows = $this->analyticsService->getTopDashboards(
+ period: $period,
+ limit: $limit
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_period',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ return ResponseHelper::success(data: $rows);
+ }//end topDashboards()
+
+ /**
+ * Handle `GET /api/admin/analytics/dashboards/{uuid}`
+ * (REQ-ANLT-007).
+ *
+ * @param string $uuid The dashboard UUID from the URL.
+ * @param string $period The period string.
+ *
+ * @return JSONResponse The response.
+ */
+ #[NoAdminRequired]
+ public function dashboardDetail(
+ string $uuid,
+ string $period='30d'
+ ): JSONResponse {
+ $forbidden = $this->assertAdmin();
+ if ($forbidden !== null) {
+ return $forbidden;
+ }
+
+ try {
+ $rows = $this->analyticsService->getDashboardDetail(
+ dashboardUuid: $uuid,
+ period: $period
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_period',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }//end try
+
+ return ResponseHelper::success(data: $rows);
+ }//end dashboardDetail()
+
+ /**
+ * Handle `GET /api/admin/analytics/summary` (REQ-ANLT-008).
+ *
+ * @param string $period The period string.
+ *
+ * @return JSONResponse The response.
+ */
+ #[NoAdminRequired]
+ public function instanceSummary(string $period='30d'): JSONResponse
+ {
+ $forbidden = $this->assertAdmin();
+ if ($forbidden !== null) {
+ return $forbidden;
+ }
+
+ try {
+ $summary = $this->analyticsService->getInstanceSummary(
+ period: $period
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_period',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ return ResponseHelper::success(data: $summary);
+ }//end instanceSummary()
+
+ /**
+ * Handle `GET /api/admin/analytics/export` (REQ-ANLT-010).
+ *
+ * Returns a `text/csv` attachment with the filename
+ * `dashboard-analytics-YYYY-MM-DD.csv` (today's UTC date).
+ *
+ * @param string $period The period string.
+ *
+ * @return Response The CSV download response or a JSON error
+ * envelope.
+ */
+ #[NoAdminRequired]
+ public function exportCsv(string $period='30d'): Response
+ {
+ $forbidden = $this->assertAdmin();
+ if ($forbidden !== null) {
+ return $forbidden;
+ }
+
+ try {
+ $csv = $this->analyticsService->generateCsvExport(
+ period: $period
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_period',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ return new DataDownloadResponse(
+ data: $csv,
+ filename: $this->analyticsService->csvExportFilename(),
+ contentType: 'text/csv'
+ );
+ }//end exportCsv()
+
+ /**
+ * Assert that the active session belongs to an administrator.
+ *
+ * @return JSONResponse|null `null` when the caller is admin, or
+ * a 403 response that the calling
+ * action should return verbatim.
+ */
+ private function assertAdmin(): ?JSONResponse
+ {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return ResponseHelper::forbidden();
+ }
+
+ if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
+ return ResponseHelper::forbidden();
+ }
+
+ return null;
+ }//end assertAdmin()
+}//end class
diff --git a/lib/Controller/ConfluenceImportController.php b/lib/Controller/ConfluenceImportController.php
new file mode 100644
index 00000000..d24b0130
--- /dev/null
+++ b/lib/Controller/ConfluenceImportController.php
@@ -0,0 +1,194 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use InvalidArgumentException;
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Service\ConfluenceImportService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUserSession;
+use Throwable;
+
+/**
+ * Admin-only Confluence import endpoints.
+ *
+ * @SuppressWarnings(PHPMD.Superglobals)
+ * `$_FILES` is the only multipart entry point under Nextcloud.
+ */
+class ConfluenceImportController extends Controller
+{
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request Request handle.
+ * @param ConfluenceImportService $importService The import orchestrator.
+ * @param IGroupManager $groupManager Group manager (admin guard).
+ * @param IUserSession $userSession Current session.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly ConfluenceImportService $importService,
+ private readonly IGroupManager $groupManager,
+ private readonly IUserSession $userSession,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * `POST /api/admin/import/confluence/dry-run` — REQ-CFLI-007.
+ *
+ * @return JSONResponse The dry-run preview, or an error response.
+ *
+ * @NoAdminRequired
+ * @NoCSRFRequired
+ */
+ public function dryRun(): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $tmpName = $this->resolveUpload();
+ if ($tmpName instanceof JSONResponse) {
+ return $tmpName;
+ }
+
+ try {
+ $result = $this->importService->dryRun(zipPath: $tmpName);
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ } catch (Throwable $e) {
+ return new JSONResponse(
+ data: ['error' => 'Confluence dry-run failed: '.$e->getMessage()],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }
+
+ return new JSONResponse(data: $result);
+ }//end dryRun()
+
+ /**
+ * `POST /api/admin/import/confluence` — REQ-CFLI-001..006, 009, 012.
+ *
+ * @param string|null $parentUuid Optional parent dashboard UUID
+ * under which root pages will be slotted.
+ *
+ * @return JSONResponse The import summary, or an error response.
+ *
+ * @NoAdminRequired
+ * @NoCSRFRequired
+ */
+ public function import(?string $parentUuid=null): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $tmpName = $this->resolveUpload();
+ if ($tmpName instanceof JSONResponse) {
+ return $tmpName;
+ }
+
+ $userId = (string) $this->userSession->getUser()?->getUID();
+
+ $resolvedParent = null;
+ if ($parentUuid !== null && $parentUuid !== '') {
+ $resolvedParent = $parentUuid;
+ }
+
+ try {
+ $result = $this->importService->import(
+ zipPath: $tmpName,
+ currentUserId: $userId,
+ parentUuid: $resolvedParent
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ } catch (Throwable $e) {
+ return new JSONResponse(
+ data: ['error' => 'Confluence import failed: '.$e->getMessage()],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }
+
+ return new JSONResponse(data: $result);
+ }//end import()
+
+ /**
+ * Locate and validate the uploaded ZIP, returning its tmp path.
+ *
+ * @return string|JSONResponse Either the tmp path or a 400 response.
+ */
+ private function resolveUpload(): string|JSONResponse
+ {
+ $upload = $_FILES['file'] ?? null;
+ if (is_array($upload) === false
+ || isset($upload['tmp_name']) === false
+ || (string) $upload['tmp_name'] === ''
+ ) {
+ return new JSONResponse(
+ data: ['error' => 'No file uploaded under field "file".'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ return (string) $upload['tmp_name'];
+ }//end resolveUpload()
+
+ /**
+ * Verify the current session belongs to a Nextcloud admin.
+ *
+ * @return JSONResponse|null Null when admin; an error response otherwise.
+ */
+ private function requireAdmin(): ?JSONResponse
+ {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
+ return ResponseHelper::forbidden(
+ message: 'Administrator privileges required.'
+ );
+ }
+
+ return null;
+ }//end requireAdmin()
+}//end class
diff --git a/lib/Controller/DashboardApiController.php b/lib/Controller/DashboardApiController.php
index 6022c8e4..dd1a1f9c 100644
--- a/lib/Controller/DashboardApiController.php
+++ b/lib/Controller/DashboardApiController.php
@@ -22,8 +22,12 @@
use InvalidArgumentException;
use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Exception\DashboardHasChildrenException;
use OCA\MyDash\Exception\PersonalDashboardsDisabledException;
+use OCA\MyDash\Service\AnalyticsService;
use OCA\MyDash\Service\DashboardService;
+use OCA\MyDash\Service\DashboardTreeService;
+use OCA\MyDash\Service\DashboardVersionService;
use OCA\MyDash\Service\PermissionService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Db\DoesNotExistException;
@@ -38,24 +42,63 @@
*
* @SuppressWarnings(PHPMD.TooManyPublicMethods)
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The dashboard API
+ * legitimately spans
+ * multiple persistence
+ * and service layers
+ * (dashboard, share,
+ * permission, factory,
+ * versions) plus
+ * personal +
+ * group-shared +
+ * resolution scopes —
+ * splitting would
+ * fragment the routing
+ * surface.
*/
class DashboardApiController extends Controller
{
/**
* Constructor
*
- * @param IRequest $request The request.
- * @param DashboardService $dashboardService The dashboard service.
- * @param PermissionService $permissionService The permission service.
- * @param LoggerInterface $logger PSR logger (used by fork
- * to report unexpected
- * errors — REQ-DASH-021).
- * @param string|null $userId The user ID.
+ * @param IRequest $request The request.
+ * @param DashboardService $dashboardService The dashboard service.
+ * @param PermissionService $permissionService The permission service.
+ * @param DashboardTreeService $treeService The tree service that
+ * owns hierarchy
+ * queries, cycle
+ * detection, slug
+ * uniqueness, path
+ * resolution, and the
+ * cascade-delete walker
+ * (REQ-DASH-023..030).
+ * @param DashboardVersionService $versionService Snapshot service
+ * (REQ-VERS-001) —
+ * automatic
+ * snapshots fire
+ * after every
+ * successful PUT
+ * via the
+ * debounced
+ * `captureSnapshot`
+ * helper.
+ * @param AnalyticsService $analyticsService The view-analytics
+ * service used by the
+ * `viewEvent` endpoint
+ * (REQ-ANLT-002).
+ * @param LoggerInterface $logger PSR logger (used by
+ * fork to report
+ * unexpected errors
+ * — REQ-DASH-021).
+ * @param string|null $userId The user ID.
*/
public function __construct(
IRequest $request,
private readonly DashboardService $dashboardService,
private readonly PermissionService $permissionService,
+ private readonly DashboardTreeService $treeService,
+ private readonly DashboardVersionService $versionService,
+ private readonly AnalyticsService $analyticsService,
private readonly LoggerInterface $logger,
private readonly ?string $userId,
) {
@@ -73,6 +116,8 @@ public function __construct(
* unioned listing.
*
* @return JSONResponse The list of dashboards.
+ *
+ * @spec dashboards:REQ-DASH-002
*/
#[NoAdminRequired]
public function list(): JSONResponse
@@ -124,6 +169,8 @@ public function visible(): JSONResponse
* Get the user's active dashboard with placements.
*
* @return JSONResponse The active dashboard data.
+ *
+ * @spec dashboards:REQ-DASH-003
*/
#[NoAdminRequired]
public function getActive(): JSONResponse
@@ -154,26 +201,120 @@ public function getActive(): JSONResponse
);
}//end getActive()
+ /**
+ * Get a single dashboard by id with its placements + permission level.
+ *
+ * Powers the front-end's `switchDashboard` flow: clicking a row in the
+ * sidebar issues `GET /api/dashboard/{id}` and the response is the
+ * same envelope shape as {@see self::getActive()}, so the store can
+ * write `activeDashboard`, `widgetPlacements`, and `permissionLevel`
+ * with no per-source branching.
+ *
+ * Returns 404 (not 403) when the dashboard exists but is not visible
+ * to the caller — this matches the `getVisibleToUser` policy and
+ * intentionally does not leak existence (REQ-DASH-020 scenario
+ * "Cannot see what you cannot read").
+ *
+ * @param int $id The dashboard ID.
+ *
+ * @return JSONResponse The dashboard envelope (200) or
+ * `{'error': 'Not found'}` (404).
+ *
+ * @spec dashboards:REQ-SWITCH-002
+ */
+ #[NoAdminRequired]
+ public function show(int $id): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $result = $this->dashboardService->getDashboardForUser(
+ dashboardId: $id,
+ userId: $this->userId
+ );
+
+ if ($result === null) {
+ return ResponseHelper::success(
+ data: ['error' => 'Not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ $dashboard = $result['dashboard'];
+ $isOwner = ($dashboard->getUserId() === $this->userId);
+ $sharedBy = null;
+ if ($isOwner === false) {
+ $sharedBy = $dashboard->getUserId();
+ }
+
+ return ResponseHelper::success(
+ data: [
+ 'dashboard' => $dashboard->jsonSerialize(),
+ 'placements' => ResponseHelper::serializeList(
+ entities: $result['placements']
+ ),
+ 'permissionLevel' => $result['permissionLevel'],
+ 'isOwner' => $isOwner,
+ 'sharedBy' => $sharedBy,
+ ]
+ );
+ }//end show()
+
/**
* Create a new dashboard.
*
* @param mixed $name The dashboard name.
* @param string|null $description The description.
+ * @param string|null $icon The icon registry key (or NULL/empty to use the default).
+ * @param string|null $parentUuid Optional parent dashboard UUID
+ * (REQ-DASH-023). NULL ⇒ root.
+ * @param string|null $slug Optional caller-supplied slug
+ * (REQ-DASH-024). NULL ⇒ derive from
+ * the name.
+ * @param int|null $sortOrder Optional sibling sort order
+ * (REQ-DASH-029). NULL ⇒ 0.
*
* @return JSONResponse The created dashboard.
+ *
+ * @spec dashboards:REQ-DASH-001
*/
#[NoAdminRequired]
public function create(
$name=null,
- ?string $description=null
+ ?string $description=null,
+ ?string $icon=null,
+ ?string $parentUuid=null,
+ ?string $slug=null,
+ ?int $sortOrder=null
): JSONResponse {
if ($this->userId === null) {
return ResponseHelper::unauthorized();
}
+ // REQ-ASET-003 (extended): admin gating runs FIRST so the response
+ // envelope is the stable `personal_dashboards_disabled` shape no
+ // matter what the request body looked like.
+ try {
+ $this->dashboardService->assertPersonalDashboardsAllowed();
+ } catch (PersonalDashboardsDisabledException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => $e->getErrorCode(),
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
$resolved = $this->resolveCreateParams(
name: $name,
- description: $description
+ description: $description,
+ icon: $icon,
+ parentUuid: $parentUuid,
+ slug: $slug,
+ sortOrder: $sortOrder
);
try {
@@ -200,16 +341,46 @@ public function create(
$dashboard = $this->dashboardService->createDashboard(
userId: $this->userId,
name: $resolved['name'],
- description: $resolved['description']
+ description: $resolved['description'],
+ icon: $resolved['icon'],
+ parentUuid: $resolved['parentUuid'],
+ slug: $resolved['slug'],
+ sortOrder: $resolved['sortOrder'],
+ seedDefaults: true
+ );
+
+ // The newly-created dashboard ships with a default widget
+ // bundle (Conduction + Sendent + Nextcloud tiles + a Files
+ // widget) seeded by the service. Returning the placements
+ // here matches the `getActive()` envelope so the store can
+ // populate `widgetPlacements` without an extra round-trip.
+ $placements = $this->dashboardService->findPlacements(
+ dashboardId: $dashboard->getId()
);
return ResponseHelper::success(
- data: ['dashboard' => $dashboard->jsonSerialize()],
+ data: [
+ 'dashboard' => $dashboard->jsonSerialize(),
+ 'placements' => ResponseHelper::serializeList(
+ entities: $placements
+ ),
+ ],
statusCode: Http::STATUS_CREATED
);
+ } catch (InvalidArgumentException $e) {
+ // REQ-DASH-023..029: parent / slug / depth / cycle violations
+ // surface as HTTP 400 with the validation message verbatim.
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_argument',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
} catch (\Exception $e) {
return ResponseHelper::error(exception: $e);
- }
+ }//end try
}//end create()
/**
@@ -219,23 +390,35 @@ public function create(
* @param string|null $name The name.
* @param string|null $description The description.
* @param array|null $placements The placements.
+ * @param string|null $icon The icon registry key, URL, or NULL to leave unchanged.
+ * @param string|null $parentUuid Optional new parent UUID (REQ-DASH-023);
+ * explicit empty string clears the
+ * parent (re-roots the dashboard).
+ * @param string|null $slug Optional new slug (REQ-DASH-024).
+ * @param int|null $sortOrder Optional new sort order (REQ-DASH-029).
*
* @return JSONResponse The updated dashboard.
+ *
+ * @spec dashboards:REQ-DASH-004
*/
#[NoAdminRequired]
public function update(
int $id,
?string $name=null,
?string $description=null,
- ?array $placements=null
+ ?array $placements=null,
+ ?string $icon=null,
+ ?string $parentUuid=null,
+ ?string $slug=null,
+ ?int $sortOrder=null
): JSONResponse {
if ($this->userId === null) {
return ResponseHelper::unauthorized();
}
- // REQ-PERM-007: Metadata-only updates (name, description) are allowed
- // for all permission levels. Widget/tile/layout changes require
- // add_only or full permission.
+ // REQ-PERM-007: Metadata-only updates (name, description, icon) are
+ // allowed for all permission levels. Widget/tile/layout changes
+ // require add_only or full permission.
$isMetadataOnly = $placements === null;
if ($isMetadataOnly === true) {
if ($this->permissionService->canEditDashboardMetadata(
@@ -259,7 +442,11 @@ public function update(
$data = $this->buildUpdateData(
name: $name,
description: $description,
- placements: $placements
+ placements: $placements,
+ icon: $icon,
+ parentUuid: $parentUuid,
+ slug: $slug,
+ sortOrder: $sortOrder
);
$dashboard = $this->dashboardService->updateDashboard(
@@ -268,9 +455,27 @@ public function update(
data: $data
);
+ // REQ-VERS-001: capture an automatic snapshot after the
+ // PUT succeeds. The version service enforces its own
+ // debounce window (60 s) so rapid drag-and-drop edits do
+ // not flood the table. Failures are swallowed so they do
+ // not surface to the dashboard PUT response.
+ $this->captureAutomaticSnapshot(dashboard: $dashboard);
+
return ResponseHelper::success(
data: ['dashboard' => $dashboard->jsonSerialize()]
);
+ } catch (InvalidArgumentException $e) {
+ // REQ-DASH-023..029: parent / slug / depth / cycle violations
+ // surface as HTTP 400 with the validation message verbatim.
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_argument',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
} catch (\Exception $e) {
return ResponseHelper::error(exception: $e);
}//end try
@@ -279,9 +484,16 @@ public function update(
/**
* Delete a dashboard.
*
+ * Honours the cascade-delete guard from REQ-DASH-030: when the
+ * dashboard has children the request MUST include `?cascade=true`
+ * (case-insensitive) — otherwise the response is HTTP 409 with the
+ * child count so the UI can surface a confirmation.
+ *
* @param int $id The dashboard ID.
*
* @return JSONResponse The deletion confirmation.
+ *
+ * @spec dashboards:REQ-DASH-005
*/
#[NoAdminRequired]
public function delete(int $id): JSONResponse
@@ -290,18 +502,148 @@ public function delete(int $id): JSONResponse
return ResponseHelper::unauthorized();
}
+ $cascade = $this->resolveCascadeFlag();
+
try {
$this->dashboardService->deleteDashboard(
dashboardId: $id,
- userId: $this->userId
+ userId: $this->userId,
+ cascade: $cascade
);
return ResponseHelper::success(data: ['status' => 'ok']);
+ } catch (DashboardHasChildrenException $e) {
+ // REQ-DASH-030: stable 409 envelope with the child count so
+ // the frontend can render "Delete N children?" before
+ // retrying with cascade=true.
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => DashboardHasChildrenException::ERROR_CODE,
+ 'message' => $e->getMessage(),
+ 'childCount' => $e->getChildCount(),
+ ],
+ statusCode: Http::STATUS_CONFLICT
+ );
} catch (\Exception $e) {
return ResponseHelper::error(exception: $e);
- }
+ }//end try
}//end delete()
+ /**
+ * GET /api/dashboards/tree — return the full nested dashboard tree
+ * (REQ-DASH-026).
+ *
+ * Each node carries `{uuid, name, slug, sortOrder, children: [...]}`.
+ * The endpoint is user-agnostic for now — the visible-to-user
+ * filter applies via REQ-DASH-013's existing endpoints; this tree is
+ * the structural view used by navigation editors and the upcoming
+ * `confluence-html-import` / `dashboard-bulk-operations` flows.
+ *
+ * @return JSONResponse The nested tree.
+ */
+ #[NoAdminRequired]
+ public function tree(): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $tree = $this->treeService->getFullTree();
+
+ return ResponseHelper::success(data: $tree);
+ }//end tree()
+
+ /**
+ * GET /api/dashboards/by-path/{path} — resolve a slug-chain path
+ * (REQ-DASH-027).
+ *
+ * Returns the matching dashboard with its computed `path` and
+ * `breadcrumbs` (REQ-DASH-025) attached. Unknown paths return 404.
+ *
+ * @param string $path The slug-joined path captured from the URL
+ * (the `{path}` placeholder is regex-allowed
+ * to include slashes — see `appinfo/routes.php`).
+ *
+ * @return JSONResponse The dashboard payload, or a 404 envelope.
+ */
+ #[NoAdminRequired]
+ public function byPath(string $path=''): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ if ($path === '') {
+ $path = (string) $this->request->getParam(key: 'path', default: '');
+ }
+
+ $dashboard = $this->treeService->resolvePath(path: $path);
+ if ($dashboard === null) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ 'message' => 'Dashboard not found at path',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ $uuid = (string) $dashboard->getUuid();
+ $serialised = $dashboard->jsonSerialize();
+ $serialised['path'] = $this->treeService->computePath(uuid: $uuid);
+ $serialised['breadcrumbs'] = $this->treeService->computeBreadcrumbs(
+ uuid: $uuid
+ );
+
+ return ResponseHelper::success(
+ data: ['dashboard' => $serialised]
+ );
+ }//end byPath()
+
+ /**
+ * GET /api/dashboards/{uuid}/path — return a dashboard's canonical
+ * slug-chain path.
+ *
+ * Used by the frontend after every sidebar switch to keep the
+ * browser URL in sync with the active dashboard. The path is the
+ * leading-slash slug-chain returned by
+ * {@see DashboardTreeService::computePath()}; an empty string means
+ * the UUID does not resolve OR the dashboard has no slug (legal —
+ * NULL slugs are simply unaddressable by path), and the frontend
+ * treats either case as "leave the URL alone".
+ *
+ * @param string $uuid Dashboard UUID captured from the URL.
+ *
+ * @return JSONResponse `{path: string}` envelope (always 200 when
+ * authorised — the empty-path case is a valid
+ * response shape the caller distinguishes
+ * client-side).
+ */
+ #[NoAdminRequired]
+ public function computePath(string $uuid=''): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ if ($uuid === '') {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'missing_uuid',
+ 'message' => 'UUID is required',
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ return ResponseHelper::success(
+ data: ['path' => $this->treeService->computePath(uuid: $uuid)]
+ );
+ }//end computePath()
+
/**
* Activate a dashboard.
*
@@ -651,20 +993,86 @@ public function setActiveDashboard(?string $uuid=null): JSONResponse
}//end setActiveDashboard()
/**
- * Fork a visible dashboard as a new personal copy.
+ * Pin (or clear) the user's EXPLICIT default-dashboard choice
+ * (wave3.7).
*
- * Creates a new `user`-type dashboard owned by the calling user,
- * deep-copying all widget placements from the source dashboard, and
- * makes it the active dashboard. Gated on `allow_user_dashboards`.
- * REQ-DASH-020..022.
+ * Distinct from {@see self::setActiveDashboard()} — this pref is
+ * only ever written when the user explicitly clicks "Set as
+ * default" on a row's cog menu, and is NOT auto-overwritten on
+ * every switch. The resolver checks it before the active pref so
+ * the pin survives across switches.
*
- * @param string $uuid The source dashboard UUID (URL parameter).
- * @param string|null $name Optional override name (JSON body field).
+ * Body shape: `{uuid: string}` — empty string clears the pin.
*
- * @return JSONResponse HTTP 201 + new dashboard on success;
- * 403 when personal dashboards are disabled;
- * 404 when the source is not visible to the user;
- * 500 on unexpected error.
+ * @param string|null $uuid The dashboard UUID, or empty string to clear.
+ *
+ * @return JSONResponse 200 `{status: 'success'}` on success; 401
+ * when the session has no user.
+ */
+ #[NoAdminRequired]
+ public function setDefaultDashboard(?string $uuid=null): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $this->dashboardService->setDefaultPreference(
+ userId: $this->userId,
+ uuid: ($uuid ?? '')
+ );
+
+ return ResponseHelper::success(data: ['status' => 'success']);
+ }//end setDefaultDashboard()
+
+ /**
+ * Read the user's EXPLICIT default-dashboard pin (wave3.7).
+ *
+ * @return JSONResponse 200 `{uuid: string}` — empty string when no
+ * pin set; 401 when the session has no user.
+ */
+ #[NoAdminRequired]
+ public function getDefaultDashboard(): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ return ResponseHelper::success(
+ data: [
+ 'uuid' => $this->dashboardService->getDefaultPreference(
+ userId: $this->userId
+ ),
+ ]
+ );
+ }//end getDefaultDashboard()
+
+ /**
+ * Fork any visible dashboard into a brand-new personal copy.
+ *
+ * REQ-DASH-020 / REQ-DASH-021 / REQ-DASH-022. Body shape:
+ * `{name?: string}` — when `name` is absent the system applies the
+ * default `t('My copy of {name}', source.name)` translated via the
+ * caller's active language.
+ *
+ * Status mapping:
+ * - HTTP 201 with the full new dashboard payload on success.
+ * - HTTP 401 when the session has no user.
+ * - HTTP 403 with stable error code `personal_dashboards_disabled`
+ * when the admin flag `allow_user_dashboards` is off — REQ-ASET-003
+ * runtime gating runs FIRST so the envelope shape is stable
+ * regardless of body contents.
+ * - HTTP 404 when the source UUID is not visible to the caller —
+ * do not leak existence (REQ-DASH-020 scenario "Cannot fork a
+ * dashboard you cannot read").
+ * - HTTP 500 when a partial-failure rollback fires — REQ-DASH-021.
+ * ADR-005: the response carries a stable error code and a generic
+ * user-facing message; the underlying exception is logged for ops.
+ *
+ * @param string $uuid The source dashboard UUID from the URL.
+ * @param string|null $name Optional explicit fork name from the body.
+ *
+ * @return JSONResponse The new dashboard payload (201) or an
+ * appropriate error envelope.
*/
#[NoAdminRequired]
public function fork(
@@ -676,7 +1084,7 @@ public function fork(
}
try {
- $dashboard = $this->dashboardService->forkAsPersonal(
+ $fork = $this->dashboardService->forkAsPersonal(
userId: $this->userId,
sourceUuid: $uuid,
name: $name
@@ -685,7 +1093,7 @@ public function fork(
return new JSONResponse(
data: [
'status' => 'success',
- 'dashboard' => $dashboard->jsonSerialize(),
+ 'dashboard' => $fork->jsonSerialize(),
],
statusCode: Http::STATUS_CREATED
);
@@ -698,7 +1106,10 @@ public function fork(
],
statusCode: Http::STATUS_FORBIDDEN
);
- } catch (DoesNotExistException $e) {
+ } catch (DoesNotExistException) {
+ // REQ-DASH-020: source not visible — 404 without leaking
+ // existence (use the canonical message rather than echoing
+ // the exception detail).
return new JSONResponse(
data: [
'status' => 'error',
@@ -707,6 +1118,8 @@ public function fork(
statusCode: Http::STATUS_NOT_FOUND
);
} catch (\Throwable $t) {
+ // REQ-DASH-021 + ADR-005: log the real cause, return a
+ // stable, generic envelope to the client.
$this->logger->error(
message: 'mydash: fork failed for user {user}: {message}',
context: [
@@ -725,31 +1138,311 @@ public function fork(
}//end try
}//end fork()
+ /**
+ * Publish a dashboard. REQ-DASH-032.
+ *
+ * Owner-or-admin gated at the service boundary; the route attribute
+ * is `#[NoAdminRequired]` because the in-body owner check is the
+ * actual authorization point (gate-semantic-auth).
+ *
+ * @param string $uuid The dashboard UUID from the URL.
+ *
+ * @return JSONResponse The updated dashboard payload.
+ */
+ #[NoAdminRequired]
+ public function publish(string $uuid): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $dashboard = $this->dashboardService->publish(
+ uuid: $uuid,
+ userId: $this->userId
+ );
+
+ return ResponseHelper::success(
+ data: ['dashboard' => $dashboard->jsonSerialize()]
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (\Exception $e) {
+ return $this->mapPublicationError(exception: $e);
+ }//end try
+ }//end publish()
+
+ /**
+ * Unpublish a dashboard. REQ-DASH-033.
+ *
+ * @param string $uuid The dashboard UUID from the URL.
+ *
+ * @return JSONResponse The updated dashboard payload.
+ */
+ #[NoAdminRequired]
+ public function unpublish(string $uuid): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $dashboard = $this->dashboardService->unpublish(
+ uuid: $uuid,
+ userId: $this->userId
+ );
+
+ return ResponseHelper::success(
+ data: ['dashboard' => $dashboard->jsonSerialize()]
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (\Exception $e) {
+ return $this->mapPublicationError(exception: $e);
+ }//end try
+ }//end unpublish()
+
+ /**
+ * Schedule a dashboard for automatic publication. REQ-DASH-034.
+ *
+ * Body: `{"publishAt": "2026-04-01T10:00:00Z"}`. Returns 400 with an
+ * i18n-friendly error message when `publishAt` is missing,
+ * unparseable, or in the past; 403 when the actor is neither owner
+ * nor admin.
+ *
+ * @param string $uuid The dashboard UUID from the URL.
+ * @param string|null $publishAt The future ISO-8601 timestamp from
+ * the request body.
+ *
+ * @return JSONResponse The updated dashboard payload.
+ */
+ #[NoAdminRequired]
+ public function schedule(
+ string $uuid,
+ ?string $publishAt=null
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ if ($publishAt === null || $publishAt === '') {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_argument',
+ 'message' => DashboardService::ERR_SCHEDULE_PAST_DATE,
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ try {
+ $dashboard = $this->dashboardService->schedule(
+ uuid: $uuid,
+ publishAt: $publishAt,
+ userId: $this->userId
+ );
+
+ return ResponseHelper::success(
+ data: ['dashboard' => $dashboard->jsonSerialize()]
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_argument',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ } catch (\Exception $e) {
+ return $this->mapPublicationError(exception: $e);
+ }//end try
+ }//end schedule()
+
+ /**
+ * Record a dashboard view event (REQ-ANLT-002).
+ *
+ * Authenticated users only — POST `{}` body. Returns HTTP 204
+ * after the daily counter has been incremented. Short-circuits
+ * silently to 204 when the user has opted out (REQ-ANLT-004) or
+ * when global analytics is disabled (REQ-ANLT-005). Returns 404
+ * when the dashboard does not exist.
+ *
+ * @param string $uuid The dashboard UUID from the URL.
+ *
+ * @return JSONResponse An empty 204 response on success, 401
+ * when unauthenticated, 404 when the
+ * dashboard does not exist.
+ */
+ #[NoAdminRequired]
+ public function viewEvent(string $uuid): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $this->analyticsService->recordViewEvent(
+ dashboardUuid: $uuid,
+ userId: $this->userId
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ return new JSONResponse(
+ data: [],
+ statusCode: Http::STATUS_NO_CONTENT
+ );
+ }//end viewEvent()
+
+ /**
+ * Map publication-related service exceptions onto the right HTTP
+ * status. REQ-DASH-032..034.
+ *
+ * The service raises `Exception` with the sentinel message
+ * {@see DashboardService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN} when
+ * the actor is not allowed; everything else falls through to the
+ * generic ResponseHelper::error path.
+ *
+ * @param \Exception $exception The thrown exception.
+ *
+ * @return JSONResponse The mapped error envelope.
+ */
+ private function mapPublicationError(\Exception $exception): JSONResponse
+ {
+ if ($exception->getMessage() === DashboardService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN
+ ) {
+ return ResponseHelper::forbidden(
+ message: DashboardService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN
+ );
+ }
+
+ return ResponseHelper::error(exception: $exception);
+ }//end mapPublicationError()
+
/**
* Resolve create parameters from JSON body or individual params.
*
+ * Forwards the new hierarchy fields (`parentUuid`, `slug`,
+ * `sortOrder`) introduced by REQ-DASH-023..029. When the caller
+ * sends a JSON body the helper inspects the array form first; when
+ * positional / typed params come via the framework binding it
+ * falls back to those.
+ *
* @param mixed $name The name parameter.
* @param string|null $description The description parameter.
+ * @param string|null $icon The icon parameter.
+ * @param string|null $parentUuid Optional parent UUID.
+ * @param string|null $slug Optional caller-supplied slug.
+ * @param int|null $sortOrder Optional sort order.
*
- * @return array The resolved name and description.
+ * @return array{name: string, description: ?string, icon: ?string, parentUuid: ?string, slug: ?string, sortOrder: int}
+ * The resolved values.
*/
private function resolveCreateParams(
$name,
- ?string $description
+ ?string $description,
+ ?string $icon=null,
+ ?string $parentUuid=null,
+ ?string $slug=null,
+ ?int $sortOrder=null
): array {
if (is_array($name) === true) {
+ $bodyIcon = ($name['icon'] ?? null);
+ $resolvedIcon = null;
+ if (is_string($bodyIcon) === true) {
+ $resolvedIcon = $bodyIcon;
+ }
+
+ $bodyParent = ($name['parentUuid'] ?? null);
+ $bodySlug = ($name['slug'] ?? null);
+ $bodySort = ($name['sortOrder'] ?? null);
+ $resolvedParent = null;
+ if (is_string($bodyParent) === true) {
+ $resolvedParent = $bodyParent;
+ }
+
+ $resolvedSlug = null;
+ if (is_string($bodySlug) === true) {
+ $resolvedSlug = $bodySlug;
+ }
+
+ $resolvedSort = 0;
+ if (is_numeric($bodySort) === true) {
+ $resolvedSort = (int) $bodySort;
+ }
+
return [
'name' => $name['name'] ?? 'My Dashboard',
'description' => $name['description'] ?? null,
+ 'icon' => $resolvedIcon,
+ 'parentUuid' => $resolvedParent,
+ 'slug' => $resolvedSlug,
+ 'sortOrder' => $resolvedSort,
];
- }
+ }//end if
return [
'name' => $name ?? 'My Dashboard',
'description' => $description,
+ 'icon' => $icon,
+ 'parentUuid' => $parentUuid,
+ 'slug' => $slug,
+ 'sortOrder' => ($sortOrder ?? 0),
];
}//end resolveCreateParams()
+ /**
+ * Read the case-insensitive `cascade` query param (REQ-DASH-030).
+ *
+ * `?cascade=true|TRUE|True|1|yes|on|cascade` → true; anything else
+ * (including the param being absent) → false.
+ *
+ * @return bool Whether cascade-delete was explicitly requested.
+ */
+ private function resolveCascadeFlag(): bool
+ {
+ $raw = $this->request->getParam(key: 'cascade');
+ if ($raw === null) {
+ return false;
+ }
+
+ $lower = strtolower((string) $raw);
+ return in_array(
+ $lower,
+ ['true', '1', 'yes', 'on', 'cascade'],
+ true
+ );
+ }//end resolveCascadeFlag()
+
/**
* Check creation permissions and return error if denied.
*
@@ -790,13 +1483,29 @@ private function checkCreatePermissions(string $userId): ?JSONResponse
* @param string|null $name The name.
* @param string|null $description The description.
* @param array|null $placements The placements.
+ * @param string|null $icon The icon registry key, URL, or NULL/empty.
+ * @param string|null $parentUuid Optional new parent UUID
+ * (REQ-DASH-023). The literal sentinel
+ * `__null__` clears the parent
+ * (re-roots the dashboard) — needed
+ * because the framework cannot
+ * distinguish "not in payload" from
+ * "explicit NULL" with typed-string
+ * binding.
+ * @param string|null $slug Optional new slug (REQ-DASH-024).
+ * @param int|null $sortOrder Optional new sort order
+ * (REQ-DASH-029).
*
* @return array The non-null update data.
*/
private function buildUpdateData(
?string $name,
?string $description,
- ?array $placements
+ ?array $placements,
+ ?string $icon=null,
+ ?string $parentUuid=null,
+ ?string $slug=null,
+ ?int $sortOrder=null
): array {
$fields = [
'name' => $name,
@@ -804,12 +1513,43 @@ private function buildUpdateData(
'placements' => $placements,
];
- return array_filter(
+ $data = array_filter(
array: $fields,
callback: function ($value) {
return $value !== null;
}
);
+
+ // Icon explicitly supports NULL/empty (resets to the default
+ // glyph), so it must be merged separately from the array_filter
+ // above. Caller distinguishes "not in payload" via the default
+ // null sentinel.
+ if ($icon !== null) {
+ $data['icon'] = $icon;
+ }
+
+ // REQ-DASH-023: `parentUuid = '__null__'` is the agreed sentinel
+ // for "re-root this dashboard" because the framework's typed
+ // string binding cannot represent an explicit NULL. Anything
+ // else (non-null string) is forwarded verbatim — including the
+ // empty string, which the service treats as a NULL parent.
+ if ($parentUuid !== null) {
+ if ($parentUuid === '__null__' || $parentUuid === '') {
+ $data['parentUuid'] = null;
+ } else {
+ $data['parentUuid'] = $parentUuid;
+ }
+ }
+
+ if ($slug !== null) {
+ $data['slug'] = $slug;
+ }
+
+ if ($sortOrder !== null) {
+ $data['sortOrder'] = $sortOrder;
+ }
+
+ return $data;
}//end buildUpdateData()
/**
@@ -842,4 +1582,42 @@ private function buildGroupUpdateData(
}
);
}//end buildGroupUpdateData()
+
+ /**
+ * Capture an automatic version snapshot after a successful update
+ * (REQ-VERS-001). The version service enforces a 60-second debounce
+ * window so a flurry of drag-and-drop saves does not flood the
+ * table.
+ *
+ * Failures here MUST NOT surface to the dashboard PUT response —
+ * the user's edit succeeded; missing one snapshot is a quality of
+ * life regression, not a data-integrity bug. We log + swallow.
+ *
+ * @param \OCA\MyDash\Db\Dashboard $dashboard The dashboard that was
+ * just updated.
+ *
+ * @return void
+ */
+ private function captureAutomaticSnapshot(
+ \OCA\MyDash\Db\Dashboard $dashboard
+ ): void {
+ if ($this->userId === null) {
+ return;
+ }
+
+ try {
+ $this->versionService->captureSnapshot(
+ dashboard: $dashboard,
+ snapshotJson: null,
+ createdBy: $this->userId,
+ note: null,
+ explicit: false
+ );
+ } catch (\Throwable $t) {
+ $this->logger->warning(
+ message: 'mydash: automatic version snapshot failed',
+ context: ['exception' => $t]
+ );
+ }
+ }//end captureAutomaticSnapshot()
}//end class
diff --git a/lib/Controller/DashboardCommentsApiController.php b/lib/Controller/DashboardCommentsApiController.php
new file mode 100644
index 00000000..10db1528
--- /dev/null
+++ b/lib/Controller/DashboardCommentsApiController.php
@@ -0,0 +1,370 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Db\DashboardMapper;
+use OCA\MyDash\Exception\CommentForbiddenException;
+use OCA\MyDash\Exception\CommentNotFoundException;
+use OCA\MyDash\Exception\InvalidCommentException;
+use OCA\MyDash\Service\CommentService;
+use OCA\MyDash\Service\PermissionService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+
+/**
+ * Controller for `/api/dashboards/{uuid}/comments` (REQ-CMNT-001..009).
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The comments controller
+ * legitimately spans the
+ * comment service,
+ * permission service,
+ * dashboard mapper and
+ * three exception types
+ * — splitting would
+ * fragment a four-route
+ * surface.
+ */
+class DashboardCommentsApiController extends Controller
+{
+ /**
+ * Constructor
+ *
+ * @param IRequest $request The HTTP request.
+ * @param CommentService $commentService Comment business logic.
+ * @param PermissionService $permissionService Dashboard permission resolver.
+ * @param DashboardMapper $dashboardMapper Used to resolve UUIDs to ids.
+ * @param string|null $userId The acting user (null when
+ * unauthenticated).
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly CommentService $commentService,
+ private readonly PermissionService $permissionService,
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly ?string $userId,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * List comments on a dashboard (REQ-CMNT-001).
+ *
+ * Returns `{enabled: bool, comments: array}`. When comments are
+ * effectively disabled the list is always empty.
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse The list envelope or an error.
+ */
+ #[NoAdminRequired]
+ public function index(string $uuid): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ if ($this->permissionService->canViewDashboard(
+ userId: $this->userId,
+ dashboardId: (int) $dashboard->getId()
+ ) === false
+ ) {
+ return ResponseHelper::forbidden();
+ }
+
+ if ($this->commentService->isEnabledFor(dashboard: $dashboard) === false) {
+ return ResponseHelper::success(
+ data: [
+ 'enabled' => false,
+ 'comments' => [],
+ ]
+ );
+ }
+
+ $comments = $this->commentService->getCommentsForDashboard(
+ dashboardUuid: $uuid
+ );
+
+ return ResponseHelper::success(
+ data: [
+ 'enabled' => true,
+ 'comments' => $comments,
+ ]
+ );
+ }//end index()
+
+ /**
+ * Create a top-level or reply comment (REQ-CMNT-002, REQ-CMNT-003).
+ *
+ * Body shape: `{message: string, parentId?: int}`.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param string|null $message The comment text.
+ * @param mixed $parentId Optional parent comment id; coerced to int.
+ *
+ * @return JSONResponse The new comment envelope or an error.
+ */
+ #[NoAdminRequired]
+ public function create(
+ string $uuid,
+ ?string $message=null,
+ $parentId=null
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ if ($this->commentService->isEnabledFor(dashboard: $dashboard) === false) {
+ return new JSONResponse(
+ data: [
+ 'error' => 'Comments are disabled on this dashboard',
+ 'errorCode' => 'comment_disabled',
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ // REQ-CMNT-009: writers must have edit-grade access. Viewers may
+ // read but never post. We reuse `canEditDashboard` so the role
+ // capability (Viewer-blocked / Admin-allowed) layers on top of
+ // the permissions capability without duplication.
+ if ($this->permissionService->canEditDashboard(
+ userId: $this->userId,
+ dashboardId: (int) $dashboard->getId()
+ ) === false
+ ) {
+ return ResponseHelper::forbidden();
+ }
+
+ $resolvedParent = $this->coerceParentId(value: $parentId);
+
+ try {
+ $comment = $this->commentService->createComment(
+ dashboardUuid: $uuid,
+ userId: (string) $this->userId,
+ message: (string) $message,
+ parentId: $resolvedParent
+ );
+ } catch (InvalidCommentException $e) {
+ return $this->renderError(exception: $e);
+ } catch (CommentNotFoundException $e) {
+ return $this->renderError(exception: $e);
+ }
+
+ return ResponseHelper::success(
+ data: $this->commentService->serialiseComment(comment: $comment),
+ statusCode: Http::STATUS_CREATED
+ );
+ }//end create()
+
+ /**
+ * Update the message of an existing comment (REQ-CMNT-004).
+ *
+ * @param string $uuid The dashboard UUID (kept for routing).
+ * @param int $id The comment ID.
+ * @param string|null $message The new message text.
+ *
+ * @return JSONResponse The updated comment envelope or an error.
+ */
+ #[NoAdminRequired]
+ public function update(
+ string $uuid,
+ int $id,
+ ?string $message=null
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ if ($this->commentService->isEnabledFor(dashboard: $dashboard) === false) {
+ return new JSONResponse(
+ data: [
+ 'error' => 'Comments are disabled on this dashboard',
+ 'errorCode' => 'comment_disabled',
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ if ($this->permissionService->canViewDashboard(
+ userId: $this->userId,
+ dashboardId: (int) $dashboard->getId()
+ ) === false
+ ) {
+ return ResponseHelper::forbidden();
+ }
+
+ try {
+ $comment = $this->commentService->updateComment(
+ commentId: $id,
+ newMessage: (string) $message,
+ currentUserId: (string) $this->userId
+ );
+ } catch (InvalidCommentException $e) {
+ return $this->renderError(exception: $e);
+ } catch (CommentForbiddenException $e) {
+ return $this->renderError(exception: $e);
+ } catch (CommentNotFoundException $e) {
+ return $this->renderError(exception: $e);
+ }
+
+ return ResponseHelper::success(
+ data: $this->commentService->serialiseComment(comment: $comment)
+ );
+ }//end update()
+
+ /**
+ * Delete (soft) a comment (REQ-CMNT-005).
+ *
+ * @param string $uuid The dashboard UUID (kept for routing).
+ * @param int $id The comment ID.
+ *
+ * @return JSONResponse Empty success or an error envelope.
+ */
+ #[NoAdminRequired]
+ public function destroy(string $uuid, int $id): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ if ($this->permissionService->canViewDashboard(
+ userId: $this->userId,
+ dashboardId: (int) $dashboard->getId()
+ ) === false
+ ) {
+ return ResponseHelper::forbidden();
+ }
+
+ try {
+ $this->commentService->deleteComment(
+ commentId: $id,
+ currentUserId: (string) $this->userId
+ );
+ } catch (CommentForbiddenException $e) {
+ return $this->renderError(exception: $e);
+ } catch (CommentNotFoundException $e) {
+ return $this->renderError(exception: $e);
+ }
+
+ return new JSONResponse(
+ data: [],
+ statusCode: Http::STATUS_NO_CONTENT
+ );
+ }//end destroy()
+
+ /**
+ * Coerce a wire-format parentId to an int|null.
+ *
+ * Accepts numeric strings (`"42"`), ints (`42`), and the absence
+ * sentinels (`null`, empty string, `0`). Anything else falls back
+ * to `null` so the comment is created as a new top-level entry —
+ * the comment service still rejects unresolved parents.
+ *
+ * @param mixed $value Raw value from the request body.
+ *
+ * @return int|null Integer parent id, or null for a top-level comment.
+ */
+ private function coerceParentId(mixed $value): ?int
+ {
+ if ($value === null || $value === '' || $value === 0 || $value === '0') {
+ return null;
+ }
+
+ if (is_int($value) === true) {
+ return $value;
+ }
+
+ if (is_string($value) === true && ctype_digit(text: $value) === true) {
+ return (int) $value;
+ }
+
+ return null;
+ }//end coerceParentId()
+
+ /**
+ * Render a comment exception as a JSON error envelope.
+ *
+ * @param InvalidCommentException|CommentForbiddenException|CommentNotFoundException $exception The exception.
+ *
+ * @return JSONResponse The error envelope.
+ */
+ private function renderError(
+ InvalidCommentException|CommentForbiddenException|CommentNotFoundException $exception
+ ): JSONResponse {
+ return new JSONResponse(
+ data: [
+ 'error' => $exception->getDisplayMessage(),
+ 'errorCode' => $exception->getErrorCode(),
+ ],
+ statusCode: $exception->getHttpStatus()
+ );
+ }//end renderError()
+}//end class
diff --git a/lib/Controller/DashboardLockApiController.php b/lib/Controller/DashboardLockApiController.php
new file mode 100644
index 00000000..71d2e1ff
--- /dev/null
+++ b/lib/Controller/DashboardLockApiController.php
@@ -0,0 +1,268 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Exception\LockConflictException;
+use OCA\MyDash\Exception\LockForbiddenException;
+use OCA\MyDash\Exception\LockNotFoundException;
+use OCA\MyDash\Service\DashboardLockService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+
+/**
+ * Controller for dashboard editing-lock endpoints (REQ-LOCK-001..008).
+ */
+class DashboardLockApiController extends Controller
+{
+ /**
+ * Constructor
+ *
+ * @param IRequest $request The HTTP request.
+ * @param DashboardLockService $lockService The lock service.
+ * @param string|null $userId The calling user ID.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly DashboardLockService $lockService,
+ private readonly ?string $userId,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * Acquire (or refresh) the lock for the given dashboard.
+ *
+ * Re-entrant for the same user — a second tab MUST receive HTTP
+ * 200 with the refreshed lock instead of HTTP 409 (REQ-LOCK-001).
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse 200 with the lock object on success,
+ * 404 when the dashboard UUID is unknown,
+ * 409 with the existing lock on conflict.
+ */
+ #[NoAdminRequired]
+ public function acquire(string $uuid): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $lock = $this->lockService->acquireLock(
+ dashboardUuid: $uuid,
+ userId: $this->userId
+ );
+ return new JSONResponse(
+ data: $lock->jsonSerialize(),
+ statusCode: Http::STATUS_OK
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (LockConflictException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'code' => LockConflictException::ERROR_CODE,
+ 'lock' => $e->getExistingLock()->jsonSerialize(),
+ ],
+ statusCode: Http::STATUS_CONFLICT
+ );
+ }//end try
+ }//end acquire()
+
+ /**
+ * Refresh the lock (heartbeat). Owner-only.
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse 200 with the refreshed lock; 404 when no
+ * active lock exists; 403 on owner mismatch.
+ */
+ #[NoAdminRequired]
+ public function heartbeat(string $uuid): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $lock = $this->lockService->heartbeat(
+ dashboardUuid: $uuid,
+ userId: $this->userId
+ );
+ return new JSONResponse(
+ data: $lock->jsonSerialize(),
+ statusCode: Http::STATUS_OK
+ );
+ } catch (LockNotFoundException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'code' => LockNotFoundException::ERROR_CODE,
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (LockForbiddenException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'code' => LockForbiddenException::ERROR_CODE,
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }//end try
+ }//end heartbeat()
+
+ /**
+ * Release the lock. Owner-or-admin.
+ *
+ * Idempotent — releasing a non-existent lock returns 204 (the
+ * caller's intent "no longer holding the lock" is satisfied).
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse 204 on success; 403 on permission mismatch.
+ */
+ #[NoAdminRequired]
+ public function release(string $uuid): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $this->lockService->releaseLock(
+ dashboardUuid: $uuid,
+ userId: $this->userId,
+ allowAdminOverride: true
+ );
+ return new JSONResponse(
+ data: [],
+ statusCode: Http::STATUS_NO_CONTENT
+ );
+ } catch (LockForbiddenException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'code' => LockForbiddenException::ERROR_CODE,
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+ }//end release()
+
+ /**
+ * Query the current lock state.
+ *
+ * Returns the lock object when active, or HTTP 404 when none
+ * exists. Stale rows are scrubbed inline by the service before
+ * the response.
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse 200 with the lock or 404 when none.
+ */
+ #[NoAdminRequired]
+ public function get(string $uuid): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $lock = $this->lockService->getLockState(dashboardUuid: $uuid);
+ if ($lock === null) {
+ return new JSONResponse(
+ data: [
+ 'error' => 'Lock not found',
+ 'code' => LockNotFoundException::ERROR_CODE,
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ return new JSONResponse(
+ data: $lock->jsonSerialize(),
+ statusCode: Http::STATUS_OK
+ );
+ }//end get()
+
+ /**
+ * Admin-only: force-release any user's lock (REQ-LOCK-006, design
+ * D4). The admin may then `acquire` normally if they want to take
+ * the lock themselves.
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse 200 on success; 403 when caller is not admin.
+ */
+ #[NoAdminRequired]
+ public function forceRelease(string $uuid): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $this->lockService->forceRelease(
+ dashboardUuid: $uuid,
+ adminUserId: $this->userId
+ );
+ return new JSONResponse(
+ data: ['status' => 'ok'],
+ statusCode: Http::STATUS_OK
+ );
+ } catch (LockForbiddenException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'code' => LockForbiddenException::ERROR_CODE,
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+ }//end forceRelease()
+}//end class
diff --git a/lib/Controller/DashboardMetadataController.php b/lib/Controller/DashboardMetadataController.php
new file mode 100644
index 00000000..c524432a
--- /dev/null
+++ b/lib/Controller/DashboardMetadataController.php
@@ -0,0 +1,240 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Db\Dashboard;
+use OCA\MyDash\Db\DashboardMapper;
+use OCA\MyDash\Exception\InvalidMetadataFieldException;
+use OCA\MyDash\Service\MetadataService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IGroupManager;
+use OCP\IRequest;
+
+/**
+ * Dashboard metadata read/write controller.
+ */
+class DashboardMetadataController extends Controller
+{
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The HTTP request.
+ * @param MetadataService $metadataService The metadata service facade.
+ * @param DashboardMapper $dashboardMapper For ownership/visibility lookup.
+ * @param IGroupManager $groupManager For group-share access checks.
+ * @param string|null $userId The active user id.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly MetadataService $metadataService,
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly IGroupManager $groupManager,
+ private readonly ?string $userId,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * `GET /api/dashboards/{uuid}/metadata` — REQ-MDFL-004 / REQ-MDFL-008.
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse 200 + flat metadata, 404 when missing,
+ * 403 when the caller cannot see the dashboard.
+ */
+ #[NoAdminRequired]
+ public function getMetadata(string $uuid): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $dashboard = $this->loadDashboard(uuid: $uuid);
+ if ($dashboard === null) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ if ($this->canRead(dashboard: $dashboard) === false) {
+ return ResponseHelper::forbidden();
+ }
+
+ $metadata = $this->metadataService->getMetadataForDashboard(
+ dashboardUuid: $uuid
+ );
+
+ return ResponseHelper::success(data: $metadata);
+ }//end getMetadata()
+
+ /**
+ * `PUT /api/dashboards/{uuid}/metadata` — REQ-MDFL-005 / REQ-MDFL-008.
+ *
+ * Body: flat key-value object. Omitted keys are NOT removed; only
+ * keys present in the payload are upserted.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param array $metadata The patch payload.
+ *
+ * @return JSONResponse 200 + updated metadata, 400 on validation
+ * failure, 404 when missing, 403 otherwise.
+ */
+ #[NoAdminRequired]
+ public function setMetadata(string $uuid, array $metadata=[]): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $dashboard = $this->loadDashboard(uuid: $uuid);
+ if ($dashboard === null) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ if ($this->canWrite(dashboard: $dashboard) === false) {
+ return ResponseHelper::forbidden();
+ }
+
+ try {
+ $updated = $this->metadataService->setMetadataForDashboard(
+ dashboardUuid: $uuid,
+ keyValues: $metadata
+ );
+ } catch (InvalidMetadataFieldException $exception) {
+ return new JSONResponse(
+ data: [
+ 'error' => InvalidMetadataFieldException::ERROR_CODE,
+ 'message' => $exception->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ return ResponseHelper::success(data: $updated);
+ }//end setMetadata()
+
+ /**
+ * Resolve a UUID to a dashboard or null.
+ *
+ * @param string $uuid The UUID.
+ *
+ * @return Dashboard|null The dashboard or null.
+ */
+ private function loadDashboard(string $uuid): ?Dashboard
+ {
+ try {
+ return $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return null;
+ }
+ }//end loadDashboard()
+
+ /**
+ * Read access check (REQ-MDFL-008).
+ *
+ * Owners always read. For group-shared dashboards, group members
+ * read. Admin templates and the default group are world-readable
+ * (consistent with the existing dashboard read paths).
+ *
+ * @param Dashboard $dashboard The dashboard.
+ *
+ * @return bool True when the active user can read.
+ */
+ private function canRead(Dashboard $dashboard): bool
+ {
+ if ($dashboard->getUserId() !== null
+ && $dashboard->getUserId() === $this->userId
+ ) {
+ return true;
+ }
+
+ $type = (string) $dashboard->getType();
+ if ($type === Dashboard::TYPE_ADMIN_TEMPLATE) {
+ return true;
+ }
+
+ $groupId = $dashboard->getGroupId();
+ if ($groupId !== null && $this->userId !== null) {
+ return $this->groupManager->isInGroup(
+ userId: $this->userId,
+ group: $groupId
+ );
+ }
+
+ return false;
+ }//end canRead()
+
+ /**
+ * Write access check (REQ-MDFL-008).
+ *
+ * Owners write. Admins can write to admin templates. Group members
+ * can write to group-shared dashboards (same gating as the existing
+ * group-shared update path).
+ *
+ * @param Dashboard $dashboard The dashboard.
+ *
+ * @return bool True when the active user can write.
+ */
+ private function canWrite(Dashboard $dashboard): bool
+ {
+ if ($dashboard->getUserId() !== null
+ && $dashboard->getUserId() === $this->userId
+ ) {
+ return true;
+ }
+
+ $type = (string) $dashboard->getType();
+ if ($type === Dashboard::TYPE_ADMIN_TEMPLATE
+ && $this->userId !== null
+ && $this->groupManager->isAdmin(userId: $this->userId) === true
+ ) {
+ return true;
+ }
+
+ $groupId = $dashboard->getGroupId();
+ if ($groupId !== null && $this->userId !== null) {
+ return $this->groupManager->isInGroup(
+ userId: $this->userId,
+ group: $groupId
+ );
+ }
+
+ return false;
+ }//end canWrite()
+}//end class
diff --git a/lib/Controller/DashboardReactionApiController.php b/lib/Controller/DashboardReactionApiController.php
new file mode 100644
index 00000000..adbfa434
--- /dev/null
+++ b/lib/Controller/DashboardReactionApiController.php
@@ -0,0 +1,260 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use InvalidArgumentException;
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Service\PermissionDeniedException;
+use OCA\MyDash\Service\ReactionService;
+use OCA\MyDash\Service\ReactionsDisabledException;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Dashboard reaction endpoints.
+ *
+ * Permission gating is runtime (calls into PermissionService through
+ * ReactionService) so every method carries `#[NoAdminRequired]` —
+ * non-admins can react if and only if they can VIEW the dashboard.
+ */
+class DashboardReactionApiController extends Controller
+{
+ /**
+ * Constructor
+ *
+ * @param IRequest $request The request.
+ * @param ReactionService $reactionService The reaction service.
+ * @param LoggerInterface $logger PSR logger.
+ * @param string|null $userId The acting user ID.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly ReactionService $reactionService,
+ private readonly LoggerInterface $logger,
+ private readonly ?string $userId,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * GET /api/dashboards/{uuid}/reactions — return the
+ * `{counts, mine, enabled}` summary. REQ-RXN-003.
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse The summary.
+ */
+ #[NoAdminRequired]
+ public function getReactions(string $uuid): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $summary = $this->reactionService->getReactionsSummary(
+ dashboardUuid: $uuid,
+ userId: $this->userId
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (PermissionDeniedException $e) {
+ return ResponseHelper::forbidden(message: $e->getMessage());
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'getReactions failed: '.$e->getMessage(),
+ context: ['exception' => $e]
+ );
+ return new JSONResponse(
+ data: ['error' => 'Operation failed'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
+
+ return ResponseHelper::success(data: $summary);
+ }//end getReactions()
+
+ /**
+ * POST /api/dashboards/{uuid}/reactions — add the calling user's
+ * reaction. Idempotent (REQ-RXN-001 scenario "User re-posts the
+ * same emoji").
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param string $emoji The emoji to add (request body field).
+ *
+ * @return JSONResponse The updated summary.
+ */
+ #[NoAdminRequired]
+ public function addReaction(string $uuid, string $emoji=''): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $summary = $this->reactionService->addReaction(
+ dashboardUuid: $uuid,
+ userId: $this->userId,
+ emoji: $emoji
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (PermissionDeniedException $e) {
+ return ResponseHelper::forbidden(message: $e->getMessage());
+ } catch (ReactionsDisabledException $e) {
+ return ResponseHelper::forbidden(message: $e->getMessage());
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'addReaction failed: '.$e->getMessage(),
+ context: ['exception' => $e]
+ );
+ return new JSONResponse(
+ data: ['error' => 'Operation failed'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
+
+ return ResponseHelper::success(data: $summary);
+ }//end addReaction()
+
+ /**
+ * DELETE /api/dashboards/{uuid}/reactions/{emoji} — remove the
+ * calling user's reaction. Idempotent (REQ-RXN-002).
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param string $emoji The emoji to remove.
+ *
+ * @return JSONResponse Empty 204 response.
+ */
+ #[NoAdminRequired]
+ public function removeReaction(string $uuid, string $emoji): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $this->reactionService->removeReaction(
+ dashboardUuid: $uuid,
+ userId: $this->userId,
+ emoji: $emoji
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (PermissionDeniedException $e) {
+ return ResponseHelper::forbidden(message: $e->getMessage());
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'removeReaction failed: '.$e->getMessage(),
+ context: ['exception' => $e]
+ );
+ return new JSONResponse(
+ data: ['error' => 'Operation failed'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
+
+ // 204 No Content — JSONResponse with empty body and explicit
+ // status (the framework still emits headers/body shape, but
+ // the contract is "204 always" per REQ-RXN-002).
+ return new JSONResponse(
+ data: [],
+ statusCode: Http::STATUS_NO_CONTENT
+ );
+ }//end removeReaction()
+
+ /**
+ * GET /api/dashboards/{uuid}/reactions/{emoji}/users — return the
+ * paginated list of reactors. REQ-RXN-004.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param string $emoji The emoji.
+ * @param string|null $cursor Optional opaque cursor (offset).
+ *
+ * @return JSONResponse The reactors page.
+ */
+ #[NoAdminRequired]
+ public function getReactorsByEmoji(
+ string $uuid,
+ string $emoji,
+ ?string $cursor=null
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $page = $this->reactionService->getReactorsByEmoji(
+ dashboardUuid: $uuid,
+ emoji: $emoji,
+ userId: $this->userId,
+ cursor: $cursor
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (PermissionDeniedException $e) {
+ return ResponseHelper::forbidden(message: $e->getMessage());
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'getReactorsByEmoji failed: '.$e->getMessage(),
+ context: ['exception' => $e]
+ );
+ return new JSONResponse(
+ data: ['error' => 'Operation failed'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
+
+ return ResponseHelper::success(data: $page);
+ }//end getReactorsByEmoji()
+}//end class
diff --git a/lib/Controller/DashboardShareApiController.php b/lib/Controller/DashboardShareApiController.php
index 07d7e0e1..b05710ac 100644
--- a/lib/Controller/DashboardShareApiController.php
+++ b/lib/Controller/DashboardShareApiController.php
@@ -31,8 +31,10 @@
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
-use OCP\AppFramework\Http\JSONResponse;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\IGroupManager;
use OCP\IRequest;
+use OCP\IUserManager;
/**
* Controller for dashboard sharing API endpoints.
@@ -47,11 +49,15 @@ class DashboardShareApiController extends Controller
*
* @param IRequest $request The request.
* @param DashboardShareService $shareService The share service.
+ * @param IUserManager $userManager Nextcloud user manager (sharee lookup).
+ * @param IGroupManager $groupManager Nextcloud group manager (sharee lookup).
* @param string|null $userId The calling user ID.
*/
public function __construct(
IRequest $request,
private readonly DashboardShareService $shareService,
+ private readonly IUserManager $userManager,
+ private readonly IGroupManager $groupManager,
private readonly ?string $userId,
) {
parent::__construct(
@@ -65,13 +71,13 @@ public function __construct(
*
* @param int $id The dashboard ID.
*
- * @return JSONResponse The list of shares.
+ * @return DataResponse The list of shares.
*/
#[NoAdminRequired]
- public function index(int $id): JSONResponse
+ public function index(int $id): DataResponse
{
if ($this->userId === null) {
- return new JSONResponse(
+ return new DataResponse(
data: ['error' => 'Not logged in'],
statusCode: Http::STATUS_UNAUTHORIZED
);
@@ -83,19 +89,18 @@ public function index(int $id): JSONResponse
userId: $this->userId
);
$serialized = array_map(
- callback: static fn($s) => $s->jsonSerialize(),
+ callback: static fn($share) => $share->jsonSerialize(),
array: $shares
);
- return new JSONResponse(data: $serialized);
+ return new DataResponse(data: $serialized);
} catch (DoesNotExistException) {
- return new JSONResponse(
+ return new DataResponse(
data: ['error' => 'Dashboard not found'],
statusCode: Http::STATUS_NOT_FOUND
);
- } catch (Exception) {
- // ADR-005: do not leak raw exception messages to clients.
- return new JSONResponse(
- data: ['error' => 'Forbidden'],
+ } catch (Exception $e) {
+ return new DataResponse(
+ data: ['error' => $e->getMessage()],
statusCode: Http::STATUS_FORBIDDEN
);
}//end try
@@ -109,7 +114,7 @@ public function index(int $id): JSONResponse
* @param string|null $shareWith The recipient.
* @param string|null $permissionLevel The permission level.
*
- * @return JSONResponse The created/updated share.
+ * @return DataResponse The created/updated share.
*/
#[NoAdminRequired]
public function create(
@@ -117,9 +122,9 @@ public function create(
?string $shareType=null,
?string $shareWith=null,
?string $permissionLevel=null
- ): JSONResponse {
+ ): DataResponse {
if ($this->userId === null) {
- return new JSONResponse(
+ return new DataResponse(
data: ['error' => 'Not logged in'],
statusCode: Http::STATUS_UNAUTHORIZED
);
@@ -133,25 +138,23 @@ public function create(
permissionLevel: (string) $permissionLevel,
callerId: $this->userId
);
- return new JSONResponse(
+ return new DataResponse(
data: $share->jsonSerialize(),
statusCode: Http::STATUS_CREATED
);
- } catch (InvalidArgumentException) {
- // ADR-005: do not leak raw exception messages to clients.
- return new JSONResponse(
- data: ['error' => 'Invalid request'],
+ } catch (InvalidArgumentException $e) {
+ return new DataResponse(
+ data: ['error' => $e->getMessage()],
statusCode: Http::STATUS_BAD_REQUEST
);
} catch (DoesNotExistException) {
- return new JSONResponse(
+ return new DataResponse(
data: ['error' => 'Dashboard not found'],
statusCode: Http::STATUS_NOT_FOUND
);
- } catch (Exception) {
- // ADR-005: do not leak raw exception messages to clients.
- return new JSONResponse(
- data: ['error' => 'Forbidden'],
+ } catch (Exception $e) {
+ return new DataResponse(
+ data: ['error' => $e->getMessage()],
statusCode: Http::STATUS_FORBIDDEN
);
}//end try
@@ -162,13 +165,13 @@ public function create(
*
* @param int $shareId The share ID.
*
- * @return JSONResponse Empty 204 on success.
+ * @return DataResponse Empty 204 on success.
*/
#[NoAdminRequired]
- public function destroy(int $shareId): JSONResponse
+ public function destroy(int $shareId): DataResponse
{
if ($this->userId === null) {
- return new JSONResponse(
+ return new DataResponse(
data: ['error' => 'Not logged in'],
statusCode: Http::STATUS_UNAUTHORIZED
);
@@ -179,16 +182,15 @@ public function destroy(int $shareId): JSONResponse
shareId: $shareId,
callerId: $this->userId
);
- return new JSONResponse(data: [], statusCode: Http::STATUS_NO_CONTENT);
+ return new DataResponse(data: [], statusCode: Http::STATUS_NO_CONTENT);
} catch (DoesNotExistException) {
- return new JSONResponse(
+ return new DataResponse(
data: ['error' => 'Share not found'],
statusCode: Http::STATUS_NOT_FOUND
);
- } catch (Exception) {
- // ADR-005: do not leak raw exception messages to clients.
- return new JSONResponse(
- data: ['error' => 'Forbidden'],
+ } catch (Exception $e) {
+ return new DataResponse(
+ data: ['error' => $e->getMessage()],
statusCode: Http::STATUS_FORBIDDEN
);
}//end try
@@ -200,13 +202,13 @@ public function destroy(int $shareId): JSONResponse
* @param int $id The dashboard ID.
* @param array|null $shares The new share list.
*
- * @return JSONResponse The new full share list.
+ * @return DataResponse The new full share list.
*/
#[NoAdminRequired]
- public function replace(int $id, ?array $shares=null): JSONResponse
+ public function replace(int $id, ?array $shares=null): DataResponse
{
if ($this->userId === null) {
- return new JSONResponse(
+ return new DataResponse(
data: ['error' => 'Not logged in'],
statusCode: Http::STATUS_UNAUTHORIZED
);
@@ -223,25 +225,23 @@ public function replace(int $id, ?array $shares=null): JSONResponse
userId: $this->userId
);
$serialized = array_map(
- callback: static fn($s) => $s->jsonSerialize(),
+ callback: static fn($share) => $share->jsonSerialize(),
array: $newShares
);
- return new JSONResponse(data: $serialized);
- } catch (InvalidArgumentException) {
- // ADR-005: do not leak raw exception messages to clients.
- return new JSONResponse(
- data: ['error' => 'Invalid request'],
+ return new DataResponse(data: $serialized);
+ } catch (InvalidArgumentException $e) {
+ return new DataResponse(
+ data: ['error' => $e->getMessage()],
statusCode: Http::STATUS_BAD_REQUEST
);
} catch (DoesNotExistException) {
- return new JSONResponse(
+ return new DataResponse(
data: ['error' => 'Dashboard not found'],
statusCode: Http::STATUS_NOT_FOUND
);
- } catch (Exception) {
- // ADR-005: do not leak raw exception messages to clients.
- return new JSONResponse(
- data: ['error' => 'Forbidden'],
+ } catch (Exception $e) {
+ return new DataResponse(
+ data: ['error' => $e->getMessage()],
statusCode: Http::STATUS_FORBIDDEN
);
}//end try
@@ -254,15 +254,15 @@ public function replace(int $id, ?array $shares=null): JSONResponse
* @param string $shareType The share type.
* @param string $shareWith The recipient user/group ID.
*
- * @return JSONResponse The count of deleted rows.
+ * @return DataResponse The count of deleted rows.
*/
#[NoAdminRequired]
public function revokeForRecipient(
string $shareType,
string $shareWith
- ): JSONResponse {
+ ): DataResponse {
if ($this->userId === null) {
- return new JSONResponse(
+ return new DataResponse(
data: ['error' => 'Not logged in'],
statusCode: Http::STATUS_UNAUTHORIZED
);
@@ -274,13 +274,60 @@ public function revokeForRecipient(
shareWith: $shareWith,
callerId: $this->userId
);
- return new JSONResponse(data: ['deleted' => $count]);
- } catch (InvalidArgumentException) {
- // ADR-005: do not leak raw exception messages to clients.
- return new JSONResponse(
- data: ['error' => 'Invalid request'],
+ return new DataResponse(data: ['deleted' => $count]);
+ } catch (InvalidArgumentException $e) {
+ return new DataResponse(
+ data: ['error' => $e->getMessage()],
statusCode: Http::STATUS_BAD_REQUEST
);
}
}//end revokeForRecipient()
+
+ /**
+ * Search users and groups for the share autocomplete picker.
+ * REQ-SHARE-006.
+ *
+ * @param string $query The search query.
+ *
+ * @return DataResponse The matching users and groups.
+ */
+ #[NoAdminRequired]
+ public function searchSharees(string $query=''): DataResponse
+ {
+ if ($this->userId === null) {
+ return new DataResponse(
+ data: ['error' => 'Not logged in'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ $trimmed = trim(string: $query);
+ if (strlen(string: $trimmed) < 1) {
+ return new DataResponse(data: ['users' => [], 'groups' => []]);
+ }
+
+ $users = [];
+ foreach ($this->userManager->search(pattern: $trimmed, limit: 10) as $user) {
+ if ($user->getUID() === $this->userId) {
+ continue;
+ }
+
+ $users[] = [
+ 'id' => $user->getUID(),
+ 'displayName' => $user->getDisplayName(),
+ ];
+ }
+
+ $groups = [];
+ foreach ($this->groupManager->search(search: $trimmed, limit: 10) as $group) {
+ $groups[] = [
+ 'id' => $group->getGID(),
+ 'displayName' => $group->getDisplayName(),
+ ];
+ }
+
+ return new DataResponse(
+ data: ['users' => $users, 'groups' => $groups]
+ );
+ }//end searchSharees()
}//end class
diff --git a/lib/Controller/DashboardTranslationApiController.php b/lib/Controller/DashboardTranslationApiController.php
new file mode 100644
index 00000000..07afd795
--- /dev/null
+++ b/lib/Controller/DashboardTranslationApiController.php
@@ -0,0 +1,519 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use Exception;
+use InvalidArgumentException;
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Db\DashboardMapper;
+use OCA\MyDash\Service\DashboardTranslationService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+
+/**
+ * Controller for dashboard translation endpoints (REQ-DASH-038..044).
+ *
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods)
+ */
+class DashboardTranslationApiController extends Controller
+{
+ /**
+ * Constructor
+ *
+ * @param IRequest $request The request.
+ * @param DashboardMapper $dashboardMapper Dashboard mapper
+ * (used for the
+ * ownership check
+ * before any
+ * translation
+ * mutation).
+ * @param DashboardTranslationService $translationService Translation
+ * service.
+ * @param string|null $userId The user ID.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly DashboardTranslationService $translationService,
+ private readonly ?string $userId,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * GET /api/dashboards/{uuid}/translations — list every translation
+ * variant for a dashboard. Returns 403 when the dashboard belongs
+ * to another user. REQ-DASH-038.
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse The list payload.
+ */
+ #[NoAdminRequired]
+ public function list(string $uuid): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $ownerCheck = $this->assertOwner(uuid: $uuid);
+ if ($ownerCheck !== null) {
+ return $ownerCheck;
+ }
+
+ $variants = $this->translationService->listVariants(
+ dashboardUuid: $uuid
+ );
+
+ $serialized = ResponseHelper::serializeList(entities: $variants);
+
+ return ResponseHelper::success(
+ data: ['translations' => $serialized]
+ );
+ }//end list()
+
+ /**
+ * POST /api/dashboards/{uuid}/translations — create a new variant.
+ *
+ * Body: `{languageCode, name?, description?, widgetTreeJson?, copyFrom?}`.
+ * Returns 201 with the created entity. Maps duplicate-language
+ * conflicts to HTTP 409. REQ-DASH-040.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param string|null $languageCode The language code from the body.
+ * @param string|null $name The optional name.
+ * @param string|null $description The optional description.
+ * @param string|null $widgetTreeJson The optional widget tree JSON.
+ * @param string|null $copyFrom Optional source language.
+ *
+ * @return JSONResponse The created variant.
+ */
+ #[NoAdminRequired]
+ public function create(
+ string $uuid,
+ ?string $languageCode=null,
+ ?string $name=null,
+ ?string $description=null,
+ ?string $widgetTreeJson=null,
+ ?string $copyFrom=null
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $ownerCheck = $this->assertOwner(uuid: $uuid);
+ if ($ownerCheck !== null) {
+ return $ownerCheck;
+ }
+
+ if ($languageCode === null || $languageCode === '') {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_argument',
+ 'message' => DashboardTranslationService::ERR_INVALID_LANGUAGE,
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ try {
+ $variant = $this->translationService->createVariant(
+ dashboardUuid: $uuid,
+ languageCode: $languageCode,
+ name: $name,
+ description: $description,
+ widgetTreeJson: $widgetTreeJson,
+ copyFromLanguage: $copyFrom
+ );
+
+ return new JSONResponse(
+ data: ['translation' => $variant->jsonSerialize()],
+ statusCode: Http::STATUS_CREATED
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_argument',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ } catch (Exception $e) {
+ if ($e->getMessage() === DashboardTranslationService::ERR_LANGUAGE_EXISTS) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'language_exists',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_CONFLICT
+ );
+ }
+
+ return ResponseHelper::error(exception: $e);
+ }//end try
+ }//end create()
+
+ /**
+ * PUT /api/dashboards/{uuid}/translations/{lang} — update a variant.
+ *
+ * Body: `{name?, description?, widgetTreeJson?}`. Returns 200 with
+ * the updated entity. REQ-DASH-041.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param string $lang The language code from the URL.
+ * @param string|null $name Optional new name.
+ * @param string|null $description Optional new description.
+ * @param string|null $widgetTreeJson Optional new widget tree JSON.
+ *
+ * @return JSONResponse The updated variant.
+ */
+ #[NoAdminRequired]
+ public function update(
+ string $uuid,
+ string $lang,
+ ?string $name=null,
+ ?string $description=null,
+ ?string $widgetTreeJson=null
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $ownerCheck = $this->assertOwner(uuid: $uuid);
+ if ($ownerCheck !== null) {
+ return $ownerCheck;
+ }
+
+ $patch = $this->buildPatch(
+ name: $name,
+ description: $description,
+ widgetTreeJson: $widgetTreeJson
+ );
+
+ try {
+ $variant = $this->translationService->updateVariant(
+ dashboardUuid: $uuid,
+ languageCode: $lang,
+ patch: $patch
+ );
+
+ return ResponseHelper::success(
+ data: ['translation' => $variant->jsonSerialize()]
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }//end try
+ }//end update()
+
+ /**
+ * DELETE /api/dashboards/{uuid}/translations/{lang} — delete a
+ * variant. Maps last-variant / primary-variant guards to HTTP 400.
+ * REQ-DASH-042.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param string $lang The language code from the URL.
+ *
+ * @return JSONResponse The status payload.
+ */
+ #[NoAdminRequired]
+ public function destroy(string $uuid, string $lang): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $ownerCheck = $this->assertOwner(uuid: $uuid);
+ if ($ownerCheck !== null) {
+ return $ownerCheck;
+ }
+
+ try {
+ $this->translationService->deleteVariant(
+ dashboardUuid: $uuid,
+ languageCode: $lang
+ );
+
+ return ResponseHelper::success(data: ['status' => 'ok']);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (Exception $e) {
+ $errorCode = 'invalid_state';
+ if ($e->getMessage() === DashboardTranslationService::ERR_LAST_VARIANT) {
+ $errorCode = 'last_variant';
+ } else if ($e->getMessage() === DashboardTranslationService::ERR_DELETE_PRIMARY) {
+ $errorCode = 'primary_variant';
+ }
+
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => $errorCode,
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }//end try
+ }//end destroy()
+
+ /**
+ * POST /api/dashboards/{uuid}/translations/{lang}/set-primary —
+ * promote a variant to primary. Idempotent. REQ-DASH-043.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param string $lang The language code from the URL.
+ *
+ * @return JSONResponse The promoted variant.
+ */
+ #[NoAdminRequired]
+ public function setPrimary(string $uuid, string $lang): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $ownerCheck = $this->assertOwner(uuid: $uuid);
+ if ($ownerCheck !== null) {
+ return $ownerCheck;
+ }
+
+ try {
+ $variant = $this->translationService->promoteVariantToPrimary(
+ dashboardUuid: $uuid,
+ languageCode: $lang
+ );
+
+ return ResponseHelper::success(
+ data: ['translation' => $variant->jsonSerialize()]
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }
+ }//end setPrimary()
+
+ /**
+ * GET /api/dashboards/{uuid}/resolved — resolve the dashboard's
+ * content for the viewer's locale. Optional `?lang=` query
+ * parameter overrides the user's Nextcloud locale; in strict mode
+ * an unknown explicit lang returns 404 instead of falling back.
+ * REQ-DASH-039.
+ *
+ * Response shape:
+ * - `dashboard`: the dashboard entity payload
+ * - `translation`: the matched translation row
+ * - `availableLanguages`: sorted list of codes
+ * - `currentLanguage`: the matched code
+ * - `isFallback`: true when the primary fallback was used
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse The resolved payload.
+ */
+ #[NoAdminRequired]
+ public function resolved(string $uuid): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ $explicitLang = $this->request->getParam(key: 'lang');
+ $hasExplicit = is_string($explicitLang) === true && $explicitLang !== '';
+
+ if ($hasExplicit === true) {
+ $variant = $this->translationService->resolveForLocale(
+ dashboardUuid: $uuid,
+ preferredLanguage: $explicitLang
+ );
+
+ // Strict mode for explicit lang param — when no exact match
+ // exists for the requested code, return 404 instead of the
+ // primary-fallback envelope. REQ-DASH-039 strict scenario.
+ $requested = \OCA\MyDash\Db\DashboardTranslationMapper::normaliseLanguageCode(
+ raw: $explicitLang
+ );
+ $matched = null;
+ if ($variant !== null) {
+ $matched = (string) $variant['translation']->getLanguageCode();
+ }
+
+ if ($variant === null || $matched !== $requested) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'language_not_available',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+ } else {
+ $variant = $this->translationService->resolveForLocale(
+ dashboardUuid: $uuid,
+ preferredLanguage: ''
+ );
+
+ // Legacy fallback — dashboards predating REQ-DASH-038 may
+ // have no translation rows yet. Materialise an in-memory
+ // variant from the dashboard's own fields so the response
+ // envelope shape stays uniform. REQ-DASH-044.
+ if ($variant === null) {
+ $variant = [
+ 'translation' => $this->translationService
+ ->materialiseLegacyVariant(dashboard: $dashboard),
+ 'isFallback' => true,
+ ];
+ }
+ }//end if
+
+ $available = $this->translationService->listAvailableLanguages(
+ dashboardUuid: $uuid
+ );
+ if (count($available) === 0) {
+ $code = (string) $variant['translation']->getLanguageCode();
+ if ($code !== '') {
+ $available = [$code];
+ }
+ }
+
+ return ResponseHelper::success(
+ data: [
+ 'dashboard' => $dashboard->jsonSerialize(),
+ 'translation' => $variant['translation']->jsonSerialize(),
+ 'availableLanguages' => $available,
+ 'currentLanguage' => $variant['translation']->getLanguageCode(),
+ 'isFallback' => $variant['isFallback'],
+ ]
+ );
+ }//end resolved()
+
+ /**
+ * Look up the dashboard and verify the current user is the owner.
+ *
+ * Returns null when the check passes; an HTTP 403 / 404 envelope
+ * when it fails. Group-shared dashboards are not addressable via
+ * the personal-scope translation endpoints — they short-circuit to
+ * 403 (the group-scoped translation flow lives separately).
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse|null The error envelope or null on success.
+ */
+ private function assertOwner(string $uuid): ?JSONResponse
+ {
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ if ($dashboard->getUserId() !== $this->userId) {
+ return ResponseHelper::forbidden();
+ }
+
+ return null;
+ }//end assertOwner()
+
+ /**
+ * Build the patch payload from individual nullable parameters.
+ *
+ * `null` means "not in payload" (skip the key); anything else (incl.
+ * the empty string) means "set it explicitly". The service then
+ * inspects key presence with `array_key_exists`.
+ *
+ * @param string|null $name The new name.
+ * @param string|null $description The new description.
+ * @param string|null $widgetTreeJson The new widget tree JSON.
+ *
+ * @return array The patch payload.
+ */
+ private function buildPatch(
+ ?string $name,
+ ?string $description,
+ ?string $widgetTreeJson
+ ): array {
+ $patch = [];
+ if ($name !== null) {
+ $patch['name'] = $name;
+ }
+
+ if ($description !== null) {
+ $patch['description'] = $description;
+ }
+
+ if ($widgetTreeJson !== null) {
+ $patch['widgetTreeJson'] = $widgetTreeJson;
+ }
+
+ return $patch;
+ }//end buildPatch()
+}//end class
diff --git a/lib/Controller/DashboardVersionApiController.php b/lib/Controller/DashboardVersionApiController.php
new file mode 100644
index 00000000..5306f4b6
--- /dev/null
+++ b/lib/Controller/DashboardVersionApiController.php
@@ -0,0 +1,280 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use Exception;
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Db\DashboardMapper;
+use OCA\MyDash\Service\DashboardVersionService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Controller for dashboard version endpoints (REQ-VERS-001..009).
+ */
+class DashboardVersionApiController extends Controller
+{
+ /**
+ * Constructor
+ *
+ * @param IRequest $request NC request.
+ * @param DashboardMapper $dashboardMapper Dashboard row lookup.
+ * @param DashboardVersionService $versionService Version service.
+ * @param LoggerInterface $logger PSR logger.
+ * @param string|null $userId Current user ID.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly DashboardVersionService $versionService,
+ private readonly LoggerInterface $logger,
+ private readonly ?string $userId,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * List the versions for a dashboard, newest-first (REQ-VERS-003).
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse The version list envelope.
+ */
+ #[NoAdminRequired]
+ public function listVersions(string $uuid): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ try {
+ $envelope = $this->versionService->listVersions(
+ dashboard: $dashboard,
+ requestingUser: $this->userId
+ );
+ } catch (Exception $e) {
+ return $this->mapServiceException(exception: $e);
+ }
+
+ return new JSONResponse(data: $envelope, statusCode: Http::STATUS_OK);
+ }//end listVersions()
+
+ /**
+ * Fetch a single snapshot body (REQ-VERS-004).
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param integer $versionNumber The version number.
+ *
+ * @return JSONResponse The full snapshot body.
+ */
+ #[NoAdminRequired]
+ public function fetchVersion(
+ string $uuid,
+ int $versionNumber
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ try {
+ $version = $this->versionService->fetchSnapshot(
+ dashboard: $dashboard,
+ versionNumber: $versionNumber,
+ requestingUser: $this->userId
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Version not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (Exception $e) {
+ return $this->mapServiceException(exception: $e);
+ }
+
+ return new JSONResponse(
+ data: [
+ 'version' => $version->jsonSerialize(),
+ 'snapshot' => $version->getSnapshotJson(),
+ ],
+ statusCode: Http::STATUS_OK
+ );
+ }//end fetchVersion()
+
+ /**
+ * Create an explicit snapshot (REQ-VERS-002). Bypasses the
+ * 60-second debounce window. The optional `note` field is read
+ * from the request body.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param string|null $note Optional snapshot note (request body).
+ *
+ * @return JSONResponse The persisted version row.
+ */
+ #[NoAdminRequired]
+ public function createVersion(
+ string $uuid,
+ ?string $note=null
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ try {
+ $version = $this->versionService->createExplicitSnapshot(
+ dashboard: $dashboard,
+ requestingUser: $this->userId,
+ note: $note
+ );
+ } catch (Exception $e) {
+ return $this->mapServiceException(exception: $e);
+ }
+
+ return new JSONResponse(
+ data: ['version' => $version->jsonSerialize()],
+ statusCode: Http::STATUS_CREATED
+ );
+ }//end createVersion()
+
+ /**
+ * Restore a snapshot (REQ-VERS-005). Captures the pre-restore
+ * state as a new snapshot before applying the historical body.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param integer $versionNumber The version number to restore.
+ *
+ * @return JSONResponse The restored snapshot envelope.
+ */
+ #[NoAdminRequired]
+ public function restoreVersion(
+ string $uuid,
+ int $versionNumber
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ try {
+ $result = $this->versionService->restoreVersion(
+ dashboard: $dashboard,
+ versionNumber: $versionNumber,
+ restoringUser: $this->userId
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Version not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (Exception $e) {
+ return $this->mapServiceException(exception: $e);
+ }
+
+ return new JSONResponse(
+ data: [
+ 'version' => $result['version']->jsonSerialize(),
+ 'snapshot' => $result['snapshot'],
+ ],
+ statusCode: Http::STATUS_OK
+ );
+ }//end restoreVersion()
+
+ /**
+ * Map a service-layer Exception to the appropriate JSON envelope.
+ *
+ * @param Exception $exception The exception.
+ *
+ * @return JSONResponse The mapped HTTP response.
+ */
+ private function mapServiceException(Exception $exception): JSONResponse
+ {
+ $message = $exception->getMessage();
+
+ if ($message === DashboardVersionService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN) {
+ return new JSONResponse(
+ data: ['error' => 'forbidden'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ $this->logger->error(
+ message: 'mydash: version operation failed',
+ context: ['exception' => $exception]
+ );
+
+ return new JSONResponse(
+ data: ['error' => 'Operation failed'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end mapServiceException()
+}//end class
diff --git a/lib/Controller/FeedController.php b/lib/Controller/FeedController.php
new file mode 100644
index 00000000..44ad5b98
--- /dev/null
+++ b/lib/Controller/FeedController.php
@@ -0,0 +1,263 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Db\FeedToken;
+use OCA\MyDash\Service\FeedService;
+use OCA\MyDash\Service\FeedTokenService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\PublicPage;
+use OCP\AppFramework\Http\DataDisplayResponse;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\IRequest;
+use OCP\IURLGenerator;
+use Throwable;
+
+/**
+ * Controller for the RSS / Atom feed endpoints.
+ */
+class FeedController extends Controller
+{
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The request.
+ * @param FeedTokenService $tokenService Token-management service.
+ * @param FeedService $feedService Feed-render service.
+ * @param IURLGenerator $urlGenerator Absolute-URL builder for
+ * feed URLs returned to
+ * the management
+ * endpoints.
+ * @param string|null $userId The calling user ID.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly FeedTokenService $tokenService,
+ private readonly FeedService $feedService,
+ private readonly IURLGenerator $urlGenerator,
+ private readonly ?string $userId,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * `GET /api/feed/token` — issue or return the caller's active token
+ * (REQ-FEED-001).
+ *
+ * @return DataResponse `{token, url}` envelope on success;
+ * `{error}` 401 when not authenticated.
+ */
+ #[NoAdminRequired]
+ public function getToken(): DataResponse
+ {
+ if ($this->userId === null) {
+ return new DataResponse(
+ data: ['error' => 'Not logged in'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ $token = $this->tokenService->getOrCreateToken(userId: $this->userId);
+ return $this->buildTokenResponse(
+ token: $token,
+ statusCode: Http::STATUS_OK
+ );
+ }//end getToken()
+
+ /**
+ * `POST /api/feed/token/regenerate` — atomically revoke the caller's
+ * existing token (if any) and issue a fresh one (REQ-FEED-002).
+ *
+ * @return DataResponse `{token, url}` envelope on success;
+ * `{error}` 401 when not authenticated.
+ */
+ #[NoAdminRequired]
+ public function regenerateToken(): DataResponse
+ {
+ if ($this->userId === null) {
+ return new DataResponse(
+ data: ['error' => 'Not logged in'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ $token = $this->tokenService->regenerateToken(userId: $this->userId);
+ return $this->buildTokenResponse(
+ token: $token,
+ statusCode: Http::STATUS_OK
+ );
+ }//end regenerateToken()
+
+ /**
+ * `DELETE /api/feed/token` — soft-revoke the caller's active token.
+ * Idempotent (REQ-FEED-003).
+ *
+ * @return DataResponse Empty 204 on success; 401 when not authenticated.
+ */
+ #[NoAdminRequired]
+ public function revokeToken(): DataResponse
+ {
+ if ($this->userId === null) {
+ return new DataResponse(
+ data: ['error' => 'Not logged in'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ $this->tokenService->revokeToken(userId: $this->userId);
+ return new DataResponse(
+ data: [],
+ statusCode: Http::STATUS_NO_CONTENT
+ );
+ }//end revokeToken()
+
+ /**
+ * `GET /feed/{token}.xml` — public feed-render endpoint
+ * (REQ-FEED-004..007).
+ *
+ * Serves RSS 2.0 by default; honours `Accept: application/atom+xml`
+ * and the `?format=atom` fallback (design D4). Invalid, unknown,
+ * AND revoked tokens all yield a uniform HTTP 404 — never a 403 —
+ * so an attacker cannot probe for token existence (REQ-FEED-004
+ * scenarios "Fetch feed with invalid token" / "Fetch feed with
+ * revoked token").
+ *
+ * @param string $token The opaque token from the URL path.
+ *
+ * @return DataDisplayResponse The serialised XML feed or a 404
+ * stub.
+ */
+ #[PublicPage]
+ #[NoCSRFRequired]
+ public function publicFeed(string $token): DataDisplayResponse
+ {
+ $resolved = $this->tokenService->resolveToken(token: $token);
+ if ($resolved === null) {
+ return new DataDisplayResponse(
+ data: '',
+ statusCode: Http::STATUS_NOT_FOUND,
+ headers: ['Content-Type' => 'text/plain; charset=utf-8']
+ );
+ }
+
+ $format = $this->resolveRequestedFormat();
+ try {
+ $body = $this->feedService->renderFeed(
+ token: $resolved,
+ format: $format
+ );
+ } catch (Throwable) {
+ // Render failures degrade to 404 rather than leaking stack
+ // traces or partial XML to anonymous callers.
+ return new DataDisplayResponse(
+ data: '',
+ statusCode: Http::STATUS_NOT_FOUND,
+ headers: ['Content-Type' => 'text/plain; charset=utf-8']
+ );
+ }
+
+ $mime = FeedService::MIME_RSS;
+ if ($format === FeedService::FORMAT_ATOM) {
+ $mime = FeedService::MIME_ATOM;
+ }
+
+ return new DataDisplayResponse(
+ data: $body,
+ statusCode: Http::STATUS_OK,
+ headers: [
+ 'Content-Type' => $mime,
+ 'Cache-Control' => 'private, max-age=300',
+ ]
+ );
+ }//end publicFeed()
+
+ /**
+ * Build the `{token, url}` envelope returned to the management
+ * endpoints. Centralises the URL construction so REQ-FEED-001 and
+ * REQ-FEED-002 cannot drift out of sync.
+ *
+ * @param FeedToken $token The active feed token.
+ * @param int $statusCode The HTTP status code.
+ *
+ * @return DataResponse The envelope.
+ */
+ private function buildTokenResponse(
+ FeedToken $token,
+ int $statusCode
+ ): DataResponse {
+ $url = $this->urlGenerator->linkToRouteAbsolute(
+ routeName: Application::APP_ID.'.feed.publicFeed',
+ arguments: ['token' => (string) $token->getToken()]
+ );
+
+ return new DataResponse(
+ data: [
+ 'token' => (string) $token->getToken(),
+ 'url' => $url,
+ ],
+ statusCode: $statusCode
+ );
+ }//end buildTokenResponse()
+
+ /**
+ * Resolve the requested feed format from the `Accept` header (with
+ * a `?format=atom` query-string fallback for clients that cannot
+ * set headers — design D4).
+ *
+ * @return string Either {@see FeedService::FORMAT_RSS} or
+ * {@see FeedService::FORMAT_ATOM}.
+ */
+ private function resolveRequestedFormat(): string
+ {
+ $explicit = (string) $this->request->getParam(
+ key: 'format',
+ default: ''
+ );
+ if (strtolower(string: $explicit) === FeedService::FORMAT_ATOM) {
+ return FeedService::FORMAT_ATOM;
+ }
+
+ $accept = (string) $this->request->getHeader(name: 'Accept');
+ if (str_contains(haystack: strtolower(string: $accept), needle: 'application/atom+xml') === true) {
+ return FeedService::FORMAT_ATOM;
+ }
+
+ return FeedService::FORMAT_RSS;
+ }//end resolveRequestedFormat()
+}//end class
diff --git a/lib/Controller/FileController.php b/lib/Controller/FileController.php
index 356545dd..723522c4 100644
--- a/lib/Controller/FileController.php
+++ b/lib/Controller/FileController.php
@@ -3,14 +3,17 @@
/**
* FileController
*
- * HTTP entry point for the file-creation capability (REQ-LBN-004). Exposes a
- * single non-admin `POST /api/files/create` endpoint that accepts
- * `{filename, dir, content}` and returns a `{status, fileId, url}` success
- * envelope or a `{status, error, message}` error envelope on validation
- * failures.
+ * HTTP entry point for the link-button-widget capability's createFile
+ * flow. Exposes:
*
- * All errors are mapped to typed envelopes — raw exception messages are NEVER
- * returned to the client.
+ * - `POST /api/files/create` (any logged-in user) — accepts JSON
+ * `{filename, dir, content}` and returns `{status, fileId, url}`
+ * where `url` deep-links into the Files app at `?openfile=`.
+ *
+ * Every typed exception from {@see \OCA\MyDash\Service\FileService} is
+ * mapped to the standardised `{status, error, message}` error envelope
+ * — raw underlying exception messages are NEVER returned to the
+ * caller (REQ-LBN-004).
*
* @category Controller
* @package OCA\MyDash\Controller
@@ -28,21 +31,24 @@
namespace OCA\MyDash\Controller;
-use OCA\MyDash\Exception\ForbiddenExtensionException;
-use OCA\MyDash\Exception\InvalidDirectoryException;
-use OCA\MyDash\Exception\InvalidFilenameException;
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Exception\ForbiddenException;
+use OCA\MyDash\Exception\ResourceException;
+use OCA\MyDash\Exception\StorageFailureException;
use OCA\MyDash\Service\FileService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
-use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
+use OCP\IUserSession;
use Psr\Log\LoggerInterface;
use Throwable;
/**
- * Controller for the link-button file-creation endpoint.
+ * Controller for the link-button-widget createFile flow.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
*/
class FileController extends Controller
{
@@ -50,116 +56,121 @@ class FileController extends Controller
* Constructor.
*
* @param IRequest $request The HTTP request.
- * @param FileService $fileService The file creation service.
+ * @param FileService $fileService File-creation pipeline.
+ * @param IUserSession $userSession Session accessor.
* @param LoggerInterface $logger PSR logger.
- * @param string|null $userId The authenticated user ID.
*/
public function __construct(
IRequest $request,
private readonly FileService $fileService,
+ private readonly IUserSession $userSession,
private readonly LoggerInterface $logger,
- private readonly ?string $userId,
) {
parent::__construct(
- appName: 'mydash',
+ appName: Application::APP_ID,
request: $request
);
}//end __construct()
/**
- * Handle `POST /api/files/create`.
- *
- * Accepts `filename`, `dir` (default `/`), and `content` (default `''`)
- * from the request, delegates strict validation and file I/O to
- * FileService, and returns a typed envelope on success or error.
+ * Handle `POST /api/files/create` (REQ-LBN-004).
*
- * @param string $filename The desired filename (basename only).
- * @param string $dir Target directory inside the user folder.
- * @param string $content Initial file content (may be empty).
+ * @param string|null $filename Leaf filename.
+ * @param string|null $dir Target subdirectory (default `/`).
+ * @param string|null $content Bytes to write (default empty).
*
- * @return JSONResponse Either `{status:'success', fileId, url}` (HTTP 200)
- * or `{status:'error', error:, message:}`
- * (HTTP 400 or 500).
+ * @return JSONResponse Either `{status, fileId, url}` on HTTP 200
+ * or `{status, error, message}` on failure.
*
- * @NoAdminRequired
* @NoCSRFRequired
*/
#[NoAdminRequired]
- #[NoCSRFRequired]
public function createFile(
- string $filename='',
- string $dir='/',
- string $content=''
+ ?string $filename=null,
+ ?string $dir='/',
+ ?string $content=''
): JSONResponse {
- if ($this->userId === null) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'not_logged_in',
- 'message' => 'Not logged in',
- ],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
-
try {
+ $userId = $this->resolveUserId();
+
$result = $this->fileService->createFile(
- userId: $this->userId,
- filename: $filename,
- dir: $dir,
- content: $content
+ userId: $userId,
+ filename: ($filename ?? ''),
+ dir: ($dir ?? '/'),
+ content: ($content ?? '')
);
return new JSONResponse(
- data: [
- 'status' => 'success',
- 'fileId' => $result['fileId'],
- 'url' => $result['url'],
- ],
+ data: $result,
statusCode: Http::STATUS_OK
);
- } catch (InvalidFilenameException $e) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => $e->getErrorCode(),
- 'message' => $e->getDisplayMessage(),
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- } catch (InvalidDirectoryException $e) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => $e->getErrorCode(),
- 'message' => $e->getDisplayMessage(),
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- } catch (ForbiddenExtensionException $e) {
+ } catch (ForbiddenException $e) {
return new JSONResponse(
data: [
'status' => 'error',
- 'error' => $e->getErrorCode(),
- 'message' => $e->getDisplayMessage(),
+ 'error' => 'forbidden',
+ 'message' => 'Authentication required',
],
- statusCode: Http::STATUS_BAD_REQUEST
+ statusCode: Http::STATUS_UNAUTHORIZED
);
+ } catch (ResourceException $e) {
+ if ($e instanceof StorageFailureException) {
+ $this->logger->error(
+ message: 'File create storage failure',
+ context: ['exception' => $e->getMessage()]
+ );
+ }
+
+ return $this->errorResponse(exception: $e);
} catch (Throwable $e) {
- // Defence in depth — never leak raw messages.
+ // Defence in depth — never leak raw messages on
+ // truly unexpected paths.
$this->logger->error(
- message: 'Unexpected file creation failure',
+ message: 'Unexpected file create failure',
context: ['exception' => $e->getMessage()]
);
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'file_creation_failed',
- 'message' => 'Failed to create file',
- ],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ $fallback = new StorageFailureException(
+ message: 'Failed to create file'
);
+
+ return $this->errorResponse(exception: $fallback);
}//end try
}//end createFile()
+
+ /**
+ * Resolve the logged-in user's ID.
+ *
+ * @return string The user's UID.
+ *
+ * @throws ForbiddenException When the request is not authenticated.
+ */
+ private function resolveUserId(): string
+ {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ throw new ForbiddenException();
+ }
+
+ return $user->getUID();
+ }//end resolveUserId()
+
+ /**
+ * Build the standardised error envelope from a typed exception.
+ *
+ * @param ResourceException $exception The typed exception.
+ *
+ * @return JSONResponse The error response.
+ */
+ private function errorResponse(ResourceException $exception): JSONResponse
+ {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => $exception->getErrorCode(),
+ 'message' => $exception->getDisplayMessage(),
+ ],
+ statusCode: $exception->getHttpStatus()
+ );
+ }//end errorResponse()
}//end class
diff --git a/lib/Controller/FilesWidgetController.php b/lib/Controller/FilesWidgetController.php
new file mode 100644
index 00000000..458cf6b9
--- /dev/null
+++ b/lib/Controller/FilesWidgetController.php
@@ -0,0 +1,448 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Db\WidgetPlacementMapper;
+use OCA\MyDash\Exception\FolderNotFoundException;
+use OCA\MyDash\Exception\NoAccessException;
+use OCA\MyDash\Service\FilesWidgetService;
+use OCA\MyDash\Service\PermissionService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+use OCP\IUserSession;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Controller for the files-widget capability.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Permission-aware
+ * file browsing
+ * inherently couples
+ * the request,
+ * placement, session,
+ * logger, and the
+ * underlying service.
+ */
+class FilesWidgetController extends Controller
+{
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request HTTP request.
+ * @param FilesWidgetService $service Files widget service.
+ * @param WidgetPlacementMapper $placementMapper Placement entity mapper.
+ * @param PermissionService $permissionService Dashboard permission gate.
+ * @param IUserSession $userSession Session accessor.
+ * @param LoggerInterface $logger PSR logger.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly FilesWidgetService $service,
+ private readonly WidgetPlacementMapper $placementMapper,
+ private readonly PermissionService $permissionService,
+ private readonly IUserSession $userSession,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * `GET /api/widgets/files/{placementId}/contents`
+ *
+ * Returns the configured folder's contents as
+ * `{items: [...], nextCursor: ?string}`. Empty folder is HTTP 200
+ * with `items: []`. Missing folder is HTTP 404. Read-denied folder
+ * is HTTP 403.
+ *
+ * @param integer $placementId The widget placement id.
+ * @param string $currentPath Sub-path inside the configured folder.
+ * @param integer $limit Page size (capped server-side).
+ * @param string $cursor Opaque pagination cursor.
+ *
+ * @return JSONResponse
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function contents(
+ int $placementId,
+ string $currentPath='/',
+ int $limit=FilesWidgetService::DEFAULT_LIMIT,
+ string $cursor=''
+ ): JSONResponse {
+ $userId = $this->resolveUserId();
+ if ($userId === null) {
+ return $this->unauthorised();
+ }
+
+ $config = $this->loadConfig(placementId: $placementId, userId: $userId);
+ if ($config === null) {
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => 'forbidden'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ try {
+ $page = $this->service->getContentsForPlacement(
+ userId: $userId,
+ config: $config,
+ currentSubPath: $currentPath,
+ limit: $limit,
+ cursor: $cursor
+ );
+
+ return new JSONResponse(
+ data: $page,
+ statusCode: Http::STATUS_OK
+ );
+ } catch (FolderNotFoundException $e) {
+ return $this->errorResponse(
+ error: 'folder_not_found',
+ status: Http::STATUS_NOT_FOUND,
+ message: $e->getDisplayMessage()
+ );
+ } catch (NoAccessException $e) {
+ return $this->errorResponse(
+ error: 'no_access',
+ status: Http::STATUS_FORBIDDEN,
+ message: $e->getDisplayMessage()
+ );
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'Unexpected files widget contents failure',
+ context: ['exception' => $e->getMessage()]
+ );
+ return $this->errorResponse(
+ error: 'unknown_error',
+ status: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
+ }//end contents()
+
+ /**
+ * `POST /api/widgets/files/{placementId}/upload`
+ *
+ * Accepts `multipart/form-data` with one or more `files[]` entries
+ * and writes them into the placement-configured folder (or a
+ * sub-path of it, if `currentPath` is supplied).
+ *
+ * @param integer $placementId The widget placement id.
+ * @param string $currentPath Sub-path inside the configured folder.
+ *
+ * @return JSONResponse
+ */
+ #[NoAdminRequired]
+ public function upload(int $placementId, string $currentPath='/'): JSONResponse
+ {
+ $userId = $this->resolveUserId();
+ if ($userId === null) {
+ return $this->unauthorised();
+ }
+
+ $config = $this->loadConfig(placementId: $placementId, userId: $userId);
+ if ($config === null) {
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => 'forbidden'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ $files = $this->normaliseUploadedFiles();
+
+ try {
+ $result = $this->service->uploadFiles(
+ userId: $userId,
+ config: $config,
+ currentSubPath: $currentPath,
+ uploadedFiles: $files
+ );
+
+ return new JSONResponse(
+ data: $result,
+ statusCode: Http::STATUS_OK
+ );
+ } catch (FolderNotFoundException $e) {
+ return $this->errorResponse(
+ error: 'folder_not_found',
+ status: Http::STATUS_NOT_FOUND,
+ message: $e->getDisplayMessage()
+ );
+ } catch (NoAccessException $e) {
+ return $this->errorResponse(
+ error: 'no_access',
+ status: Http::STATUS_FORBIDDEN,
+ message: $e->getDisplayMessage()
+ );
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'Unexpected files widget upload failure',
+ context: ['exception' => $e->getMessage()]
+ );
+ return $this->errorResponse(
+ error: 'unknown_error',
+ status: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
+ }//end upload()
+
+ /**
+ * `DELETE /api/widgets/files/{placementId}/files/{fileId}`
+ *
+ * Moves the supplied file into the user's trash bin.
+ *
+ * @param integer $placementId The widget placement id.
+ * @param integer $fileId File id (must live inside the
+ * configured folder).
+ *
+ * @return JSONResponse
+ */
+ #[NoAdminRequired]
+ public function destroy(int $placementId, int $fileId): JSONResponse
+ {
+ $userId = $this->resolveUserId();
+ if ($userId === null) {
+ return $this->unauthorised();
+ }
+
+ $config = $this->loadConfig(placementId: $placementId, userId: $userId);
+ if ($config === null) {
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => 'forbidden'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ try {
+ $result = $this->service->deleteFile(
+ userId: $userId,
+ config: $config,
+ fileId: $fileId
+ );
+
+ return new JSONResponse(
+ data: $result,
+ statusCode: Http::STATUS_OK
+ );
+ } catch (FolderNotFoundException $e) {
+ return $this->errorResponse(
+ error: 'folder_not_found',
+ status: Http::STATUS_NOT_FOUND,
+ message: $e->getDisplayMessage()
+ );
+ } catch (NoAccessException $e) {
+ return $this->errorResponse(
+ error: 'no_access',
+ status: Http::STATUS_FORBIDDEN,
+ message: $e->getDisplayMessage()
+ );
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'Unexpected files widget delete failure',
+ context: ['exception' => $e->getMessage()]
+ );
+ return $this->errorResponse(
+ error: 'unknown_error',
+ status: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
+ }//end destroy()
+
+ /**
+ * Resolve the active user's UID, or `null` for anonymous.
+ *
+ * @return string|null
+ */
+ private function resolveUserId(): ?string
+ {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return null;
+ }
+
+ return $user->getUID();
+ }//end resolveUserId()
+
+ /**
+ * Load the placement, gate it through {@see PermissionService}, and
+ * return the parsed `widgetContent` config blob.
+ *
+ * Returns `null` when the placement is missing OR the user cannot
+ * view the underlying dashboard. The caller maps `null` to a
+ * forbidden response so missing-vs-no-access is indistinguishable
+ * to the client.
+ *
+ * @param integer $placementId Widget placement id.
+ * @param string $userId Viewing user's UID.
+ *
+ * @return array|null
+ */
+ private function loadConfig(int $placementId, string $userId): ?array
+ {
+ try {
+ $placement = $this->placementMapper->find(id: $placementId);
+ } catch (Throwable $e) {
+ return null;
+ }
+
+ $dashboardId = $placement->getDashboardId();
+ if ($this->permissionService->canViewDashboard(
+ userId: $userId,
+ dashboardId: $dashboardId
+ ) === false
+ ) {
+ return null;
+ }
+
+ $config = $placement->getStyleConfigArray();
+ // The registry stores the type-specific blob inside `content`
+ // (matching the discriminated `{type, content}` shape used by
+ // every other widget — see widgetRegistry.js).
+ if (isset($config['content']) === true && is_array($config['content']) === true) {
+ return $config['content'];
+ }
+
+ return $config;
+ }//end loadConfig()
+
+ /**
+ * Convert PHP's `$_FILES` super-global into a flat list of
+ * upload entries. Supports both single (`files=...`) and
+ * multi-part (`files[]=...`) submissions.
+ *
+ * @return list
+ */
+ private function normaliseUploadedFiles(): array
+ {
+ // @phpstan-ignore-next-line — superglobal access is mixed.
+ $raw = $_FILES['files'] ?? null;
+ if (is_array($raw) === false) {
+ return [];
+ }
+
+ $names = ($raw['name'] ?? null);
+ $tmps = ($raw['tmp_name'] ?? null);
+ $sizes = ($raw['size'] ?? null);
+ $errors = ($raw['error'] ?? null);
+
+ $entries = [];
+ if (is_array($names) === true) {
+ $count = count($names);
+ if (is_array($tmps) === false) {
+ $tmps = [];
+ }
+
+ if (is_array($sizes) === false) {
+ $sizes = [];
+ }
+
+ if (is_array($errors) === false) {
+ $errors = [];
+ }
+
+ for ($i = 0; $i < $count; $i++) {
+ $entries[] = [
+ 'name' => (string) ($names[$i] ?? ''),
+ 'tmp_name' => (string) ($tmps[$i] ?? ''),
+ 'size' => (int) ($sizes[$i] ?? 0),
+ 'error' => (int) ($errors[$i] ?? UPLOAD_ERR_NO_FILE),
+ ];
+ }
+ } else if ($names !== null) {
+ $entries[] = [
+ 'name' => (string) $names,
+ 'tmp_name' => (string) ($tmps ?? ''),
+ 'size' => (int) ($sizes ?? 0),
+ 'error' => (int) ($errors ?? UPLOAD_ERR_NO_FILE),
+ ];
+ }//end if
+
+ return $entries;
+ }//end normaliseUploadedFiles()
+
+ /**
+ * Build a 401 envelope for the anonymous case.
+ *
+ * @return JSONResponse
+ */
+ private function unauthorised(): JSONResponse
+ {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'unauthorized',
+ 'message' => 'Authentication required',
+ ],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }//end unauthorised()
+
+ /**
+ * Build a typed error envelope.
+ *
+ * The status code is restricted to the union of HTTP status codes
+ * accepted by {@see JSONResponse::__construct()} so that PHPStan
+ * can verify the literal at every call-site.
+ *
+ * @param string $error Machine-readable error code.
+ * @param int<100,511> $status HTTP status code.
+ * @param string|null $message Optional human-readable message.
+ *
+ * @return JSONResponse
+ */
+ private function errorResponse(string $error, int $status=Http::STATUS_BAD_REQUEST, ?string $message=null): JSONResponse
+ {
+ $payload = [
+ 'status' => 'error',
+ 'error' => $error,
+ ];
+
+ if ($message !== null) {
+ $payload['message'] = $message;
+ }
+
+ return new JSONResponse(
+ data: $payload,
+ statusCode: $status
+ );
+ }//end errorResponse()
+}//end class
diff --git a/lib/Controller/HealthController.php b/lib/Controller/HealthController.php
index 5a134a8b..02ea3e05 100644
--- a/lib/Controller/HealthController.php
+++ b/lib/Controller/HealthController.php
@@ -54,6 +54,8 @@ public function __construct(
* @return JSONResponse JSON response with health status and checks.
*
* @NoCSRFRequired
+ *
+ * @spec prometheus-metrics:REQ-PROM-007
*/
public function index(): JSONResponse
{
diff --git a/lib/Controller/MetadataAdminController.php b/lib/Controller/MetadataAdminController.php
new file mode 100644
index 00000000..03b5b960
--- /dev/null
+++ b/lib/Controller/MetadataAdminController.php
@@ -0,0 +1,320 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Exception\InvalidMetadataFieldException;
+use OCA\MyDash\Exception\MetadataFieldHasValuesException;
+use OCA\MyDash\Service\MetadataService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUserSession;
+
+/**
+ * Admin field-definition CRUD controller.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Constructor wires
+ * the standard Nextcloud admin trio (group manager + session +
+ * request) plus the capability service.
+ */
+class MetadataAdminController extends Controller
+{
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The HTTP request.
+ * @param MetadataService $metadataService The metadata service facade.
+ * @param IGroupManager $groupManager Group manager (admin gate).
+ * @param IUserSession $userSession Active session accessor.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly MetadataService $metadataService,
+ private readonly IGroupManager $groupManager,
+ private readonly IUserSession $userSession,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * `GET /api/admin/metadata-fields` — list all field definitions
+ * (REQ-MDFL-001).
+ *
+ * @return JSONResponse The fields array + count, or 403.
+ */
+ #[NoAdminRequired]
+ public function listFields(): JSONResponse
+ {
+ $forbidden = $this->assertAdmin();
+ if ($forbidden !== null) {
+ return $forbidden;
+ }
+
+ $fields = $this->metadataService->listFields();
+
+ return ResponseHelper::success(
+ data: [
+ 'fields' => ResponseHelper::serializeList(entities: $fields),
+ 'count' => count($fields),
+ ]
+ );
+ }//end listFields()
+
+ /**
+ * `POST /api/admin/metadata-fields` — create a new field definition
+ * (REQ-MDFL-001).
+ *
+ * @param string $key The slug.
+ * @param string $label The display label.
+ * @param string $type The field type.
+ * @param array|null $options Option set (select types).
+ * @param int $required 0 / 1.
+ * @param int $sortOrder UI sort order.
+ *
+ * @return JSONResponse 201 + field, 400 on validation failure,
+ * 403 for non-admins.
+ */
+ #[NoAdminRequired]
+ public function createField(
+ string $key='',
+ string $label='',
+ string $type='',
+ ?array $options=null,
+ int $required=0,
+ int $sortOrder=0
+ ): JSONResponse {
+ $forbidden = $this->assertAdmin();
+ if ($forbidden !== null) {
+ return $forbidden;
+ }
+
+ try {
+ $field = $this->metadataService->createFieldDefinition(
+ key: $key,
+ label: $label,
+ type: $type,
+ options: $options,
+ required: $required,
+ sortOrder: $sortOrder
+ );
+ } catch (InvalidMetadataFieldException $exception) {
+ return self::badRequest(message: $exception->getMessage());
+ }
+
+ return new JSONResponse(
+ data: $field->jsonSerialize(),
+ statusCode: Http::STATUS_CREATED
+ );
+ }//end createField()
+
+ /**
+ * `GET /api/admin/metadata-fields/{id}` — fetch a single field
+ * definition (REQ-MDFL-001).
+ *
+ * @param int $id The field id.
+ *
+ * @return JSONResponse 200 + field, 404 when missing, 403 for non-admins.
+ */
+ #[NoAdminRequired]
+ public function getField(int $id): JSONResponse
+ {
+ $forbidden = $this->assertAdmin();
+ if ($forbidden !== null) {
+ return $forbidden;
+ }
+
+ try {
+ $field = $this->metadataService->getField(id: $id);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Field not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ return ResponseHelper::success(data: $field->jsonSerialize());
+ }//end getField()
+
+ /**
+ * `PUT /api/admin/metadata-fields/{id}` — update label / sortOrder
+ * / required / options. Forbids `key` rename (REQ-MDFL-002).
+ *
+ * @param int $id The field id.
+ * @param string|null $label The new label.
+ * @param int|null $sortOrder The new sort order.
+ * @param int|null $required The new required flag.
+ * @param array|null $options The new option set.
+ * @param string|null $key Forbidden — triggers 400.
+ *
+ * @return JSONResponse 200 + field, 400 on validation failure,
+ * 404 when missing, 403 for non-admins.
+ */
+ #[NoAdminRequired]
+ public function updateField(
+ int $id,
+ ?string $label=null,
+ ?int $sortOrder=null,
+ ?int $required=null,
+ ?array $options=null,
+ ?string $key=null
+ ): JSONResponse {
+ $forbidden = $this->assertAdmin();
+ if ($forbidden !== null) {
+ return $forbidden;
+ }
+
+ $patch = [];
+ if ($key !== null) {
+ $patch['key'] = $key;
+ }
+
+ if ($label !== null) {
+ $patch['label'] = $label;
+ }
+
+ if ($sortOrder !== null) {
+ $patch['sortOrder'] = $sortOrder;
+ }
+
+ if ($required !== null) {
+ $patch['required'] = $required;
+ }
+
+ // Distinguish "not supplied" from "null to clear". The router
+ // delivers `null` when the body omits the key; treat any
+ // explicit array (including empty) as "set options" so admins
+ // can clear an option set on non-select types.
+ if ($options !== null) {
+ $patch['options'] = $options;
+ }
+
+ try {
+ $field = $this->metadataService->updateFieldDefinition(
+ id: $id,
+ patch: $patch
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Field not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (InvalidMetadataFieldException $exception) {
+ return self::badRequest(message: $exception->getMessage());
+ }
+
+ return ResponseHelper::success(data: $field->jsonSerialize());
+ }//end updateField()
+
+ /**
+ * `DELETE /api/admin/metadata-fields/{id}?cascade=true` —
+ * REQ-MDFL-003.
+ *
+ * @param int $id The field id.
+ * @param bool $cascade Whether to cascade-delete dependent values.
+ *
+ * @return JSONResponse 200 on success, 409 when soft-deletion blocked,
+ * 404 when missing, 403 for non-admins.
+ */
+ #[NoAdminRequired]
+ public function deleteField(int $id, bool $cascade=false): JSONResponse
+ {
+ $forbidden = $this->assertAdmin();
+ if ($forbidden !== null) {
+ return $forbidden;
+ }
+
+ try {
+ $this->metadataService->deleteFieldDefinition(
+ id: $id,
+ cascade: $cascade
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Field not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (MetadataFieldHasValuesException $exception) {
+ return new JSONResponse(
+ data: [
+ 'error' => MetadataFieldHasValuesException::ERROR_CODE,
+ 'message' => $exception->getMessage(),
+ 'valueCount' => $exception->getValueCount(),
+ ],
+ statusCode: Http::STATUS_CONFLICT
+ );
+ }
+
+ return ResponseHelper::success(data: ['status' => 'ok']);
+ }//end deleteField()
+
+ /**
+ * Build a 400-with-error envelope.
+ *
+ * @param string $message The validation message.
+ *
+ * @return JSONResponse The 400 response.
+ */
+ private static function badRequest(string $message): JSONResponse
+ {
+ return new JSONResponse(
+ data: [
+ 'error' => InvalidMetadataFieldException::ERROR_CODE,
+ 'message' => $message,
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }//end badRequest()
+
+ /**
+ * Reject non-admin callers.
+ *
+ * @return JSONResponse|null `null` when the caller is an admin, or
+ * a 403 response that the calling action
+ * should return verbatim.
+ */
+ private function assertAdmin(): ?JSONResponse
+ {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return ResponseHelper::forbidden();
+ }
+
+ if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
+ return ResponseHelper::forbidden();
+ }
+
+ return null;
+ }//end assertAdmin()
+}//end class
diff --git a/lib/Controller/MetricsController.php b/lib/Controller/MetricsController.php
index 0127c6aa..545e6f91 100644
--- a/lib/Controller/MetricsController.php
+++ b/lib/Controller/MetricsController.php
@@ -57,6 +57,8 @@ public function __construct(
* @return TextPlainResponse Plain text response with Prometheus metrics.
*
* @NoCSRFRequired
+ *
+ * @spec prometheus-metrics:REQ-PROM-001
*/
public function index(): TextPlainResponse
{
diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php
index 9ec92f87..87896e98 100644
--- a/lib/Controller/PageController.php
+++ b/lib/Controller/PageController.php
@@ -3,11 +3,14 @@
/**
* PageController
*
- * Controller for rendering the main MyDash workspace page. The workspace
- * initial-state payload is constructed via
- * {@see \OCA\MyDash\Service\InitialStateBuilder} per REQ-INIT-001 — direct
- * calls to IInitialState::provideInitialState are forbidden here (and any
- * other controller) and enforced by a grep lint test.
+ * Controller for rendering the main MyDash workspace page (REQ-INIT-001,
+ * REQ-INIT-002). The page-render path constructs an
+ * {@see \OCA\MyDash\Service\InitialStateBuilder} for
+ * {@see \OCA\MyDash\Service\InitialState\Page::WORKSPACE}, populates every
+ * key declared in the spec's Data Model, and applies — direct calls to
+ * {@see \OCP\AppFramework\Services\IInitialState::provideInitialState()}
+ * are forbidden here (and any other controller) and enforced by the
+ * `lint:initial-state` CI guard.
*
* @category Controller
* @package OCA\MyDash\Controller
@@ -24,134 +27,313 @@
use OCA\MyDash\AppInfo\Application;
use OCA\MyDash\Db\Dashboard;
-use OCA\MyDash\Service\AdminSettingsService;
use OCA\MyDash\Service\AdminTemplateService;
use OCA\MyDash\Service\DashboardService;
+use OCA\MyDash\Service\DashboardTreeService;
+use OCA\MyDash\Service\InitialState\Page;
use OCA\MyDash\Service\InitialStateBuilder;
-use OCA\MyDash\Service\Page;
+use OCA\MyDash\Service\RoleFeaturePermissionService;
+use OCA\MyDash\Service\WidgetService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
-use OCP\Dashboard\IManager as IDashboardManager;
-use OCP\IGroupManager;
+use OCP\Dashboard\IManager;
use OCP\IRequest;
use OCP\IUserSession;
use OCP\Util;
+use Psr\Log\LoggerInterface;
+use Throwable;
+/**
+ * Workspace page controller — wires the typed initial-state contract.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Boot path needs widget,
+ * dashboard, settings and
+ * group services to fill
+ * the contract.
+ */
class PageController extends Controller
{
/**
- * Constructor
+ * Constructor.
*
- * @param IRequest $request The request.
- * @param IInitialState $initialState Nextcloud initial-state service.
- * @param IDashboardManager $dashboardManager Dashboard widget manager.
- * @param IUserSession $userSession Current user session.
- * @param IGroupManager $groupManager Group membership lookup.
- * @param AdminSettingsService $adminSettings MyDash admin settings.
- * @param DashboardService $dashboardService Dashboard service (active
- * resolver —
- * REQ-DASH-018).
- * @param AdminTemplateService $templateService Template service (primary
- * group resolver —
- * REQ-TMPL-012).
+ * @param IRequest $request The request.
+ * @param IManager $dashboardManager Nextcloud dashboard widget manager.
+ * @param IInitialState $initialState The Nextcloud initial-state service.
+ * @param IUserSession $userSession Active user session.
+ * @param WidgetService $widgetService Available-widgets descriptor formatter.
+ * @param DashboardService $dashboardService Dashboard listing + resolver
+ * (also exposes the
+ * `allow_user_dashboards` flag
+ * — REQ-ASET-003).
+ * @param AdminTemplateService $adminTemplateService Primary-group routing
+ * resolver (REQ-TMPL-012,
+ * REQ-TMPL-013).
+ * @param RoleFeaturePermissionService $roleFeaturePerm Per-user widget
+ * allow-list source
+ * (REQ-RFP-009..010).
+ * @param DashboardTreeService $treeService Slug-chain
+ * resolver used by the
+ * deep-link route.
+ * @param LoggerInterface $logger Used to record
+ * silent fallback
+ * when a deep-link
+ * path doesn't
+ * resolve to a
+ * visible dashboard.
*/
public function __construct(
IRequest $request,
+ private readonly IManager $dashboardManager,
private readonly IInitialState $initialState,
- private readonly IDashboardManager $dashboardManager,
private readonly IUserSession $userSession,
- private readonly IGroupManager $groupManager,
- private readonly AdminSettingsService $adminSettings,
+ private readonly WidgetService $widgetService,
private readonly DashboardService $dashboardService,
- private readonly AdminTemplateService $templateService,
+ private readonly AdminTemplateService $adminTemplateService,
+ private readonly RoleFeaturePermissionService $roleFeaturePerm,
+ private readonly DashboardTreeService $treeService,
+ private readonly LoggerInterface $logger,
) {
parent::__construct(appName: Application::APP_ID, request: $request);
}//end __construct()
/**
- * Render the main workspace index page.
+ * Deep-link entry point — `/apps/mydash/{deepLink}`.
+ *
+ * Symfony binds the captured slug-chain into `$deepLink`. Delegating
+ * to {@see self::index()} keeps the workspace render path single-
+ * sourced; the optional path argument merely overrides the active
+ * dashboard before initial-state assembly.
+ *
+ * @param string $deepLink Slug-chain captured from the URL (may
+ * contain `/` separators).
+ *
+ * @return TemplateResponse The workspace template response.
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function deepLink(string $deepLink=''): TemplateResponse
+ {
+ return $this->index(deepLink: $deepLink);
+ }//end deepLink()
+
+ /**
+ * Render the workspace page.
*
- * Builds the workspace initial-state payload via InitialStateBuilder
- * (REQ-INIT-001) and applies it before the template is rendered.
+ * Wires the full workspace initial-state contract into the template via
+ * {@see InitialStateBuilder}. Every key declared in REQ-INIT-002 is set
+ * before `apply()` runs; missing keys raise
+ * {@see \OCA\MyDash\Exception\MissingInitialStateException} so the page
+ * never renders with a partial payload.
+ *
+ * Deep-link path: when `$deepLink` resolves through the tree service
+ * to a dashboard the user can read, that dashboard is used as the
+ * active one (overriding the resolver's seven-step fallback). When
+ * the path doesn't resolve (renamed, deleted, never existed, or not
+ * visible to the caller), the controller logs a warning and falls
+ * back silently — bookmarks of stale slug chains still land on
+ * something instead of 404'ing.
+ *
+ * @param string $deepLink Optional slug-chain selecting the active
+ * dashboard. Empty string ⇒ default resolver.
*
* @return TemplateResponse The template response.
*/
#[NoAdminRequired]
#[NoCSRFRequired]
- public function index(): TemplateResponse
+ public function index(string $deepLink=''): TemplateResponse
{
Util::addScript(application: Application::APP_ID, file: 'mydash-main');
Util::addStyle(application: Application::APP_ID, file: 'mydash');
// Load all widget scripts so legacy widgets can register their callbacks.
- $widgets = $this->loadWidgetScripts();
+ $this->loadWidgetScripts();
- $user = $this->userSession->getUser();
- $isAdmin = false;
- $userId = null;
+ $user = $this->userSession->getUser();
+ $userId = '';
if ($user !== null) {
- $userId = $user->getUID();
- $isAdmin = $this->groupManager->isAdmin(userId: $userId);
+ $userId = $user->getUID();
+ }
+
+ // Routing resolver — REQ-TMPL-012 / REQ-TMPL-013. The
+ // `AdminTemplateService` walks the admin-configured `group_order`
+ // priority list and returns the first group the user belongs to,
+ // OR the literal `'default'` sentinel when nothing matches. The
+ // display name comes from the same service so the lookup lives in
+ // exactly one place.
+ $primaryGroupId = Dashboard::DEFAULT_GROUP_ID;
+ $primaryGroupName = $this->adminTemplateService->resolvePrimaryGroupDisplayName(
+ groupId: Dashboard::DEFAULT_GROUP_ID
+ );
+ if ($userId !== '') {
+ $primaryGroupId = $this->adminTemplateService->resolvePrimaryGroup(
+ userId: $userId
+ );
+ $primaryGroupName = $this->adminTemplateService->resolvePrimaryGroupDisplayName(
+ groupId: $primaryGroupId
+ );
+ }
+
+ $isAdmin = false;
+ if ($userId !== '') {
+ $isAdmin = $this->dashboardService->isAdmin(userId: $userId);
}
- // Resolve the primary group via the canonical REQ-TMPL-012 authority.
- $primaryGroup = Dashboard::DEFAULT_GROUP_ID;
- if ($userId !== null) {
- $primaryGroup = $this->templateService->resolvePrimaryGroup(userId: $userId);
+ $visible = [];
+ if ($userId !== '') {
+ $visible = $this->dashboardService->getVisibleToUser(userId: $userId);
}
- $primaryGroupName = $primaryGroup;
- if ($primaryGroup !== 'default'
- && $this->groupManager->groupExists(gid: $primaryGroup) === true
- ) {
- $group = $this->groupManager->get(gid: $primaryGroup);
- if ($group !== null) {
- $primaryGroupName = $group->getDisplayName();
+ $groupDashboards = [];
+ $userDashboards = [];
+ foreach ($visible as $entry) {
+ $dashboard = $entry['dashboard'];
+ // Dashboard entity has no icon column today — surface an empty
+ // string so the frontend descriptor shape matches REQ-INIT-002.
+ $descriptor = [
+ 'id' => (string) $dashboard->getUuid(),
+ 'name' => (string) $dashboard->getName(),
+ 'icon' => '',
+ 'source' => $entry['source'],
+ ];
+
+ if ($entry['source'] === Dashboard::SOURCE_USER) {
+ unset($descriptor['source']);
+ $userDashboards[] = $descriptor;
+ continue;
}
+
+ $groupDashboards[] = $descriptor;
}
- // Resolve the active dashboard via the REQ-DASH-018 precedence chain.
+ $active = null;
+ if ($userId !== '') {
+ // Deep-link override: when the URL carries a slug-chain we
+ // try to land the user on that dashboard before consulting
+ // the seven-step resolver. Failures (path doesn't resolve,
+ // not visible, throws) are swallowed so a stale bookmark
+ // still opens *something* instead of breaking.
+ if ($deepLink !== '') {
+ try {
+ $resolved = $this->treeService->resolvePath(path: $deepLink);
+ if ($resolved !== null) {
+ $active = $this->dashboardService->getDashboardForUser(
+ dashboardId: $resolved->getId(),
+ userId: $userId
+ );
+ }
+ } catch (Throwable $t) {
+ $this->logger->warning(
+ message: 'mydash: deep-link resolution failed for path "{path}": {message}',
+ context: [
+ 'path' => $deepLink,
+ 'message' => $t->getMessage(),
+ ]
+ );
+ }
+
+ if ($active === null) {
+ $this->logger->info(
+ message: 'mydash: deep-link path "{path}" not visible — falling back to default resolver',
+ context: ['path' => $deepLink]
+ );
+ }
+ }//end if
+
+ if ($active === null) {
+ $active = $this->dashboardService->resolveActiveDashboard(
+ userId: $userId,
+ primaryGroupId: $primaryGroupId
+ );
+ }
+ }//end if
+
$activeDashboardId = '';
- $dashboardSource = 'group';
- if ($userId !== null) {
- $resolved = $this->dashboardService->resolveActiveDashboard(
- userId: $userId,
- primaryGroupId: $primaryGroup
+ $dashboardSource = Dashboard::SOURCE_GROUP;
+ $layout = [];
+ $deepLinkPath = '';
+ if ($active !== null) {
+ $activeDashboard = $active['dashboard'];
+ $activeDashboardId = (string) $activeDashboard->getUuid();
+ $dashboardSource = (string) $active['source'];
+ $placements = $this->widgetService->getDashboardPlacements(
+ dashboardId: $activeDashboard->getId()
);
- if ($resolved !== null) {
- $activeDashboardId = (string) $resolved['dashboard']->getUuid();
- $dashboardSource = $resolved['source'];
+ $layout = array_map(
+ callback: function ($placement) {
+ return $placement->jsonSerialize();
+ },
+ array: $placements
+ );
+ // Canonical slug-chain for whatever dashboard ended up active —
+ // the frontend reads this to keep the URL in sync (e.g. after
+ // a parent rename, a stale bookmarked path is normalised
+ // in-place via `history.replaceState`).
+ try {
+ $deepLinkPath = $this->treeService->computePath(
+ uuid: (string) $activeDashboard->getUuid()
+ );
+ } catch (Throwable $t) {
+ $this->logger->warning(
+ message: 'mydash: failed to compute path for active dashboard {uuid}: {message}',
+ context: [
+ 'uuid' => (string) $activeDashboard->getUuid(),
+ 'message' => $t->getMessage(),
+ ]
+ );
}
- }
+ }//end if
- $settings = $this->adminSettings->getSettings();
+ $allowUserDashboards = $this->dashboardService->getAllowUserDashboards();
$builder = new InitialStateBuilder(
initialState: $this->initialState,
page: Page::WORKSPACE
);
+
+ $builder
+ ->setWidgets($this->widgetService->getAvailableWidgets())
+ ->setLayout($layout)
+ ->setPrimaryGroup($primaryGroupId)
+ ->setPrimaryGroupName($primaryGroupName)
+ ->setIsAdmin($isAdmin)
+ ->setActiveDashboardId($activeDashboardId)
+ ->setDashboardSource($dashboardSource)
+ ->setGroupDashboards($groupDashboards)
+ ->setUserDashboards($userDashboards)
+ ->setAllowUserDashboards($allowUserDashboards);
+
+ // PR #95 (role-based-content): per-user widget allow-list.
+ // `null` = no admin policy for this user (unlimited).
+ $allowedWidgets = null;
+ if ($userId !== '') {
+ $allowedWidgets = $this->roleFeaturePerm->getAllowedWidgetIds(
+ userId: $userId
+ );
+ }
+
$builder
- ->setWidgets(widgets: $this->describeWidgets(widgets: $widgets))
- ->setLayout(layout: [])
- ->setPrimaryGroup(primaryGroup: $primaryGroup)
- ->setPrimaryGroupName(primaryGroupName: $primaryGroupName)
- ->setIsAdmin(isAdmin: $isAdmin)
- ->setActiveDashboardId(activeDashboardId: $activeDashboardId)
- ->setDashboardSource(dashboardSource: $dashboardSource)
- ->setGroupDashboards(groupDashboards: [])
- ->setUserDashboards(userDashboards: [])
- ->setAllowUserDashboards(
- allowUserDashboards: (bool) ($settings['allowUserDashboards'] ?? false)
- )
+ ->setAllowedWidgets($allowedWidgets)
+ ->setDeepLinkPath($deepLinkPath)
->apply();
- return new TemplateResponse(
+ // REQ-SHELL-001: pass the chrome slot ids so Nextcloud treats
+ // `#app-workspace` as the main content slot and allocates no left
+ // navigation panel (the runtime shell renders its own slide-in
+ // sidebar via `dashboard-switcher-sidebar`). Renderer parameter
+ // names match the Nextcloud chrome conventions.
+ $response = new TemplateResponse(
appName: Application::APP_ID,
- templateName: 'index'
+ templateName: 'index',
+ params: [
+ 'id-app-content' => '#app-workspace',
+ 'id-app-navigation' => null,
+ ]
);
+
+ return $response;
}//end index()
/**
@@ -173,27 +355,4 @@ private function loadWidgetScripts(): array
return $widgets;
}//end loadWidgetScripts()
-
- /**
- * Build serialisable widget descriptors for the initial-state payload.
- *
- * @param array $widgets Widget map.
- *
- * @return array
- */
- private function describeWidgets(array $widgets): array
- {
- $descriptors = [];
- foreach ($widgets as $id => $widget) {
- $descriptors[] = [
- 'id' => $id,
- 'title' => $widget->getTitle(),
- 'iconClass' => $widget->getIconClass(),
- 'iconUrl' => '',
- 'url' => $widget->getUrl(),
- ];
- }
-
- return $descriptors;
- }//end describeWidgets()
}//end class
diff --git a/lib/Controller/PeopleWidgetController.php b/lib/Controller/PeopleWidgetController.php
new file mode 100644
index 00000000..0c285d1e
--- /dev/null
+++ b/lib/Controller/PeopleWidgetController.php
@@ -0,0 +1,171 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use InvalidArgumentException;
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Service\PeopleWidgetService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+use Psr\Log\LoggerInterface;
+
+/**
+ * REST controller exposing the paginated People widget directory.
+ */
+class PeopleWidgetController extends Controller
+{
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request HTTP request.
+ * @param PeopleWidgetService $service People widget service.
+ * @param LoggerInterface $logger Logger for ResponseHelper::error.
+ * @param string|null $userId Active user (null when anonymous).
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly PeopleWidgetService $service,
+ private readonly LoggerInterface $logger,
+ private readonly ?string $userId,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * GET /api/people
+ *
+ * REQ-PPL-003: returns `{users, total, hasMore}`, offset-based
+ * pagination, capped at {@see PeopleWidgetService::MAX_LIMIT}.
+ *
+ * Request parameters:
+ * - `filters` JSON-encoded array of FilterObject entries
+ * (see REQ-PPL-002 / REQ-PPL-006).
+ * - `excludeDisabled` 1/0 boolean (default 1).
+ * - `showBirthdays` 1/0 boolean (default 1).
+ * - `sortBy` One of `displayName` (default), `group`,
+ * `recent-activity` (rejected with 400).
+ * - `limit` 1..100 (default 50).
+ * - `offset` >= 0 (default 0).
+ *
+ * @param string|null $filters JSON-encoded filter list.
+ * @param int|null $excludeDisabled 1 to exclude disabled users.
+ * @param int|null $showBirthdays 1 to include birthdate field.
+ * @param string|null $sortBy Sort key.
+ * @param int|null $limit Page size.
+ * @param int|null $offset Page offset.
+ *
+ * @return JSONResponse
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function getUsers(
+ ?string $filters=null,
+ ?int $excludeDisabled=1,
+ ?int $showBirthdays=1,
+ ?string $sortBy='displayName',
+ ?int $limit=PeopleWidgetService::DEFAULT_LIMIT,
+ ?int $offset=0,
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $parsedFilters = $this->parseFilters(raw: $filters);
+ } catch (InvalidArgumentException $e) {
+ return ResponseHelper::error(
+ exception: $e,
+ statusCode: Http::STATUS_BAD_REQUEST,
+ logger: $this->logger,
+ message: 'Invalid filters parameter'
+ );
+ }
+
+ try {
+ $payload = $this->service->listUsers(
+ filters: $parsedFilters,
+ excludeDisabled: ($excludeDisabled ?? 1) === 1,
+ showBirthdays: ($showBirthdays ?? 1) === 1,
+ sortBy: ($sortBy ?? 'displayName'),
+ limit: ($limit ?? PeopleWidgetService::DEFAULT_LIMIT),
+ offset: ($offset ?? 0),
+ );
+ } catch (InvalidArgumentException $e) {
+ return ResponseHelper::error(
+ exception: $e,
+ statusCode: Http::STATUS_BAD_REQUEST,
+ logger: $this->logger,
+ message: 'Invalid request parameters'
+ );
+ }
+
+ return ResponseHelper::success(data: $payload);
+ }//end getUsers()
+
+ /**
+ * Parse the JSON-encoded `filters` query parameter. Returns `[]` when
+ * the parameter is absent or an empty string. Throws on malformed
+ * JSON or on a top-level non-array.
+ *
+ * Per-entry shape validation is intentionally permissive: the service
+ * layer ignores unknown filter keys, so extra fields are tolerated.
+ *
+ * @param string|null $raw The raw query string value.
+ *
+ * @return array>
+ *
+ * @throws InvalidArgumentException When the JSON is malformed or the
+ * decoded value is not an array.
+ */
+ private function parseFilters(?string $raw): array
+ {
+ if ($raw === null || $raw === '') {
+ return [];
+ }
+
+ $decoded = json_decode(json: $raw, associative: true);
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ throw new InvalidArgumentException(
+ message: 'filters: malformed JSON ('.json_last_error_msg().')'
+ );
+ }
+
+ if (is_array(value: $decoded) === false) {
+ throw new InvalidArgumentException(
+ message: 'filters: expected JSON array'
+ );
+ }
+
+ // Re-key so PHPStan is happy with the array shape.
+ return array_values(array: $decoded);
+ }//end parseFilters()
+}//end class
diff --git a/lib/Controller/ResourceController.php b/lib/Controller/ResourceController.php
index c60014dc..56397ee6 100644
--- a/lib/Controller/ResourceController.php
+++ b/lib/Controller/ResourceController.php
@@ -3,15 +3,18 @@
/**
* ResourceController
*
- * HTTP entry point for the resource-uploads capability. Exposes a
- * single admin-only `POST /api/resources` endpoint that accepts a
- * raw JSON body of the shape `{base64: 'data:image/;base64,...'}`
- * and returns a standardised `{status, url, name, size}` envelope.
+ * HTTP entry point for the resource-uploads capability. Exposes:
*
- * The read side of the same capability (REQ-RES-006..008 — public
- * serve + listing) is delivered by `ResourceServeController`, kept in
- * a sibling class so this controller's dependency graph stays under
- * the PHPMD CouplingBetweenObjects limit.
+ * - `POST /api/resources` (admin-only) — accepts a raw JSON body of
+ * the shape `{base64: 'data:image/;base64,...'}` and returns
+ * a standardised `{status, url, name, size}` envelope.
+ * - `GET /apps/mydash/resource/{filename}` (any logged-in user) —
+ * streams the resource bytes with extension-derived Content-Type
+ * and `Cache-Control: public, max-age=31536000`. The `uniqid`
+ * suffix in REQ-RES-004 filenames doubles as the cache buster.
+ * - `GET /api/resources` (any logged-in user) — lists the uploaded
+ * resources as `{name, url, size, modifiedAt}` tuples ordered by
+ * `modifiedAt` desc.
*
* All errors are mapped to a `{status: 'error', error: ,
* message: }` envelope — raw exception messages are NEVER
@@ -33,13 +36,21 @@
namespace OCA\MyDash\Controller;
+use OCA\MyDash\AppInfo\Application;
use OCA\MyDash\Exception\ForbiddenException;
use OCA\MyDash\Exception\ResourceException;
use OCA\MyDash\Exception\StorageFailureException;
use OCA\MyDash\Service\ResourceService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\JSONResponse;
+use OCP\AppFramework\Http\StreamResponse;
+use OCP\Files\IAppData;
+use OCP\Files\NotFoundException;
+use OCP\Files\NotPermittedException;
+use OCP\Files\SimpleFS\ISimpleFile;
+use OCP\IL10N;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IUserSession;
@@ -47,10 +58,34 @@
use Throwable;
/**
- * Admin-only resource upload controller.
+ * Resource upload + serving controller.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
*/
class ResourceController extends Controller
{
+ /**
+ * Maximum bytes we are willing to read into memory when serving
+ * a resource. Mirrors {@see ResourceService::MAX_BYTES}.
+ */
+ private const SERVE_MAX_BYTES = (5 * 1024 * 1024);
+
+ /**
+ * Map of lowercase file extensions to Content-Type strings used by
+ * the {@see getResource()} streamer. Anything not in this map
+ * falls back to `application/octet-stream`.
+ *
+ * @var array
+ */
+ private const CONTENT_TYPE_MAP = [
+ 'jpg' => 'image/jpeg',
+ 'jpeg' => 'image/jpeg',
+ 'png' => 'image/png',
+ 'gif' => 'image/gif',
+ 'svg' => 'image/svg+xml',
+ 'webp' => 'image/webp',
+ ];
+
/**
* Constructor.
*
@@ -59,6 +94,8 @@ class ResourceController extends Controller
* @param ResourceUploadRequestParser $parser Parses request body.
* @param IUserSession $userSession Session accessor.
* @param IGroupManager $groupManager Admin checker.
+ * @param IAppData $appData App-data accessor.
+ * @param IL10N $l10n Translator.
* @param LoggerInterface $logger PSR logger.
*/
public function __construct(
@@ -67,6 +104,8 @@ public function __construct(
private readonly ResourceUploadRequestParser $parser,
private readonly IUserSession $userSession,
private readonly IGroupManager $groupManager,
+ private readonly IAppData $appData,
+ private readonly IL10N $l10n,
private readonly LoggerInterface $logger,
) {
parent::__construct(
@@ -137,6 +176,254 @@ public function upload(): JSONResponse
}//end try
}//end upload()
+ /**
+ * Handle `GET /apps/mydash/resource/{filename}` (REQ-RES-006).
+ *
+ * Streams the bytes of an uploaded resource via `php://memory`
+ * (REQ-RES-008) with a Content-Type derived from the file
+ * extension and `Cache-Control: public, max-age=31536000`. The
+ * `uniqid` suffix produced by REQ-RES-004 already acts as the
+ * cache-busting key — re-uploading the same logical asset
+ * produces a fresh filename so clients can cache aggressively.
+ *
+ * Path traversal protection: the `{filename}` parameter is
+ * validated as a leaf name; any embedded `/` or `..` (decoded
+ * from the route parameter) results in HTTP 404 — never a 200
+ * with a system file.
+ *
+ * Files larger than the upload cap (5 MB per REQ-RES-003) are
+ * refused with HTTP 413 BEFORE the bytes are loaded into memory
+ * — only reachable via manual filesystem tampering, but bounds
+ * worst-case memory usage.
+ *
+ * @param string $filename The leaf filename inside the resources
+ * folder.
+ *
+ * @return StreamResponse|JSONResponse The streamed bytes, or a
+ * JSON error envelope on
+ * `not_found` / `file_too_large`.
+ *
+ * @NoCSRFRequired
+ */
+ #[NoAdminRequired]
+ public function getResource(string $filename)
+ {
+ // Leaf-only guard — the Symfony route param matches `[^/]+`
+ // by default, but defence in depth catches any encoded
+ // `..%2F` that decoders may have already collapsed.
+ if ($this->isUnsafeFilename(filename: $filename) === true) {
+ return $this->notFoundResponse();
+ }
+
+ try {
+ $folder = $this->appData->getFolder(name: ResourceService::FOLDER);
+ } catch (NotFoundException $e) {
+ return $this->notFoundResponse();
+ }
+
+ try {
+ $file = $folder->getFile(name: $filename);
+ } catch (NotFoundException $e) {
+ return $this->notFoundResponse();
+ }
+
+ $size = (int) $file->getSize();
+ if ($size > self::SERVE_MAX_BYTES) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'file_too_large',
+ 'message' => $this->l10n->t('Resource exceeds the 5 MB serving cap'),
+ ],
+ statusCode: Http::STATUS_REQUEST_ENTITY_TOO_LARGE
+ );
+ }
+
+ try {
+ $bytes = $file->getContent();
+ } catch (NotFoundException | NotPermittedException $e) {
+ return $this->notFoundResponse();
+ }
+
+ // Stream via php://memory — bounded by the size guard above
+ // so worst-case memory is the 5 MB upload cap.
+ $stream = fopen(filename: 'php://memory', mode: 'r+');
+ if ($stream === false) {
+ $this->logger->error(message: 'Could not open php://memory for resource serving');
+ return $this->notFoundResponse();
+ }
+
+ fwrite(stream: $stream, data: $bytes);
+ rewind(stream: $stream);
+
+ $contentType = $this->contentTypeFor(filename: $filename);
+
+ $response = new StreamResponse(
+ filePath: $stream,
+ status: Http::STATUS_OK,
+ headers: [
+ 'Content-Type' => $contentType,
+ 'Cache-Control' => 'public, max-age=31536000',
+ ]
+ );
+
+ return $response;
+ }//end getResource()
+
+ /**
+ * Handle `GET /api/resources` (REQ-RES-007).
+ *
+ * Lists every file in `/resources/` as `{name, url,
+ * size, modifiedAt}` tuples ordered by `modifiedAt` descending.
+ * If the folder does not yet exist (no upload has happened), the
+ * response is `{status: 'success', resources: []}` with HTTP 200
+ * — never 404.
+ *
+ * Authentication: any logged-in user (NOT admin-gated) — the
+ * uploaded resources are referenced by every dashboard render so
+ * gating the listing would break dashboards for non-admins.
+ *
+ * @return JSONResponse `{status: 'success', resources: [...]}`.
+ *
+ * @NoCSRFRequired
+ */
+ #[NoAdminRequired]
+ public function listResources(): JSONResponse
+ {
+ try {
+ $folder = $this->appData->getFolder(name: ResourceService::FOLDER);
+ } catch (NotFoundException $e) {
+ // Empty folder → empty array, HTTP 200 (NOT 404).
+ return new JSONResponse(
+ data: [
+ 'status' => 'success',
+ 'resources' => [],
+ ],
+ statusCode: Http::STATUS_OK
+ );
+ }
+
+ $resources = [];
+ foreach ($folder->getDirectoryListing() as $file) {
+ $resources[] = $this->describeResource(file: $file);
+ }
+
+ // Sort by modifiedAt descending (newest first).
+ usort(
+ $resources,
+ static function (array $left, array $right): int {
+ return ($right['mtime'] <=> $left['mtime']);
+ }
+ );
+
+ // Strip internal sort key.
+ $resources = array_map(
+ static function (array $row): array {
+ unset($row['mtime']);
+ return $row;
+ },
+ $resources
+ );
+
+ return new JSONResponse(
+ data: [
+ 'status' => 'success',
+ 'resources' => $resources,
+ ],
+ statusCode: Http::STATUS_OK
+ );
+ }//end listResources()
+
+ /**
+ * Describe a single resource file for the listing response.
+ *
+ * Includes an internal `mtime` field used purely for sorting —
+ * stripped before the response is serialised.
+ *
+ * @param ISimpleFile $file The file to describe.
+ *
+ * @return array{name: string, url: string, size: int, modifiedAt: string, mtime: int}
+ */
+ private function describeResource(ISimpleFile $file): array
+ {
+ $name = $file->getName();
+ $mtime = $file->getMTime();
+
+ return [
+ 'name' => $name,
+ 'url' => ('/apps/'.Application::APP_ID.'/resource/'.$name),
+ 'size' => (int) $file->getSize(),
+ 'modifiedAt' => gmdate(format: 'Y-m-d\TH:i:s\Z', timestamp: $mtime),
+ 'mtime' => $mtime,
+ ];
+ }//end describeResource()
+
+ /**
+ * Test whether a filename is unsafe to use as a leaf name.
+ *
+ * Rejects any name containing a path separator (`/`, `\`) or a
+ * literal `..` segment, plus the empty string. Treated as 404 by
+ * the caller to avoid leaking the existence of a system path.
+ *
+ * @param string $filename The decoded route parameter.
+ *
+ * @return bool True if the name is unsafe.
+ */
+ private function isUnsafeFilename(string $filename): bool
+ {
+ if ($filename === '') {
+ return true;
+ }
+
+ if (strpos(haystack: $filename, needle: '/') !== false) {
+ return true;
+ }
+
+ if (strpos(haystack: $filename, needle: '\\') !== false) {
+ return true;
+ }
+
+ if (strpos(haystack: $filename, needle: '..') !== false) {
+ return true;
+ }
+
+ return false;
+ }//end isUnsafeFilename()
+
+ /**
+ * Map a filename's extension to a Content-Type string.
+ *
+ * Lowercased lookup against {@see CONTENT_TYPE_MAP}; unknown
+ * extensions fall back to `application/octet-stream`.
+ *
+ * @param string $filename The leaf filename.
+ *
+ * @return string The Content-Type for the StreamResponse.
+ */
+ private function contentTypeFor(string $filename): string
+ {
+ $extension = strtolower(string: pathinfo(path: $filename, flags: PATHINFO_EXTENSION));
+
+ return (self::CONTENT_TYPE_MAP[$extension] ?? 'application/octet-stream');
+ }//end contentTypeFor()
+
+ /**
+ * Build a 404 response with an empty body.
+ *
+ * Per REQ-RES-006 the body MAY be empty — we deliberately do not
+ * leak which path was missing or which traversal pattern was
+ * blocked.
+ *
+ * @return JSONResponse The 404 response.
+ */
+ private function notFoundResponse(): JSONResponse
+ {
+ return new JSONResponse(
+ data: [],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }//end notFoundResponse()
+
/**
* Throw if the current session user is not an admin.
*
diff --git a/lib/Controller/RoleFeaturePermissionApiController.php b/lib/Controller/RoleFeaturePermissionApiController.php
new file mode 100644
index 00000000..a76044b5
--- /dev/null
+++ b/lib/Controller/RoleFeaturePermissionApiController.php
@@ -0,0 +1,250 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Service\RoleFeaturePermissionService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUserSession;
+
+/**
+ * Admin-only API for role-feature permissions and role-layout defaults.
+ *
+ * @spec openspec/changes/role-based-content/tasks.md#task-3
+ */
+class RoleFeaturePermissionApiController extends Controller
+{
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The HTTP request.
+ * @param RoleFeaturePermissionService $service Permission service.
+ * @param IGroupManager $groupMgr Group manager (admin guard).
+ * @param IUserSession $userSession The current user session.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly RoleFeaturePermissionService $service,
+ private readonly IGroupManager $groupMgr,
+ private readonly IUserSession $userSession,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * GET /api/role-feature-permissions
+ *
+ * @return JSONResponse The list of permission rows.
+ */
+ public function listPermissions(): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $rows = $this->service->listPermissions();
+ return ResponseHelper::success(
+ data: ResponseHelper::serializeList(entities: $rows)
+ );
+ }//end listPermissions()
+
+ /**
+ * POST /api/role-feature-permissions
+ *
+ * Body: `{name, description?, groupId, allowedWidgets, deniedWidgets?, priorityWeights?}`.
+ * Upsert keyed by `groupId`.
+ *
+ * @return JSONResponse The persisted row.
+ */
+ public function savePermission(): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ try {
+ $payload = $this->readJsonBody();
+ $entity = $this->service->savePermission(data: $payload);
+ return ResponseHelper::success(
+ data: $entity->jsonSerialize(),
+ statusCode: Http::STATUS_CREATED
+ );
+ } catch (\InvalidArgumentException $e) {
+ return ResponseHelper::error(
+ exception: $e,
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ } catch (\Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }//end try
+ }//end savePermission()
+
+ /**
+ * DELETE /api/role-feature-permissions/{id}
+ *
+ * @param int $id The row id.
+ *
+ * @return JSONResponse Empty 204 on success.
+ */
+ public function deletePermission(int $id): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ try {
+ $this->service->deletePermission(id: $id);
+ return new JSONResponse(data: [], statusCode: Http::STATUS_NO_CONTENT);
+ } catch (DoesNotExistException $e) {
+ return ResponseHelper::error(
+ exception: $e,
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }//end try
+ }//end deletePermission()
+
+ /**
+ * GET /api/role-layout-defaults
+ *
+ * @return JSONResponse The list of layout default rows.
+ */
+ public function listLayoutDefaults(): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $rows = $this->service->listLayoutDefaults();
+ return ResponseHelper::success(
+ data: ResponseHelper::serializeList(entities: $rows)
+ );
+ }//end listLayoutDefaults()
+
+ /**
+ * POST /api/role-layout-defaults
+ *
+ * @return JSONResponse The persisted row.
+ */
+ public function saveLayoutDefault(): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ try {
+ $payload = $this->readJsonBody();
+ $entity = $this->service->saveLayoutDefault(data: $payload);
+ return ResponseHelper::success(
+ data: $entity->jsonSerialize(),
+ statusCode: Http::STATUS_CREATED
+ );
+ } catch (\InvalidArgumentException $e) {
+ return ResponseHelper::error(
+ exception: $e,
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ } catch (\Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }//end try
+ }//end saveLayoutDefault()
+
+ /**
+ * DELETE /api/role-layout-defaults/{id}
+ *
+ * @param int $id The row id.
+ *
+ * @return JSONResponse Empty 204 on success.
+ */
+ public function deleteLayoutDefault(int $id): JSONResponse
+ {
+ $guard = $this->requireAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ try {
+ $this->service->deleteLayoutDefault(id: $id);
+ return new JSONResponse(data: [], statusCode: Http::STATUS_NO_CONTENT);
+ } catch (DoesNotExistException $e) {
+ return ResponseHelper::error(
+ exception: $e,
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }//end try
+ }//end deleteLayoutDefault()
+
+ /**
+ * Read the request body as a JSON object (best-effort).
+ *
+ * @return array The decoded body, or `[]` when not JSON.
+ */
+ private function readJsonBody(): array
+ {
+ $raw = file_get_contents(filename: 'php://input');
+ if ($raw === false || $raw === '') {
+ return $this->request->getParams();
+ }
+
+ $decoded = json_decode(json: $raw, associative: true);
+ if (is_array(value: $decoded) === false) {
+ return $this->request->getParams();
+ }
+
+ return $decoded;
+ }//end readJsonBody()
+
+ /**
+ * Admin guard — same pattern as `AdminController::requireAdmin()`.
+ *
+ * @return JSONResponse|null `null` when caller is admin; the error otherwise.
+ */
+ private function requireAdmin(): ?JSONResponse
+ {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ if ($this->groupMgr->isAdmin(userId: $user->getUID()) === false) {
+ return ResponseHelper::forbidden(
+ message: 'Administrator privileges required.'
+ );
+ }
+
+ return null;
+ }//end requireAdmin()
+}//end class
diff --git a/lib/Controller/RuleApiController.php b/lib/Controller/RuleApiController.php
index 9fec1044..1460be1d 100644
--- a/lib/Controller/RuleApiController.php
+++ b/lib/Controller/RuleApiController.php
@@ -58,6 +58,8 @@ public function __construct(
* @param int $placementId The placement ID.
*
* @return JSONResponse The conditional rules.
+ *
+ * @spec conditional-visibility:REQ-VIS-002
*/
#[NoAdminRequired]
public function getRules(int $placementId): JSONResponse
@@ -86,24 +88,47 @@ public function getRules(int $placementId): JSONResponse
/**
* Add a conditional rule to a widget placement.
*
- * @param int $placementId The placement ID.
- * @param string $ruleType The rule type.
- * @param array $ruleConfig The rule configuration.
- * @param bool $isInclude Whether this is an include rule.
+ * @param int $placementId The placement ID.
+ * @param string|null $ruleType The rule type.
+ * @param array|null $ruleConfig The rule configuration.
+ * @param bool $isInclude Whether this is an include rule.
*
* @return JSONResponse The created rule.
+ *
+ * @spec conditional-visibility:REQ-VIS-001
*/
#[NoAdminRequired]
public function addRule(
int $placementId,
- string $ruleType,
- array $ruleConfig,
+ ?string $ruleType=null,
+ ?array $ruleConfig=null,
bool $isInclude=true
): JSONResponse {
if ($this->userId === null) {
return ResponseHelper::unauthorized();
}
+ // Validate body shape explicitly so missing fields return a clean
+ // 400 instead of a TypeError 500 from the dispatcher. Mirrors the
+ // hardening on WidgetApiController::addWidget.
+ if ($ruleType === null || $ruleType === '') {
+ return ResponseHelper::error(
+ exception: new \InvalidArgumentException(
+ 'Missing required field: ruleType'
+ ),
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ if ($ruleConfig === null) {
+ return ResponseHelper::error(
+ exception: new \InvalidArgumentException(
+ 'Missing required field: ruleConfig'
+ ),
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
try {
$this->permissionService->verifyPlacementOwnership(
userId: $this->userId,
@@ -134,6 +159,8 @@ public function addRule(
* @param bool|null $isInclude Whether this is an include rule.
*
* @return JSONResponse The updated rule.
+ *
+ * @spec conditional-visibility:REQ-VIS-003
*/
#[NoAdminRequired]
public function updateRule(
@@ -172,6 +199,8 @@ public function updateRule(
* @param int $ruleId The rule ID.
*
* @return JSONResponse The deletion confirmation.
+ *
+ * @spec conditional-visibility:REQ-VIS-004
*/
#[NoAdminRequired]
public function deleteRule(int $ruleId): JSONResponse
diff --git a/lib/Controller/TemplateController.php b/lib/Controller/TemplateController.php
new file mode 100644
index 00000000..fc2ddd26
--- /dev/null
+++ b/lib/Controller/TemplateController.php
@@ -0,0 +1,200 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Controller;
+
+use InvalidArgumentException;
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Exception\ForbiddenException;
+use OCA\MyDash\Service\AdminTemplateService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+use OCP\IUserSession;
+
+/**
+ * Controller for the template gallery + save-as-template flow
+ * (REQ-TMPL-014, REQ-TMPL-015).
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
+ */
+class TemplateController extends Controller
+{
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The request.
+ * @param AdminTemplateService $templateService The template service.
+ * @param IUserSession $userSession The current user
+ * session (required for
+ * the owner check).
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly AdminTemplateService $templateService,
+ private readonly IUserSession $userSession,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * `GET /api/templates/gallery` — list all admin templates with the
+ * gallery metadata (REQ-TMPL-014).
+ *
+ * Query parameters:
+ * - `category` (string, optional): exact-match category filter.
+ * - `sort` (string, optional): `name` (default) or `updatedAt`.
+ *
+ * Available to every logged-in user. Returns a `{status: 'success',
+ * templates: [...]}` envelope with no widget bodies — gallery is a
+ * list view, not a render.
+ *
+ * @param string|null $category Optional category filter.
+ * @param string $sort Sort key (`name` or `updatedAt`).
+ *
+ * @return JSONResponse The gallery list envelope.
+ */
+ #[NoAdminRequired]
+ public function gallery(
+ ?string $category=null,
+ string $sort='name'
+ ): JSONResponse {
+ $templates = $this->templateService->getGallery(
+ category: $category,
+ sortBy: $sort
+ );
+
+ return new JSONResponse(
+ data: [
+ 'status' => 'success',
+ 'templates' => $templates,
+ ],
+ statusCode: Http::STATUS_OK
+ );
+ }//end gallery()
+
+ /**
+ * `POST /api/dashboards/{uuid}/save-as-template` — convert a
+ * personal dashboard into a new admin template (REQ-TMPL-015).
+ *
+ * Body fields (JSON):
+ * - `name` (string, required): template display name.
+ * - `description` (string, optional): long-form gallery description.
+ * - `category` (string, optional): free-form category label.
+ * - `previewImage` (string, optional): pre-uploaded preview URL.
+ *
+ * Owner check enforced inside the service: source row MUST have
+ * `userId === ` AND `type === 'user'`. Mismatches map
+ * to HTTP 403 — never 404 — to keep the contract consistent with
+ * the spec.
+ *
+ * @param string $uuid The source dashboard UUID.
+ * @param string|null $name Template name (required).
+ * @param string|null $description Long-form description.
+ * @param string|null $category Free-form category label.
+ * @param string|null $previewImage Pre-uploaded preview URL.
+ *
+ * @return JSONResponse The new template envelope.
+ */
+ #[NoAdminRequired]
+ public function saveAsTemplate(
+ string $uuid,
+ ?string $name=null,
+ ?string $description=null,
+ ?string $category=null,
+ ?string $previewImage=null
+ ): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'unauthenticated',
+ 'message' => 'Login required',
+ ],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ $metadata = [
+ 'name' => (string) $name,
+ 'description' => $description,
+ 'category' => $category,
+ 'previewImage' => $previewImage,
+ ];
+
+ try {
+ $template = $this->templateService->saveAsTemplate(
+ userId: $user->getUID(),
+ dashboardUuid: $uuid,
+ metadata: $metadata
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_payload',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ } catch (ForbiddenException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'forbidden',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ } catch (DoesNotExistException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'forbidden',
+ 'message' => 'Dashboard not found or not owned by you',
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }//end try
+
+ return new JSONResponse(
+ data: [
+ 'status' => 'success',
+ 'template' => $template->jsonSerialize(),
+ ],
+ statusCode: Http::STATUS_CREATED
+ );
+ }//end saveAsTemplate()
+}//end class
diff --git a/lib/Controller/TileApiController.php b/lib/Controller/TileApiController.php
index 6277dd23..d94c26a5 100644
--- a/lib/Controller/TileApiController.php
+++ b/lib/Controller/TileApiController.php
@@ -5,6 +5,13 @@
*
* Controller for tile API endpoints.
*
+ * Per REQ-TILE-001 (DEPRECATED) the write endpoints (`create`, `update`,
+ * `destroy`) MUST return HTTP 410 Gone with the documented envelope so
+ * legacy clients are explicitly directed at the unified add-widget flow
+ * (REQ-WDG-022 / REQ-TILE-PLACEMENT). The read endpoint (`index`) keeps
+ * working so admin tooling and migration scripts can still inspect the
+ * existing `oc_mydash_tiles` rows during the deprecation window.
+ *
* @category Controller
* @package OCA\MyDash\Controller
* @author Conduction b.v.
@@ -25,6 +32,7 @@
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\JSONResponse;
+use OCP\IL10N;
use OCP\IRequest;
/**
@@ -32,16 +40,27 @@
*/
class TileApiController extends Controller
{
+ /**
+ * Replacement pointer surfaced in the HTTP 410 Gone envelope. Kept as
+ * a class constant so the controller, its tests, and any future
+ * migration tooling reference the same string.
+ *
+ * @var string
+ */
+ public const REPLACEMENT_HINT = 'POST /api/dashboards/{uuid}/widgets with type:tile';
+
/**
* Constructor
*
* @param IRequest $request The request.
* @param TileService $tileService The tile service.
+ * @param IL10N $l10n The localisation helper.
* @param string|null $userId The user ID.
*/
public function __construct(
IRequest $request,
private readonly TileService $tileService,
+ private readonly IL10N $l10n,
private readonly ?string $userId,
) {
parent::__construct(
@@ -53,7 +72,14 @@ public function __construct(
/**
* List all tiles for the current user.
*
+ * Read-only endpoint kept for backwards compatibility per the
+ * DEPRECATED REQ-TILE-001 spec — admin tooling and migration scripts
+ * may still inspect `oc_mydash_tiles` rows during the deprecation
+ * window. The write endpoints return HTTP 410 Gone instead.
+ *
* @return JSONResponse The list of tiles.
+ *
+ * @spec tiles:REQ-TILE-002
*/
#[NoAdminRequired]
#[NoCSRFRequired]
@@ -73,190 +99,77 @@ public function index(): JSONResponse
/**
* Create a new tile.
*
- * @param string|null $title The tile title.
- * @param string|null $icon The icon.
- * @param string|null $iconType The icon type.
- * @param string|null $backgroundColor The background color.
- * @param string|null $textColor The text color.
- * @param string|null $linkType The link type.
- * @param string|null $linkValue The link value.
+ * DEPRECATED — returns HTTP 410 Gone. New tile placements MUST be
+ * created via the unified add-widget flow with `type: tile`
+ * (REQ-WDG-022 / REQ-TILE-PLACEMENT). Parameters retained for
+ * route binding compatibility only and intentionally ignored.
+ *
+ * @return JSONResponse The HTTP 410 Gone envelope.
*
- * @return JSONResponse The created tile.
+ * @spec tiles:REQ-TILE-001
*/
#[NoAdminRequired]
#[NoCSRFRequired]
- public function create(
- ?string $title=null,
- ?string $icon=null,
- ?string $iconType=null,
- ?string $backgroundColor=null,
- ?string $textColor=null,
- ?string $linkType=null,
- ?string $linkValue=null
- ): JSONResponse {
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- try {
- $tile = $this->tileService->createTile(
- userId: $this->userId,
- title: $title ?? 'New Tile',
- icon: $icon ?? 'icon-link',
- iconType: $iconType ?? 'class',
- backgroundColor: $backgroundColor ?? '#0082c9',
- textColor: $textColor ?? '#ffffff',
- linkType: $linkType ?? 'url',
- linkValue: $linkValue ?? '#'
- );
-
- return ResponseHelper::success(
- data: $tile->jsonSerialize(),
- statusCode: Http::STATUS_CREATED
- );
- } catch (\Exception $e) {
- return ResponseHelper::error(exception: $e);
- }//end try
+ public function create(): JSONResponse
+ {
+ return $this->goneResponse();
}//end create()
/**
* Update a tile.
*
- * @param int|array $id The tile ID or JSON data.
- * @param string|null $title The tile title.
- * @param string|null $icon The icon.
- * @param string|null $iconType The icon type.
- * @param string|null $backgroundColor The background color.
- * @param string|null $textColor The text color.
- * @param string|null $linkType The link type.
- * @param string|null $linkValue The link value.
+ * DEPRECATED — returns HTTP 410 Gone. Tile placement edits MUST go
+ * through the standard widget-placement update endpoint
+ * (REQ-WDG-022 / REQ-TILE-PLACEMENT). The legacy parameter list is
+ * preserved so existing routing wiring continues to bind without
+ * raising an `InvalidArgumentException`.
*
- * @return JSONResponse The updated tile.
+ * @return JSONResponse The HTTP 410 Gone envelope.
+ *
+ * @spec tiles:REQ-TILE-003
*/
#[NoAdminRequired]
#[NoCSRFRequired]
- public function update(
- $id,
- $title=null,
- ?string $icon=null,
- ?string $iconType=null,
- ?string $backgroundColor=null,
- ?string $textColor=null,
- ?string $linkType=null,
- ?string $linkValue=null
- ): JSONResponse {
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- $resolvedData = $this->resolveUpdateData(
- id: $id,
- title: $title,
- icon: $icon,
- iconType: $iconType,
- bgColor: $backgroundColor,
- textColor: $textColor,
- linkType: $linkType,
- linkValue: $linkValue
- );
-
- if ($resolvedData['id'] === null) {
- return ResponseHelper::success(
- data: ['error' => 'Missing tile ID'],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- try {
- $tile = $this->tileService->updateTile(
- id: (int) $resolvedData['id'],
- userId: $this->userId,
- data: $resolvedData['data']
- );
-
- return ResponseHelper::success(
- data: $tile->jsonSerialize()
- );
- } catch (\Exception $e) {
- return ResponseHelper::error(exception: $e);
- }//end try
+ public function update(): JSONResponse
+ {
+ return $this->goneResponse();
}//end update()
/**
- * Resolve update data from either JSON body or parameters.
+ * Delete a tile.
+ *
+ * DEPRECATED — returns HTTP 410 Gone. Tile placement removal goes
+ * through the standard widget-placement delete endpoint going
+ * forward (REQ-WDG-022 / REQ-TILE-PLACEMENT).
*
- * @param mixed $id The tile ID or JSON body.
- * @param mixed $title The title.
- * @param string|null $icon The icon.
- * @param string|null $iconType The icon type.
- * @param string|null $bgColor The background color.
- * @param string|null $textColor The text color.
- * @param string|null $linkType The link type.
- * @param string|null $linkValue The link value.
+ * @return JSONResponse The HTTP 410 Gone envelope.
*
- * @return array The resolved ID and data.
+ * @spec tiles:REQ-TILE-004
*/
- private function resolveUpdateData(
- $id,
- $title,
- ?string $icon,
- ?string $iconType,
- ?string $bgColor,
- ?string $textColor,
- ?string $linkType,
- ?string $linkValue
- ): array {
- if (is_array($id) === true) {
- return [
- 'id' => $id['id'] ?? null,
- 'data' => $id,
- ];
- }
-
- $fields = [
- 'title' => $title,
- 'icon' => $icon,
- 'iconType' => $iconType,
- 'backgroundColor' => $bgColor,
- 'textColor' => $textColor,
- 'linkType' => $linkType,
- 'linkValue' => $linkValue,
- ];
-
- return [
- 'id' => $id,
- 'data' => array_filter(
- array: $fields,
- callback: function ($value) {
- return $value !== null;
- }
- ),
- ];
- }//end resolveUpdateData()
+ #[NoAdminRequired]
+ public function destroy(): JSONResponse
+ {
+ return $this->goneResponse();
+ }//end destroy()
/**
- * Delete a tile.
- *
- * @param int $id The tile ID.
+ * Build the HTTP 410 Gone envelope shared by every write endpoint.
*
- * @return JSONResponse Success response.
+ * @return JSONResponse The envelope `{status, message, replacement}`.
*/
- #[NoAdminRequired]
- public function destroy(int $id): JSONResponse
+ private function goneResponse(): JSONResponse
{
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- try {
- $this->tileService->deleteTile(
- id: $id,
- userId: $this->userId
- );
+ $message = $this->l10n->t(
+ 'The reusable tile API is no longer available. Use the unified add-widget flow with type:tile instead.'
+ );
- return ResponseHelper::success(data: ['status' => 'ok']);
- } catch (\Exception $e) {
- return ResponseHelper::error(exception: $e);
- }
- }//end destroy()
+ return new JSONResponse(
+ data: [
+ 'status' => 'gone',
+ 'message' => $message,
+ 'replacement' => self::REPLACEMENT_HINT,
+ ],
+ statusCode: Http::STATUS_GONE
+ );
+ }//end goneResponse()
}//end class
diff --git a/lib/Controller/WidgetApiController.php b/lib/Controller/WidgetApiController.php
index afeb8688..36ab982c 100644
--- a/lib/Controller/WidgetApiController.php
+++ b/lib/Controller/WidgetApiController.php
@@ -18,33 +18,53 @@
namespace OCA\MyDash\Controller;
+use DateTimeImmutable;
use OCA\MyDash\AppInfo\Application;
-use OCA\MyDash\Service\WidgetService;
+use OCA\MyDash\Service\CalendarWidgetService;
+use OCA\MyDash\Service\NewsWidgetService;
use OCA\MyDash\Service\PermissionService;
+use OCA\MyDash\Service\RoleFeaturePermissionService;
+use OCA\MyDash\Service\WidgetPlacementService;
+use OCA\MyDash\Service\WidgetService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
+use Throwable;
/**
* Controller for managing dashboard widgets.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Routes for calendar
+ * events, tiles, and
+ * generic widget CRUD
+ * share this controller.
+ * @SuppressWarnings(PHPMD.LongVariable)
*/
class WidgetApiController extends Controller
{
/**
* Constructor
*
- * @param IRequest $request The request.
- * @param WidgetService $widgetService The widget service.
- * @param PermissionService $permissionService The permission service.
- * @param string|null $userId The user ID.
+ * @param IRequest $request The request.
+ * @param WidgetService $widgetService The widget service.
+ * @param PermissionService $permissionService The permission service.
+ * @param NewsWidgetService $newsWidgetService The news widget service.
+ * @param CalendarWidgetService $calendarWidgetService The calendar widget service (REQ-CAL-003).
+ * @param WidgetPlacementService $widgetPlacementService Placement-payload validators (REQ-CONT-006).
+ * @param RoleFeaturePermissionService $roleFeaturePerm Role-feature filter (REQ-RFP-001..010).
+ * @param string|null $userId The user ID.
*/
public function __construct(
IRequest $request,
private readonly WidgetService $widgetService,
private readonly PermissionService $permissionService,
+ private readonly NewsWidgetService $newsWidgetService,
+ private readonly CalendarWidgetService $calendarWidgetService,
+ private readonly WidgetPlacementService $widgetPlacementService,
+ private readonly RoleFeaturePermissionService $roleFeaturePerm,
private readonly ?string $userId,
) {
parent::__construct(
@@ -54,16 +74,46 @@ public function __construct(
}//end __construct()
/**
- * List all available Nextcloud widgets.
+ * List all available Nextcloud widgets, filtered by the caller's
+ * role-feature permissions (REQ-RFP-001 / REQ-RFP-003).
*
* @return JSONResponse The list of available widgets.
+ *
+ * @spec widgets:REQ-WDG-001
*/
#[NoAdminRequired]
public function listAvailable(): JSONResponse
{
- return ResponseHelper::success(
- data: $this->widgetService->getAvailableWidgets()
+ $widgets = $this->widgetService->getAvailableWidgets();
+
+ if ($this->userId === null) {
+ return ResponseHelper::success(data: $widgets);
+ }
+
+ $allowed = $this->roleFeaturePerm->getAllowedWidgetIds(
+ userId: $this->userId
);
+ if ($allowed === null) {
+ // Backwards-compat: nothing configured, return everything.
+ return ResponseHelper::success(data: $widgets);
+ }
+
+ $filtered = array_values(
+ array: array_filter(
+ array: $widgets,
+ callback: function (array $w) use ($allowed): bool {
+ $id = (string) ($w['id'] ?? '');
+ return $id !== ''
+ && in_array(
+ needle: $id,
+ haystack: $allowed,
+ strict: true
+ );
+ }
+ )
+ );
+
+ return ResponseHelper::success(data: $filtered);
}//end listAvailable()
/**
@@ -73,6 +123,8 @@ public function listAvailable(): JSONResponse
* @param int $limit Maximum items per widget.
*
* @return JSONResponse The widget items.
+ *
+ * @spec widgets:REQ-WDG-002
*/
#[NoAdminRequired]
#[NoCSRFRequired]
@@ -104,11 +156,13 @@ public function getItems(
* @param int $gridHeight Grid height.
*
* @return JSONResponse The created widget placement.
+ *
+ * @spec widgets:REQ-WDG-003
*/
#[NoAdminRequired]
public function addWidget(
int $dashboardId,
- string $widgetId,
+ ?string $widgetId=null,
int $gridX=0,
int $gridY=0,
int $gridWidth=4,
@@ -118,6 +172,20 @@ public function addWidget(
return ResponseHelper::unauthorized();
}
+ // Validate the widgetId parameter explicitly so a missing /
+ // empty value returns 400 rather than letting PHP's TypeError
+ // bubble up to a 500. The route declared `string $widgetId`
+ // (non-nullable) before — Newman's drift test sends a body
+ // without the field and used to crash the dispatcher.
+ if ($widgetId === null || $widgetId === '') {
+ return ResponseHelper::error(
+ exception: new \InvalidArgumentException(
+ 'Missing required field: widgetId'
+ ),
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
if ($this->permissionService->canAddWidget(
userId: $this->userId,
dashboardId: $dashboardId
@@ -126,6 +194,25 @@ public function addWidget(
return ResponseHelper::forbidden();
}
+ // REQ-CONT-006: reject deeply-nested container payloads BEFORE
+ // touching the placement mapper so no rows are inserted on a
+ // depth violation. Tolerant of non-container payloads (no-op
+ // when the request carries no `content.placements[]` blob).
+ $contentParam = $this->request->getParam(key: 'content');
+ if (is_array($contentParam) === true) {
+ try {
+ $this->widgetPlacementService->validateContainerDepth(
+ content: $contentParam
+ );
+ } catch (\InvalidArgumentException $depthError) {
+ if ($depthError->getMessage() === 'container_depth_exceeded') {
+ return $this->containerDepthExceededResponse();
+ }
+
+ return ResponseHelper::error(exception: $depthError);
+ }
+ }
+
try {
$placement = $this->widgetService->addWidget(
dashboardId: $dashboardId,
@@ -145,6 +232,27 @@ public function addWidget(
}
}//end addWidget()
+ /**
+ * Build the canonical "container_depth_exceeded" error response
+ * (REQ-CONT-006). HTTP 400 with the documented envelope shape:
+ * `{status: 'error', error: 'container_depth_exceeded', maxDepth: 3}`.
+ *
+ * @return JSONResponse
+ *
+ * @spec container-widget:REQ-CONT-006
+ */
+ private function containerDepthExceededResponse(): JSONResponse
+ {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'container_depth_exceeded',
+ 'maxDepth' => WidgetPlacementService::MAX_CONTAINER_DEPTH,
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }//end containerDepthExceededResponse()
+
/**
* Add a tile to a dashboard.
*
@@ -190,6 +298,8 @@ public function addTile(int $dashboardId): JSONResponse
* @param int $placementId The placement ID.
*
* @return JSONResponse The updated widget placement.
+ *
+ * @spec widgets:REQ-WDG-004
*/
#[NoAdminRequired]
public function updatePlacement(int $placementId): JSONResponse
@@ -206,6 +316,24 @@ public function updatePlacement(int $placementId): JSONResponse
return ResponseHelper::forbidden();
}
+ // REQ-CONT-006: validate the container depth invariant on
+ // update too — a placement can grow nested children via PUT
+ // without ever going through addWidget.
+ $contentParam = $this->request->getParam(key: 'content');
+ if (is_array($contentParam) === true) {
+ try {
+ $this->widgetPlacementService->validateContainerDepth(
+ content: $contentParam
+ );
+ } catch (\InvalidArgumentException $depthError) {
+ if ($depthError->getMessage() === 'container_depth_exceeded') {
+ return $this->containerDepthExceededResponse();
+ }
+
+ return ResponseHelper::error(exception: $depthError);
+ }
+ }
+
try {
$placement = $this->widgetService->updatePlacement(
placementId: $placementId,
@@ -228,6 +356,8 @@ public function updatePlacement(int $placementId): JSONResponse
* @param int $placementId The placement ID.
*
* @return JSONResponse The removal confirmation.
+ *
+ * @spec widgets:REQ-WDG-005
*/
#[NoAdminRequired]
public function removePlacement(int $placementId): JSONResponse
@@ -254,4 +384,206 @@ public function removePlacement(int $placementId): JSONResponse
return ResponseHelper::error(exception: $e);
}
}//end removePlacement()
+
+ /**
+ * Fetch merged news widget items for a placement (REQ-NEWS-003).
+ *
+ * Validates the caller, clamps the limit, and delegates to
+ * {@see NewsWidgetService::getItemsForPlacement()}. The response
+ * shape is `{items: array, feedsFailed: int, failedUrls: array}`
+ * — the placement-level metadata filter is applied server-side
+ * (REQ-NEWS-007), and a placement that fails the filter responds
+ * with an empty items array (no HTTP fetch occurs).
+ *
+ * @param integer $placementId Placement entity id.
+ * @param integer|null $limit Optional caller cap (default 10,
+ * rejected when outside [1, 50]).
+ *
+ * @return JSONResponse
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function newsItems(int $placementId, ?int $limit=10): JSONResponse
+ {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $effectiveLimit = $limit;
+ if ($effectiveLimit === null) {
+ $effectiveLimit = 10;
+ }
+
+ if ($effectiveLimit < 1 || $effectiveLimit > 50) {
+ return new JSONResponse(
+ data: ['error' => 'limit out of range (1..50)'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ if ($this->permissionService->canStyleWidget(
+ userId: $this->userId,
+ placementId: $placementId
+ ) === false
+ ) {
+ return ResponseHelper::forbidden();
+ }
+
+ $payload = $this->newsWidgetService->getItemsForPlacement(
+ placementId: $placementId,
+ limit: $effectiveLimit
+ );
+
+ return ResponseHelper::success(data: $payload);
+ }//end newsItems()
+
+ /**
+ * Get aggregated events for a calendar-widget placement.
+ *
+ * Returns merged + sorted events from internal NC calendars and
+ * external ICS feeds configured on the placement. The date range
+ * is mandatory and is capped at one year in the controller as a
+ * defensive measure against runaway RRULE expansion.
+ *
+ * REQ-CAL-003.
+ *
+ * @param int $placementId The placement ID.
+ * @param string $from ISO 8601 start.
+ * @param string $to ISO 8601 end.
+ *
+ * @return JSONResponse The aggregated events payload.
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function calendarEvents(
+ int $placementId,
+ string $from='',
+ string $to=''
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ if ($from === '' || $to === '') {
+ return new JSONResponse(
+ data: ['error' => 'Both from and to are required ISO 8601 timestamps'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ try {
+ $start = new DateTimeImmutable(datetime: $from);
+ $end = new DateTimeImmutable(datetime: $to);
+ } catch (Throwable $exception) {
+ unset($exception);
+ return new JSONResponse(
+ data: ['error' => 'Invalid date format'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ if ($end < $start) {
+ return new JSONResponse(
+ data: ['error' => '`to` must be greater than or equal to `from`'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ // Defensive 1-year cap per design D1 to bound RRULE expansion.
+ $maxEnd = $start->modify(modifier: '+1 year');
+ if ($end > $maxEnd) {
+ $end = $maxEnd;
+ }
+
+ try {
+ $placement = $this->widgetService->getPlacement(placementId: $placementId);
+ } catch (Throwable $exception) {
+ unset($exception);
+ return new JSONResponse(
+ data: ['error' => 'Placement not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ if ($this->permissionService->canStyleWidget(
+ userId: $this->userId,
+ placementId: $placementId
+ ) === false
+ ) {
+ return ResponseHelper::forbidden();
+ }
+
+ $config = $this->extractCalendarConfig(placement: $placement);
+
+ try {
+ $result = $this->calendarWidgetService->getEvents(
+ config: $config,
+ from: $start->format(format: \DATE_ATOM),
+ to: $end->format(format: \DATE_ATOM)
+ );
+ } catch (\Exception $exception) {
+ return ResponseHelper::error(exception: $exception);
+ }
+
+ return ResponseHelper::success(data: $result);
+ }//end calendarEvents()
+
+ /**
+ * Pull `internalCalendars`/`externalIcsUrls` arrays out of the
+ * placement's widgetContent JSON, applying defaults.
+ *
+ * @param object $placement The placement entity (WidgetPlacement).
+ *
+ * @return array{
+ * internalCalendars: array,
+ * externalIcsUrls: array,
+ * viewMode: string,
+ * daysAhead: int,
+ * colorByCalendar: bool
+ * }
+ */
+ private function extractCalendarConfig(object $placement): array
+ {
+ $serialized = [];
+ if (method_exists(object_or_class: $placement, method: 'jsonSerialize') === true) {
+ $serialized = (array) $placement->jsonSerialize();
+ }
+
+ $widgetContent = $serialized['widgetContent'] ?? $serialized['content'] ?? [];
+
+ if (is_string(value: $widgetContent) === true) {
+ $decoded = json_decode(json: $widgetContent, associative: true);
+ $widgetContent = [];
+ if (is_array(value: $decoded) === true) {
+ $widgetContent = $decoded;
+ }
+ }
+
+ $widgetContent = (array) $widgetContent;
+
+ $internal = (array) ($widgetContent['internalCalendars'] ?? []);
+ $external = (array) ($widgetContent['externalIcsUrls'] ?? []);
+
+ // Cast every entry to string for safety; drop empty strings.
+ $internal = array_values(
+ array: array_filter(
+ array: array_map(callback: 'strval', array: $internal),
+ callback: static fn(string $value): bool => $value !== ''
+ )
+ );
+ $external = array_values(
+ array: array_filter(
+ array: array_map(callback: 'strval', array: $external),
+ callback: static fn(string $value): bool => $value !== ''
+ )
+ );
+
+ return [
+ 'internalCalendars' => $internal,
+ 'externalIcsUrls' => $external,
+ 'viewMode' => (string) ($widgetContent['viewMode'] ?? 'agenda'),
+ 'daysAhead' => (int) ($widgetContent['daysAhead'] ?? 14),
+ 'colorByCalendar' => (bool) ($widgetContent['colorByCalendar'] ?? true),
+ ];
+ }//end extractCalendarConfig()
}//end class
diff --git a/lib/Db/AdminSetting.php b/lib/Db/AdminSetting.php
index 3cb7a778..8674011c 100644
--- a/lib/Db/AdminSetting.php
+++ b/lib/Db/AdminSetting.php
@@ -63,13 +63,106 @@ class AdminSetting extends Entity implements JsonSerializable
public const KEY_DEFAULT_GRID_COLUMNS = 'default_grid_columns';
/**
- * Setting key for the ordered list of "active" Nextcloud group IDs that
- * MyDash treats as in scope for workspace routing (REQ-ASET-012).
+ * Setting key for the admin-chosen group priority order
+ * (REQ-ASET-012). Persisted as a JSON string list of Nextcloud
+ * group IDs in the order the admin chose; corrupt JSON resolves
+ * to `[]` at the service layer (defensive read). MyDash treats
+ * these as in scope for workspace routing.
*
* @var string
*/
public const KEY_GROUP_ORDER = 'group_order';
+ /**
+ * Setting key for the link-button-widget createFile extension allow-list.
+ *
+ * Stored as a JSON array of lowercase extensions without dots
+ * (e.g. `["txt","md","docx"]`). Default values are returned by
+ * {@see \OCA\MyDash\Service\FileService::getAllowedExtensions()}.
+ *
+ * @var string
+ */
+ public const KEY_LINK_CREATE_FILE_EXTENSIONS = 'link_create_file_extensions';
+
+ /**
+ * Setting key for the global default comments toggle (REQ-CMNT-008).
+ *
+ * Stored as a JSON-encoded boolean. Default is `true` — comments are
+ * enabled across all dashboards unless an admin disables the global
+ * switch or a per-dashboard `commentsEnabled = 0` overrides.
+ *
+ * @var string
+ */
+ public const KEY_COMMENTS_ENABLED_DEFAULT = 'comments_enabled_default';
+
+ /**
+ * Setting key for the global footer master toggle (REQ-FTR-001).
+ * Boolean; default `false` (footer hidden out of the box).
+ *
+ * @var string
+ */
+ public const KEY_FOOTER_ENABLED = 'footer_enabled';
+
+ /**
+ * Setting key for the raw HTML footer body (REQ-FTR-002).
+ * String, max 8 KB; sanitised server-side before persistence.
+ * Defaults to empty string when unset.
+ *
+ * @var string
+ */
+ public const KEY_FOOTER_HTML = 'footer_html';
+
+ /**
+ * Setting key for the structured-mode footer config (REQ-FTR-003).
+ * JSON object with the documented keys
+ * (`logoUrl?, organisation?, address?, links?, legal?, copyrightYear?, layoutMode`).
+ * Defaults to empty object when unset.
+ *
+ * @var string
+ */
+ public const KEY_FOOTER_CONFIG = 'footer_config';
+
+ /**
+ * Setting key for the optional footer background-colour override
+ * (REQ-FTR-009). Hex string (`#rrggbb` or `#rgb`) or NULL to fall
+ * back to the NC theme variable.
+ *
+ * @var string
+ */
+ public const KEY_FOOTER_BACKGROUND_COLOR = 'footer_background_color';
+
+ /**
+ * Setting key for the optional footer text-colour override
+ * (REQ-FTR-009). Hex string (`#rrggbb` or `#rgb`) or NULL to fall
+ * back to the NC theme variable.
+ *
+ * @var string
+ */
+ public const KEY_FOOTER_TEXT_COLOR = 'footer_text_color';
+
+ /**
+ * Setting key tracking first-run setup wizard completion (REQ-WIZ-001).
+ *
+ * Stored as JSON `true` once the admin clicks "Finish" in the wizard or
+ * the `mydash:setup` CLI command runs to completion. Defaults to `false`
+ * (banner visible) when the row is missing.
+ *
+ * @var string
+ */
+ public const KEY_SETUP_WIZARD_COMPLETE = 'setup_wizard_complete';
+
+ /**
+ * Setting key for the dashboard content storage backend (REQ-WIZ-003).
+ *
+ * Stored as a JSON string: either `"database"` (default) or
+ * `"groupfolder"` once the admin completes Step 2. The
+ * `groupfolder-storage-backend` capability is the eventual consumer;
+ * the wizard merely persists the choice.
+ *
+ * @var string
+ */
+ public const KEY_CONTENT_STORAGE = 'content_storage';
+
/**
* The setting key.
*
diff --git a/lib/Db/AdminSettingMapper.php b/lib/Db/AdminSettingMapper.php
index 5b4c3e75..9eeed8dd 100644
--- a/lib/Db/AdminSettingMapper.php
+++ b/lib/Db/AdminSettingMapper.php
@@ -104,13 +104,13 @@ public function setSetting(string $key, mixed $value): AdminSetting
try {
$setting = $this->findByKey(key: $key);
$setting->setValueEncoded($value);
- $setting->setUpdatedAt((new DateTime())->format(format: 'Y-m-d H:i:s'));
+ $setting->setUpdatedAt((new DateTime())->format(format: 'c'));
return $this->update(entity: $setting);
} catch (DoesNotExistException) {
$setting = new AdminSetting();
$setting->setSettingKey($key);
$setting->setValueEncoded($value);
- $setting->setUpdatedAt((new DateTime())->format(format: 'Y-m-d H:i:s'));
+ $setting->setUpdatedAt((new DateTime())->format(format: 'c'));
return $this->insert(entity: $setting);
}
}//end setSetting()
diff --git a/lib/Db/CleanupResult.php b/lib/Db/CleanupResult.php
new file mode 100644
index 00000000..d00ab475
--- /dev/null
+++ b/lib/Db/CleanupResult.php
@@ -0,0 +1,189 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use DateTime;
+use JsonSerializable;
+
+/**
+ * Per-category cleanup result DTO.
+ */
+class CleanupResult implements JsonSerializable
+{
+ /**
+ * Constructor.
+ *
+ * @param array $byCategory Per-category orphan counts.
+ * @param int $totalRows Sum of `byCategory` values.
+ * @param int $durationMs Wall-clock duration in
+ * milliseconds.
+ * @param bool $dryRun True for dry-run purges; the
+ * scan path always sets this
+ * to `false`.
+ * @param string $scannedAt ISO-8601 timestamp the
+ * result was produced
+ * (`Y-m-d\TH:i:s\Z` UTC).
+ * @param array $skipped Categories that were
+ * skipped (missing tables,
+ * disabled feature).
+ */
+ public function __construct(
+ private array $byCategory,
+ private int $totalRows,
+ private int $durationMs,
+ private bool $dryRun,
+ private string $scannedAt,
+ private array $skipped=[],
+ ) {
+ }//end __construct()
+
+ /**
+ * Build a CleanupResult from a per-category map.
+ *
+ * Sums `byCategory` to derive `totalRows` and stamps `scannedAt`
+ * with the current UTC time. Convenience constructor for both the
+ * scan and purge paths.
+ *
+ * @param array $byCategory Per-category counts.
+ * @param int $durationMs Wall-clock duration in ms.
+ * @param bool $dryRun True for dry-run purges.
+ * @param array $skipped Skipped category names.
+ *
+ * @return CleanupResult The constructed DTO.
+ */
+ public static function fromCounts(
+ array $byCategory,
+ int $durationMs,
+ bool $dryRun=false,
+ array $skipped=[]
+ ): CleanupResult {
+ $total = 0;
+ foreach ($byCategory as $count) {
+ $total += (int) $count;
+ }
+
+ $scannedAt = (new DateTime())->format(format: 'Y-m-d\TH:i:s\Z');
+
+ return new CleanupResult(
+ byCategory: $byCategory,
+ totalRows: $total,
+ durationMs: $durationMs,
+ dryRun: $dryRun,
+ scannedAt: $scannedAt,
+ skipped: $skipped,
+ );
+ }//end fromCounts()
+
+ /**
+ * Get the per-category orphan count map.
+ *
+ * @return array The per-category counts.
+ */
+ public function getByCategory(): array
+ {
+ return $this->byCategory;
+ }//end getByCategory()
+
+ /**
+ * Get the total row count across all categories.
+ *
+ * @return int The total.
+ */
+ public function getTotalRows(): int
+ {
+ return $this->totalRows;
+ }//end getTotalRows()
+
+ /**
+ * Get the wall-clock duration in milliseconds.
+ *
+ * @return int The duration.
+ */
+ public function getDurationMs(): int
+ {
+ return $this->durationMs;
+ }//end getDurationMs()
+
+ /**
+ * Whether this result represents a dry-run.
+ *
+ * @return bool True for dry-run, false otherwise.
+ */
+ public function isDryRun(): bool
+ {
+ return $this->dryRun;
+ }//end isDryRun()
+
+ /**
+ * Get the ISO-8601 UTC timestamp the result was produced.
+ *
+ * @return string The timestamp.
+ */
+ public function getScannedAt(): string
+ {
+ return $this->scannedAt;
+ }//end getScannedAt()
+
+ /**
+ * Get the list of category names that were skipped (missing
+ * tables, disabled feature, etc).
+ *
+ * @return array The skipped category names.
+ */
+ public function getSkipped(): array
+ {
+ return $this->skipped;
+ }//end getSkipped()
+
+ /**
+ * Serialize to JSON for API responses.
+ *
+ * @return array The serialized result.
+ */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'byCategory' => $this->byCategory,
+ 'totalRows' => $this->totalRows,
+ 'durationMs' => $this->durationMs,
+ 'dryRun' => $this->dryRun,
+ 'scannedAt' => $this->scannedAt,
+ 'skipped' => $this->skipped,
+ ];
+ }//end jsonSerialize()
+}//end class
diff --git a/lib/Db/ConditionalRuleMapper.php b/lib/Db/ConditionalRuleMapper.php
index df99c192..48161033 100644
--- a/lib/Db/ConditionalRuleMapper.php
+++ b/lib/Db/ConditionalRuleMapper.php
@@ -122,4 +122,85 @@ public function deleteByPlacementId(int $placementId): void
$qb->executeStatement();
}//end deleteByPlacementId()
+
+ /**
+ * Count conditional-rule rows whose `widget_placement_id` no
+ * longer points at any row in `mydash_widget_placements`.
+ *
+ * Used by the orphaned-data-cleanup scan path (REQ-CLN-001).
+ * Rules are normally cleared by the placement removal flow; a row
+ * left behind here typically indicates a manual SQL delete or an
+ * older code path that didn't cascade.
+ *
+ * @return int The number of orphaned rule rows.
+ */
+ public function countOrphaned(): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select($qb->func()->count('r.id'))
+ ->from(from: $this->getTableName(), alias: 'r')
+ ->leftJoin(
+ fromAlias: 'r',
+ join: 'mydash_widget_placements',
+ alias: 'p',
+ condition: 'p.id = r.widget_placement_id'
+ )
+ ->where($qb->expr()->isNull(x: 'p.id'));
+
+ $result = $qb->executeQuery();
+ $count = $result->fetchOne();
+ $result->closeCursor();
+
+ return (int) ($count ?? 0);
+ }//end countOrphaned()
+
+ /**
+ * Delete conditional-rule rows whose `widget_placement_id` no
+ * longer points at any row in `mydash_widget_placements`.
+ *
+ * Companion to {@see self::countOrphaned()} on the purge path
+ * (REQ-CLN-002). Resolves the orphan IDs first via a SELECT and
+ * then deletes by primary key for portability across drivers.
+ *
+ * @return int The number of rows deleted.
+ */
+ public function deleteOrphaned(): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: 'r.id')
+ ->from(from: $this->getTableName(), alias: 'r')
+ ->leftJoin(
+ fromAlias: 'r',
+ join: 'mydash_widget_placements',
+ alias: 'p',
+ condition: 'p.id = r.widget_placement_id'
+ )
+ ->where($qb->expr()->isNull(x: 'p.id'));
+
+ $result = $qb->executeQuery();
+ $ids = [];
+ while (($row = $result->fetch()) !== false) {
+ $ids[] = (int) $row['id'];
+ }
+
+ $result->closeCursor();
+
+ if (count(value: $ids) === 0) {
+ return 0;
+ }
+
+ $delete = $this->db->getQueryBuilder();
+ $delete->delete(delete: $this->getTableName())
+ ->where(
+ $delete->expr()->in(
+ x: 'id',
+ y: $delete->createNamedParameter(
+ value: $ids,
+ type: IQueryBuilder::PARAM_INT_ARRAY
+ )
+ )
+ );
+
+ return $delete->executeStatement();
+ }//end deleteOrphaned()
}//end class
diff --git a/lib/Db/Dashboard.php b/lib/Db/Dashboard.php
index 474a4732..d3fd1bb9 100644
--- a/lib/Db/Dashboard.php
+++ b/lib/Db/Dashboard.php
@@ -30,6 +30,8 @@
* @method void setName(?string $name)
* @method string|null getDescription()
* @method void setDescription(?string $description)
+ * @method string|null getIcon()
+ * @method void setIcon(?string $icon)
* @method string|null getType()
* @method void setType(?string $type)
* @method string|null getUserId()
@@ -52,10 +54,73 @@
* @method void setCreatedAt(?string $createdAt)
* @method string|null getUpdatedAt()
* @method void setUpdatedAt(?string $updatedAt)
+ * @method string|null getParentUuid()
+ * @method void setParentUuid(?string $parentUuid)
+ * @method string|null getSlug()
+ * @method void setSlug(?string $slug)
+ * @method int getSortOrder()
+ * @method void setSortOrder(int $sortOrder)
+ * @method string getPublicationStatus()
+ * @method void setPublicationStatus(string $publicationStatus)
+ * @method string|null getPublishAt()
+ * @method void setPublishAt(?string $publishAt)
+ * @method string|null getPublishedAt()
+ * @method void setPublishedAt(?string $publishedAt)
+ * @method int|null getCommentsEnabled()
+ * @method void setCommentsEnabled(?int $commentsEnabled)
+ * @method int|null getReactionsEnabled()
+ * @method void setReactionsEnabled(?int $reactionsEnabled)
+ * @method string getDashboardFooterMode()
+ * @method void setDashboardFooterMode(string $dashboardFooterMode)
+ * @method string|null getDashboardFooterHtml()
+ * @method void setDashboardFooterHtml(?string $dashboardFooterHtml)
+ * @method string|null getTemplateCategory()
+ * @method void setTemplateCategory(?string $templateCategory)
+ * @method string|null getTemplateDescription()
+ * @method void setTemplateDescription(?string $templateDescription)
+ * @method string|null getTemplatePreviewImage()
+ * @method void setTemplatePreviewImage(?string $templatePreviewImage)
+ *
+ * @SuppressWarnings(PHPMD.TooManyFields) Each field maps to a documented
+ * column on `oc_mydash_dashboards`
+ * that the frontend or service
+ * layer reads directly; splitting
+ * would break the entity↔DB row
+ * contract. Each new capability
+ * adds one column (groupId,
+ * isDefault, isActive,
+ * commentsEnabled,
+ * reactionsEnabled,
+ * dashboardFooterMode,
+ * dashboardFooterHtml,
+ * templateCategory,
+ * templateDescription,
+ * templatePreviewImage, ...).
+ * Aggregates personal,
+ * group-shared, hierarchy,
+ * publication-state,
+ * footer-override and
+ * template-discovery columns on a
+ * single row; cross-cutting
+ * capabilities use the same table
+ * per the admin-templates /
+ * dashboard-tree /
+ * footer-customization designs.
*/
class Dashboard extends Entity implements JsonSerializable
{
+ /**
+ * Maximum supported dashboard tree depth (root + 4 descendants).
+ *
+ * Enforced at write time by `DashboardTreeService::validateDepth()`
+ * and surfaced via the `validateDepth` guard called from the create
+ * and update controllers (REQ-DASH-028).
+ *
+ * @var integer
+ */
+ public const MAX_DEPTH = 5;
+
/**
* Dashboard type for admin templates.
*
@@ -122,6 +187,32 @@ class Dashboard extends Entity implements JsonSerializable
*/
public const SOURCE_DEFAULT = 'default';
+ /**
+ * Publication status: dashboard is a draft, visible only to its owner
+ * (and Nextcloud admins). REQ-DASH-031..037.
+ *
+ * @var string
+ */
+ public const STATUS_DRAFT = 'draft';
+
+ /**
+ * Publication status: dashboard is published and follows the normal
+ * visibility / share rules. REQ-DASH-031..037.
+ *
+ * @var string
+ */
+ public const STATUS_PUBLISHED = 'published';
+
+ /**
+ * Publication status: dashboard is scheduled for automatic publication
+ * at a future timestamp held in `publishAt`. Behaves as `draft` until
+ * `publishAt <= now()`, after which read-time materialisation flips it
+ * to `published`. REQ-DASH-034.
+ *
+ * @var string
+ */
+ public const STATUS_SCHEDULED = 'scheduled';
+
/**
* Permission level for view only.
*
@@ -143,6 +234,45 @@ class Dashboard extends Entity implements JsonSerializable
*/
public const PERMISSION_FULL = 'full';
+ /**
+ * Footer mode: dashboard inherits the global instance footer
+ * (or none if globally disabled). REQ-FTR-006.
+ *
+ * @var string
+ */
+ public const FOOTER_MODE_INHERIT = 'inherit';
+
+ /**
+ * Footer mode: dashboard hides the footer regardless of any
+ * globally-enabled footer. REQ-FTR-006.
+ *
+ * @var string
+ */
+ public const FOOTER_MODE_HIDDEN = 'hidden';
+
+ /**
+ * Footer mode: dashboard renders its own
+ * {@see Dashboard::$dashboardFooterHtml} (sanitised identically to
+ * the global HTML), bypassing the instance footer entirely.
+ * REQ-FTR-006.
+ *
+ * @var string
+ */
+ public const FOOTER_MODE_CUSTOM = 'custom';
+
+ /**
+ * Allow-list of legal {@see Dashboard::$dashboardFooterMode} values.
+ * Used by `DashboardService::applyDashboardUpdates()` to validate
+ * patches before persisting (REQ-FTR-006).
+ *
+ * @var array
+ */
+ public const FOOTER_MODES = [
+ self::FOOTER_MODE_INHERIT,
+ self::FOOTER_MODE_HIDDEN,
+ self::FOOTER_MODE_CUSTOM,
+ ];
+
/**
* The UUID.
*
@@ -164,6 +294,24 @@ class Dashboard extends Entity implements JsonSerializable
*/
protected ?string $description = null;
+ /**
+ * The dashboard icon.
+ *
+ * Opaque string owned by the frontend `dashboard-icons` capability.
+ * Three legal value classes:
+ * - NULL or empty string — render the frontend `DEFAULT_ICON`
+ * - A registry key (e.g. `'ViewDashboard'`, `'Home'`) — looked up
+ * in `DASHBOARD_ICONS` by the `IconRenderer` component
+ * - A URL (starts with `/` or `http`) — rendered as ` ` by the
+ * sibling `custom-icon-upload-pattern` capability
+ *
+ * The backend never inspects this value; it is stored verbatim and
+ * the discriminator + lookup live in `src/constants/dashboardIcons.js`.
+ *
+ * @var string|null
+ */
+ protected ?string $icon = null;
+
/**
* The dashboard type.
*
@@ -246,6 +394,180 @@ class Dashboard extends Entity implements JsonSerializable
*/
protected ?string $updatedAt = null;
+ /**
+ * The parent dashboard UUID (REQ-DASH-023).
+ *
+ * NULL for root dashboards. Children reference their parent by
+ * UUID (the same value the entity exposes via {@see Dashboard::$uuid}).
+ * Cycle prevention and depth enforcement live in
+ * `DashboardTreeService` — callers MUST go through the service rather
+ * than mutating `parentUuid` directly via the mapper.
+ *
+ * @var string|null
+ */
+ protected ?string $parentUuid = null;
+
+ /**
+ * The URL-safe slug (REQ-DASH-024).
+ *
+ * Unique among siblings (per-parent). Auto-generated from the name
+ * by `SlugGenerator::slugify()` when not supplied. Slugs combine
+ * to form the path (REQ-DASH-025) — `/marketing/campaigns/q1`.
+ *
+ * @var string|null
+ */
+ protected ?string $slug = null;
+
+ /**
+ * The sibling sort order (REQ-DASH-029).
+ *
+ * Defaults to 0; ties broken alphabetically by `name`. Lower values
+ * appear first in tree responses.
+ *
+ * @var integer
+ */
+ protected int $sortOrder = 0;
+
+ /**
+ * The publication status (REQ-DASH-031).
+ *
+ * One of {@see Dashboard::STATUS_DRAFT}, {@see Dashboard::STATUS_PUBLISHED},
+ * {@see Dashboard::STATUS_SCHEDULED}. The PHP default mirrors the
+ * database column default (`'published'`) so pre-existing rows and
+ * raw `new Dashboard()` constructions remain visible until any
+ * future migration explicitly flips them. New dashboards created
+ * via {@see DashboardFactory::create()} are explicitly overridden
+ * to `'draft'` immediately before persistence (REQ-DASH-031, design
+ * D2), so the safe default at the application boundary is still
+ * "create now, share later".
+ *
+ * @var string
+ */
+ protected string $publicationStatus = self::STATUS_PUBLISHED;
+
+ /**
+ * The scheduled publish timestamp (REQ-DASH-031, REQ-DASH-033).
+ *
+ * Required when {@see Dashboard::$publicationStatus} is
+ * {@see Dashboard::STATUS_SCHEDULED}; ignored otherwise. ISO-8601
+ * timestamp string in the database (DATETIME column, NULL allowed).
+ *
+ * @var string|null
+ */
+ protected ?string $publishAt = null;
+
+ /**
+ * The first-publication timestamp (REQ-DASH-031, REQ-DASH-032).
+ *
+ * Set automatically the first time the dashboard transitions to
+ * {@see Dashboard::STATUS_PUBLISHED}; preserved when later
+ * unpublished so the audit trail survives the round-trip.
+ *
+ * @var string|null
+ */
+ protected ?string $publishedAt = null;
+
+ /**
+ * Per-dashboard comments toggle (REQ-CMNT-007).
+ *
+ * Three legal values:
+ * - NULL — inherit the global `mydash.comments_enabled_default`
+ * admin setting.
+ * - 1 (SMALLINT) — force comments on regardless of global toggle.
+ * - 0 (SMALLINT) — force comments off regardless of global toggle.
+ *
+ * Stored as a nullable SMALLINT to keep the same wire format as
+ * `is_default` / `is_active`. Resolution lives in
+ * {@see Dashboard::isCommentsEffectivelyEnabled()} so callers do
+ * not have to re-implement the precedence rules.
+ *
+ * @var integer|null
+ */
+ protected ?int $commentsEnabled = null;
+
+ /**
+ * Per-dashboard reactions toggle (REQ-RXN-006).
+ *
+ * Tri-state SMALLINT NULL column:
+ * - NULL → follow the global `mydash.reactions_enabled_default`
+ * admin setting
+ * - 1 → reactions force-enabled on this dashboard, overriding
+ * the global setting
+ * - 0 → reactions force-disabled on this dashboard, overriding
+ * the global setting
+ *
+ * Resolution lives in {@see \OCA\MyDash\Service\ReactionService}.
+ *
+ * @var integer|null
+ */
+ protected ?int $reactionsEnabled = null;
+
+ /**
+ * Per-dashboard footer override mode (REQ-FTR-006).
+ *
+ * One of {@see Dashboard::FOOTER_MODE_INHERIT},
+ * {@see Dashboard::FOOTER_MODE_HIDDEN},
+ * {@see Dashboard::FOOTER_MODE_CUSTOM}. Defaults to `inherit` so
+ * dashboards created before the migration ran (and any newly
+ * persisted entity that does not explicitly set a mode) follow
+ * the instance-wide footer setting. Invariant enforced at the
+ * service layer: when the mode is `custom`,
+ * {@see Dashboard::$dashboardFooterHtml} MUST be a non-empty
+ * sanitised HTML string; for both `inherit` and `hidden` it MUST
+ * be NULL (the service clears stale HTML on mode flip).
+ *
+ * @var string
+ */
+ protected string $dashboardFooterMode = self::FOOTER_MODE_INHERIT;
+
+ /**
+ * Per-dashboard footer HTML (REQ-FTR-006).
+ *
+ * Sanitised server-side via the same allow-list as the global
+ * footer (footer-customization design D4). Only consulted when
+ * {@see Dashboard::$dashboardFooterMode} is
+ * {@see Dashboard::FOOTER_MODE_CUSTOM}; NULL otherwise.
+ *
+ * @var string|null
+ */
+ protected ?string $dashboardFooterHtml = null;
+
+ /**
+ * The template gallery category (REQ-TMPL-016).
+ *
+ * Free-form admin-curated label (max 64 chars). NULL means "no
+ * category" — surfaced in the gallery alongside categorised templates
+ * (sorted last by the default `(category NULLS LAST, name)` ordering).
+ * Only meaningful for rows with `type = 'admin_template'`; ignored on
+ * `user` and `group_shared` rows.
+ *
+ * @var string|null
+ */
+ protected ?string $templateCategory = null;
+
+ /**
+ * The template gallery long-form description (REQ-TMPL-016).
+ *
+ * Stored in a TEXT column so callers can use richer prose than the
+ * regular {@see Dashboard::$description} field. Surfaced verbatim
+ * in the gallery card. Only meaningful for `admin_template` rows.
+ *
+ * @var string|null
+ */
+ protected ?string $templateDescription = null;
+
+ /**
+ * The template gallery preview image URL (REQ-TMPL-017).
+ *
+ * URL produced by the resource-uploads pipeline (typically
+ * `/apps/mydash/resource/`); stored verbatim. NULL means
+ * "no preview image" — gallery card renders a placeholder. Only
+ * meaningful for `admin_template` rows.
+ *
+ * @var string|null
+ */
+ protected ?string $templatePreviewImage = null;
+
/**
* Constructor
*
@@ -263,6 +585,11 @@ public function __construct()
// SMALLINT in DB (0/1).
$this->addType(fieldName: 'isActive', type: 'integer');
// SMALLINT in DB (0/1).
+ $this->addType(fieldName: 'sortOrder', type: 'integer');
+ $this->addType(fieldName: 'commentsEnabled', type: 'integer');
+ // Nullable SMALLINT (NULL / 0 / 1) — REQ-CMNT-007.
+ $this->addType(fieldName: 'reactionsEnabled', type: 'integer');
+ // SMALLINT NULL in DB — null means follow global setting (REQ-RXN-006).
}//end __construct()
/**
@@ -307,21 +634,68 @@ public function setTargetGroupsArray(array $groups): void
public function jsonSerialize(): array
{
return [
- 'id' => $this->getId(),
- 'uuid' => $this->uuid,
- 'name' => $this->name,
- 'description' => $this->description,
- 'type' => $this->type,
- 'userId' => $this->userId,
- 'groupId' => $this->groupId,
- 'basedOnTemplate' => $this->basedOnTemplate,
- 'gridColumns' => $this->gridColumns,
- 'permissionLevel' => $this->permissionLevel,
- 'targetGroups' => $this->getTargetGroupsArray(),
- 'isDefault' => $this->isDefault,
- 'isActive' => $this->isActive,
- 'createdAt' => $this->createdAt,
- 'updatedAt' => $this->updatedAt,
+ 'id' => $this->getId(),
+ 'uuid' => $this->uuid,
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'icon' => $this->icon,
+ 'type' => $this->type,
+ 'userId' => $this->userId,
+ 'groupId' => $this->groupId,
+ 'basedOnTemplate' => $this->basedOnTemplate,
+ 'gridColumns' => $this->gridColumns,
+ 'permissionLevel' => $this->permissionLevel,
+ 'targetGroups' => $this->getTargetGroupsArray(),
+ 'isDefault' => $this->isDefault,
+ 'isActive' => $this->isActive,
+ 'commentsEnabled' => $this->commentsEnabled,
+ // REQ-RXN-006 — null/1/0 tri-state.
+ 'reactionsEnabled' => $this->reactionsEnabled,
+ 'createdAt' => $this->createdAt,
+ 'updatedAt' => $this->updatedAt,
+ 'parentUuid' => $this->parentUuid,
+ 'slug' => $this->slug,
+ 'sortOrder' => $this->sortOrder,
+ 'publicationStatus' => $this->publicationStatus,
+ 'publishAt' => $this->publishAt,
+ 'publishedAt' => $this->publishedAt,
+ // REQ-FTR-006 — surface the per-dashboard footer override
+ // fields on every API payload so frontend renderers can
+ // decide whether to draw an instance footer, hide it, or
+ // render the dashboard-specific HTML.
+ 'dashboardFooterMode' => $this->dashboardFooterMode,
+ 'dashboardFooterHtml' => $this->dashboardFooterHtml,
+ 'templateCategory' => $this->templateCategory,
+ 'templateDescription' => $this->templateDescription,
+ 'templatePreviewImage' => $this->templatePreviewImage,
];
}//end jsonSerialize()
+
+ /**
+ * Whether comments are effectively enabled on this dashboard
+ * (REQ-CMNT-007, REQ-CMNT-008).
+ *
+ * Resolution order:
+ * - When `commentsEnabled` is 1 → true (per-dashboard force-on).
+ * - When `commentsEnabled` is 0 → false (per-dashboard force-off).
+ * - When NULL → fall back to the supplied global default.
+ *
+ * @param bool $globalDefault The resolved value of the global
+ * `mydash.comments_enabled_default`
+ * admin setting.
+ *
+ * @return bool True when comments are effectively enabled.
+ */
+ public function isCommentsEffectivelyEnabled(bool $globalDefault): bool
+ {
+ if ($this->commentsEnabled === 1) {
+ return true;
+ }
+
+ if ($this->commentsEnabled === 0) {
+ return false;
+ }
+
+ return $globalDefault;
+ }//end isCommentsEffectivelyEnabled()
}//end class
diff --git a/lib/Db/DashboardLock.php b/lib/Db/DashboardLock.php
new file mode 100644
index 00000000..a8fb0729
--- /dev/null
+++ b/lib/Db/DashboardLock.php
@@ -0,0 +1,188 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 EUPL-1.2
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use DateTime;
+use JsonSerializable;
+use OCP\AppFramework\Db\Entity;
+
+/**
+ * Dashboard editing-lock entity.
+ *
+ * @method string|null getDashboardUuid()
+ * @method void setDashboardUuid(?string $dashboardUuid)
+ * @method string|null getUserId()
+ * @method void setUserId(?string $userId)
+ * @method string|null getDisplayName()
+ * @method void setDisplayName(?string $displayName)
+ * @method string|null getCreatedAt()
+ * @method void setCreatedAt(?string $createdAt)
+ * @method string|null getUpdatedAt()
+ * @method void setUpdatedAt(?string $updatedAt)
+ */
+class DashboardLock extends Entity implements JsonSerializable
+{
+
+ /**
+ * Default lock TTL in seconds (15 minutes).
+ *
+ * @var int
+ */
+ public const LOCK_TIMEOUT_SECONDS = 900;
+
+ /**
+ * The dashboard UUID this lock guards.
+ *
+ * @var string|null
+ */
+ protected ?string $dashboardUuid = null;
+
+ /**
+ * The Nextcloud user ID of the lock owner.
+ *
+ * @var string|null
+ */
+ protected ?string $userId = null;
+
+ /**
+ * Cached display name at acquire time (for UI feedback).
+ *
+ * @var string|null
+ */
+ protected ?string $displayName = null;
+
+ /**
+ * The creation timestamp (set on first acquire, never updated).
+ *
+ * @var string|null
+ */
+ protected ?string $createdAt = null;
+
+ /**
+ * The last-heartbeat timestamp; bumped on every heartbeat.
+ *
+ * @var string|null
+ */
+ protected ?string $updatedAt = null;
+
+ /**
+ * Constructor — registers column types.
+ *
+ * @return void
+ */
+ public function __construct()
+ {
+ $this->addType(fieldName: 'id', type: 'integer');
+ }//end __construct()
+
+ /**
+ * Whether this lock is past its TTL (`updatedAt + 15 min < now`).
+ *
+ * Returns `true` for an entity with no `updatedAt` (defensive
+ * default — a malformed row should not block a fresh acquire).
+ *
+ * @return bool True when the lock is stale.
+ */
+ public function isExpired(): bool
+ {
+ if ($this->updatedAt === null) {
+ return true;
+ }
+
+ $heartbeat = strtotime(datetime: $this->updatedAt);
+ if ($heartbeat === false) {
+ return true;
+ }
+
+ return ($heartbeat + self::LOCK_TIMEOUT_SECONDS) < time();
+ }//end isExpired()
+
+ /**
+ * Number of seconds remaining before this lock expires.
+ *
+ * Returns 0 (never negative) for an already-expired lock.
+ *
+ * @return int Seconds remaining, clamped to >= 0.
+ */
+ public function expiresIn(): int
+ {
+ if ($this->updatedAt === null) {
+ return 0;
+ }
+
+ $heartbeat = strtotime(datetime: $this->updatedAt);
+ if ($heartbeat === false) {
+ return 0;
+ }
+
+ $remaining = (($heartbeat + self::LOCK_TIMEOUT_SECONDS) - time());
+
+ return max(0, $remaining);
+ }//end expiresIn()
+
+ /**
+ * Compute the implied expiry timestamp (`updatedAt + 15 min`).
+ *
+ * Useful for clients that prefer an absolute moment over a duration.
+ * Returns `null` for an entity without `updatedAt`.
+ *
+ * @return string|null ISO-style timestamp (Y-m-d H:i:s) or null.
+ */
+ public function impliedExpiresAt(): ?string
+ {
+ if ($this->updatedAt === null) {
+ return null;
+ }
+
+ $heartbeat = strtotime(datetime: $this->updatedAt);
+ if ($heartbeat === false) {
+ return null;
+ }
+
+ $expiry = ($heartbeat + self::LOCK_TIMEOUT_SECONDS);
+ return (new DateTime())->setTimestamp(timestamp: $expiry)
+ ->format(format: 'Y-m-d H:i:s');
+ }//end impliedExpiresAt()
+
+ /**
+ * Serialize to JSON for API responses (REQ-LOCK-004 shape).
+ *
+ * @return array The serialized lock.
+ */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->getId(),
+ 'dashboardUuid' => $this->dashboardUuid,
+ 'userId' => $this->userId,
+ 'displayName' => $this->displayName,
+ 'acquiredAt' => $this->createdAt,
+ 'lastHeartbeat' => $this->updatedAt,
+ 'expiresAt' => $this->impliedExpiresAt(),
+ 'expiresIn' => $this->expiresIn(),
+ 'lockTimeoutSec' => self::LOCK_TIMEOUT_SECONDS,
+ ];
+ }//end jsonSerialize()
+}//end class
diff --git a/lib/Db/DashboardLockMapper.php b/lib/Db/DashboardLockMapper.php
new file mode 100644
index 00000000..a85ba67f
--- /dev/null
+++ b/lib/Db/DashboardLockMapper.php
@@ -0,0 +1,277 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use DateTime;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Db\QBMapper;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+
+/**
+ * Mapper for DashboardLock entities.
+ *
+ * @extends QBMapper
+ */
+class DashboardLockMapper extends QBMapper
+{
+ /**
+ * Constructor
+ *
+ * @param IDBConnection $db The database connection.
+ */
+ public function __construct(IDBConnection $db)
+ {
+ parent::__construct(
+ db: $db,
+ tableName: 'mydash_dashboard_locks',
+ entityClass: DashboardLock::class
+ );
+ }//end __construct()
+
+ /**
+ * Find any lock row for the given dashboard (active or stale).
+ *
+ * Returns the row regardless of expiry — callers SHOULD apply the
+ * `DashboardLock::isExpired()` predicate after the fetch when they
+ * need active-only semantics. This is used by the inline cleanup
+ * helper as well as by the service-layer ownership check.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return DashboardLock The lock row.
+ *
+ * @throws DoesNotExistException When no lock row exists.
+ */
+ public function findByDashboardUuid(string $dashboardUuid): DashboardLock
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ );
+
+ return $this->findEntity(query: $qb);
+ }//end findByDashboardUuid()
+
+ /**
+ * Find an active (non-expired) lock for the given dashboard.
+ *
+ * The active threshold is computed in-query as `now - 15 minutes`;
+ * any row with `updated_at >= threshold` is returned. Stale rows
+ * are silently treated as if they don't exist.
+ *
+ * Returns `null` rather than throwing when no active lock exists,
+ * since "no active lock" is a normal state.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return DashboardLock|null The active lock or null.
+ */
+ public function findActive(string $dashboardUuid): ?DashboardLock
+ {
+ $threshold = $this->expiryThreshold();
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->gte(
+ x: 'updated_at',
+ y: $qb->createNamedParameter(value: $threshold)
+ )
+ );
+
+ try {
+ return $this->findEntity(query: $qb);
+ } catch (DoesNotExistException) {
+ return null;
+ }
+ }//end findActive()
+
+ /**
+ * Find every lock owned by a given user (active and stale).
+ *
+ * Used for auditing — admins may want to see all editing sessions
+ * for a particular user across dashboards.
+ *
+ * @param string $userId The owner user ID.
+ *
+ * @return DashboardLock[] The locks (newest heartbeat first).
+ */
+ public function findByUserId(string $userId): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'user_id',
+ y: $qb->createNamedParameter(value: $userId)
+ )
+ )
+ ->orderBy(sort: 'updated_at', order: 'DESC');
+
+ return $this->findEntities(query: $qb);
+ }//end findByUserId()
+
+ /**
+ * Delete the lock row for a single dashboard when its `updated_at`
+ * is older than `now - 15 minutes`.
+ *
+ * Targeted (not a full-table sweep) so the operation stays cheap
+ * and deterministic — it only touches the row the caller is about
+ * to act on. Returns the number of rows deleted (0 or 1).
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return int The number of rows deleted (0 or 1).
+ */
+ public function deleteExpiredForDashboard(string $dashboardUuid): int
+ {
+ $threshold = $this->expiryThreshold();
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(delete: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->lt(
+ x: 'updated_at',
+ y: $qb->createNamedParameter(value: $threshold)
+ )
+ );
+
+ return $qb->executeStatement();
+ }//end deleteExpiredForDashboard()
+
+ /**
+ * Delete every stale lock row across all dashboards.
+ *
+ * Provided for completeness — not called on the hot path. Useful
+ * for an optional background sweeper in a follow-up change.
+ *
+ * @return int The number of rows deleted.
+ */
+ public function deleteAllExpired(): int
+ {
+ $threshold = $this->expiryThreshold();
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(delete: $this->getTableName())
+ ->where(
+ $qb->expr()->lt(
+ x: 'updated_at',
+ y: $qb->createNamedParameter(value: $threshold)
+ )
+ );
+
+ return $qb->executeStatement();
+ }//end deleteAllExpired()
+
+ /**
+ * Delete the lock row for a single dashboard regardless of expiry.
+ *
+ * Used by `DashboardLockService::releaseLock()` and the cascade
+ * triggered by `DashboardService::delete()` (REQ-LOCK-008).
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return int The number of rows deleted (0 or 1).
+ */
+ public function deleteByDashboardUuid(string $dashboardUuid): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(delete: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ );
+
+ return $qb->executeStatement();
+ }//end deleteByDashboardUuid()
+
+ /**
+ * Count every stale lock row across all dashboards.
+ *
+ * Mirror of {@see self::deleteAllExpired()} for the orphaned-data
+ * cleanup scan path (REQ-CLN-001). Same `updated_at < now - 15
+ * min` predicate so the scan total matches the purge delete count
+ * one-to-one.
+ *
+ * @return int The number of stale rows.
+ */
+ public function countAllExpired(): int
+ {
+ $threshold = $this->expiryThreshold();
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->select($qb->func()->count('*'))
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->lt(
+ x: 'updated_at',
+ y: $qb->createNamedParameter(value: $threshold)
+ )
+ );
+
+ $result = $qb->executeQuery();
+ $count = $result->fetchOne();
+ $result->closeCursor();
+
+ return (int) ($count ?? 0);
+ }//end countAllExpired()
+
+ /**
+ * Compute the active-lock threshold timestamp (`now - 15 min`).
+ *
+ * Centralised so the in-query expiry filter and the explicit
+ * delete predicate stay in sync.
+ *
+ * @return string The threshold formatted as `Y-m-d H:i:s`.
+ */
+ private function expiryThreshold(): string
+ {
+ $threshold = (new DateTime());
+ $threshold->setTimestamp(
+ timestamp: (time() - DashboardLock::LOCK_TIMEOUT_SECONDS)
+ );
+ return $threshold->format(format: 'Y-m-d H:i:s');
+ }//end expiryThreshold()
+}//end class
diff --git a/lib/Db/DashboardMapper.php b/lib/Db/DashboardMapper.php
index 52b0f171..eaeead97 100644
--- a/lib/Db/DashboardMapper.php
+++ b/lib/Db/DashboardMapper.php
@@ -32,6 +32,9 @@
* @extends QBMapper
*
* @SuppressWarnings(PHPMD.TooManyPublicMethods)
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Personal + group-shared
+ * + tree + publication-state + template-discovery query paths converge here
+ * per the cross-cutting `dashboards` table design.
*/
class DashboardMapper extends QBMapper
{
@@ -637,6 +640,382 @@ public function setGroupDefaultUuid(
return $qb->executeStatement();
}//end setGroupDefaultUuid()
+ /**
+ * Find direct children of a parent dashboard (REQ-DASH-026, REQ-DASH-029).
+ *
+ * Returns rows ordered by `sort_order` ASC, then `name` ASC for tie
+ * breaking. Pass `null` for `$parentUuid` to fetch root dashboards
+ * (`parent_uuid IS NULL`). The query is user-agnostic — caller is
+ * responsible for filtering visible-to-user results in the service
+ * layer.
+ *
+ * @param string|null $parentUuid The parent UUID (null ⇒ root).
+ *
+ * @return Dashboard[] The direct children, ordered for tree display.
+ */
+ public function findByParent(?string $parentUuid): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName());
+
+ if ($parentUuid === null) {
+ $qb->where($qb->expr()->isNull(x: 'parent_uuid'));
+ } else {
+ $qb->where(
+ $qb->expr()->eq(
+ x: 'parent_uuid',
+ y: $qb->createNamedParameter(value: $parentUuid)
+ )
+ );
+ }
+
+ $qb->orderBy(sort: 'sort_order', order: 'ASC')
+ ->addOrderBy(sort: 'name', order: 'ASC');
+
+ return $this->findEntities(query: $qb);
+ }//end findByParent()
+
+ /**
+ * Find a single child by `(parent_uuid, slug)` (REQ-DASH-024,
+ * REQ-DASH-027).
+ *
+ * Used by the path resolver to walk the tree segment by segment.
+ * Returns `null` when no row matches — the resolver translates that
+ * into a 404 to the caller. Slug comparison is case-insensitive
+ * (slugs are stored lowercase by `SlugGenerator::slugify()` but
+ * `LOWER(slug)` keeps stale rows uppercased pre-migration matching
+ * predictably).
+ *
+ * @param string|null $parentUuid The parent UUID, or null for root.
+ * @param string $slug The slug to look up (case folded).
+ *
+ * @return Dashboard|null The matching child, or null when not found.
+ */
+ public function findChildBySlug(
+ ?string $parentUuid,
+ string $slug
+ ): ?Dashboard {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName());
+
+ if ($parentUuid === null) {
+ $qb->where($qb->expr()->isNull(x: 'parent_uuid'));
+ } else {
+ $qb->where(
+ $qb->expr()->eq(
+ x: 'parent_uuid',
+ y: $qb->createNamedParameter(value: $parentUuid)
+ )
+ );
+ }
+
+ $qb->andWhere(
+ $qb->expr()->eq(
+ x: $qb->func()->lower('slug'),
+ y: $qb->createNamedParameter(value: strtolower($slug))
+ )
+ )->setMaxResults(maxResults: 1);
+
+ $cursor = $qb->executeQuery();
+ $row = $cursor->fetch();
+ $cursor->closeCursor();
+
+ if ($row === false) {
+ return null;
+ }
+
+ return Dashboard::fromRow($row);
+ }//end findChildBySlug()
+
+ /**
+ * Count direct children of a parent dashboard.
+ *
+ * Used by the cascade-delete guard (REQ-DASH-030) to populate the
+ * 409 response body without materialising every child entity.
+ *
+ * @param string $parentUuid The parent UUID.
+ *
+ * @return int The number of direct children.
+ */
+ public function countChildrenByParent(string $parentUuid): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select($qb->func()->count('*', 'cnt'))
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'parent_uuid',
+ y: $qb->createNamedParameter(value: $parentUuid)
+ )
+ );
+
+ $cursor = $qb->executeQuery();
+ $row = $cursor->fetch();
+ $cursor->closeCursor();
+
+ if ($row === false || isset($row['cnt']) === false) {
+ return 0;
+ }
+
+ return (int) $row['cnt'];
+ }//end countChildrenByParent()
+
+ /**
+ * Walk the ancestor chain upward from a child dashboard (REQ-DASH-025).
+ *
+ * Returns the ancestor entities in **root-first** order (so the
+ * caller can append the child itself to build a breadcrumb list
+ * without re-sorting). The walk hard-stops at
+ * {@see Dashboard::MAX_DEPTH} hops to defend against pre-existing
+ * malformed cycles surviving from before the cycle guard shipped.
+ *
+ * @param string $uuid The child dashboard UUID.
+ *
+ * @return Dashboard[] The ancestor entities ordered root → parent
+ * (the child itself is NOT included).
+ */
+ public function findAncestors(string $uuid): array
+ {
+ $ancestors = [];
+ $cursor = $uuid;
+
+ for ($i = 0; $i < Dashboard::MAX_DEPTH; $i++) {
+ try {
+ $current = $this->findByUuid(uuid: $cursor);
+ } catch (DoesNotExistException) {
+ break;
+ }
+
+ $parent = $current->getParentUuid();
+ if ($parent === null || $parent === '') {
+ break;
+ }
+
+ try {
+ $parentEntity = $this->findByUuid(uuid: $parent);
+ } catch (DoesNotExistException) {
+ break;
+ }
+
+ // Prepend so the final list is root-first.
+ array_unshift($ancestors, $parentEntity);
+ $cursor = $parent;
+ }//end for
+
+ return $ancestors;
+ }//end findAncestors()
+
+ /**
+ * Walk every descendant of an ancestor dashboard.
+ *
+ * Implements iterative breadth-first traversal capped at
+ * {@see Dashboard::MAX_DEPTH} hops below the ancestor. Used by the
+ * cascade-delete path (REQ-DASH-030) and by the cycle-detection
+ * guard (REQ-DASH-028) when the proposed parent is the dashboard
+ * itself or a known descendant.
+ *
+ * @param string $ancestorUuid The ancestor dashboard UUID.
+ *
+ * @return Dashboard[] Every descendant dashboard, breadth-first.
+ */
+ public function findDescendants(string $ancestorUuid): array
+ {
+ $descendants = [];
+ $frontier = [$ancestorUuid];
+
+ // Cap iterations at MAX_DEPTH as a belt-and-braces guard against
+ // pre-existing malformed cycles surviving from before the cycle
+ // guard shipped — the for limit is the explicit defence.
+ for ($depth = 0; $depth < Dashboard::MAX_DEPTH; $depth++) {
+ if (count($frontier) === 0) {
+ break;
+ }
+
+ $next = [];
+ foreach ($frontier as $parentUuid) {
+ $children = $this->findByParent(parentUuid: $parentUuid);
+ foreach ($children as $child) {
+ $descendants[] = $child;
+ $childUuid = $child->getUuid();
+ if ($childUuid !== null && $childUuid !== '') {
+ $next[] = $childUuid;
+ }
+ }
+ }
+
+ $frontier = $next;
+ }
+
+ return $descendants;
+ }//end findDescendants()
+
+ /**
+ * Find every scheduled dashboard whose `publishAt` is past-due.
+ *
+ * Used by the optional eager-materialisation path in
+ * {@see \OCA\MyDash\Service\DashboardService::materialiseScheduledDashboards()}.
+ * Lazy materialisation in the visibility filter remains the
+ * correctness contract — this mapper exists only for cleaner audit
+ * data on the underlying table. REQ-DASH-034.
+ *
+ * @return Dashboard[] The due-scheduled dashboards.
+ */
+ public function findDueScheduled(): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'publication_status',
+ y: $qb->createNamedParameter(
+ value: Dashboard::STATUS_SCHEDULED
+ )
+ )
+ )
+ ->andWhere(
+ $qb->expr()->lte(
+ x: 'publish_at',
+ y: $qb->createNamedParameter(
+ value: (new DateTime())->format(format: 'Y-m-d H:i:s')
+ )
+ )
+ );
+
+ return $this->findEntities(query: $qb);
+ }//end findDueScheduled()
+
+ /**
+ * Find all admin templates for the discovery gallery (REQ-TMPL-014).
+ *
+ * Lists rows with `type = 'admin_template'`, optionally filtered to
+ * a single `template_category`. Sort order:
+ * - `sortBy = 'name'` (default): `template_category` ascending with
+ * NULL values last, then `name` ascending — matches the gallery's
+ * "group by category, alphabetical inside each group" UX.
+ * - `sortBy = 'updatedAt'`: `updated_at` descending (most recent
+ * first) for the "Recently updated" tab.
+ *
+ * No widget placements are fetched — the gallery is a list view, not
+ * a render. Callers wanting a count of placements per template should
+ * issue a single follow-up query (or use a lightweight COUNT query)
+ * rather than N+1 over `findByDashboardId`.
+ *
+ * @param string|null $category Optional category exact-match filter
+ * (NULL → no filter).
+ * @param string $sortBy Sort key: `'name'` (default) or
+ * `'updatedAt'`.
+ *
+ * @return Dashboard[] The matching admin templates.
+ */
+ public function findAllTemplatesForGallery(
+ ?string $category=null,
+ string $sortBy='name'
+ ): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'type',
+ y: $qb->createNamedParameter(
+ value: Dashboard::TYPE_ADMIN_TEMPLATE
+ )
+ )
+ );
+
+ if ($category !== null && $category !== '') {
+ $qb->andWhere(
+ $qb->expr()->eq(
+ x: 'template_category',
+ y: $qb->createNamedParameter(value: $category)
+ )
+ );
+ }
+
+ if ($sortBy === 'updatedAt') {
+ $qb->orderBy(sort: 'updated_at', order: 'DESC');
+ } else {
+ // Default: category ASC (NULL last), then name ASC.
+ //
+ // Nextcloud's `IQueryBuilder::orderBy()` escapes its `sort`
+ // argument as a column identifier, so passing a literal
+ // CASE WHEN expression there produces invalid SQL on
+ // PostgreSQL ("column does not exist"). Wrap the
+ // synthetic sort key via `createFunction()` instead — that
+ // emits the expression verbatim, preserving NULL-last
+ // semantics across pgsql / mysql / sqlite without needing
+ // driver-specific NULLS LAST clauses.
+ $qb->orderBy(
+ sort: $qb->createFunction(
+ 'CASE WHEN template_category IS NULL THEN 1 ELSE 0 END'
+ ),
+ order: 'ASC'
+ )
+ ->addOrderBy(sort: 'template_category', order: 'ASC')
+ ->addOrderBy(sort: 'name', order: 'ASC');
+ }//end if
+
+ return $this->findEntities(query: $qb);
+ }//end findAllTemplatesForGallery()
+
+ /**
+ * Find a personal dashboard by `(userId, uuid)` for the
+ * save-as-template ownership check (REQ-TMPL-015).
+ *
+ * Restricts to `type = 'user'` so admins cannot save group-shared or
+ * admin-template rows as templates via this code path. Returns
+ * `null` when no row matches — caller treats as forbidden / not
+ * found per the REQ-TMPL-015 contract.
+ *
+ * @param string $userId The owning user ID.
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return Dashboard|null The owned dashboard, or `null` when no
+ * match.
+ */
+ public function findOwnedByUserAndUuid(
+ string $userId,
+ string $uuid
+ ): ?Dashboard {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'user_id',
+ y: $qb->createNamedParameter(value: $userId)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->eq(
+ x: 'uuid',
+ y: $qb->createNamedParameter(value: $uuid)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->eq(
+ x: 'type',
+ y: $qb->createNamedParameter(
+ value: Dashboard::TYPE_USER
+ )
+ )
+ )
+ ->setMaxResults(maxResults: 1);
+
+ $cursor = $qb->executeQuery();
+ $row = $cursor->fetch();
+ $cursor->closeCursor();
+
+ if ($row === false) {
+ return null;
+ }
+
+ return Dashboard::fromRow($row);
+ }//end findOwnedByUserAndUuid()
+
/**
* Clear default flag on all admin templates.
*
diff --git a/lib/Db/DashboardReaction.php b/lib/Db/DashboardReaction.php
new file mode 100644
index 00000000..78218341
--- /dev/null
+++ b/lib/Db/DashboardReaction.php
@@ -0,0 +1,115 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 EUPL-1.2
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use DateTime;
+use JsonSerializable;
+use OCP\AppFramework\Db\Entity;
+
+/**
+ * Dashboard reaction entity.
+ *
+ * @method string|null getDashboardUuid()
+ * @method void setDashboardUuid(?string $dashboardUuid)
+ * @method string|null getUserId()
+ * @method void setUserId(?string $userId)
+ * @method string|null getEmoji()
+ * @method void setEmoji(?string $emoji)
+ * @method \DateTime|null getReactedAt()
+ * @method void setReactedAt(?\DateTime $reactedAt)
+ */
+class DashboardReaction extends Entity implements JsonSerializable
+{
+
+ /**
+ * The dashboard UUID this reaction targets.
+ *
+ * @var string|null
+ */
+ protected ?string $dashboardUuid = null;
+
+ /**
+ * The Nextcloud user ID of the reactor.
+ *
+ * @var string|null
+ */
+ protected ?string $userId = null;
+
+ /**
+ * The unicode emoji string (e.g. `👍`, `❤️`, `🎉`).
+ *
+ * @var string|null
+ */
+ protected ?string $emoji = null;
+
+ /**
+ * The instant the reaction was created.
+ *
+ * @var \DateTime|null
+ */
+ protected ?DateTime $reactedAt = null;
+
+ /**
+ * Constructor — registers column types.
+ *
+ * @return void
+ */
+ public function __construct()
+ {
+ $this->addType(fieldName: 'id', type: 'integer');
+ $this->addType(fieldName: 'reactedAt', type: 'datetime');
+ }//end __construct()
+
+ /**
+ * Format the reaction timestamp the same way other dashboard
+ * timestamps are exposed in the JSON envelope. REQ-RXN-001
+ * scenario "User adds a reaction to a dashboard they can view".
+ *
+ * @return string|null `Y-m-d H:i:s` format or null when unset.
+ */
+ public function getReactedAtFormatted(): ?string
+ {
+ if ($this->reactedAt === null) {
+ return null;
+ }
+
+ return $this->reactedAt->format(format: 'Y-m-d H:i:s');
+ }//end getReactedAtFormatted()
+
+ /**
+ * Serialize to JSON.
+ *
+ * @return array The serialized reaction.
+ */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->getId(),
+ 'dashboardUuid' => $this->dashboardUuid,
+ 'userId' => $this->userId,
+ 'emoji' => $this->emoji,
+ 'reactedAt' => $this->getReactedAtFormatted(),
+ ];
+ }//end jsonSerialize()
+}//end class
diff --git a/lib/Db/DashboardReactionMapper.php b/lib/Db/DashboardReactionMapper.php
new file mode 100644
index 00000000..8ae39ef9
--- /dev/null
+++ b/lib/Db/DashboardReactionMapper.php
@@ -0,0 +1,325 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use DateTime;
+use Exception;
+use OCP\AppFramework\Db\QBMapper;
+use OCP\DB\Exception as DbException;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+
+/**
+ * Mapper for DashboardReaction entities.
+ *
+ * @extends QBMapper
+ */
+class DashboardReactionMapper extends QBMapper
+{
+ /**
+ * Constructor
+ *
+ * @param IDBConnection $db The database connection.
+ */
+ public function __construct(IDBConnection $db)
+ {
+ parent::__construct(
+ db: $db,
+ tableName: 'mydash_dashboard_reactions',
+ entityClass: DashboardReaction::class
+ );
+ }//end __construct()
+
+ /**
+ * Find all reactions for a dashboard, newest first.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return DashboardReaction[] The reactions.
+ */
+ public function findByDashboard(string $dashboardUuid): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ )
+ ->orderBy(sort: 'reacted_at', order: 'DESC');
+
+ return $this->findEntities(query: $qb);
+ }//end findByDashboard()
+
+ /**
+ * Find reactions for a single emoji on a dashboard, oldest first
+ * (chronological, matching REQ-RXN-004 scenario "User retrieves
+ * reactors for a specific emoji").
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $emoji The emoji.
+ * @param int|null $limit Optional row cap (used for the 100-item
+ * pagination cap).
+ * @param int|null $offset Optional offset (cursor pagination).
+ *
+ * @return DashboardReaction[] The reactions.
+ */
+ public function findByEmoji(
+ string $dashboardUuid,
+ string $emoji,
+ ?int $limit=null,
+ ?int $offset=null
+ ): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->eq(
+ x: 'emoji',
+ y: $qb->createNamedParameter(value: $emoji)
+ )
+ )
+ ->orderBy(sort: 'reacted_at', order: 'ASC')
+ ->addOrderBy(sort: 'id', order: 'ASC');
+
+ if ($limit !== null) {
+ $qb->setMaxResults(maxResults: $limit);
+ }
+
+ if ($offset !== null) {
+ $qb->setFirstResult(firstResult: $offset);
+ }
+
+ return $this->findEntities(query: $qb);
+ }//end findByEmoji()
+
+ /**
+ * Find every reaction the calling user has placed on a dashboard.
+ *
+ * @param string $userId The user ID.
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return DashboardReaction[] The reactions.
+ */
+ public function findByUser(string $userId, string $dashboardUuid): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->eq(
+ x: 'user_id',
+ y: $qb->createNamedParameter(value: $userId)
+ )
+ );
+
+ return $this->findEntities(query: $qb);
+ }//end findByUser()
+
+ /**
+ * Aggregate reaction counts per emoji for a single dashboard.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return array Map of `emoji => count`.
+ */
+ public function countByEmoji(string $dashboardUuid): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: 'emoji')
+ ->selectAlias(
+ select: $qb->func()->count('*'),
+ alias: 'cnt'
+ )
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ )
+ ->groupBy(groupBys: 'emoji');
+
+ $result = $qb->executeQuery();
+ $counts = [];
+ while (($row = $result->fetch()) !== false) {
+ $counts[(string) $row['emoji']] = (int) $row['cnt'];
+ }
+
+ $result->closeCursor();
+
+ return $counts;
+ }//end countByEmoji()
+
+ /**
+ * Insert a reaction row. Throws on duplicate (caller is expected to
+ * either swallow the duplicate exception for idempotent semantics
+ * or surface it as a 4xx error).
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $userId The user ID.
+ * @param string $emoji The emoji.
+ *
+ * @return DashboardReaction The inserted reaction.
+ *
+ * @throws DbException When the unique constraint is violated.
+ */
+ public function addReaction(
+ string $dashboardUuid,
+ string $userId,
+ string $emoji
+ ): DashboardReaction {
+ $entity = new DashboardReaction();
+ // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ // Entity setters resolve via __call which forwards $args[0]; named
+ // parameters MUST NOT be used here.
+ $entity->setDashboardUuid($dashboardUuid);
+ $entity->setUserId($userId);
+ $entity->setEmoji($emoji);
+ $entity->setReactedAt(new DateTime());
+ // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+
+ return $this->insert(entity: $entity);
+ }//end addReaction()
+
+ /**
+ * Delete a single reaction matching `(dashboardUuid, userId, emoji)`.
+ *
+ * Idempotent — when nothing matches, returns false rather than
+ * raising an exception.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $userId The user ID.
+ * @param string $emoji The emoji.
+ *
+ * @return bool True when a row was deleted, false when none matched.
+ */
+ public function removeReaction(
+ string $dashboardUuid,
+ string $userId,
+ string $emoji
+ ): bool {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(delete: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->eq(
+ x: 'user_id',
+ y: $qb->createNamedParameter(value: $userId)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->eq(
+ x: 'emoji',
+ y: $qb->createNamedParameter(value: $emoji)
+ )
+ );
+
+ return $qb->executeStatement() > 0;
+ }//end removeReaction()
+
+ /**
+ * Cascade-delete every reaction tied to a dashboard. Used by the
+ * `ReactionsListener` (REQ-CSC-003) and the `DashboardService`
+ * cascade path.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return int The number of rows deleted.
+ */
+ public function deleteByDashboardUuid(string $dashboardUuid): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(delete: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ );
+
+ return $qb->executeStatement();
+ }//end deleteByDashboardUuid()
+
+ /**
+ * Count the rows for a dashboard that match a single emoji.
+ *
+ * Cheap helper used by the pagination response so the
+ * controller can decide whether to surface a `nextCursor` link.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $emoji The emoji.
+ *
+ * @return int The total number of reactions for that emoji.
+ */
+ public function countReactorsByEmoji(
+ string $dashboardUuid,
+ string $emoji
+ ): int {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: $qb->func()->count('*', 'cnt'))
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->eq(
+ x: 'emoji',
+ y: $qb->createNamedParameter(value: $emoji)
+ )
+ );
+
+ $result = $qb->executeQuery();
+ $row = $result->fetch();
+ $result->closeCursor();
+
+ if (is_array($row) === false) {
+ return 0;
+ }
+
+ return (int) ($row['cnt'] ?? 0);
+ }//end countReactorsByEmoji()
+}//end class
diff --git a/lib/Db/DashboardShareMapper.php b/lib/Db/DashboardShareMapper.php
index 0db49cdd..6ea45068 100644
--- a/lib/Db/DashboardShareMapper.php
+++ b/lib/Db/DashboardShareMapper.php
@@ -184,6 +184,54 @@ public function findShare(
}
}//end findShare()
+ /**
+ * Find every share that grants access to a given user, considering both
+ * direct user shares and group shares. REQ-SHARE-002.
+ *
+ * @param string $userId The recipient user id.
+ * @param string[] $groupIds The recipient's group ids.
+ *
+ * @return DashboardShare[] The shares.
+ */
+ public function findForRecipient(string $userId, array $groupIds): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName());
+
+ $userClause = $qb->expr()->andX(
+ $qb->expr()->eq(
+ x: 'share_type',
+ y: $qb->createNamedParameter(value: DashboardShare::SHARE_TYPE_USER)
+ ),
+ $qb->expr()->eq(
+ x: 'share_with',
+ y: $qb->createNamedParameter(value: $userId)
+ )
+ );
+
+ if (count($groupIds) > 0) {
+ $groupClause = $qb->expr()->andX(
+ $qb->expr()->eq(
+ x: 'share_type',
+ y: $qb->createNamedParameter(value: DashboardShare::SHARE_TYPE_GROUP)
+ ),
+ $qb->expr()->in(
+ x: 'share_with',
+ y: $qb->createNamedParameter(
+ value: $groupIds,
+ type: IQueryBuilder::PARAM_STR_ARRAY
+ )
+ )
+ );
+ $qb->where($qb->expr()->orX($userClause, $groupClause));
+ } else {
+ $qb->where($userClause);
+ }
+
+ return $this->findEntities(query: $qb);
+ }//end findForRecipient()
+
/**
* Delete all shares for a dashboard.
*
@@ -266,6 +314,89 @@ public function deleteNotIn(int $dashboardId, array $keepKeys): int
return $deleted;
}//end deleteNotIn()
+ /**
+ * Count share rows whose `dashboard_id` no longer points at any
+ * row in `mydash_dashboards`.
+ *
+ * Used by the orphaned-data-cleanup scan path (REQ-CLN-001)
+ * to surface the count of dangling shares that survived a manual
+ * SQL delete or a corrupted cascade. Shares are normally cleared
+ * by `DashboardService::delete()` in-band, so this number SHOULD
+ * be 0 on a healthy install.
+ *
+ * @return int The number of orphaned share rows.
+ */
+ public function countOrphaned(): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select($qb->func()->count('s.id'))
+ ->from(from: $this->getTableName(), alias: 's')
+ ->leftJoin(
+ fromAlias: 's',
+ join: 'mydash_dashboards',
+ alias: 'd',
+ condition: 'd.id = s.dashboard_id'
+ )
+ ->where($qb->expr()->isNull(x: 'd.id'));
+
+ $result = $qb->executeQuery();
+ $count = $result->fetchOne();
+ $result->closeCursor();
+
+ return (int) ($count ?? 0);
+ }//end countOrphaned()
+
+ /**
+ * Delete share rows whose `dashboard_id` no longer points at any
+ * row in `mydash_dashboards`.
+ *
+ * Companion to {@see self::countOrphaned()} on the purge path
+ * (REQ-CLN-002). Resolves the orphan IDs first via a SELECT and
+ * then deletes them by primary key — Doctrine DBAL doesn't
+ * support DELETE…JOIN portably across MySQL/Postgres/SQLite.
+ *
+ * @return int The number of rows deleted.
+ */
+ public function deleteOrphaned(): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: 's.id')
+ ->from(from: $this->getTableName(), alias: 's')
+ ->leftJoin(
+ fromAlias: 's',
+ join: 'mydash_dashboards',
+ alias: 'd',
+ condition: 'd.id = s.dashboard_id'
+ )
+ ->where($qb->expr()->isNull(x: 'd.id'));
+
+ $result = $qb->executeQuery();
+ $ids = [];
+ while (($row = $result->fetch()) !== false) {
+ $ids[] = (int) $row['id'];
+ }
+
+ $result->closeCursor();
+
+ if (count(value: $ids) === 0) {
+ return 0;
+ }
+
+ $delete = $this->db->getQueryBuilder();
+ $delete->delete(delete: $this->getTableName())
+ ->where(
+ $delete->expr()->in(
+ x: 'id',
+ y: $delete->createNamedParameter(
+ value: $ids,
+ type: IQueryBuilder::PARAM_INT_ARRAY
+ )
+ )
+ );
+
+ return $delete->executeStatement();
+ }//end deleteOrphaned()
+
/**
* Delete all shares the caller owns that target a specific recipient.
*
diff --git a/lib/Db/DashboardTranslation.php b/lib/Db/DashboardTranslation.php
new file mode 100644
index 00000000..363e2276
--- /dev/null
+++ b/lib/Db/DashboardTranslation.php
@@ -0,0 +1,154 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 EUPL-1.2
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use JsonSerializable;
+use OCP\AppFramework\Db\Entity;
+
+/**
+ * Dashboard translation entity.
+ *
+ * @method string|null getDashboardUuid()
+ * @method void setDashboardUuid(?string $dashboardUuid)
+ * @method string|null getLanguageCode()
+ * @method void setLanguageCode(?string $languageCode)
+ * @method string|null getName()
+ * @method void setName(?string $name)
+ * @method string|null getDescription()
+ * @method void setDescription(?string $description)
+ * @method string|null getWidgetTreeJson()
+ * @method void setWidgetTreeJson(?string $widgetTreeJson)
+ * @method int getIsPrimary()
+ * @method void setIsPrimary(int $isPrimary)
+ * @method string|null getCreatedAt()
+ * @method void setCreatedAt(?string $createdAt)
+ * @method string|null getUpdatedAt()
+ * @method void setUpdatedAt(?string $updatedAt)
+ */
+class DashboardTranslation extends Entity implements JsonSerializable
+{
+
+ /**
+ * Default fallback locale when the dashboard owner has no Nextcloud
+ * `core/lang` user preference set. REQ-DASH-039 (locale resolution).
+ *
+ * @var string
+ */
+ public const DEFAULT_LANGUAGE = 'en';
+
+ /**
+ * The dashboard UUID this translation belongs to.
+ *
+ * @var string|null
+ */
+ protected ?string $dashboardUuid = null;
+
+ /**
+ * The 2-character ISO 639-1 base language code (e.g. 'nl', 'en',
+ * 'de', 'fr'). Stored normalised — see
+ * {@see DashboardTranslationMapper::normaliseLanguageCode()} for the
+ * normalisation rule. REQ-DASH-038, design D2.
+ *
+ * @var string|null
+ */
+ protected ?string $languageCode = null;
+
+ /**
+ * The localised dashboard name.
+ *
+ * @var string|null
+ */
+ protected ?string $name = null;
+
+ /**
+ * The localised dashboard description.
+ *
+ * @var string|null
+ */
+ protected ?string $description = null;
+
+ /**
+ * The localised widget tree JSON blob. Mirrors the existing
+ * `oc_mydash_dashboards.widget_tree_json` column shape so the
+ * serializer is interchangeable.
+ *
+ * @var string|null
+ */
+ protected ?string $widgetTreeJson = null;
+
+ /**
+ * Whether this variant is the primary fallback for the dashboard
+ * (SMALLINT 0/1). Exactly one row per dashboard MUST be primary;
+ * invariant enforced by service layer. REQ-DASH-038.
+ *
+ * @var integer
+ */
+ protected int $isPrimary = 0;
+
+ /**
+ * The creation timestamp.
+ *
+ * @var string|null
+ */
+ protected ?string $createdAt = null;
+
+ /**
+ * The update timestamp.
+ *
+ * @var string|null
+ */
+ protected ?string $updatedAt = null;
+
+ /**
+ * Constructor — registers column types.
+ *
+ * @return void
+ */
+ public function __construct()
+ {
+ $this->addType(fieldName: 'id', type: 'integer');
+ $this->addType(fieldName: 'isPrimary', type: 'integer');
+ }//end __construct()
+
+ /**
+ * Serialize to JSON.
+ *
+ * @return array The serialized translation.
+ */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->getId(),
+ 'dashboardUuid' => $this->dashboardUuid,
+ 'languageCode' => $this->languageCode,
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'widgetTreeJson' => $this->widgetTreeJson,
+ 'isPrimary' => $this->isPrimary,
+ 'createdAt' => $this->createdAt,
+ 'updatedAt' => $this->updatedAt,
+ ];
+ }//end jsonSerialize()
+}//end class
diff --git a/lib/Db/DashboardTranslationMapper.php b/lib/Db/DashboardTranslationMapper.php
new file mode 100644
index 00000000..ca6e0acc
--- /dev/null
+++ b/lib/Db/DashboardTranslationMapper.php
@@ -0,0 +1,347 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Db\QBMapper;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+
+/**
+ * Mapper for DashboardTranslation entities.
+ *
+ * @extends QBMapper
+ */
+class DashboardTranslationMapper extends QBMapper
+{
+ /**
+ * Constructor
+ *
+ * @param IDBConnection $db The database connection.
+ */
+ public function __construct(IDBConnection $db)
+ {
+ parent::__construct(
+ db: $db,
+ tableName: 'mydash_dash_translations',
+ entityClass: DashboardTranslation::class
+ );
+ }//end __construct()
+
+ /**
+ * Normalise an arbitrary locale string to the canonical 2-character
+ * base code stored in the table. REQ-DASH-038, design D2.
+ *
+ * Accepts any of: `nl`, `nl_NL`, `nl-NL`, `nl-BE`, `EN`, `fr_FR`.
+ * Returns the lowercased characters before the first separator
+ * (`_` or `-`). An empty input returns the empty string — caller
+ * decides whether to fall back to {@see DashboardTranslation::DEFAULT_LANGUAGE}.
+ *
+ * @param string $raw The raw locale string.
+ *
+ * @return string The 2-char (or fewer) base code in lowercase.
+ */
+ public static function normaliseLanguageCode(string $raw): string
+ {
+ $trimmed = trim($raw);
+ if ($trimmed === '') {
+ return '';
+ }
+
+ $lower = strtolower($trimmed);
+ // Split on either the underscore or hyphen separator and take
+ // the first segment — handles BCP-47 (`nl-BE`), POSIX
+ // (`nl_NL`), and bare codes (`nl`) uniformly.
+ $separatorPos = strcspn($lower, '_-');
+ return substr($lower, 0, $separatorPos);
+ }//end normaliseLanguageCode()
+
+ /**
+ * Find a translation by ID.
+ *
+ * @param int $id The translation ID.
+ *
+ * @return DashboardTranslation The translation entity.
+ *
+ * @throws DoesNotExistException When not found.
+ */
+ public function find(int $id): DashboardTranslation
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'id',
+ y: $qb->createNamedParameter(
+ value: $id,
+ type: IQueryBuilder::PARAM_INT
+ )
+ )
+ );
+
+ return $this->findEntity(query: $qb);
+ }//end find()
+
+ /**
+ * Find every translation variant for a dashboard.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return DashboardTranslation[] The translation rows, ordered by
+ * language code.
+ */
+ public function findByDashboardUuid(string $dashboardUuid): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ )
+ ->orderBy(sort: 'language_code', order: 'ASC');
+
+ return $this->findEntities(query: $qb);
+ }//end findByDashboardUuid()
+
+ /**
+ * Find a single translation by `(dashboardUuid, languageCode)`.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $languageCode The (raw) language code; will be
+ * normalised to the stored form.
+ *
+ * @return DashboardTranslation|null The variant or null when not found.
+ */
+ public function findByDashboardUuidAndLanguage(
+ string $dashboardUuid,
+ string $languageCode
+ ): ?DashboardTranslation {
+ $normalised = self::normaliseLanguageCode(raw: $languageCode);
+ if ($normalised === '') {
+ return null;
+ }
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->eq(
+ x: 'language_code',
+ y: $qb->createNamedParameter(value: $normalised)
+ )
+ )
+ ->setMaxResults(maxResults: 1);
+
+ try {
+ return $this->findEntity(query: $qb);
+ } catch (DoesNotExistException) {
+ return null;
+ }
+ }//end findByDashboardUuidAndLanguage()
+
+ /**
+ * Find the primary translation for a dashboard.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return DashboardTranslation|null The primary variant or null when
+ * no rows exist for the dashboard.
+ */
+ public function findPrimaryByDashboardUuid(
+ string $dashboardUuid
+ ): ?DashboardTranslation {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->eq(
+ x: 'is_primary',
+ y: $qb->createNamedParameter(
+ value: 1,
+ type: IQueryBuilder::PARAM_INT
+ )
+ )
+ )
+ ->setMaxResults(maxResults: 1);
+
+ try {
+ return $this->findEntity(query: $qb);
+ } catch (DoesNotExistException) {
+ return null;
+ }
+ }//end findPrimaryByDashboardUuid()
+
+ /**
+ * Resolve the best translation for a dashboard given a preferred
+ * language using the spec's matching strategy. REQ-DASH-039.
+ *
+ * Steps:
+ * 1. Normalise `preferredLanguage` to its 2-char base code.
+ * 2. If a row exists for `(uuid, normalised)`, return it (exact
+ * match — covers both true exact matches and spec D2 "prefix"
+ * matches because both reduce to the same normalised code).
+ * 3. Else fall back to the primary variant.
+ * 4. Else return null (no rows at all — caller treats as 404 or
+ * uses the legacy fields on the parent dashboard row).
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $preferredLanguage The viewer's preferred locale
+ * (raw, may be `nl_NL`, `nl-BE`,
+ * empty, etc.).
+ *
+ * @return DashboardTranslation|null The resolved variant or null.
+ */
+ public function findByDashboardUuidWithLocaleMatching(
+ string $dashboardUuid,
+ string $preferredLanguage
+ ): ?DashboardTranslation {
+ $exact = $this->findByDashboardUuidAndLanguage(
+ dashboardUuid: $dashboardUuid,
+ languageCode: $preferredLanguage
+ );
+ if ($exact !== null) {
+ return $exact;
+ }
+
+ return $this->findPrimaryByDashboardUuid(
+ dashboardUuid: $dashboardUuid
+ );
+ }//end findByDashboardUuidWithLocaleMatching()
+
+ /**
+ * Delete every translation variant attached to a dashboard.
+ *
+ * Used by the cascade-delete path in
+ * {@see \OCA\MyDash\Service\DashboardService::deleteDashboard()}.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return int The number of rows deleted.
+ */
+ public function deleteByDashboardUuid(string $dashboardUuid): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(delete: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ );
+
+ return $qb->executeStatement();
+ }//end deleteByDashboardUuid()
+
+ /**
+ * Clear the `is_primary` flag on every variant for a dashboard,
+ * optionally excepting one UUID. Used by the promote-primary
+ * transactional flip. REQ-DASH-042.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param int|null $exceptId Optional row ID to leave untouched.
+ *
+ * @return int The number of rows affected.
+ */
+ public function clearPrimary(
+ string $dashboardUuid,
+ ?int $exceptId=null
+ ): int {
+ $qb = $this->db->getQueryBuilder();
+ $qb->update(update: $this->getTableName())
+ ->set(
+ key: 'is_primary',
+ value: $qb->createNamedParameter(
+ value: 0,
+ type: IQueryBuilder::PARAM_INT
+ )
+ )
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ );
+
+ if ($exceptId !== null) {
+ $qb->andWhere(
+ $qb->expr()->neq(
+ x: 'id',
+ y: $qb->createNamedParameter(
+ value: $exceptId,
+ type: IQueryBuilder::PARAM_INT
+ )
+ )
+ );
+ }
+
+ return $qb->executeStatement();
+ }//end clearPrimary()
+
+ /**
+ * Count translation variants for a dashboard.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return int The number of variants.
+ */
+ public function countByDashboardUuid(string $dashboardUuid): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select($qb->func()->count('*', 'cnt'))
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ );
+
+ $cursor = $qb->executeQuery();
+ $row = $cursor->fetch();
+ $cursor->closeCursor();
+
+ if ($row === false || isset($row['cnt']) === false) {
+ return 0;
+ }
+
+ return (int) $row['cnt'];
+ }//end countByDashboardUuid()
+}//end class
diff --git a/lib/Db/DashboardVersion.php b/lib/Db/DashboardVersion.php
new file mode 100644
index 00000000..98df4365
--- /dev/null
+++ b/lib/Db/DashboardVersion.php
@@ -0,0 +1,142 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 EUPL-1.2
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use JsonSerializable;
+use OCP\AppFramework\Db\Entity;
+
+/**
+ * Dashboard version snapshot entity (REQ-VERS-001..009).
+ *
+ * @method string|null getDashboardUuid()
+ * @method void setDashboardUuid(?string $dashboardUuid)
+ * @method int|null getVersionNumber()
+ * @method void setVersionNumber(?int $versionNumber)
+ * @method string|null getSnapshotJson()
+ * @method void setSnapshotJson(?string $snapshotJson)
+ * @method string|null getCreatedBy()
+ * @method void setCreatedBy(?string $createdBy)
+ * @method string|null getCreatedAt()
+ * @method void setCreatedAt(?string $createdAt)
+ * @method string|null getNote()
+ * @method void setNote(?string $note)
+ */
+class DashboardVersion extends Entity implements JsonSerializable
+{
+
+ /**
+ * The dashboard UUID this snapshot belongs to.
+ *
+ * @var string|null
+ */
+ protected ?string $dashboardUuid = null;
+
+ /**
+ * Per-dashboard monotonic version number. Starts at 1 and never
+ * resets — pruning the oldest rows does NOT renumber the survivors
+ * (REQ-VERS-006 scenario "pruning does not affect versionNumber
+ * sequence").
+ *
+ * @var integer|null
+ */
+ protected ?int $versionNumber = null;
+
+ /**
+ * The full JSON snapshot body — widget placements, dashboard
+ * metadata, anything required to restore the dashboard byte-for-byte
+ * (REQ-VERS-001 scenario "snapshot captures full state"). Stored
+ * as a TEXT-class column (MEDIUMTEXT on MySQL, TEXT on Postgres /
+ * SQLite) so up to ~16 MB of JSON is supported.
+ *
+ * @var string|null
+ */
+ protected ?string $snapshotJson = null;
+
+ /**
+ * The Nextcloud user ID that triggered the snapshot creation. May be
+ * the original author of the PUT (automatic snapshot) or an explicit
+ * caller (POST /versions / POST /restore).
+ *
+ * @var string|null
+ */
+ protected ?string $createdBy = null;
+
+ /**
+ * The snapshot creation timestamp (Y-m-d H:i:s).
+ *
+ * @var string|null
+ */
+ protected ?string $createdAt = null;
+
+ /**
+ * Optional note / label supplied with explicit snapshots
+ * (REQ-VERS-002). NULL for automatic snapshots and explicit
+ * snapshots whose payload omitted or empty-stringed the field.
+ *
+ * @var string|null
+ */
+ protected ?string $note = null;
+
+ /**
+ * Constructor — registers integer column types for proper ORM hydration.
+ *
+ * @return void
+ */
+ public function __construct()
+ {
+ $this->addType(fieldName: 'id', type: 'integer');
+ $this->addType(fieldName: 'versionNumber', type: 'integer');
+ }//end __construct()
+
+ /**
+ * Serialize to JSON for API responses.
+ *
+ * The full snapshot body is intentionally NOT included in list
+ * responses (REQ-VERS-003) — callers MUST hit
+ * `GET /api/dashboards/{uuid}/versions/{versionNumber}` to fetch the
+ * snapshot. The list serialisation includes a `sizeBytes` hint so
+ * clients can render the row weight without downloading the body.
+ *
+ * @return array The serialized version metadata.
+ */
+ public function jsonSerialize(): array
+ {
+ $size = 0;
+ if ($this->snapshotJson !== null) {
+ $size = strlen($this->snapshotJson);
+ }
+
+ return [
+ 'id' => $this->getId(),
+ 'dashboardUuid' => $this->dashboardUuid,
+ 'versionNumber' => $this->versionNumber,
+ 'createdBy' => $this->createdBy,
+ 'createdAt' => $this->createdAt,
+ 'note' => $this->note,
+ 'sizeBytes' => $size,
+ ];
+ }//end jsonSerialize()
+}//end class
diff --git a/lib/Db/DashboardVersionMapper.php b/lib/Db/DashboardVersionMapper.php
new file mode 100644
index 00000000..7f554cf8
--- /dev/null
+++ b/lib/Db/DashboardVersionMapper.php
@@ -0,0 +1,336 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Db\QBMapper;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+
+/**
+ * Mapper for DashboardVersion entities.
+ *
+ * @extends QBMapper
+ */
+class DashboardVersionMapper extends QBMapper
+{
+
+ /**
+ * Default per-dashboard retention limit (REQ-VERS-006).
+ *
+ * @var integer
+ */
+ public const DEFAULT_RETENTION = 50;
+
+ /**
+ * Constructor
+ *
+ * @param IDBConnection $db The database connection.
+ */
+ public function __construct(IDBConnection $db)
+ {
+ parent::__construct(
+ db: $db,
+ tableName: 'mydash_dashboard_versions',
+ entityClass: DashboardVersion::class
+ );
+ }//end __construct()
+
+ /**
+ * Find every version row for a given dashboard, newest-first.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return DashboardVersion[] The version rows ordered by versionNumber DESC.
+ */
+ public function findByDashboardUuid(string $dashboardUuid): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ )
+ ->orderBy(sort: 'version_number', order: 'DESC');
+
+ return $this->findEntities(query: $qb);
+ }//end findByDashboardUuid()
+
+ /**
+ * Find the newest N version rows for a dashboard, newest-first.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param integer $limit Maximum number of rows.
+ *
+ * @return DashboardVersion[] The newest-first version rows.
+ */
+ public function findLatestByDashboard(
+ string $dashboardUuid,
+ int $limit
+ ): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ )
+ ->orderBy(sort: 'version_number', order: 'DESC')
+ ->setMaxResults(maxResults: $limit);
+
+ return $this->findEntities(query: $qb);
+ }//end findLatestByDashboard()
+
+ /**
+ * Find a single version row by (dashboardUuid, versionNumber).
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param integer $versionNumber The version number.
+ *
+ * @return DashboardVersion The version row.
+ *
+ * @throws DoesNotExistException When the row does not exist.
+ */
+ public function findByDashboardAndVersion(
+ string $dashboardUuid,
+ int $versionNumber
+ ): DashboardVersion {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->eq(
+ x: 'version_number',
+ y: $qb->createNamedParameter(
+ value: $versionNumber,
+ type: IQueryBuilder::PARAM_INT
+ )
+ )
+ );
+
+ return $this->findEntity(query: $qb);
+ }//end findByDashboardAndVersion()
+
+ /**
+ * Find the highest versionNumber currently stored for a dashboard.
+ *
+ * Used by {@see DashboardVersionService::captureSnapshot()} to allocate
+ * the next monotonic versionNumber. Returns `0` when the dashboard
+ * has no snapshots yet.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return integer The highest versionNumber, or 0 if none.
+ */
+ public function findMaxVersionNumber(string $dashboardUuid): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $qb->select($qb->func()->max('version_number'))
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ );
+
+ $result = $qb->executeQuery();
+ $value = $result->fetchOne();
+ $result->closeCursor();
+
+ if ($value === false || $value === null) {
+ return 0;
+ }
+
+ return (int) $value;
+ }//end findMaxVersionNumber()
+
+ /**
+ * Count how many version rows exist for a dashboard.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return integer The version row count.
+ */
+ public function countByDashboard(string $dashboardUuid): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $qb->select($qb->func()->count('*'))
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ );
+
+ $result = $qb->executeQuery();
+ $value = $result->fetchOne();
+ $result->closeCursor();
+
+ if ($value === false || $value === null) {
+ return 0;
+ }
+
+ return (int) $value;
+ }//end countByDashboard()
+
+ /**
+ * Delete the oldest rows for a dashboard until at most `$keepCount`
+ * remain (REQ-VERS-006).
+ *
+ * Pruning is monotonic-safe: surviving rows keep their original
+ * versionNumber values, so the next snapshot continues the sequence
+ * (REQ-VERS-006 scenario "pruning does not affect versionNumber
+ * sequence").
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param integer $keepCount The maximum row count to retain.
+ *
+ * @return integer The number of rows deleted.
+ */
+ public function pruneOldVersions(
+ string $dashboardUuid,
+ int $keepCount=self::DEFAULT_RETENTION
+ ): int {
+ $total = $this->countByDashboard(dashboardUuid: $dashboardUuid);
+ if ($total <= $keepCount) {
+ return 0;
+ }
+
+ // Delete the lowest version_number rows (oldest by monotonic
+ // numbering). We DO NOT use ORDER BY + LIMIT in the DELETE
+ // because Postgres does not support it; instead we determine
+ // the cutoff version number then delete everything below it.
+ $cutoff = $this->findCutoffVersionNumber(
+ dashboardUuid: $dashboardUuid,
+ keepCount: $keepCount
+ );
+ if ($cutoff === null) {
+ return 0;
+ }
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(delete: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->lt(
+ x: 'version_number',
+ y: $qb->createNamedParameter(
+ value: $cutoff,
+ type: IQueryBuilder::PARAM_INT
+ )
+ )
+ );
+
+ return $qb->executeStatement();
+ }//end pruneOldVersions()
+
+ /**
+ * Delete every version row for a given dashboard (cascade cleanup).
+ *
+ * Invoked from the dashboard delete path / cascade-events listener.
+ * Idempotent — calling on a dashboard with no versions is a no-op.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return integer The number of rows deleted.
+ */
+ public function deleteByDashboardUuid(string $dashboardUuid): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(delete: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ );
+
+ return $qb->executeStatement();
+ }//end deleteByDashboardUuid()
+
+ /**
+ * Resolve the lowest versionNumber that should survive pruning.
+ *
+ * Returns the (total - keep + 1)-th newest versionNumber. Anything
+ * strictly less than this value is older and gets dropped.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param integer $keepCount The maximum row count to retain.
+ *
+ * @return integer|null The cutoff versionNumber, or null if nothing to prune.
+ */
+ private function findCutoffVersionNumber(
+ string $dashboardUuid,
+ int $keepCount
+ ): ?int {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: 'version_number')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ )
+ ->orderBy(sort: 'version_number', order: 'DESC')
+ ->setMaxResults(maxResults: $keepCount);
+
+ $result = $qb->executeQuery();
+ $rows = $result->fetchAll();
+ $result->closeCursor();
+
+ if (count($rows) < $keepCount) {
+ return null;
+ }
+
+ $last = end($rows);
+ if ($last === false || isset($last['version_number']) === false) {
+ return null;
+ }
+
+ return (int) $last['version_number'];
+ }//end findCutoffVersionNumber()
+}//end class
diff --git a/lib/Db/DashboardView.php b/lib/Db/DashboardView.php
new file mode 100644
index 00000000..05afeb83
--- /dev/null
+++ b/lib/Db/DashboardView.php
@@ -0,0 +1,104 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use JsonSerializable;
+use OCP\AppFramework\Db\Entity;
+
+/**
+ * Dashboard view-analytics aggregate entity (REQ-ANLT-001).
+ *
+ * @method string|null getDashboardUuid()
+ * @method void setDashboardUuid(?string $dashboardUuid)
+ * @method string|null getViewBucket()
+ * @method void setViewBucket(?string $viewBucket)
+ * @method int getViewCount()
+ * @method void setViewCount(int $viewCount)
+ * @method int getUniqueViewerCount()
+ * @method void setUniqueViewerCount(int $uniqueViewerCount)
+ */
+class DashboardView extends Entity implements JsonSerializable
+{
+
+ /**
+ * Dashboard UUID this aggregate row belongs to.
+ *
+ * @var string|null
+ */
+ protected ?string $dashboardUuid = null;
+
+ /**
+ * Calendar date in UTC (YYYY-MM-DD).
+ *
+ * @var string|null
+ */
+ protected ?string $viewBucket = null;
+
+ /**
+ * Total view-event count for the bucket.
+ *
+ * @var integer
+ */
+ protected int $viewCount = 0;
+
+ /**
+ * Distinct viewer count for the bucket (cache-deduped).
+ *
+ * @var integer
+ */
+ protected int $uniqueViewerCount = 0;
+
+ /**
+ * Constructor — register column types.
+ *
+ * @return void
+ */
+ public function __construct()
+ {
+ $this->addType(fieldName: 'id', type: 'integer');
+ $this->addType(fieldName: 'viewCount', type: 'integer');
+ $this->addType(fieldName: 'uniqueViewerCount', type: 'integer');
+ }//end __construct()
+
+ /**
+ * Serialize to JSON.
+ *
+ * Stable, frontend-facing key names. The `viewBucket` key is the
+ * UTC date string `YYYY-MM-DD` — matches the spec scenarios in
+ * REQ-ANLT-007.
+ *
+ * @return array The serialized aggregate row.
+ */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->getId(),
+ 'dashboardUuid' => $this->dashboardUuid,
+ 'viewBucket' => $this->viewBucket,
+ 'viewCount' => $this->viewCount,
+ 'uniqueViewerCount' => $this->uniqueViewerCount,
+ ];
+ }//end jsonSerialize()
+}//end class
diff --git a/lib/Db/DashboardViewMapper.php b/lib/Db/DashboardViewMapper.php
new file mode 100644
index 00000000..f158af83
--- /dev/null
+++ b/lib/Db/DashboardViewMapper.php
@@ -0,0 +1,379 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Db\QBMapper;
+use OCP\IDBConnection;
+
+/**
+ * Mapper for dashboard view-analytics aggregate rows.
+ *
+ * @extends QBMapper
+ *
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods)
+ */
+class DashboardViewMapper extends QBMapper
+{
+ /**
+ * Constructor.
+ *
+ * @param IDBConnection $db The database connection.
+ */
+ public function __construct(IDBConnection $db)
+ {
+ parent::__construct(
+ db: $db,
+ tableName: 'mydash_dashboard_views',
+ entityClass: DashboardView::class
+ );
+ }//end __construct()
+
+ /**
+ * Find the aggregate row for `(dashboardUuid, viewBucket)`.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $viewBucket The UTC date `YYYY-MM-DD`.
+ *
+ * @return DashboardView|null The row or `null` when absent.
+ */
+ public function findByDashboardAndBucket(
+ string $dashboardUuid,
+ string $viewBucket
+ ): ?DashboardView {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->eq(
+ x: 'view_bucket',
+ y: $qb->createNamedParameter(value: $viewBucket)
+ )
+ )
+ ->setMaxResults(maxResults: 1);
+
+ try {
+ return $this->findEntity(query: $qb);
+ } catch (DoesNotExistException) {
+ return null;
+ }
+ }//end findByDashboardAndBucket()
+
+ /**
+ * Find every aggregate row for one dashboard within an inclusive
+ * date range, ordered by `viewBucket` ascending (REQ-ANLT-007).
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $startDate Inclusive lower bound (`YYYY-MM-DD`).
+ * @param string $endDate Inclusive upper bound (`YYYY-MM-DD`).
+ *
+ * @return DashboardView[] The rows in ascending date order.
+ */
+ public function findByDashboardInRange(
+ string $dashboardUuid,
+ string $startDate,
+ string $endDate
+ ): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->gte(
+ x: 'view_bucket',
+ y: $qb->createNamedParameter(value: $startDate)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->lte(
+ x: 'view_bucket',
+ y: $qb->createNamedParameter(value: $endDate)
+ )
+ )
+ ->orderBy(sort: 'view_bucket', order: 'ASC');
+
+ return $this->findEntities(query: $qb);
+ }//end findByDashboardInRange()
+
+ /**
+ * Find the top-N dashboards by total view count in an inclusive
+ * date range (REQ-ANLT-006). Returns an array of plain assoc
+ * rows (not entities) because the result is an aggregate over
+ * many rows per dashboard, not a single row per UUID.
+ *
+ * @param string $startDate Inclusive lower bound (`YYYY-MM-DD`).
+ * @param string $endDate Inclusive upper bound (`YYYY-MM-DD`).
+ * @param int $limit Maximum rows to return.
+ *
+ * @return array
+ * Aggregate rows ordered by `viewCount` descending.
+ */
+ public function findTopDashboardsInRange(
+ string $startDate,
+ string $endDate,
+ int $limit
+ ): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(
+ 'dashboard_uuid'
+ )
+ ->selectAlias(
+ $qb->func()->sum('view_count'),
+ 'view_count_sum'
+ )
+ ->selectAlias(
+ $qb->func()->sum('unique_viewer_count'),
+ 'unique_viewer_sum'
+ )
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->gte(
+ x: 'view_bucket',
+ y: $qb->createNamedParameter(value: $startDate)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->lte(
+ x: 'view_bucket',
+ y: $qb->createNamedParameter(value: $endDate)
+ )
+ )
+ ->groupBy('dashboard_uuid')
+ ->orderBy(sort: 'view_count_sum', order: 'DESC')
+ ->setMaxResults(maxResults: $limit);
+
+ $cursor = $qb->executeQuery();
+ $rows = [];
+ while (($row = $cursor->fetch()) !== false) {
+ $rows[] = [
+ 'dashboardUuid' => (string) $row['dashboard_uuid'],
+ 'viewCount' => (int) $row['view_count_sum'],
+ 'uniqueViewerCount' => (int) $row['unique_viewer_sum'],
+ ];
+ }
+
+ $cursor->closeCursor();
+
+ return $rows;
+ }//end findTopDashboardsInRange()
+
+ /**
+ * Compute instance-wide totals across an inclusive date range
+ * (REQ-ANLT-008).
+ *
+ * @param string $startDate Inclusive lower bound (`YYYY-MM-DD`).
+ * @param string $endDate Inclusive upper bound (`YYYY-MM-DD`).
+ *
+ * @return array{totalViewCount: int, totalUniqueViewers: int, dashboardCount: int}
+ * The computed totals.
+ */
+ public function findInstanceSummaryInRange(
+ string $startDate,
+ string $endDate
+ ): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->selectAlias(
+ $qb->func()->sum('view_count'),
+ 'total_view_count'
+ )
+ ->selectAlias(
+ $qb->func()->sum('unique_viewer_count'),
+ 'total_unique'
+ )
+ ->selectAlias(
+ $qb->createFunction(
+ 'COUNT(DISTINCT dashboard_uuid)'
+ ),
+ 'dashboard_count'
+ )
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->gte(
+ x: 'view_bucket',
+ y: $qb->createNamedParameter(value: $startDate)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->lte(
+ x: 'view_bucket',
+ y: $qb->createNamedParameter(value: $endDate)
+ )
+ );
+
+ $cursor = $qb->executeQuery();
+ $row = $cursor->fetch();
+ $cursor->closeCursor();
+
+ if ($row === false) {
+ return [
+ 'totalViewCount' => 0,
+ 'totalUniqueViewers' => 0,
+ 'dashboardCount' => 0,
+ ];
+ }
+
+ return [
+ 'totalViewCount' => (int) ($row['total_view_count'] ?? 0),
+ 'totalUniqueViewers' => (int) ($row['total_unique'] ?? 0),
+ 'dashboardCount' => (int) ($row['dashboard_count'] ?? 0),
+ ];
+ }//end findInstanceSummaryInRange()
+
+ /**
+ * Find every aggregate row in an inclusive date range, ordered
+ * for CSV export (REQ-ANLT-010).
+ *
+ * @param string $startDate Inclusive lower bound (`YYYY-MM-DD`).
+ * @param string $endDate Inclusive upper bound (`YYYY-MM-DD`).
+ *
+ * @return DashboardView[] The rows ordered by `(dashboard_uuid,
+ * view_bucket)` ascending.
+ */
+ public function findAllInRange(
+ string $startDate,
+ string $endDate
+ ): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->gte(
+ x: 'view_bucket',
+ y: $qb->createNamedParameter(value: $startDate)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->lte(
+ x: 'view_bucket',
+ y: $qb->createNamedParameter(value: $endDate)
+ )
+ )
+ ->orderBy(sort: 'dashboard_uuid', order: 'ASC')
+ ->addOrderBy(sort: 'view_bucket', order: 'ASC');
+
+ return $this->findEntities(query: $qb);
+ }//end findAllInRange()
+
+ /**
+ * Increment counters for `(dashboardUuid, viewBucket)`,
+ * inserting the row when it does not yet exist (REQ-ANLT-001
+ * "one row per dashboard per day").
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $viewBucket The UTC date `YYYY-MM-DD`.
+ * @param int $viewCountDelta Increment for `view_count`.
+ * @param int $uniqueCountDelta Increment for
+ * `unique_viewer_count`.
+ *
+ * @return DashboardView The persisted row after the update.
+ */
+ public function upsertView(
+ string $dashboardUuid,
+ string $viewBucket,
+ int $viewCountDelta,
+ int $uniqueCountDelta
+ ): DashboardView {
+ $existing = $this->findByDashboardAndBucket(
+ dashboardUuid: $dashboardUuid,
+ viewBucket: $viewBucket
+ );
+
+ if ($existing !== null) {
+ $existing->setViewCount(
+ $existing->getViewCount() + $viewCountDelta
+ );
+ $existing->setUniqueViewerCount(
+ $existing->getUniqueViewerCount() + $uniqueCountDelta
+ );
+
+ return $this->update(entity: $existing);
+ }
+
+ $row = new DashboardView();
+ $row->setDashboardUuid($dashboardUuid);
+ $row->setViewBucket($viewBucket);
+ $row->setViewCount(max($viewCountDelta, 0));
+ $row->setUniqueViewerCount(max($uniqueCountDelta, 0));
+
+ return $this->insert(entity: $row);
+ }//end upsertView()
+
+ /**
+ * Delete every aggregate row whose `view_bucket` is strictly
+ * older than `$beforeDate` (REQ-ANLT-009).
+ *
+ * @param string $beforeDate Exclusive cutoff (`YYYY-MM-DD`).
+ *
+ * @return int The number of rows deleted.
+ */
+ public function deleteOlderThan(string $beforeDate): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(delete: $this->getTableName())
+ ->where(
+ $qb->expr()->lt(
+ x: 'view_bucket',
+ y: $qb->createNamedParameter(value: $beforeDate)
+ )
+ );
+
+ return $qb->executeStatement();
+ }//end deleteOlderThan()
+
+ /**
+ * Delete every aggregate row for a single dashboard (used when
+ * the parent dashboard itself is deleted — eager cleanup so a
+ * later cascade FK migration is not required).
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return int The number of rows deleted.
+ */
+ public function deleteByDashboard(string $dashboardUuid): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(delete: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ );
+
+ return $qb->executeStatement();
+ }//end deleteByDashboard()
+}//end class
diff --git a/lib/Db/FeedCache.php b/lib/Db/FeedCache.php
new file mode 100644
index 00000000..f9e9db05
--- /dev/null
+++ b/lib/Db/FeedCache.php
@@ -0,0 +1,212 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 EUPL-1.2
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use JsonSerializable;
+use OCP\AppFramework\Db\Entity;
+
+/**
+ * Feed-cache entity (REQ-FRJ-001..012).
+ *
+ * @method string|null getFeedUrl()
+ * @method void setFeedUrl(?string $feedUrl)
+ * @method string|null getLastFetchedAt()
+ * @method void setLastFetchedAt(?string $lastFetchedAt)
+ * @method string|null getLastSuccessAt()
+ * @method void setLastSuccessAt(?string $lastSuccessAt)
+ * @method string|null getLastFailureReason()
+ * @method void setLastFailureReason(?string $lastFailureReason)
+ * @method string|null getEtag()
+ * @method void setEtag(?string $etag)
+ * @method string|null getLastModified()
+ * @method void setLastModified(?string $lastModified)
+ * @method string|null getItemsJson()
+ * @method void setItemsJson(?string $itemsJson)
+ */
+class FeedCache extends Entity implements JsonSerializable
+{
+ /**
+ * Maximum number of cached items per feed (REQ-FRJ-005).
+ *
+ * @var integer
+ */
+ public const MAX_ITEMS = 50;
+
+ /**
+ * The remote feed URL — UNIQUE across the table.
+ *
+ * @var string|null
+ */
+ protected ?string $feedUrl = null;
+
+ /**
+ * The last fetch attempt timestamp; set on every tick (including
+ * 304s and failures) so the orphan-cleanup job can age out feeds
+ * that are no longer referenced.
+ *
+ * @var string|null
+ */
+ protected ?string $lastFetchedAt = null;
+
+ /**
+ * The last successful fetch timestamp; only updated on 2xx (and 304
+ * — see REQ-FRJ-004 "items untouched, only lastFetchedAt updated"
+ * which is implemented by NOT updating lastSuccessAt on 304 unless
+ * the prior cache row was empty). Failures (timeout, 4xx, 5xx, parse
+ * error) leave it untouched.
+ *
+ * @var string|null
+ */
+ protected ?string $lastSuccessAt = null;
+
+ /**
+ * The last failure reason — either a transport error
+ * (`"timeout: ..."`), an HTTP error (`"410 Gone"`), a parse error
+ * (`"parse error: ..."`), an allow-list reject
+ * (`"host not in allow-list"`), or a size-cap reject
+ * (`"response too large"`). Null on the first row insert and after
+ * any successful fetch.
+ *
+ * @var string|null
+ */
+ protected ?string $lastFailureReason = null;
+
+ /**
+ * The most recent `ETag` response header value — used for
+ * `If-None-Match` on the next conditional GET (REQ-FRJ-004).
+ *
+ * @var string|null
+ */
+ protected ?string $etag = null;
+
+ /**
+ * The most recent `Last-Modified` response header value — used for
+ * `If-Modified-Since` on the next conditional GET (REQ-FRJ-004).
+ *
+ * @var string|null
+ */
+ protected ?string $lastModified = null;
+
+ /**
+ * The cached normalised items as a JSON-encoded string. Null until
+ * the first successful fetch. Capped at {@see self::MAX_ITEMS} items
+ * via {@see self::encodeItems()}.
+ *
+ * @var string|null
+ */
+ protected ?string $itemsJson = null;
+
+ /**
+ * Constructor — registers column types so the auto-increment id
+ * hydrates as int rather than string.
+ *
+ * @return void
+ */
+ public function __construct()
+ {
+ $this->addType(fieldName: 'id', type: 'integer');
+ }//end __construct()
+
+ /**
+ * Decode the persisted items JSON.
+ *
+ * @return array> The items, or an empty
+ * array when null/invalid.
+ */
+ public function decodeItems(): array
+ {
+ if ($this->itemsJson === null || $this->itemsJson === '') {
+ return [];
+ }
+
+ $decoded = json_decode(
+ json: $this->itemsJson,
+ associative: true
+ );
+
+ if (is_array(value: $decoded) === false) {
+ return [];
+ }
+
+ return $decoded;
+ }//end decodeItems()
+
+ /**
+ * JSON-encode and persist a list of normalised items, capping at
+ * {@see self::MAX_ITEMS} entries (REQ-FRJ-005).
+ *
+ * Items MUST already be sorted newest-first by the caller; this
+ * method does NOT sort. The cap takes the leading slice (assumed to
+ * be the newest entries).
+ *
+ * @param array> $items The items.
+ *
+ * @return void
+ */
+ public function encodeItems(array $items): void
+ {
+ if (count(value: $items) > self::MAX_ITEMS) {
+ $items = array_slice(
+ array: $items,
+ offset: 0,
+ length: self::MAX_ITEMS
+ );
+ }
+
+ $encoded = json_encode(
+ value: $items,
+ flags: (JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
+ );
+
+ if ($encoded === false) {
+ $this->setItemsJson(null);
+ } else {
+ $this->setItemsJson($encoded);
+ }
+ }//end encodeItems()
+
+ /**
+ * Serialize to JSON for diagnostics / admin UI.
+ *
+ * The cached items are emitted as a decoded array so the admin UI
+ * does not need to re-decode them on the client side.
+ *
+ * @return array The serialized feed-cache row.
+ */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->getId(),
+ 'feedUrl' => $this->feedUrl,
+ 'lastFetchedAt' => $this->lastFetchedAt,
+ 'lastSuccessAt' => $this->lastSuccessAt,
+ 'lastFailureReason' => $this->lastFailureReason,
+ 'etag' => $this->etag,
+ 'lastModified' => $this->lastModified,
+ 'items' => $this->decodeItems(),
+ ];
+ }//end jsonSerialize()
+}//end class
diff --git a/lib/Db/FeedCacheMapper.php b/lib/Db/FeedCacheMapper.php
new file mode 100644
index 00000000..531a38cc
--- /dev/null
+++ b/lib/Db/FeedCacheMapper.php
@@ -0,0 +1,137 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use DateTimeInterface;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Db\QBMapper;
+use OCP\IDBConnection;
+
+/**
+ * Mapper for FeedCache entities.
+ *
+ * @extends QBMapper
+ */
+class FeedCacheMapper extends QBMapper
+{
+ /**
+ * Constructor.
+ *
+ * @param IDBConnection $db The database connection.
+ */
+ public function __construct(IDBConnection $db)
+ {
+ parent::__construct(
+ db: $db,
+ tableName: 'mydash_feed_cache',
+ entityClass: FeedCache::class
+ );
+ }//end __construct()
+
+ /**
+ * Find a cache row by its `feed_url`.
+ *
+ * @param string $feedUrl The feed URL.
+ *
+ * @return FeedCache The cache row.
+ *
+ * @throws DoesNotExistException When no row exists for this URL.
+ */
+ public function findByUrl(string $feedUrl): FeedCache
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'feed_url',
+ y: $qb->createNamedParameter(value: $feedUrl)
+ )
+ );
+
+ return $this->findEntity(query: $qb);
+ }//end findByUrl()
+
+ /**
+ * Insert-if-missing for a feed URL — returns the existing row when
+ * one is on file (REQ-FRJ-003 "New feed URL upserted before first
+ * fetch"). The returned row has all metadata fields null on first
+ * insert; the caller sets timestamps / headers / items.
+ *
+ * @param string $feedUrl The feed URL.
+ *
+ * @return FeedCache The existing or freshly-inserted row.
+ */
+ public function upsertUrl(string $feedUrl): FeedCache
+ {
+ try {
+ return $this->findByUrl(feedUrl: $feedUrl);
+ } catch (DoesNotExistException) {
+ $row = new FeedCache();
+ $row->setFeedUrl($feedUrl);
+ return $this->insert(entity: $row);
+ }
+ }//end upsertUrl()
+
+ /**
+ * Enumerate all cached feeds (REQ-FRJ-008 batch processing reads
+ * the alphabetically-sorted set returned here).
+ *
+ * @return FeedCache[] All cached feeds, sorted ascending by URL.
+ */
+ public function findAll(): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->orderBy(sort: 'feed_url', order: 'ASC');
+
+ return $this->findEntities(query: $qb);
+ }//end findAll()
+
+ /**
+ * Find rows whose `last_fetched_at` is older than `$cutoff` — the
+ * orphan-cleanup hand-off surface required by REQ-FRJ-009.
+ *
+ * @param DateTimeInterface $cutoff The cutoff timestamp (typically
+ * `now() - 30 days`).
+ *
+ * @return FeedCache[] The orphan candidates.
+ */
+ public function findOrphanedBefore(DateTimeInterface $cutoff): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->lt(
+ x: 'last_fetched_at',
+ y: $qb->createNamedParameter(
+ value: $cutoff->format(format: 'Y-m-d H:i:s')
+ )
+ )
+ );
+
+ return $this->findEntities(query: $qb);
+ }//end findOrphanedBefore()
+}//end class
diff --git a/lib/Db/FeedToken.php b/lib/Db/FeedToken.php
new file mode 100644
index 00000000..a6862ec2
--- /dev/null
+++ b/lib/Db/FeedToken.php
@@ -0,0 +1,136 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 EUPL-1.2
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use JsonSerializable;
+use OCP\AppFramework\Db\Entity;
+
+/**
+ * Feed-token entity (REQ-FEED-001..009).
+ *
+ * @method string|null getUserId()
+ * @method void setUserId(?string $userId)
+ * @method string|null getToken()
+ * @method void setToken(?string $token)
+ * @method string|null getCreatedAt()
+ * @method void setCreatedAt(?string $createdAt)
+ * @method string|null getLastUsedAt()
+ * @method void setLastUsedAt(?string $lastUsedAt)
+ * @method string|null getRevokedAt()
+ * @method void setRevokedAt(?string $revokedAt)
+ */
+class FeedToken extends Entity implements JsonSerializable
+{
+
+ /**
+ * The owning Nextcloud user ID.
+ *
+ * @var string|null
+ */
+ protected ?string $userId = null;
+
+ /**
+ * The cryptographically-random base64url-encoded opaque token.
+ *
+ * @var string|null
+ */
+ protected ?string $token = null;
+
+ /**
+ * The token-issue timestamp.
+ *
+ * @var string|null
+ */
+ protected ?string $createdAt = null;
+
+ /**
+ * The last-used timestamp; touched on every successful public feed
+ * fetch (REQ-FEED-004 scenario "Fetch feed with valid token").
+ *
+ * @var string|null
+ */
+ protected ?string $lastUsedAt = null;
+
+ /**
+ * The soft-revoke timestamp; non-null marks the token as revoked
+ * (REQ-FEED-002 / REQ-FEED-003). Public feed lookup MUST treat
+ * revoked tokens as "not found" — no leak of revocation status.
+ *
+ * @var string|null
+ */
+ protected ?string $revokedAt = null;
+
+ /**
+ * Constructor — registers column types.
+ *
+ * @return void
+ */
+ public function __construct()
+ {
+ $this->addType(fieldName: 'id', type: 'integer');
+ }//end __construct()
+
+ /**
+ * Whether this token has been soft-revoked.
+ *
+ * @return bool True when `revokedAt` is non-null.
+ */
+ public function isRevoked(): bool
+ {
+ return ($this->revokedAt !== null);
+ }//end isRevoked()
+
+ /**
+ * Whether this token is currently valid (not revoked).
+ *
+ * @return bool True when `revokedAt` is null.
+ */
+ public function isValid(): bool
+ {
+ return ($this->revokedAt === null);
+ }//end isValid()
+
+ /**
+ * Serialize to JSON.
+ *
+ * The `token` field is intentionally serialised; it is the value
+ * returned to the owning user from the management endpoints. Public
+ * feed-render code paths MUST NOT serialise the entity — they only
+ * use it to resolve the `userId`.
+ *
+ * @return array The serialized feed-token.
+ */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->getId(),
+ 'userId' => $this->userId,
+ 'token' => $this->token,
+ 'createdAt' => $this->createdAt,
+ 'lastUsedAt' => $this->lastUsedAt,
+ 'revokedAt' => $this->revokedAt,
+ ];
+ }//end jsonSerialize()
+}//end class
diff --git a/lib/Db/FeedTokenMapper.php b/lib/Db/FeedTokenMapper.php
new file mode 100644
index 00000000..f457de6f
--- /dev/null
+++ b/lib/Db/FeedTokenMapper.php
@@ -0,0 +1,172 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use DateTime;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Db\QBMapper;
+use OCP\IDBConnection;
+
+/**
+ * Mapper for FeedToken entities.
+ *
+ * @extends QBMapper
+ */
+class FeedTokenMapper extends QBMapper
+{
+ /**
+ * Constructor.
+ *
+ * @param IDBConnection $db The database connection.
+ */
+ public function __construct(IDBConnection $db)
+ {
+ parent::__construct(
+ db: $db,
+ tableName: 'mydash_feed_tokens',
+ entityClass: FeedToken::class
+ );
+ }//end __construct()
+
+ /**
+ * Find an active (non-revoked) token by its opaque value.
+ *
+ * Used by the public `/feed/{token}.xml` endpoint. Revoked tokens
+ * MUST be reported as `DoesNotExistException` so the controller can
+ * map both "no record" and "revoked" to a single HTTP 404
+ * (REQ-FEED-004 — no revocation-status leak).
+ *
+ * @param string $token The opaque token value.
+ *
+ * @return FeedToken The active token.
+ *
+ * @throws DoesNotExistException When the token is unknown or revoked.
+ */
+ public function findByToken(string $token): FeedToken
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'token',
+ y: $qb->createNamedParameter(value: $token)
+ )
+ )
+ ->andWhere($qb->expr()->isNull(x: 'revoked_at'));
+
+ return $this->findEntity(query: $qb);
+ }//end findByToken()
+
+ /**
+ * Find the active (non-revoked) token row for a given user.
+ *
+ * Returns `null` (no exception) when the user has not yet opted in
+ * (REQ-FEED-008) or has only revoked records on file. Used by both
+ * the GET /api/feed/token "issue or return existing" path and the
+ * regenerate path (which must explicitly revoke the row first).
+ *
+ * @param string $userId The owning user ID.
+ *
+ * @return FeedToken|null The active token, or null when none exists.
+ */
+ public function findByUserId(string $userId): ?FeedToken
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'user_id',
+ y: $qb->createNamedParameter(value: $userId)
+ )
+ )
+ ->andWhere($qb->expr()->isNull(x: 'revoked_at'));
+
+ try {
+ return $this->findEntity(query: $qb);
+ } catch (DoesNotExistException) {
+ return null;
+ }
+ }//end findByUserId()
+
+ /**
+ * Update the `last_used_at` timestamp on a successful public feed
+ * fetch (REQ-FEED-004). Persists immediately.
+ *
+ * @param FeedToken $token The token to touch.
+ *
+ * @return void
+ */
+ public function updateLastUsed(FeedToken $token): void
+ {
+ $now = (new DateTime())->format(format: 'Y-m-d H:i:s');
+ $token->setLastUsedAt($now);
+ $this->update(entity: $token);
+ }//end updateLastUsed()
+
+ /**
+ * Soft-revoke a token by stamping `revoked_at`. Idempotent — calling
+ * twice is a no-op past the first stamp. (REQ-FEED-003.)
+ *
+ * @param FeedToken $token The token to revoke.
+ *
+ * @return void
+ */
+ public function softRevoke(FeedToken $token): void
+ {
+ if ($token->isRevoked() === true) {
+ return;
+ }
+
+ $now = (new DateTime())->format(format: 'Y-m-d H:i:s');
+ $token->setRevokedAt($now);
+ $this->update(entity: $token);
+ }//end softRevoke()
+
+ /**
+ * Hard-delete every feed-token row for `$userId`, regardless of
+ * revoked state. Used by `regenerateToken` because the table's
+ * `mydash_feed_tok_user_uq` unique constraint sits on `user_id`
+ * alone — the soft-revoke pattern (one active + N revoked rows
+ * per user) trips it on the next insert. Feed tokens are
+ * regenerable user secrets so we drop revoked history rather
+ * than maintain a parallel audit table.
+ *
+ * @param string $userId The owning user ID.
+ *
+ * @return void
+ */
+ public function deleteAllForUser(string $userId): void
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(delete: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'user_id',
+ y: $qb->createNamedParameter(value: $userId)
+ )
+ );
+ $qb->executeStatement();
+ }//end deleteAllForUser()
+}//end class
diff --git a/lib/Db/MetadataField.php b/lib/Db/MetadataField.php
new file mode 100644
index 00000000..1b6c4a50
--- /dev/null
+++ b/lib/Db/MetadataField.php
@@ -0,0 +1,258 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use JsonSerializable;
+use OCP\AppFramework\Db\Entity;
+
+/**
+ * Metadata field-definition entity.
+ *
+ * @method string getFieldKey()
+ * @method void setFieldKey(string $fieldKey)
+ * @method string getLabel()
+ * @method void setLabel(string $label)
+ * @method string getType()
+ * @method void setType(string $type)
+ * @method string|null getOptions()
+ * @method void setOptions(?string $options)
+ * @method int getRequired()
+ * @method void setRequired(int $required)
+ * @method int getSortOrder()
+ * @method void setSortOrder(int $sortOrder)
+ * @method string|null getCreatedAt()
+ * @method void setCreatedAt(?string $createdAt)
+ * @method string|null getUpdatedAt()
+ * @method void setUpdatedAt(?string $updatedAt)
+ */
+class MetadataField extends Entity implements JsonSerializable
+{
+ /**
+ * Free-form text value type.
+ *
+ * @var string
+ */
+ public const TYPE_TEXT = 'text';
+
+ /**
+ * Decimal-number value type (validated as numeric at write).
+ *
+ * @var string
+ */
+ public const TYPE_NUMBER = 'number';
+
+ /**
+ * ISO-8601 date string (YYYY-MM-DD).
+ *
+ * @var string
+ */
+ public const TYPE_DATE = 'date';
+
+ /**
+ * Single-choice from a defined option set.
+ *
+ * @var string
+ */
+ public const TYPE_SELECT = 'select';
+
+ /**
+ * Multiple-choice from a defined option set; stored as JSON array.
+ *
+ * @var string
+ */
+ public const TYPE_MULTI_SELECT = 'multi-select';
+
+ /**
+ * Boolean flag, encoded as the literal string `"0"` or `"1"`.
+ *
+ * @var string
+ */
+ public const TYPE_BOOLEAN = 'boolean';
+
+ /**
+ * All valid field-type discriminators. Validated by the service
+ * layer; rejected with HTTP 400 when a write supplies a value
+ * outside this enumeration.
+ *
+ * @var array
+ */
+ public const VALID_TYPES = [
+ self::TYPE_TEXT,
+ self::TYPE_NUMBER,
+ self::TYPE_DATE,
+ self::TYPE_SELECT,
+ self::TYPE_MULTI_SELECT,
+ self::TYPE_BOOLEAN,
+ ];
+
+ /**
+ * Machine-name slug (lowercase alphanumeric + underscore, max 64).
+ *
+ * @var string
+ */
+ protected string $fieldKey = '';
+
+ /**
+ * Human-readable label.
+ *
+ * @var string
+ */
+ protected string $label = '';
+
+ /**
+ * Type discriminator (one of {@see VALID_TYPES}).
+ *
+ * @var string
+ */
+ protected string $type = self::TYPE_TEXT;
+
+ /**
+ * JSON-encoded options array for select / multi-select types.
+ *
+ * @var string|null
+ */
+ protected ?string $options = null;
+
+ /**
+ * Required flag (0 = optional, 1 = required).
+ *
+ * @var integer
+ */
+ protected int $required = 0;
+
+ /**
+ * Sort order for admin UI rendering and list-endpoint ordering.
+ *
+ * @var integer
+ */
+ protected int $sortOrder = 0;
+
+ /**
+ * ISO-8601 creation timestamp.
+ *
+ * @var string|null
+ */
+ protected ?string $createdAt = null;
+
+ /**
+ * ISO-8601 update timestamp.
+ *
+ * @var string|null
+ */
+ protected ?string $updatedAt = null;
+
+ /**
+ * Constructor — declare ORM column types.
+ */
+ public function __construct()
+ {
+ $this->addType(fieldName: 'id', type: 'integer');
+ $this->addType(fieldName: 'required', type: 'integer');
+ $this->addType(fieldName: 'sortOrder', type: 'integer');
+ }//end __construct()
+
+ /**
+ * Returns true when the field accepts a constrained option set.
+ *
+ * @return bool True for `select` and `multi-select`.
+ */
+ public function isSelectType(): bool
+ {
+ return ($this->type === self::TYPE_SELECT
+ || $this->type === self::TYPE_MULTI_SELECT);
+ }//end isSelectType()
+
+ /**
+ * Decode the persisted `options` JSON into a PHP array.
+ *
+ * Defensive: returns an empty array when the column is NULL or
+ * malformed. Never throws.
+ *
+ * @return array The decoded option strings.
+ */
+ public function getOptionsArray(): array
+ {
+ if ($this->options === null || $this->options === '') {
+ return [];
+ }
+
+ $decoded = json_decode(json: $this->options, associative: true);
+ if (is_array($decoded) === false) {
+ return [];
+ }
+
+ $strings = [];
+ foreach ($decoded as $entry) {
+ if (is_string($entry) === true) {
+ $strings[] = $entry;
+ }
+ }
+
+ return $strings;
+ }//end getOptionsArray()
+
+ /**
+ * Encode and persist an options array (or NULL).
+ *
+ * @param array|null $options The option list or null.
+ *
+ * @return void
+ */
+ public function setOptionsArray(?array $options): void
+ {
+ if ($options === null) {
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $this->setOptions(null);
+ return;
+ }
+
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $this->setOptions(json_encode($options));
+ }//end setOptionsArray()
+
+ /**
+ * JSON serialise to the API contract shape.
+ *
+ * @return array The serialised field.
+ */
+ public function jsonSerialize(): array
+ {
+ $options = null;
+ if ($this->isSelectType() === true) {
+ $options = $this->getOptionsArray();
+ }
+
+ return [
+ 'id' => $this->getId(),
+ 'key' => $this->fieldKey,
+ 'label' => $this->label,
+ 'type' => $this->type,
+ 'options' => $options,
+ 'required' => $this->required,
+ 'sortOrder' => $this->sortOrder,
+ 'createdAt' => $this->createdAt,
+ 'updatedAt' => $this->updatedAt,
+ ];
+ }//end jsonSerialize()
+}//end class
diff --git a/lib/Db/MetadataFieldMapper.php b/lib/Db/MetadataFieldMapper.php
new file mode 100644
index 00000000..1ac24abf
--- /dev/null
+++ b/lib/Db/MetadataFieldMapper.php
@@ -0,0 +1,232 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Db\QBMapper;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+
+/**
+ * Mapper for the metadata-field-definition table.
+ *
+ * @extends QBMapper
+ */
+class MetadataFieldMapper extends QBMapper
+{
+ /**
+ * Constructor.
+ *
+ * @param IDBConnection $db The database connection.
+ */
+ public function __construct(IDBConnection $db)
+ {
+ parent::__construct(
+ db: $db,
+ tableName: 'mydash_meta_fields',
+ entityClass: MetadataField::class
+ );
+ }//end __construct()
+
+ /**
+ * Return all fields ordered by `sort_order` ascending, then by id.
+ *
+ * @return MetadataField[] The full registry.
+ */
+ public function findAll(): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->orderBy(sort: 'sort_order', order: 'ASC')
+ ->addOrderBy(sort: 'id', order: 'ASC');
+
+ return $this->findEntities(query: $qb);
+ }//end findAll()
+
+ /**
+ * Find a field by primary key.
+ *
+ * @param int $id The field id.
+ *
+ * @return MetadataField The found field.
+ *
+ * @throws DoesNotExistException When the row is missing.
+ */
+ public function findById(int $id): MetadataField
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'id',
+ y: $qb->createNamedParameter(
+ value: $id,
+ type: IQueryBuilder::PARAM_INT
+ )
+ )
+ );
+
+ return $this->findEntity(query: $qb);
+ }//end findById()
+
+ /**
+ * Find a field by its unique machine-name slug.
+ *
+ * @param string $key The field slug.
+ *
+ * @return MetadataField The found field.
+ *
+ * @throws DoesNotExistException When the row is missing.
+ */
+ public function findByKey(string $key): MetadataField
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'field_key',
+ y: $qb->createNamedParameter(value: $key)
+ )
+ );
+
+ return $this->findEntity(query: $qb);
+ }//end findByKey()
+
+ /**
+ * Bulk-load fields by id (used by the read-side JOIN replacement).
+ *
+ * @param int[] $ids The field ids.
+ *
+ * @return array Indexed by field id.
+ */
+ public function findByIds(array $ids): array
+ {
+ if (count($ids) === 0) {
+ return [];
+ }
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->in(
+ x: 'id',
+ y: $qb->createNamedParameter(
+ value: $ids,
+ type: IQueryBuilder::PARAM_INT_ARRAY
+ )
+ )
+ );
+
+ $entities = $this->findEntities(query: $qb);
+ $byId = [];
+ foreach ($entities as $entity) {
+ $byId[(int) $entity->getId()] = $entity;
+ }
+
+ return $byId;
+ }//end findByIds()
+
+ /**
+ * Count how many value rows reference the given field id.
+ *
+ * Used by the soft/cascade delete gate: when the field has live
+ * values the caller MUST supply `?cascade=true` to confirm.
+ *
+ * @param int $fieldId The field id.
+ *
+ * @return int The number of dependent value rows.
+ */
+ public function countValuesForField(int $fieldId): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select($qb->func()->count('*', 'cnt'))
+ ->from(from: 'mydash_meta_values')
+ ->where(
+ $qb->expr()->eq(
+ x: 'field_id',
+ y: $qb->createNamedParameter(
+ value: $fieldId,
+ type: IQueryBuilder::PARAM_INT
+ )
+ )
+ );
+
+ $result = $qb->executeQuery();
+ $row = $result->fetch();
+ $result->closeCursor();
+
+ if (is_array($row) === false || array_key_exists(key: 'cnt', array: $row) === false) {
+ return 0;
+ }
+
+ return (int) $row['cnt'];
+ }//end countValuesForField()
+
+ /**
+ * Delete a field definition and cascade-delete every value row
+ * that references it. Returns true even when the field had no
+ * dependent rows (the caller already confirmed deletion).
+ *
+ * @param int $fieldId The field id.
+ *
+ * @return bool True on success.
+ */
+ public function deleteWithCascade(int $fieldId): bool
+ {
+ // Cascade values first so an interrupted run never leaves a
+ // gap between definition and dependents.
+ $valueDelete = $this->db->getQueryBuilder();
+ $valueDelete->delete(delete: 'mydash_meta_values')
+ ->where(
+ $valueDelete->expr()->eq(
+ x: 'field_id',
+ y: $valueDelete->createNamedParameter(
+ value: $fieldId,
+ type: IQueryBuilder::PARAM_INT
+ )
+ )
+ );
+ $valueDelete->executeStatement();
+
+ $fieldDelete = $this->db->getQueryBuilder();
+ $fieldDelete->delete(delete: $this->getTableName())
+ ->where(
+ $fieldDelete->expr()->eq(
+ x: 'id',
+ y: $fieldDelete->createNamedParameter(
+ value: $fieldId,
+ type: IQueryBuilder::PARAM_INT
+ )
+ )
+ );
+ $fieldDelete->executeStatement();
+
+ return true;
+ }//end deleteWithCascade()
+}//end class
diff --git a/lib/Db/MetadataValue.php b/lib/Db/MetadataValue.php
new file mode 100644
index 00000000..0d96c291
--- /dev/null
+++ b/lib/Db/MetadataValue.php
@@ -0,0 +1,86 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use JsonSerializable;
+use OCP\AppFramework\Db\Entity;
+
+/**
+ * Metadata value entity.
+ *
+ * @method string getDashboardUuid()
+ * @method void setDashboardUuid(string $dashboardUuid)
+ * @method int getFieldId()
+ * @method void setFieldId(int $fieldId)
+ * @method string getValue()
+ * @method void setValue(string $value)
+ */
+class MetadataValue extends Entity implements JsonSerializable
+{
+
+ /**
+ * Dashboard UUID this value belongs to.
+ *
+ * @var string
+ */
+ protected string $dashboardUuid = '';
+
+ /**
+ * Foreign key to the field-definition row.
+ *
+ * @var integer
+ */
+ protected int $fieldId = 0;
+
+ /**
+ * Type-encoded value string.
+ *
+ * @var string
+ */
+ protected string $value = '';
+
+ /**
+ * Constructor — declare ORM column types.
+ */
+ public function __construct()
+ {
+ $this->addType(fieldName: 'id', type: 'integer');
+ $this->addType(fieldName: 'fieldId', type: 'integer');
+ }//end __construct()
+
+ /**
+ * JSON serialise.
+ *
+ * @return array The serialised value record.
+ */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->getId(),
+ 'dashboardUuid' => $this->dashboardUuid,
+ 'fieldId' => $this->fieldId,
+ 'value' => $this->value,
+ ];
+ }//end jsonSerialize()
+}//end class
diff --git a/lib/Db/MetadataValueMapper.php b/lib/Db/MetadataValueMapper.php
new file mode 100644
index 00000000..67639514
--- /dev/null
+++ b/lib/Db/MetadataValueMapper.php
@@ -0,0 +1,196 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Db\QBMapper;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+
+/**
+ * Mapper for the metadata-value table.
+ *
+ * @extends QBMapper
+ */
+class MetadataValueMapper extends QBMapper
+{
+ /**
+ * Constructor.
+ *
+ * @param IDBConnection $db The database connection.
+ */
+ public function __construct(IDBConnection $db)
+ {
+ parent::__construct(
+ db: $db,
+ tableName: 'mydash_meta_values',
+ entityClass: MetadataValue::class
+ );
+ }//end __construct()
+
+ /**
+ * Find every value row for the given dashboard.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return MetadataValue[] The matching rows.
+ */
+ public function findByDashboard(string $dashboardUuid): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ );
+
+ return $this->findEntities(query: $qb);
+ }//end findByDashboard()
+
+ /**
+ * Find the single value row for the (dashboardUuid, fieldId) pair.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param int $fieldId The field id.
+ *
+ * @return MetadataValue|null The value row, or null when missing.
+ */
+ public function findOne(string $dashboardUuid, int $fieldId): ?MetadataValue
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->andX(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ ),
+ $qb->expr()->eq(
+ x: 'field_id',
+ y: $qb->createNamedParameter(
+ value: $fieldId,
+ type: IQueryBuilder::PARAM_INT
+ )
+ )
+ )
+ );
+
+ try {
+ return $this->findEntity(query: $qb);
+ } catch (DoesNotExistException) {
+ return null;
+ }
+ }//end findOne()
+
+ /**
+ * Insert or update the value row for the (dashboardUuid, fieldId) pair.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param int $fieldId The field id.
+ * @param string $value The encoded value.
+ *
+ * @return MetadataValue The persisted entity.
+ */
+ public function upsert(
+ string $dashboardUuid,
+ int $fieldId,
+ string $value
+ ): MetadataValue {
+ $existing = $this->findOne(
+ dashboardUuid: $dashboardUuid,
+ fieldId: $fieldId
+ );
+
+ if ($existing !== null) {
+ // Entity __call routes setter args via $args[0]; named params
+ // would land in the wrong slot.
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $existing->setValue($value);
+ return $this->update(entity: $existing);
+ }
+
+ $row = new MetadataValue();
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $row->setDashboardUuid($dashboardUuid);
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $row->setFieldId($fieldId);
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $row->setValue($value);
+
+ return $this->insert(entity: $row);
+ }//end upsert()
+
+ /**
+ * Delete every value row for the given dashboard (used when a
+ * dashboard itself is deleted upstream).
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return int The number of rows removed.
+ */
+ public function deleteByDashboard(string $dashboardUuid): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(delete: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_uuid',
+ y: $qb->createNamedParameter(value: $dashboardUuid)
+ )
+ );
+
+ return $qb->executeStatement();
+ }//end deleteByDashboard()
+
+ /**
+ * Find every dashboard UUID with a value for the given field id.
+ * Used by the metadata-filter query path (REQ-MDFL-007).
+ *
+ * @param int $fieldId The field id.
+ *
+ * @return MetadataValue[] The matching rows.
+ */
+ public function findByField(int $fieldId): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'field_id',
+ y: $qb->createNamedParameter(
+ value: $fieldId,
+ type: IQueryBuilder::PARAM_INT
+ )
+ )
+ );
+
+ return $this->findEntities(query: $qb);
+ }//end findByField()
+}//end class
diff --git a/lib/Db/RoleAssignment.php b/lib/Db/RoleAssignment.php
new file mode 100644
index 00000000..45db0228
--- /dev/null
+++ b/lib/Db/RoleAssignment.php
@@ -0,0 +1,213 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 EUPL-1.2
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use JsonSerializable;
+use OCP\AppFramework\Db\Entity;
+
+/**
+ * Role assignment entity (REQ-ROLE-004).
+ *
+ * @method string|null getUserId()
+ * @method void setUserId(?string $userId)
+ * @method string|null getGroupId()
+ * @method void setGroupId(?string $groupId)
+ * @method string|null getRole()
+ * @method void setRole(?string $role)
+ * @method string|null getAssignedBy()
+ * @method void setAssignedBy(?string $assignedBy)
+ * @method string|null getAssignedAt()
+ * @method void setAssignedAt(?string $assignedAt)
+ */
+class RoleAssignment extends Entity implements JsonSerializable
+{
+
+ /**
+ * The Dashboard Admin role — full delegation within MyDash. REQ-ROLE-001.
+ *
+ * @var string
+ */
+ public const ROLE_ADMIN = 'admin';
+
+ /**
+ * The Dashboard Editor role — content delegation. REQ-ROLE-002.
+ *
+ * @var string
+ */
+ public const ROLE_EDITOR = 'editor';
+
+ /**
+ * The Dashboard Viewer role — read-only access. REQ-ROLE-003.
+ *
+ * @var string
+ */
+ public const ROLE_VIEWER = 'viewer';
+
+ /**
+ * All valid role values.
+ *
+ * @var string[]
+ */
+ public const VALID_ROLES = [
+ self::ROLE_ADMIN,
+ self::ROLE_EDITOR,
+ self::ROLE_VIEWER,
+ ];
+
+ /**
+ * Numeric ranks used by REQ-ROLE-005 highest-privilege-wins comparison
+ * when resolving across multiple group assignments.
+ *
+ * @var array
+ */
+ public const ROLE_RANKS = [
+ self::ROLE_ADMIN => 2,
+ self::ROLE_EDITOR => 1,
+ self::ROLE_VIEWER => 0,
+ ];
+
+ /**
+ * Source string used by REQ-ROLE-006 when the user is a Nextcloud admin.
+ *
+ * @var string
+ */
+ public const SOURCE_NC_ADMIN = 'nc-admin';
+
+ /**
+ * Source string used by REQ-ROLE-006 when a direct user assignment exists.
+ *
+ * @var string
+ */
+ public const SOURCE_USER_ASSIGNED = 'user-assigned';
+
+ /**
+ * Source-string prefix used by REQ-ROLE-006 when the role comes from
+ * a group assignment. The full source is `group-assigned:{groupId}`.
+ *
+ * @var string
+ */
+ public const SOURCE_GROUP_ASSIGNED_PREFIX = 'group-assigned:';
+
+ /**
+ * The Nextcloud user ID, or null for group assignments.
+ *
+ * @var string|null
+ */
+ protected ?string $userId = null;
+
+ /**
+ * The Nextcloud group ID, or null for user assignments.
+ *
+ * @var string|null
+ */
+ protected ?string $groupId = null;
+
+ /**
+ * The role name. One of admin / editor / viewer.
+ *
+ * @var string|null
+ */
+ protected ?string $role = null;
+
+ /**
+ * The user ID of the admin who created this assignment (audit).
+ *
+ * @var string|null
+ */
+ protected ?string $assignedBy = null;
+
+ /**
+ * Assignment timestamp ('c' format ISO-8601).
+ *
+ * @var string|null
+ */
+ protected ?string $assignedAt = null;
+
+ /**
+ * Constructor — register column types so the ORM hydrates the integer ID.
+ *
+ * @return void
+ */
+ public function __construct()
+ {
+ $this->addType(fieldName: 'id', type: 'integer');
+ }//end __construct()
+
+ /**
+ * Whether this row is a user assignment (`userId` is set).
+ *
+ * @return bool True when `userId` is populated.
+ */
+ public function isUserAssignment(): bool
+ {
+ return $this->userId !== null && $this->userId !== '';
+ }//end isUserAssignment()
+
+ /**
+ * Whether this row is a group assignment (`groupId` is set).
+ *
+ * @return bool True when `groupId` is populated.
+ */
+ public function isGroupAssignment(): bool
+ {
+ return $this->groupId !== null && $this->groupId !== '';
+ }//end isGroupAssignment()
+
+ /**
+ * Convenience accessor that returns the row's target identifier:
+ * the user ID when this is a user assignment, the group ID otherwise.
+ *
+ * @return string|null The target ID, or null when neither is set.
+ */
+ public function getTarget(): ?string
+ {
+ if ($this->isUserAssignment() === true) {
+ return $this->userId;
+ }
+
+ if ($this->isGroupAssignment() === true) {
+ return $this->groupId;
+ }
+
+ return null;
+ }//end getTarget()
+
+ /**
+ * Serialize to JSON. Field names mirror the API contract documented in
+ * REQ-ROLE-006.
+ *
+ * @return array The serialized assignment.
+ */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->getId(),
+ 'userId' => $this->userId,
+ 'groupId' => $this->groupId,
+ 'role' => $this->role,
+ 'assignedBy' => $this->assignedBy,
+ 'assignedAt' => $this->assignedAt,
+ ];
+ }//end jsonSerialize()
+}//end class
diff --git a/lib/Db/RoleAssignmentMapper.php b/lib/Db/RoleAssignmentMapper.php
new file mode 100644
index 00000000..c2ff2ab3
--- /dev/null
+++ b/lib/Db/RoleAssignmentMapper.php
@@ -0,0 +1,323 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Db\QBMapper;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+
+/**
+ * Mapper for RoleAssignment entities.
+ *
+ * @extends QBMapper
+ *
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods) The CRUD, lookup, and
+ * cascade methods are
+ * each cohesive entry
+ * points covering the
+ * REQ-ROLE-004 / 010 /
+ * 011 specifications;
+ * splitting would obscure
+ * the table contract.
+ */
+class RoleAssignmentMapper extends QBMapper
+{
+ /**
+ * Constructor
+ *
+ * @param IDBConnection $db The database connection.
+ */
+ public function __construct(IDBConnection $db)
+ {
+ parent::__construct(
+ db: $db,
+ tableName: 'mydash_role_assignments',
+ entityClass: RoleAssignment::class
+ );
+ }//end __construct()
+
+ /**
+ * Find an assignment by its primary key.
+ *
+ * @param int $id The assignment ID.
+ *
+ * @return RoleAssignment The assignment.
+ *
+ * @throws DoesNotExistException When not found.
+ */
+ public function findById(int $id): RoleAssignment
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'id',
+ y: $qb->createNamedParameter(
+ value: $id,
+ type: IQueryBuilder::PARAM_INT
+ )
+ )
+ );
+
+ return $this->findEntity(query: $qb);
+ }//end findById()
+
+ /**
+ * Return every assignment, ordered by ID for stable listing.
+ *
+ * @return RoleAssignment[] Every row in the table.
+ */
+ public function findAll(): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->orderBy(sort: 'id', order: 'ASC');
+
+ return $this->findEntities(query: $qb);
+ }//end findAll()
+
+ /**
+ * Find all direct user assignments for a single user.
+ *
+ * Per REQ-ROLE-005 only direct user assignments live here — group
+ * assignments are looked up via {@see findByGroupIds} given the user's
+ * group memberships.
+ *
+ * @param string $userId The Nextcloud user ID.
+ *
+ * @return RoleAssignment[] Zero or more direct user assignments.
+ */
+ public function findByUser(string $userId): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'user_id',
+ y: $qb->createNamedParameter(value: $userId)
+ )
+ )
+ ->orderBy(sort: 'id', order: 'ASC');
+
+ return $this->findEntities(query: $qb);
+ }//end findByUser()
+
+ /**
+ * Find all assignments scoped to a single group.
+ *
+ * @param string $groupId The Nextcloud group ID.
+ *
+ * @return RoleAssignment[] Zero or more group assignments.
+ */
+ public function findByGroup(string $groupId): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'group_id',
+ y: $qb->createNamedParameter(value: $groupId)
+ )
+ )
+ ->orderBy(sort: 'id', order: 'ASC');
+
+ return $this->findEntities(query: $qb);
+ }//end findByGroup()
+
+ /**
+ * Find all assignments scoped to any of the given group IDs.
+ *
+ * Used by RoleService when collecting candidate group assignments
+ * during effective-role resolution. REQ-ROLE-005.
+ *
+ * @param string[] $groupIds The Nextcloud group IDs.
+ *
+ * @return RoleAssignment[] Zero or more matching group assignments.
+ */
+ public function findByGroupIds(array $groupIds): array
+ {
+ if (count($groupIds) === 0) {
+ return [];
+ }
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->in(
+ x: 'group_id',
+ y: $qb->createNamedParameter(
+ value: $groupIds,
+ type: IQueryBuilder::PARAM_STR_ARRAY
+ )
+ )
+ )
+ ->orderBy(sort: 'group_id', order: 'ASC');
+
+ return $this->findEntities(query: $qb);
+ }//end findByGroupIds()
+
+ /**
+ * Find a duplicate user-role assignment (defensive uniqueness check).
+ *
+ * @param string $userId The user ID.
+ * @param string $role The role name.
+ *
+ * @return RoleAssignment|null The match or null when none exists.
+ */
+ public function findUserRole(string $userId, string $role): ?RoleAssignment
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'user_id',
+ y: $qb->createNamedParameter(value: $userId)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->eq(
+ x: 'role',
+ y: $qb->createNamedParameter(value: $role)
+ )
+ );
+
+ try {
+ return $this->findEntity(query: $qb);
+ } catch (DoesNotExistException) {
+ return null;
+ }
+ }//end findUserRole()
+
+ /**
+ * Find a duplicate group-role assignment (defensive uniqueness check).
+ *
+ * @param string $groupId The group ID.
+ * @param string $role The role name.
+ *
+ * @return RoleAssignment|null The match or null when none exists.
+ */
+ public function findGroupRole(
+ string $groupId,
+ string $role
+ ): ?RoleAssignment {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'group_id',
+ y: $qb->createNamedParameter(value: $groupId)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->eq(
+ x: 'role',
+ y: $qb->createNamedParameter(value: $role)
+ )
+ );
+
+ try {
+ return $this->findEntity(query: $qb);
+ } catch (DoesNotExistException) {
+ return null;
+ }
+ }//end findGroupRole()
+
+ /**
+ * Delete all direct user assignments for a single user.
+ *
+ * Invoked by the user-deletion cascade. REQ-ROLE-010.
+ *
+ * @param string $userId The user ID.
+ *
+ * @return int The number of rows deleted.
+ */
+ public function deleteByUserId(string $userId): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(delete: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'user_id',
+ y: $qb->createNamedParameter(value: $userId)
+ )
+ );
+
+ return $qb->executeStatement();
+ }//end deleteByUserId()
+
+ /**
+ * Delete all assignments scoped to a single group.
+ *
+ * Invoked by the group-deletion cascade. REQ-ROLE-011.
+ *
+ * @param string $groupId The group ID.
+ *
+ * @return int The number of rows deleted.
+ */
+ public function deleteByGroupId(string $groupId): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(delete: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'group_id',
+ y: $qb->createNamedParameter(value: $groupId)
+ )
+ );
+
+ return $qb->executeStatement();
+ }//end deleteByGroupId()
+
+ /**
+ * Delete a single assignment by ID.
+ *
+ * @param int $id The assignment ID.
+ *
+ * @return int The number of rows deleted (0 when no row matched).
+ */
+ public function deleteById(int $id): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(delete: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'id',
+ y: $qb->createNamedParameter(
+ value: $id,
+ type: IQueryBuilder::PARAM_INT
+ )
+ )
+ );
+
+ return $qb->executeStatement();
+ }//end deleteById()
+}//end class
diff --git a/lib/Db/RoleFeaturePermission.php b/lib/Db/RoleFeaturePermission.php
new file mode 100644
index 00000000..a08a1c48
--- /dev/null
+++ b/lib/Db/RoleFeaturePermission.php
@@ -0,0 +1,207 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 EUPL-1.2
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use JsonSerializable;
+use OCP\AppFramework\Db\Entity;
+
+/**
+ * Role-feature-permission entity (REQ-RFP-001..010).
+ *
+ * `allowedWidgets` and `deniedWidgets` are persisted as JSON-encoded text
+ * columns; `priorityWeights` is a JSON-encoded `{widgetId: int}` map.
+ * Decoded values are exposed via the `*Decoded` accessors so callers do
+ * not have to json_decode at every call site.
+ *
+ * @method string getName()
+ * @method void setName(string $name)
+ * @method string|null getDescription()
+ * @method void setDescription(?string $description)
+ * @method string getGroupId()
+ * @method void setGroupId(string $groupId)
+ * @method string|null getAllowedWidgets()
+ * @method void setAllowedWidgets(?string $allowedWidgets)
+ * @method string|null getDeniedWidgets()
+ * @method void setDeniedWidgets(?string $deniedWidgets)
+ * @method string|null getPriorityWeights()
+ * @method void setPriorityWeights(?string $priorityWeights)
+ * @method string|null getCreatedAt()
+ * @method void setCreatedAt(?string $createdAt)
+ * @method string|null getUpdatedAt()
+ * @method void setUpdatedAt(?string $updatedAt)
+ */
+class RoleFeaturePermission extends Entity implements JsonSerializable
+{
+ /**
+ * Sentinel group id used as a catch-all fallback when a user's
+ * `group_order` priority does not yield a match (REQ-RFP-009).
+ *
+ * @var string
+ */
+ public const GROUP_DEFAULT = 'default';
+
+ /**
+ * Human-readable name (e.g. "Medewerker widget-rechten").
+ *
+ * @var string
+ */
+ protected string $name = '';
+
+ /**
+ * Optional purpose / notes.
+ *
+ * @var string|null
+ */
+ protected ?string $description = null;
+
+ /**
+ * Nextcloud group ID this permission applies to.
+ *
+ * @var string
+ */
+ protected string $groupId = '';
+
+ /**
+ * JSON-encoded list of widget IDs the group may add. Empty list = no
+ * widgets allowed; null after construction = unset.
+ *
+ * @var string|null
+ */
+ protected ?string $allowedWidgets = null;
+
+ /**
+ * JSON-encoded list of widget IDs explicitly denied. Wins over
+ * `allowedWidgets` when merging multiple groups (deny-wins, REQ-RFP-005).
+ *
+ * @var string|null
+ */
+ protected ?string $deniedWidgets = null;
+
+ /**
+ * JSON-encoded map of `{widgetId: int}` priority weights. Higher value =
+ * earlier in seeded layout / dashboard ordering.
+ *
+ * @var string|null
+ */
+ protected ?string $priorityWeights = null;
+
+ /**
+ * ISO-8601 creation timestamp.
+ *
+ * @var string|null
+ */
+ protected ?string $createdAt = null;
+
+ /**
+ * ISO-8601 last-modification timestamp.
+ *
+ * @var string|null
+ */
+ protected ?string $updatedAt = null;
+
+ /**
+ * Constructor — registers ORM column types.
+ */
+ public function __construct()
+ {
+ $this->addType(fieldName: 'id', type: 'integer');
+ }//end __construct()
+
+ /**
+ * Get `allowedWidgets` decoded into a string array.
+ *
+ * @return array The list of allowed widget IDs (empty array if unset).
+ */
+ public function getAllowedWidgetsDecoded(): array
+ {
+ if ($this->allowedWidgets === null || $this->allowedWidgets === '') {
+ return [];
+ }
+
+ $decoded = json_decode(json: $this->allowedWidgets, associative: true);
+ if (is_array($decoded) === false) {
+ return [];
+ }
+
+ return array_values(array: $decoded);
+ }//end getAllowedWidgetsDecoded()
+
+ /**
+ * Get `deniedWidgets` decoded into a string array.
+ *
+ * @return array The list of denied widget IDs (empty array if unset).
+ */
+ public function getDeniedWidgetsDecoded(): array
+ {
+ if ($this->deniedWidgets === null || $this->deniedWidgets === '') {
+ return [];
+ }
+
+ $decoded = json_decode(json: $this->deniedWidgets, associative: true);
+ if (is_array($decoded) === false) {
+ return [];
+ }
+
+ return array_values(array: $decoded);
+ }//end getDeniedWidgetsDecoded()
+
+ /**
+ * Get `priorityWeights` decoded into a `{widgetId: int}` map.
+ *
+ * @return array The decoded priority map (empty array if unset).
+ */
+ public function getPriorityWeightsDecoded(): array
+ {
+ if ($this->priorityWeights === null || $this->priorityWeights === '') {
+ return [];
+ }
+
+ $decoded = json_decode(json: $this->priorityWeights, associative: true);
+ if (is_array($decoded) === false) {
+ return [];
+ }
+
+ return $decoded;
+ }//end getPriorityWeightsDecoded()
+
+ /**
+ * Serialize to a JSON-friendly array.
+ *
+ * @return array The serialized representation.
+ */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->getId(),
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'groupId' => $this->groupId,
+ 'allowedWidgets' => $this->getAllowedWidgetsDecoded(),
+ 'deniedWidgets' => $this->getDeniedWidgetsDecoded(),
+ 'priorityWeights' => (object) $this->getPriorityWeightsDecoded(),
+ 'createdAt' => $this->createdAt,
+ 'updatedAt' => $this->updatedAt,
+ ];
+ }//end jsonSerialize()
+}//end class
diff --git a/lib/Db/RoleFeaturePermissionMapper.php b/lib/Db/RoleFeaturePermissionMapper.php
new file mode 100644
index 00000000..960bfc11
--- /dev/null
+++ b/lib/Db/RoleFeaturePermissionMapper.php
@@ -0,0 +1,147 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Db\QBMapper;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+
+/**
+ * Mapper for `mydash_role_feature_perms`.
+ *
+ * @extends QBMapper
+ */
+class RoleFeaturePermissionMapper extends QBMapper
+{
+ /**
+ * Constructor.
+ *
+ * @param IDBConnection $db The database connection.
+ */
+ public function __construct(IDBConnection $db)
+ {
+ parent::__construct(
+ db: $db,
+ tableName: 'mydash_role_feature_perms',
+ entityClass: RoleFeaturePermission::class
+ );
+ }//end __construct()
+
+ /**
+ * Find a permission row by primary key id.
+ *
+ * @param int $id The row id.
+ *
+ * @return RoleFeaturePermission The found row.
+ *
+ * @throws DoesNotExistException When no row with that id exists.
+ */
+ public function find(int $id): RoleFeaturePermission
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'id',
+ y: $qb->createNamedParameter(
+ value: $id,
+ type: IQueryBuilder::PARAM_INT
+ )
+ )
+ );
+
+ return $this->findEntity(query: $qb);
+ }//end find()
+
+ /**
+ * Find all RoleFeaturePermission rows.
+ *
+ * @return RoleFeaturePermission[] All rows ordered by group_id.
+ */
+ public function findAll(): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->orderBy(sort: 'group_id', order: 'ASC');
+
+ return $this->findEntities(query: $qb);
+ }//end findAll()
+
+ /**
+ * Find a permission row by group ID.
+ *
+ * @param string $groupId The Nextcloud group ID.
+ *
+ * @return RoleFeaturePermission The matching row.
+ *
+ * @throws DoesNotExistException When no row exists for the given group.
+ */
+ public function findByGroupId(string $groupId): RoleFeaturePermission
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'group_id',
+ y: $qb->createNamedParameter(value: $groupId)
+ )
+ );
+
+ return $this->findEntity(query: $qb);
+ }//end findByGroupId()
+
+ /**
+ * Find all permission rows whose group_id is in the supplied list. The
+ * caller is responsible for ordering by `group_order` priority — this
+ * mapper just returns whatever the storage engine supplies.
+ *
+ * @param array $groupIds The list of Nextcloud group IDs to match.
+ *
+ * @return RoleFeaturePermission[] Zero or more matching rows.
+ */
+ public function findByGroupIds(array $groupIds): array
+ {
+ if (count(value: $groupIds) === 0) {
+ return [];
+ }
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->in(
+ x: 'group_id',
+ y: $qb->createNamedParameter(
+ value: $groupIds,
+ type: IQueryBuilder::PARAM_STR_ARRAY
+ )
+ )
+ );
+
+ return $this->findEntities(query: $qb);
+ }//end findByGroupIds()
+}//end class
diff --git a/lib/Db/RoleLayoutDefault.php b/lib/Db/RoleLayoutDefault.php
new file mode 100644
index 00000000..1825e95a
--- /dev/null
+++ b/lib/Db/RoleLayoutDefault.php
@@ -0,0 +1,182 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 EUPL-1.2
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use JsonSerializable;
+use OCP\AppFramework\Db\Entity;
+
+/**
+ * Role-layout-default entity (REQ-RFP-002).
+ *
+ * @method string getName()
+ * @method void setName(string $name)
+ * @method string|null getDescription()
+ * @method void setDescription(?string $description)
+ * @method string getGroupId()
+ * @method void setGroupId(string $groupId)
+ * @method string getWidgetId()
+ * @method void setWidgetId(string $widgetId)
+ * @method int getGridX()
+ * @method void setGridX(int $gridX)
+ * @method int getGridY()
+ * @method void setGridY(int $gridY)
+ * @method int getGridWidth()
+ * @method void setGridWidth(int $gridWidth)
+ * @method int getGridHeight()
+ * @method void setGridHeight(int $gridHeight)
+ * @method int getSortOrder()
+ * @method void setSortOrder(int $sortOrder)
+ * @method int getIsCompulsory()
+ * @method void setIsCompulsory(int $isCompulsory)
+ * @method string|null getCreatedAt()
+ * @method void setCreatedAt(?string $createdAt)
+ * @method string|null getUpdatedAt()
+ * @method void setUpdatedAt(?string $updatedAt)
+ */
+class RoleLayoutDefault extends Entity implements JsonSerializable
+{
+
+ /**
+ * Display name (e.g. "Manager — activiteiten").
+ *
+ * @var string
+ */
+ protected string $name = '';
+
+ /**
+ * Optional notes about why this widget appears at this position.
+ *
+ * @var string|null
+ */
+ protected ?string $description = null;
+
+ /**
+ * Nextcloud group ID this default layout slot applies to.
+ *
+ * @var string
+ */
+ protected string $groupId = '';
+
+ /**
+ * Nextcloud Dashboard widget ID to seed.
+ *
+ * @var string
+ */
+ protected string $widgetId = '';
+
+ /**
+ * Column position (0-based).
+ *
+ * @var integer
+ */
+ protected int $gridX = 0;
+
+ /**
+ * Row position (0-based).
+ *
+ * @var integer
+ */
+ protected int $gridY = 0;
+
+ /**
+ * Widget width in grid columns (min 1).
+ *
+ * @var integer
+ */
+ protected int $gridWidth = 4;
+
+ /**
+ * Widget height in grid rows (min 1).
+ *
+ * @var integer
+ */
+ protected int $gridHeight = 4;
+
+ /**
+ * Sort order within the layout (lower = rendered first).
+ *
+ * @var integer
+ */
+ protected int $sortOrder = 0;
+
+ /**
+ * 0/1 — when 1, the user cannot remove this widget from a seeded layout.
+ *
+ * @var integer
+ */
+ protected int $isCompulsory = 0;
+
+ /**
+ * ISO-8601 creation timestamp.
+ *
+ * @var string|null
+ */
+ protected ?string $createdAt = null;
+
+ /**
+ * ISO-8601 last-modification timestamp.
+ *
+ * @var string|null
+ */
+ protected ?string $updatedAt = null;
+
+ /**
+ * Constructor — registers ORM column types.
+ */
+ public function __construct()
+ {
+ $this->addType(fieldName: 'id', type: 'integer');
+ $this->addType(fieldName: 'gridX', type: 'integer');
+ $this->addType(fieldName: 'gridY', type: 'integer');
+ $this->addType(fieldName: 'gridWidth', type: 'integer');
+ $this->addType(fieldName: 'gridHeight', type: 'integer');
+ $this->addType(fieldName: 'sortOrder', type: 'integer');
+ $this->addType(fieldName: 'isCompulsory', type: 'integer');
+ }//end __construct()
+
+ /**
+ * Serialize to a JSON-friendly array.
+ *
+ * @return array The serialized representation.
+ */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'id' => $this->getId(),
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'groupId' => $this->groupId,
+ 'widgetId' => $this->widgetId,
+ 'gridX' => $this->gridX,
+ 'gridY' => $this->gridY,
+ 'gridWidth' => $this->gridWidth,
+ 'gridHeight' => $this->gridHeight,
+ 'sortOrder' => $this->sortOrder,
+ 'isCompulsory' => $this->isCompulsory === 1,
+ 'createdAt' => $this->createdAt,
+ 'updatedAt' => $this->updatedAt,
+ ];
+ }//end jsonSerialize()
+}//end class
diff --git a/lib/Db/RoleLayoutDefaultMapper.php b/lib/Db/RoleLayoutDefaultMapper.php
new file mode 100644
index 00000000..28197bdf
--- /dev/null
+++ b/lib/Db/RoleLayoutDefaultMapper.php
@@ -0,0 +1,149 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Db;
+
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Db\QBMapper;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+
+/**
+ * Mapper for `mydash_role_layout_defaults`.
+ *
+ * @extends QBMapper
+ */
+class RoleLayoutDefaultMapper extends QBMapper
+{
+ /**
+ * Constructor.
+ *
+ * @param IDBConnection $db The database connection.
+ */
+ public function __construct(IDBConnection $db)
+ {
+ parent::__construct(
+ db: $db,
+ tableName: 'mydash_role_layout_defaults',
+ entityClass: RoleLayoutDefault::class
+ );
+ }//end __construct()
+
+ /**
+ * Find a layout-default row by primary key id.
+ *
+ * @param int $id The row id.
+ *
+ * @return RoleLayoutDefault The found row.
+ *
+ * @throws DoesNotExistException When no row with that id exists.
+ */
+ public function find(int $id): RoleLayoutDefault
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'id',
+ y: $qb->createNamedParameter(
+ value: $id,
+ type: IQueryBuilder::PARAM_INT
+ )
+ )
+ );
+
+ return $this->findEntity(query: $qb);
+ }//end find()
+
+ /**
+ * List all default layout rows ordered by group then sort_order.
+ *
+ * @return RoleLayoutDefault[] All rows.
+ */
+ public function findAll(): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->orderBy(sort: 'group_id', order: 'ASC')
+ ->addOrderBy(sort: 'sort_order', order: 'ASC');
+
+ return $this->findEntities(query: $qb);
+ }//end findAll()
+
+ /**
+ * Find all default-layout rows for a single group, ordered by `sort_order`.
+ * Used by `RoleFeaturePermissionService::seedLayoutFromRoleDefaults()`.
+ *
+ * @param string $groupId The Nextcloud group ID.
+ *
+ * @return RoleLayoutDefault[] Ordered rows; empty array when nothing seeded.
+ */
+ public function findByGroupId(string $groupId): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'group_id',
+ y: $qb->createNamedParameter(value: $groupId)
+ )
+ )
+ ->orderBy(sort: 'sort_order', order: 'ASC');
+
+ return $this->findEntities(query: $qb);
+ }//end findByGroupId()
+
+ /**
+ * Find an existing row by `(group_id, widget_id)` — used to enforce
+ * upsert behaviour when an admin re-saves the same default.
+ *
+ * @param string $groupId The Nextcloud group ID.
+ * @param string $widgetId The Nextcloud Dashboard widget ID.
+ *
+ * @return RoleLayoutDefault The matching row.
+ *
+ * @throws DoesNotExistException When no row exists for the combination.
+ */
+ public function findByGroupAndWidget(string $groupId, string $widgetId): RoleLayoutDefault
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'group_id',
+ y: $qb->createNamedParameter(value: $groupId)
+ )
+ )
+ ->andWhere(
+ $qb->expr()->eq(
+ x: 'widget_id',
+ y: $qb->createNamedParameter(value: $widgetId)
+ )
+ );
+
+ return $this->findEntity(query: $qb);
+ }//end findByGroupAndWidget()
+}//end class
diff --git a/lib/Db/WidgetPlacement.php b/lib/Db/WidgetPlacement.php
index 53c6fd83..a3bd12a0 100644
--- a/lib/Db/WidgetPlacement.php
+++ b/lib/Db/WidgetPlacement.php
@@ -182,7 +182,20 @@ class WidgetPlacement extends Entity implements JsonSerializable
protected ?string $tileTitle = null;
/**
- * The tile icon.
+ * The tile icon — opaque single-column field.
+ *
+ * Per `dashboard-icons` REQ-ICON-009 the column may hold ANY of the
+ * following three forms; the backend treats the column as opaque
+ * and the frontend `IconRenderer` discriminates at render time via
+ * `isCustomIconUrl()`:
+ *
+ * - `null` — render the default icon
+ * - A built-in registry key (e.g. `'Star'`, `'ViewDashboard'`)
+ * - A URL beginning with `/` or `http` (custom icon uploaded via
+ * the `resource-uploads` capability)
+ *
+ * Switching between forms is a plain `UPDATE`; no schema migration
+ * is required and no auxiliary discriminator column exists.
*
* @var string|null
*/
diff --git a/lib/Db/WidgetPlacementMapper.php b/lib/Db/WidgetPlacementMapper.php
index e1b7fafa..4a5f2298 100644
--- a/lib/Db/WidgetPlacementMapper.php
+++ b/lib/Db/WidgetPlacementMapper.php
@@ -102,6 +102,31 @@ public function findByDashboardId(int $dashboardId): array
return $this->findEntities(query: $qb);
}//end findByDashboardId()
+ /**
+ * Find all placements that reference a given widget id across the
+ * entire instance — used by {@see \OCA\MyDash\Service\FeedRefreshService::discoverFeedUrls()}
+ * to pull every news-widget placement before each refresh tick
+ * (REQ-FRJ-003).
+ *
+ * @param string $widgetId The widget id (e.g. `'mydash_news'`).
+ *
+ * @return WidgetPlacement[] The matching placements.
+ */
+ public function findByWidgetId(string $widgetId): array
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: '*')
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'widget_id',
+ y: $qb->createNamedParameter(value: $widgetId)
+ )
+ );
+
+ return $this->findEntities(query: $qb);
+ }//end findByWidgetId()
+
/**
* Find placement by dashboard and widget ID.
*
@@ -222,60 +247,189 @@ public function updatePositions(array $updates): void
}//end updatePositions()
/**
- * Clone all placements from a source dashboard into a target dashboard.
+ * Clone all placements from one dashboard to another.
+ *
+ * Reads the source rows via {@see self::findByDashboardId()} and
+ * inserts a new row per source under `$targetDashboardId`. All
+ * widget-, tile-, style- and grid-fields are byte-for-byte copied
+ * (REQ-DASH-020) — including resource URL fields like `tileIcon`
+ * which intentionally point at the same shared resource record
+ * (REQ-DASH-022). The new rows receive fresh `id`, `dashboardId`
+ * pointing at the target, and `createdAt` / `updatedAt` set to now.
*
- * Fetches every placement row belonging to `$sourceDashboardId` and
- * inserts fresh copies under `$targetDashboardId` with new auto-
- * generated IDs and reset `createdAt`/`updatedAt` timestamps. All
- * other fields — gridX/Y/W/H, widgetId, customTitle, styleConfig,
- * showTitle, isCompulsory, isVisible, sortOrder, tileType, tileTitle,
- * tileIcon, tileIconType, tileBackgroundColor, tileTextColor,
- * tileLinkType, tileLinkValue, customIcon — are copied verbatim so
- * the fork is a true visual clone. Resource URLs (e.g. tileIcon) are
- * NOT duplicated; both dashboards reference the same URL (REQ-DASH-022).
+ * Used by {@see \OCA\MyDash\Service\DashboardService::forkAsPersonal()}
+ * inside a single transaction — any DB exception thrown here MUST
+ * be left to bubble so the caller can roll back.
*
* @param int $sourceDashboardId The source dashboard ID.
- * @param int $targetDashboardId The target (new) dashboard ID.
+ * @param int $targetDashboardId The destination dashboard ID.
*
- * @return void
+ * @return int The number of placements cloned.
*/
public function cloneToDashboard(
int $sourceDashboardId,
int $targetDashboardId
- ): void {
- $now = (new DateTime())->format(format: 'Y-m-d H:i:s');
- $placements = $this->findByDashboardId(dashboardId: $sourceDashboardId);
+ ): int {
+ $rows = $this->findByDashboardId(dashboardId: $sourceDashboardId);
+ $now = (new DateTime())->format(format: 'Y-m-d H:i:s');
+ $count = 0;
- foreach ($placements as $source) {
+ foreach ($rows as $row) {
$clone = new WidgetPlacement();
+ // Force the new dashboard id — the rest of the fields below
+ // are byte-for-byte copies of the source row.
$clone->setDashboardId($targetDashboardId);
- $clone->setWidgetId($source->getWidgetId());
- $clone->setGridX($source->getGridX());
- $clone->setGridY($source->getGridY());
- $clone->setGridWidth($source->getGridWidth());
- $clone->setGridHeight($source->getGridHeight());
- $clone->setIsCompulsory($source->getIsCompulsory());
- $clone->setIsVisible($source->getIsVisible());
- $clone->setStyleConfig($source->getStyleConfig());
- $clone->setCustomTitle($source->getCustomTitle());
- $clone->setCustomIcon($source->getCustomIcon());
- $clone->setShowTitle($source->getShowTitle());
- $clone->setSortOrder($source->getSortOrder());
- $clone->setTileType($source->getTileType());
- $clone->setTileTitle($source->getTileTitle());
- $clone->setTileIcon($source->getTileIcon());
- $clone->setTileIconType($source->getTileIconType());
- $clone->setTileBackgroundColor($source->getTileBackgroundColor());
- $clone->setTileTextColor($source->getTileTextColor());
- $clone->setTileLinkType($source->getTileLinkType());
- $clone->setTileLinkValue($source->getTileLinkValue());
+ $clone->setWidgetId($row->getWidgetId());
+ $clone->setGridX($row->getGridX());
+ $clone->setGridY($row->getGridY());
+ $clone->setGridWidth($row->getGridWidth());
+ $clone->setGridHeight($row->getGridHeight());
+ $clone->setIsCompulsory($row->getIsCompulsory());
+ $clone->setIsVisible($row->getIsVisible());
+ $clone->setStyleConfig($row->getStyleConfig());
+ $clone->setCustomTitle($row->getCustomTitle());
+ $clone->setCustomIcon($row->getCustomIcon());
+ $clone->setShowTitle($row->getShowTitle());
+ $clone->setSortOrder($row->getSortOrder());
+ $clone->setTileType($row->getTileType());
+ $clone->setTileTitle($row->getTileTitle());
+ // REQ-DASH-022: same `/apps/mydash/resource/...` URL — no
+ // file is duplicated in app data, both dashboards reference
+ // the shared resource record.
+ $clone->setTileIcon($row->getTileIcon());
+ $clone->setTileIconType($row->getTileIconType());
+ $clone->setTileBackgroundColor($row->getTileBackgroundColor());
+ $clone->setTileTextColor($row->getTileTextColor());
+ $clone->setTileLinkType($row->getTileLinkType());
+ $clone->setTileLinkValue($row->getTileLinkValue());
$clone->setCreatedAt($now);
$clone->setUpdatedAt($now);
$this->insert(entity: $clone);
+ $count++;
}//end foreach
+
+ return $count;
}//end cloneToDashboard()
+ /**
+ * Count placement rows whose `dashboard_id` no longer points at
+ * any row in `mydash_dashboards`.
+ *
+ * Used by the orphaned-data-cleanup scan path (REQ-CLN-001).
+ * Placements are normally cleared by `DashboardService::delete()`;
+ * a row left behind here usually indicates a crashed delete path
+ * or a manual SQL operation.
+ *
+ * @return int The number of orphaned placement rows.
+ */
+ public function countOrphaned(): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select($qb->func()->count('p.id'))
+ ->from(from: $this->getTableName(), alias: 'p')
+ ->leftJoin(
+ fromAlias: 'p',
+ join: 'mydash_dashboards',
+ alias: 'd',
+ condition: 'd.id = p.dashboard_id'
+ )
+ ->where($qb->expr()->isNull(x: 'd.id'));
+
+ $result = $qb->executeQuery();
+ $count = $result->fetchOne();
+ $result->closeCursor();
+
+ return (int) ($count ?? 0);
+ }//end countOrphaned()
+
+ /**
+ * Delete placement rows whose `dashboard_id` no longer points at
+ * any row in `mydash_dashboards`.
+ *
+ * Companion to {@see self::countOrphaned()} on the purge path
+ * (REQ-CLN-002). Resolves the orphan IDs first via a SELECT and
+ * then deletes by primary key for portability across drivers.
+ *
+ * @return int The number of rows deleted.
+ */
+ public function deleteOrphaned(): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(selects: 'p.id')
+ ->from(from: $this->getTableName(), alias: 'p')
+ ->leftJoin(
+ fromAlias: 'p',
+ join: 'mydash_dashboards',
+ alias: 'd',
+ condition: 'd.id = p.dashboard_id'
+ )
+ ->where($qb->expr()->isNull(x: 'd.id'));
+
+ $result = $qb->executeQuery();
+ $ids = [];
+ while (($row = $result->fetch()) !== false) {
+ $ids[] = (int) $row['id'];
+ }
+
+ $result->closeCursor();
+
+ if (count(value: $ids) === 0) {
+ return 0;
+ }
+
+ $delete = $this->db->getQueryBuilder();
+ $delete->delete(delete: $this->getTableName())
+ ->where(
+ $delete->expr()->in(
+ x: 'id',
+ y: $delete->createNamedParameter(
+ value: $ids,
+ type: IQueryBuilder::PARAM_INT_ARRAY
+ )
+ )
+ );
+
+ return $delete->executeStatement();
+ }//end deleteOrphaned()
+
+ /**
+ * Count widget placements for a dashboard (REQ-TMPL-014).
+ *
+ * Used by the gallery serialiser so the response includes the
+ * `widgetCount` field without fetching the full placement entities
+ * (the gallery is a list view; widget bodies are not included).
+ *
+ * @param int $dashboardId The dashboard ID.
+ *
+ * @return int The number of placements (0 when none).
+ */
+ public function countByDashboardId(int $dashboardId): int
+ {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select($qb->func()->count('*', 'cnt'))
+ ->from(from: $this->getTableName())
+ ->where(
+ $qb->expr()->eq(
+ x: 'dashboard_id',
+ y: $qb->createNamedParameter(
+ value: $dashboardId,
+ type: IQueryBuilder::PARAM_INT
+ )
+ )
+ );
+
+ $cursor = $qb->executeQuery();
+ $row = $cursor->fetch();
+ $cursor->closeCursor();
+
+ if ($row === false || isset($row['cnt']) === false) {
+ return 0;
+ }
+
+ return (int) $row['cnt'];
+ }//end countByDashboardId()
+
/**
* Get max sort order for a dashboard.
*
diff --git a/lib/Event/DashboardDeletedEvent.php b/lib/Event/DashboardDeletedEvent.php
new file mode 100644
index 00000000..5278dd1a
--- /dev/null
+++ b/lib/Event/DashboardDeletedEvent.php
@@ -0,0 +1,111 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Event;
+
+use DateTimeImmutable;
+use OCP\EventDispatcher\Event;
+
+/**
+ * Event signalling that a dashboard has been soft-deleted.
+ *
+ * The event is fired synchronously inside the same PHP request — every
+ * registered listener executes before `DashboardService::delete()`
+ * returns. Listeners receive a row that is already soft-deleted; they
+ * MUST NOT attempt to re-delete the main dashboard row.
+ *
+ * @see \OCA\MyDash\Listener\WidgetPlacementsListener
+ * @see \OCA\MyDash\Listener\CommentsListener
+ * @see \OCA\MyDash\Listener\ReactionsListener
+ * @see \OCA\MyDash\Listener\LocksListener
+ * @see \OCA\MyDash\Listener\VersionsListener
+ * @see \OCA\MyDash\Listener\PublicSharesListener
+ * @see \OCA\MyDash\Listener\MetadataValuesListener
+ * @see \OCA\MyDash\Listener\TranslationsListener
+ * @see \OCA\MyDash\Listener\ViewAnalyticsListener
+ * @see \OCA\MyDash\Listener\TreeListener
+ */
+final class DashboardDeletedEvent extends Event
+{
+ /**
+ * Constructor.
+ *
+ * @param string $dashboardUuid The UUID of the deleted dashboard.
+ * @param string $ownerUserId The owner user ID at the time of deletion.
+ * @param string $type The dashboard type (`user`, `group_shared`, `admin_template`).
+ * @param DateTimeImmutable $deletedAt The instant the soft-delete completed.
+ */
+ public function __construct(
+ private readonly string $dashboardUuid,
+ private readonly string $ownerUserId,
+ private readonly string $type,
+ private readonly DateTimeImmutable $deletedAt,
+ ) {
+ parent::__construct();
+ }//end __construct()
+
+ /**
+ * Get the UUID of the deleted dashboard.
+ *
+ * @return string The dashboard UUID.
+ */
+ public function getDashboardUuid(): string
+ {
+ return $this->dashboardUuid;
+ }//end getDashboardUuid()
+
+ /**
+ * Get the owner user ID at the time of deletion.
+ *
+ * For `group_shared` dashboards this is the actor (admin) who
+ * performed the delete, not the original creator. REQ-CSC-001
+ * scenario "Event carries correct type for group-shared dashboard".
+ *
+ * @return string The owner / actor user ID.
+ */
+ public function getOwnerUserId(): string
+ {
+ return $this->ownerUserId;
+ }//end getOwnerUserId()
+
+ /**
+ * Get the dashboard type.
+ *
+ * @return string One of `user`, `group_shared`, `admin_template`.
+ */
+ public function getType(): string
+ {
+ return $this->type;
+ }//end getType()
+
+ /**
+ * Get the deletion timestamp.
+ *
+ * @return DateTimeImmutable The instant the soft-delete completed.
+ */
+ public function getDeletedAt(): DateTimeImmutable
+ {
+ return $this->deletedAt;
+ }//end getDeletedAt()
+}//end class
diff --git a/lib/Exception/CommentForbiddenException.php b/lib/Exception/CommentForbiddenException.php
new file mode 100644
index 00000000..11ea7c9d
--- /dev/null
+++ b/lib/Exception/CommentForbiddenException.php
@@ -0,0 +1,59 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Exception;
+
+/**
+ * Comment mutation refused (REQ-CMNT-002, REQ-CMNT-004, REQ-CMNT-005,
+ * REQ-CMNT-007, REQ-CMNT-009).
+ */
+class CommentForbiddenException extends ResourceException
+{
+
+ /**
+ * Stable error code returned in the response envelope.
+ *
+ * @var string
+ */
+ protected string $errorCode = 'comment_forbidden';
+
+ /**
+ * HTTP status code.
+ *
+ * @var integer
+ */
+ protected int $httpStatus = 403;
+
+ /**
+ * Constructor.
+ *
+ * @param string $message Display message (translatable English string).
+ */
+ public function __construct(
+ string $message='Comment mutation refused'
+ ) {
+ parent::__construct(message: $message);
+ }//end __construct()
+}//end class
diff --git a/lib/Exception/CommentNotFoundException.php b/lib/Exception/CommentNotFoundException.php
new file mode 100644
index 00000000..d7a8b507
--- /dev/null
+++ b/lib/Exception/CommentNotFoundException.php
@@ -0,0 +1,57 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Exception;
+
+/**
+ * Comment not found (REQ-CMNT-003..005).
+ */
+class CommentNotFoundException extends ResourceException
+{
+
+ /**
+ * Stable error code returned in the response envelope.
+ *
+ * @var string
+ */
+ protected string $errorCode = 'comment_not_found';
+
+ /**
+ * HTTP status code.
+ *
+ * @var integer
+ */
+ protected int $httpStatus = 404;
+
+ /**
+ * Constructor.
+ *
+ * @param string $message Display message (translatable English string).
+ */
+ public function __construct(
+ string $message='Comment not found'
+ ) {
+ parent::__construct(message: $message);
+ }//end __construct()
+}//end class
diff --git a/lib/Exception/DashboardHasChildrenException.php b/lib/Exception/DashboardHasChildrenException.php
new file mode 100644
index 00000000..040fce36
--- /dev/null
+++ b/lib/Exception/DashboardHasChildrenException.php
@@ -0,0 +1,67 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Exception;
+
+use Exception;
+
+/**
+ * Cascade-delete guard tripped — the dashboard has children
+ * (REQ-DASH-030).
+ */
+class DashboardHasChildrenException extends Exception
+{
+ /**
+ * Stable error code returned in the response envelope.
+ *
+ * @var string
+ */
+ public const ERROR_CODE = 'dashboard_has_children';
+
+ /**
+ * Constructor.
+ *
+ * @param int $childCount The number of direct children blocking the
+ * delete. Surfaced in the HTTP 409 response
+ * body so the UI can display the count.
+ */
+ public function __construct(
+ private readonly int $childCount
+ ) {
+ parent::__construct(
+ message: 'Dashboard has '.$childCount.' children. Use ?cascade=true to delete the subtree.'
+ );
+ }//end __construct()
+
+ /**
+ * Returns the number of direct children blocking the delete.
+ *
+ * @return int The child count.
+ */
+ public function getChildCount(): int
+ {
+ return $this->childCount;
+ }//end getChildCount()
+}//end class
diff --git a/lib/Exception/DuplicateRoleAssignmentException.php b/lib/Exception/DuplicateRoleAssignmentException.php
new file mode 100644
index 00000000..30acc64f
--- /dev/null
+++ b/lib/Exception/DuplicateRoleAssignmentException.php
@@ -0,0 +1,56 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Exception;
+
+/**
+ * Duplicate role assignment (REQ-ROLE-004).
+ */
+class DuplicateRoleAssignmentException extends ResourceException
+{
+
+ /**
+ * Stable error code returned in the response envelope.
+ *
+ * @var string
+ */
+ protected string $errorCode = 'duplicate_role_assignment';
+
+ /**
+ * HTTP status code.
+ *
+ * @var integer
+ */
+ protected int $httpStatus = 409;
+
+ /**
+ * Constructor.
+ *
+ * @param string $message Display message (translatable English string).
+ */
+ public function __construct(
+ string $message='That target already has the requested role assigned'
+ ) {
+ parent::__construct(message: $message);
+ }//end __construct()
+}//end class
diff --git a/lib/Exception/FileTypeNotAllowedException.php b/lib/Exception/FileTypeNotAllowedException.php
new file mode 100644
index 00000000..2e5f3916
--- /dev/null
+++ b/lib/Exception/FileTypeNotAllowedException.php
@@ -0,0 +1,56 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Exception;
+
+/**
+ * Extension is outside the admin-configured allow-list.
+ */
+class FileTypeNotAllowedException extends ResourceException
+{
+
+ /**
+ * Stable error code.
+ *
+ * @var string
+ */
+ protected string $errorCode = 'file_type_not_allowed';
+
+ /**
+ * HTTP status.
+ *
+ * @var integer
+ */
+ protected int $httpStatus = 400;
+
+ /**
+ * Constructor.
+ *
+ * @param string $message Display message.
+ */
+ public function __construct(
+ string $message='File type not allowed'
+ ) {
+ parent::__construct(message: $message);
+ }//end __construct()
+}//end class
diff --git a/lib/Exception/FolderNotFoundException.php b/lib/Exception/FolderNotFoundException.php
new file mode 100644
index 00000000..97e1315e
--- /dev/null
+++ b/lib/Exception/FolderNotFoundException.php
@@ -0,0 +1,56 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Exception;
+
+/**
+ * Files-widget folder-resolution failure.
+ */
+class FolderNotFoundException extends ResourceException
+{
+
+ /**
+ * Stable error code surfaced to the API consumer.
+ *
+ * @var string
+ */
+ protected string $errorCode = 'folder_not_found';
+
+ /**
+ * HTTP status code used by the controller envelope.
+ *
+ * @var integer
+ */
+ protected int $httpStatus = 404;
+
+ /**
+ * Constructor.
+ *
+ * @param string $message Optional override message.
+ */
+ public function __construct(string $message='The configured folder no longer exists')
+ {
+ parent::__construct(message: $message);
+ }//end __construct()
+}//end class
diff --git a/lib/Exception/InvalidCommentException.php b/lib/Exception/InvalidCommentException.php
new file mode 100644
index 00000000..2809ddea
--- /dev/null
+++ b/lib/Exception/InvalidCommentException.php
@@ -0,0 +1,62 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Exception;
+
+/**
+ * Invalid comment payload (REQ-CMNT-002, REQ-CMNT-003).
+ */
+class InvalidCommentException extends ResourceException
+{
+
+ /**
+ * Stable error code returned in the response envelope.
+ *
+ * @var string
+ */
+ protected string $errorCode = 'invalid_comment';
+
+ /**
+ * HTTP status code.
+ *
+ * @var integer
+ */
+ protected int $httpStatus = 400;
+
+ /**
+ * Constructor.
+ *
+ * @param string $message Display message (translatable English string).
+ * @param string $errorCode Optional override for the error code so the
+ * spec scenarios can distinguish empty-message
+ * from nested-reply errors.
+ */
+ public function __construct(
+ string $message='Comment payload is invalid',
+ string $errorCode='invalid_comment'
+ ) {
+ $this->errorCode = $errorCode;
+ parent::__construct(message: $message);
+ }//end __construct()
+}//end class
diff --git a/lib/Exception/InvalidDirectoryException.php b/lib/Exception/InvalidDirectoryException.php
index dd82bf2f..30e1f96f 100644
--- a/lib/Exception/InvalidDirectoryException.php
+++ b/lib/Exception/InvalidDirectoryException.php
@@ -3,9 +3,9 @@
/**
* InvalidDirectoryException
*
- * Raised when the supplied directory path fails validation (e.g. contains
- * path-traversal segments or null bytes). Maps to HTTP 400 + error code
- * `invalid_directory`.
+ * Raised when the supplied target directory for `POST /api/files/create`
+ * contains a path-traversal sequence (`..`) or a null byte. Maps to
+ * HTTP 400 + error code `invalid_directory`.
*
* @category Exception
* @package OCA\MyDash\Exception
@@ -24,7 +24,7 @@
namespace OCA\MyDash\Exception;
/**
- * Supplied directory path contains disallowed traversal sequences.
+ * Directory failed strict validation (path-traversal or null byte).
*/
class InvalidDirectoryException extends ResourceException
{
diff --git a/lib/Exception/InvalidFilenameException.php b/lib/Exception/InvalidFilenameException.php
index 916de541..1c4e6b90 100644
--- a/lib/Exception/InvalidFilenameException.php
+++ b/lib/Exception/InvalidFilenameException.php
@@ -3,9 +3,10 @@
/**
* InvalidFilenameException
*
- * Raised when the supplied filename fails the strict regex validation
- * or contains path-traversal characters. Maps to HTTP 400 + error code
- * `invalid_filename`.
+ * Raised when the supplied filename for `POST /api/files/create` fails the
+ * strict regex validation (REQ-LBN-004): empty, too long, contains `..`,
+ * `/`, `\`, null byte, or fails the `^[a-zA-Z0-9_\-. ]+$` pattern. Maps
+ * to HTTP 400 + error code `invalid_filename`.
*
* @category Exception
* @package OCA\MyDash\Exception
@@ -24,7 +25,7 @@
namespace OCA\MyDash\Exception;
/**
- * Supplied filename is empty, too long, or contains disallowed characters.
+ * Filename failed strict validation (empty, too long, or disallowed characters).
*/
class InvalidFilenameException extends ResourceException
{
diff --git a/lib/Exception/InvalidMetadataFieldException.php b/lib/Exception/InvalidMetadataFieldException.php
new file mode 100644
index 00000000..744be66f
--- /dev/null
+++ b/lib/Exception/InvalidMetadataFieldException.php
@@ -0,0 +1,40 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Exception;
+
+use Exception;
+
+/**
+ * Validation failure on the dashboard-metadata-fields capability.
+ */
+class InvalidMetadataFieldException extends Exception
+{
+ /**
+ * Stable error code returned in the response envelope.
+ *
+ * @var string
+ */
+ public const ERROR_CODE = 'invalid_metadata_field';
+}//end class
diff --git a/lib/Exception/InvalidRoleAssignmentException.php b/lib/Exception/InvalidRoleAssignmentException.php
new file mode 100644
index 00000000..a99e8901
--- /dev/null
+++ b/lib/Exception/InvalidRoleAssignmentException.php
@@ -0,0 +1,57 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Exception;
+
+/**
+ * Invalid role-assignment payload (REQ-ROLE-004).
+ */
+class InvalidRoleAssignmentException extends ResourceException
+{
+
+ /**
+ * Stable error code returned in the response envelope.
+ *
+ * @var string
+ */
+ protected string $errorCode = 'invalid_role_assignment';
+
+ /**
+ * HTTP status code.
+ *
+ * @var integer
+ */
+ protected int $httpStatus = 400;
+
+ /**
+ * Constructor.
+ *
+ * @param string $message Display message (translatable English string).
+ */
+ public function __construct(
+ string $message='Role assignment payload is invalid'
+ ) {
+ parent::__construct(message: $message);
+ }//end __construct()
+}//end class
diff --git a/lib/Exception/LockConflictException.php b/lib/Exception/LockConflictException.php
new file mode 100644
index 00000000..0fa05f35
--- /dev/null
+++ b/lib/Exception/LockConflictException.php
@@ -0,0 +1,68 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Exception;
+
+use Exception;
+use OCA\MyDash\Db\DashboardLock;
+
+/**
+ * Lock acquisition refused — a different user already holds an active
+ * lock on the dashboard (REQ-LOCK-001).
+ */
+class LockConflictException extends Exception
+{
+ /**
+ * Stable error code returned in the response envelope.
+ *
+ * @var string
+ */
+ public const ERROR_CODE = 'lock_conflict';
+
+ /**
+ * Constructor.
+ *
+ * @param string $message Human-readable error message.
+ * @param DashboardLock $existingLock The existing lock that blocked
+ * the acquire — surfaced verbatim
+ * in the 409 response body.
+ */
+ public function __construct(
+ string $message,
+ private readonly DashboardLock $existingLock,
+ ) {
+ parent::__construct(message: $message);
+ }//end __construct()
+
+ /**
+ * The existing lock that triggered the conflict.
+ *
+ * @return DashboardLock The existing lock.
+ */
+ public function getExistingLock(): DashboardLock
+ {
+ return $this->existingLock;
+ }//end getExistingLock()
+}//end class
diff --git a/lib/Exception/LockForbiddenException.php b/lib/Exception/LockForbiddenException.php
new file mode 100644
index 00000000..668d5e9e
--- /dev/null
+++ b/lib/Exception/LockForbiddenException.php
@@ -0,0 +1,41 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Exception;
+
+use Exception;
+
+/**
+ * Lock operation refused — caller is neither owner nor admin
+ * (REQ-LOCK-002, REQ-LOCK-003, REQ-LOCK-006).
+ */
+class LockForbiddenException extends Exception
+{
+ /**
+ * Stable error code returned in the response envelope.
+ *
+ * @var string
+ */
+ public const ERROR_CODE = 'lock_forbidden';
+}//end class
diff --git a/lib/Exception/LockNotFoundException.php b/lib/Exception/LockNotFoundException.php
new file mode 100644
index 00000000..f2771ff8
--- /dev/null
+++ b/lib/Exception/LockNotFoundException.php
@@ -0,0 +1,40 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Exception;
+
+use Exception;
+
+/**
+ * No active lock exists for the requested dashboard (REQ-LOCK-002).
+ */
+class LockNotFoundException extends Exception
+{
+ /**
+ * Stable error code returned in the response envelope.
+ *
+ * @var string
+ */
+ public const ERROR_CODE = 'lock_not_found';
+}//end class
diff --git a/lib/Exception/MetadataFieldHasValuesException.php b/lib/Exception/MetadataFieldHasValuesException.php
new file mode 100644
index 00000000..2ee5a871
--- /dev/null
+++ b/lib/Exception/MetadataFieldHasValuesException.php
@@ -0,0 +1,64 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Exception;
+
+use Exception;
+
+/**
+ * Cascade-delete guard tripped — the field still has values
+ * (REQ-MDFL-003).
+ */
+class MetadataFieldHasValuesException extends Exception
+{
+ /**
+ * Stable error code returned in the response envelope.
+ *
+ * @var string
+ */
+ public const ERROR_CODE = 'metadata_field_has_values';
+
+ /**
+ * Constructor.
+ *
+ * @param int $valueCount The number of dependent value rows.
+ */
+ public function __construct(
+ private readonly int $valueCount
+ ) {
+ parent::__construct(
+ message: 'Metadata field has '.$valueCount.' values. Use ?cascade=true to delete them.'
+ );
+ }//end __construct()
+
+ /**
+ * Returns the number of dependent value rows.
+ *
+ * @return int The value count.
+ */
+ public function getValueCount(): int
+ {
+ return $this->valueCount;
+ }//end getValueCount()
+}//end class
diff --git a/lib/Exception/MissingInitialStateException.php b/lib/Exception/MissingInitialStateException.php
index 221a1fb8..3835235f 100644
--- a/lib/Exception/MissingInitialStateException.php
+++ b/lib/Exception/MissingInitialStateException.php
@@ -3,20 +3,28 @@
/**
* MissingInitialStateException
*
- * Thrown by {@see \OCA\MyDash\Service\InitialStateBuilder::apply()} when a
- * controller invokes apply() without first having set every key declared as
- * required for the chosen Page. Catching this at apply() time guarantees the
- * frontend never renders against a half-populated initial-state payload.
+ * Raised by {@see \OCA\MyDash\Service\InitialStateBuilder::apply()} when a
+ * required initial-state key was not set for the chosen page before the
+ * builder was applied. The exception message names the page and the
+ * missing key, so the failure is actionable in dev and CI alike. Catching
+ * this at apply() time guarantees the frontend never renders against a
+ * half-populated initial-state payload.
+ *
+ * Maps to HTTP 500 in production (the page cannot render without its
+ * boot snapshot); the CI test suite catches the same condition earlier
+ * via the per-page builder tests.
+ *
+ * Part of the `initial-state-contract` capability — see REQ-INIT-001.
*
* @category Exception
* @package OCA\MyDash\Exception
* @author Conduction b.v.
- * @copyright 2024 Conduction b.v.
+ * @copyright 2026 Conduction b.v.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2024 MyDash Contributors
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
@@ -27,25 +35,55 @@
use RuntimeException;
/**
- * Raised when a required initial-state key was not set before apply().
- *
- * See REQ-INIT-001 in the initial-state-contract spec.
+ * Required initial-state key was not set on the builder before apply (REQ-INIT-001).
*/
class MissingInitialStateException extends RuntimeException
{
+
+ /**
+ * Stable error code for the response envelope.
+ *
+ * @var string
+ */
+ protected string $errorCode = 'missing_initial_state_key';
+
+ /**
+ * HTTP status code.
+ *
+ * @var integer
+ */
+ protected int $httpStatus = 500;
+
/**
- * Construct a new MissingInitialStateException.
+ * Constructor.
*
- * @param string $page The page name (workspace|admin) the builder targets.
+ * @param string $page The page identifier (e.g. 'workspace', 'admin').
* @param string $key The missing required key.
*/
public function __construct(string $page, string $key)
{
- $message = sprintf(
- 'Missing required initial-state key "%s" for page "%s". Use InitialStateBuilder setters before apply().',
- $key,
- $page
- );
- parent::__construct(message: $message);
+ $template = 'MyDash initial-state contract violation: page "%s" requires key "%s"';
+ $template .= ' but it was not set on the builder before apply().';
+ parent::__construct(message: sprintf($template, $page, $key));
}//end __construct()
+
+ /**
+ * Get the stable error code.
+ *
+ * @return string The error code.
+ */
+ public function getErrorCode(): string
+ {
+ return $this->errorCode;
+ }//end getErrorCode()
+
+ /**
+ * Get the HTTP status code.
+ *
+ * @return integer The status code.
+ */
+ public function getHttpStatus(): int
+ {
+ return $this->httpStatus;
+ }//end getHttpStatus()
}//end class
diff --git a/lib/Exception/NoAccessException.php b/lib/Exception/NoAccessException.php
new file mode 100644
index 00000000..3164cb1f
--- /dev/null
+++ b/lib/Exception/NoAccessException.php
@@ -0,0 +1,57 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Exception;
+
+/**
+ * Files-widget access-denied condition.
+ */
+class NoAccessException extends ResourceException
+{
+
+ /**
+ * Stable error code surfaced to the API consumer.
+ *
+ * @var string
+ */
+ protected string $errorCode = 'no_access';
+
+ /**
+ * HTTP status code used by the controller envelope.
+ *
+ * @var integer
+ */
+ protected int $httpStatus = 403;
+
+ /**
+ * Constructor.
+ *
+ * @param string $message Optional override message.
+ */
+ public function __construct(string $message="You don't have access to this folder")
+ {
+ parent::__construct(message: $message);
+ }//end __construct()
+}//end class
diff --git a/lib/Exception/ResourceException.php b/lib/Exception/ResourceException.php
index 095ff343..53c23f8b 100644
--- a/lib/Exception/ResourceException.php
+++ b/lib/Exception/ResourceException.php
@@ -28,6 +28,15 @@
/**
* Base exception for resource-uploads.
+ *
+ * @SuppressWarnings(PHPMD.NumberOfChildren) The exception hierarchy
+ * legitimately covers every
+ * dedicated error domain in
+ * MyDash (resources, files,
+ * role assignments, dashboard
+ * gating). Subclassing here
+ * keeps the stable error code
+ * contract centralised.
*/
abstract class ResourceException extends Exception
{
diff --git a/lib/Exception/ShowcaseNotFoundException.php b/lib/Exception/ShowcaseNotFoundException.php
new file mode 100644
index 00000000..bed178c4
--- /dev/null
+++ b/lib/Exception/ShowcaseNotFoundException.php
@@ -0,0 +1,33 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Exception;
+
+use RuntimeException;
+
+/**
+ * Raised when a showcase ID is unknown.
+ */
+class ShowcaseNotFoundException extends RuntimeException
+{
+}//end class
diff --git a/lib/Job/FeedRefreshJob.php b/lib/Job/FeedRefreshJob.php
new file mode 100644
index 00000000..c5ed640e
--- /dev/null
+++ b/lib/Job/FeedRefreshJob.php
@@ -0,0 +1,165 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Job;
+
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Service\FeedRefreshService;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\TimedJob;
+use OCP\IConfig;
+use OCP\Lock\ILockingProvider;
+use OCP\Lock\LockedException;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Periodic feed-refresh job.
+ */
+class FeedRefreshJob extends TimedJob
+{
+ /**
+ * App config key — admin-tunable refresh interval in seconds
+ * (REQ-FRJ-002). Clamped to [300, 86400] (5 min .. 24 h).
+ *
+ * @var string
+ */
+ public const CONFIG_KEY_INTERVAL = 'feed_refresh_interval_seconds';
+
+ /**
+ * Default refresh interval in seconds (60 minutes).
+ *
+ * @var integer
+ */
+ public const DEFAULT_INTERVAL = 3600;
+
+ /**
+ * Minimum refresh interval clamp (5 minutes).
+ *
+ * @var integer
+ */
+ public const MIN_INTERVAL = 300;
+
+ /**
+ * Maximum refresh interval clamp (24 hours).
+ *
+ * @var integer
+ */
+ public const MAX_INTERVAL = 86400;
+
+ /**
+ * Global cluster lock id — only one job instance runs at a time
+ * (REQ-FRJ-007).
+ *
+ * @var string
+ */
+ public const LOCK_ID = 'mydash_feed_refresh_running';
+
+ /**
+ * Constructor — clamps the configured interval and registers it
+ * with the parent TimedJob.
+ *
+ * @param ITimeFactory $time The TimedJob clock.
+ * @param IConfig $config The Nextcloud config reader.
+ * @param FeedRefreshService $refreshService The refresh worker.
+ * @param ILockingProvider $lockingProvider The cluster lock provider.
+ * @param LoggerInterface $logger The diagnostic logger.
+ */
+ public function __construct(
+ ITimeFactory $time,
+ private readonly IConfig $config,
+ private readonly FeedRefreshService $refreshService,
+ private readonly ILockingProvider $lockingProvider,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(time: $time);
+
+ $configured = (int) $this->config->getAppValue(
+ Application::APP_ID,
+ self::CONFIG_KEY_INTERVAL,
+ (string) self::DEFAULT_INTERVAL
+ );
+ if ($configured <= 0) {
+ $configured = self::DEFAULT_INTERVAL;
+ }
+
+ $clamped = max(self::MIN_INTERVAL, min(self::MAX_INTERVAL, $configured));
+
+ $this->setInterval(seconds: $clamped);
+ }//end __construct()
+
+ /**
+ * Execute one tick of the refresh job.
+ *
+ * @param mixed $argument The TimedJob argument (unused).
+ *
+ * @return void
+ */
+ protected function run(mixed $argument): void
+ {
+ try {
+ $this->lockingProvider->acquireLock(
+ path: self::LOCK_ID,
+ type: ILockingProvider::LOCK_EXCLUSIVE
+ );
+ } catch (LockedException) {
+ $this->logger->warning(
+ message: 'FeedRefreshJob already running; skipping this tick',
+ context: ['app' => Application::APP_ID]
+ );
+ return;
+ }
+
+ try {
+ $result = $this->refreshService->refreshAll();
+ $this->logger->info(
+ message: 'FeedRefreshJob tick completed',
+ context: [
+ 'app' => Application::APP_ID,
+ 'processedCount' => $result['processedCount'],
+ 'successCount' => $result['successCount'],
+ 'failureCount' => $result['failureCount'],
+ 'durationMs' => $result['durationMs'],
+ ]
+ );
+ } catch (Throwable $exception) {
+ $this->logger->error(
+ message: 'FeedRefreshJob tick raised an unexpected exception',
+ context: [
+ 'app' => Application::APP_ID,
+ 'exception' => $exception->getMessage(),
+ ]
+ );
+ } finally {
+ $this->lockingProvider->releaseLock(
+ path: self::LOCK_ID,
+ type: ILockingProvider::LOCK_EXCLUSIVE
+ );
+ }//end try
+ }//end run()
+}//end class
diff --git a/lib/Listener/CommentsListener.php b/lib/Listener/CommentsListener.php
new file mode 100644
index 00000000..268a7dfe
--- /dev/null
+++ b/lib/Listener/CommentsListener.php
@@ -0,0 +1,90 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Listener;
+
+use OCA\MyDash\Event\DashboardDeletedEvent;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Deletes Nextcloud comments for the deleted dashboard.
+ *
+ * @implements IEventListener
+ */
+class CommentsListener implements IEventListener
+{
+ /**
+ * Constructor.
+ *
+ * @param LoggerInterface $logger PSR-3 logger for log-and-continue
+ * failure handling per REQ-CSC-006.
+ */
+ public function __construct(
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Handle the DashboardDeletedEvent.
+ *
+ * @param Event $event The event.
+ *
+ * @return void
+ */
+ public function handle(Event $event): void
+ {
+ if (($event instanceof DashboardDeletedEvent) === false) {
+ return;
+ }
+
+ try {
+ // TODO(dashboard-comments): inject ICommentsManager and call
+ // ICommentsManager::deleteCommentsAtObject('mydash_dashboard',
+ // $event->getDashboardUuid()). Stub registered for cascade
+ // scaffolding — see REQ-CSC-003 scenario "Comments are
+ // removed via ICommentsManager".
+ $this->logger->debug(
+ message: sprintf(
+ 'mydash CommentsListener: stub invoked for dashboard %s',
+ $event->getDashboardUuid()
+ ),
+ context: ['app' => 'mydash']
+ );
+ } catch (Throwable $t) {
+ $this->logger->warning(
+ message: sprintf(
+ 'mydash CommentsListener: failed for dashboard %s: %s',
+ $event->getDashboardUuid(),
+ $t->getMessage()
+ ),
+ context: ['app' => 'mydash']
+ );
+ }//end try
+ }//end handle()
+}//end class
diff --git a/lib/Listener/GroupDeletedListener.php b/lib/Listener/GroupDeletedListener.php
new file mode 100644
index 00000000..f3ac2aaa
--- /dev/null
+++ b/lib/Listener/GroupDeletedListener.php
@@ -0,0 +1,100 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Listener;
+
+use OCA\MyDash\Service\RoleService;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\Group\Events\GroupDeletedEvent;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Cascade role-assignment removal on group deletion (REQ-ROLE-011 +
+ * REQ-CSC-005 scaffolding for the wider group-scoped cleanup roadmap).
+ *
+ * @implements IEventListener
+ */
+class GroupDeletedListener implements IEventListener
+{
+ /**
+ * Constructor
+ *
+ * @param RoleService $roleService The role service.
+ * @param LoggerInterface $logger PSR-3 logger for cascade failures.
+ */
+ public function __construct(
+ private readonly RoleService $roleService,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Handle the GroupDeletedEvent — remove every role assignment
+ * scoped to the deleted group.
+ *
+ * @param Event $event The event.
+ *
+ * @return void
+ */
+ public function handle(Event $event): void
+ {
+ if (($event instanceof GroupDeletedEvent) === false) {
+ return;
+ }
+
+ $groupId = (string) $event->getGroup()->getGID();
+
+ try {
+ $this->roleService->deleteByGroupId(groupId: $groupId);
+ } catch (Throwable $t) {
+ // Cascade is best-effort — log and continue so the group
+ // deletion event chain is not interrupted.
+ $this->logger->error(
+ message: sprintf(
+ 'mydash GroupDeletedListener: failed to cascade role-assignment cleanup for group %s: %s',
+ $groupId,
+ $t->getMessage()
+ ),
+ context: ['app' => 'mydash']
+ );
+ }//end try
+ }//end handle()
+}//end class
diff --git a/lib/Listener/LocksListener.php b/lib/Listener/LocksListener.php
new file mode 100644
index 00000000..69718c75
--- /dev/null
+++ b/lib/Listener/LocksListener.php
@@ -0,0 +1,87 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Listener;
+
+use OCA\MyDash\Event\DashboardDeletedEvent;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Deletes lock rows for the deleted dashboard.
+ *
+ * @implements IEventListener
+ */
+class LocksListener implements IEventListener
+{
+ /**
+ * Constructor.
+ *
+ * @param LoggerInterface $logger PSR-3 logger for log-and-continue
+ * failure handling per REQ-CSC-006.
+ */
+ public function __construct(
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Handle the DashboardDeletedEvent.
+ *
+ * @param Event $event The event.
+ *
+ * @return void
+ */
+ public function handle(Event $event): void
+ {
+ if (($event instanceof DashboardDeletedEvent) === false) {
+ return;
+ }
+
+ try {
+ // TODO(dashboard-locking): DELETE FROM
+ // oc_mydash_dashboard_locks WHERE dashboardUuid = ?.
+ // Stub registered for cascade scaffolding — live cleanup
+ // owned by the locking subsystem.
+ $this->logger->debug(
+ message: sprintf(
+ 'mydash LocksListener: stub invoked for dashboard %s',
+ $event->getDashboardUuid()
+ ),
+ context: ['app' => 'mydash']
+ );
+ } catch (Throwable $t) {
+ $this->logger->warning(
+ message: sprintf(
+ 'mydash LocksListener: failed for dashboard %s: %s',
+ $event->getDashboardUuid(),
+ $t->getMessage()
+ ),
+ context: ['app' => 'mydash']
+ );
+ }//end try
+ }//end handle()
+}//end class
diff --git a/lib/Listener/MetadataValuesListener.php b/lib/Listener/MetadataValuesListener.php
new file mode 100644
index 00000000..78fda86a
--- /dev/null
+++ b/lib/Listener/MetadataValuesListener.php
@@ -0,0 +1,87 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Listener;
+
+use OCA\MyDash\Event\DashboardDeletedEvent;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Deletes metadata values for the deleted dashboard.
+ *
+ * @implements IEventListener
+ */
+class MetadataValuesListener implements IEventListener
+{
+ /**
+ * Constructor.
+ *
+ * @param LoggerInterface $logger PSR-3 logger for log-and-continue
+ * failure handling per REQ-CSC-006.
+ */
+ public function __construct(
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Handle the DashboardDeletedEvent.
+ *
+ * @param Event $event The event.
+ *
+ * @return void
+ */
+ public function handle(Event $event): void
+ {
+ if (($event instanceof DashboardDeletedEvent) === false) {
+ return;
+ }
+
+ try {
+ // TODO(dashboard-metadata-fields): DELETE FROM
+ // oc_mydash_metadata_values WHERE dashboardUuid = ?.
+ // Stub registered for cascade scaffolding — live cleanup
+ // owned by the metadata-fields subsystem.
+ $this->logger->debug(
+ message: sprintf(
+ 'mydash MetadataValuesListener: stub invoked for dashboard %s',
+ $event->getDashboardUuid()
+ ),
+ context: ['app' => 'mydash']
+ );
+ } catch (Throwable $t) {
+ $this->logger->warning(
+ message: sprintf(
+ 'mydash MetadataValuesListener: failed for dashboard %s: %s',
+ $event->getDashboardUuid(),
+ $t->getMessage()
+ ),
+ context: ['app' => 'mydash']
+ );
+ }//end try
+ }//end handle()
+}//end class
diff --git a/lib/Listener/PublicSharesListener.php b/lib/Listener/PublicSharesListener.php
new file mode 100644
index 00000000..d7f42230
--- /dev/null
+++ b/lib/Listener/PublicSharesListener.php
@@ -0,0 +1,89 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Listener;
+
+use OCA\MyDash\Event\DashboardDeletedEvent;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Soft-revokes public shares for the deleted dashboard.
+ *
+ * @implements IEventListener
+ */
+class PublicSharesListener implements IEventListener
+{
+ /**
+ * Constructor.
+ *
+ * @param LoggerInterface $logger PSR-3 logger for log-and-continue
+ * failure handling per REQ-CSC-006.
+ */
+ public function __construct(
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Handle the DashboardDeletedEvent.
+ *
+ * @param Event $event The event.
+ *
+ * @return void
+ */
+ public function handle(Event $event): void
+ {
+ if (($event instanceof DashboardDeletedEvent) === false) {
+ return;
+ }
+
+ try {
+ // TODO(dashboard-public-share): UPDATE oc_mydash_public_shares
+ // SET revokedAt = NOW() WHERE dashboardUuid = ?
+ // AND revokedAt IS NULL. Stub registered for cascade
+ // scaffolding — see REQ-CSC-003 scenario "Public shares
+ // are soft-revoked, not hard-deleted".
+ $this->logger->debug(
+ message: sprintf(
+ 'mydash PublicSharesListener: stub invoked for dashboard %s',
+ $event->getDashboardUuid()
+ ),
+ context: ['app' => 'mydash']
+ );
+ } catch (Throwable $t) {
+ $this->logger->warning(
+ message: sprintf(
+ 'mydash PublicSharesListener: failed for dashboard %s: %s',
+ $event->getDashboardUuid(),
+ $t->getMessage()
+ ),
+ context: ['app' => 'mydash']
+ );
+ }//end try
+ }//end handle()
+}//end class
diff --git a/lib/Listener/ReactionsListener.php b/lib/Listener/ReactionsListener.php
new file mode 100644
index 00000000..505ba3fa
--- /dev/null
+++ b/lib/Listener/ReactionsListener.php
@@ -0,0 +1,97 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Listener;
+
+use OCA\MyDash\Event\DashboardDeletedEvent;
+use OCA\MyDash\Service\ReactionService;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Deletes reactions for the deleted dashboard. REQ-RXN-009.
+ *
+ * @implements IEventListener
+ */
+class ReactionsListener implements IEventListener
+{
+ /**
+ * Constructor.
+ *
+ * @param ReactionService $reactionService Service that owns the
+ * `deleteReactionsByDashboard`
+ * cascade path.
+ * @param LoggerInterface $logger PSR-3 logger for
+ * log-and-continue failure
+ * handling per REQ-CSC-006.
+ */
+ public function __construct(
+ private readonly ReactionService $reactionService,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Handle the DashboardDeletedEvent.
+ *
+ * @param Event $event The event.
+ *
+ * @return void
+ */
+ public function handle(Event $event): void
+ {
+ if (($event instanceof DashboardDeletedEvent) === false) {
+ return;
+ }
+
+ $uuid = $event->getDashboardUuid();
+
+ try {
+ $deleted = $this->reactionService->deleteReactionsByDashboard(
+ dashboardUuid: $uuid
+ );
+
+ $this->logger->debug(
+ message: sprintf(
+ 'mydash ReactionsListener: deleted %d reactions for dashboard %s',
+ $deleted,
+ $uuid
+ ),
+ context: ['app' => 'mydash']
+ );
+ } catch (Throwable $t) {
+ $this->logger->warning(
+ message: sprintf(
+ 'mydash ReactionsListener: failed for dashboard %s: %s',
+ $uuid,
+ $t->getMessage()
+ ),
+ context: ['app' => 'mydash']
+ );
+ }//end try
+ }//end handle()
+}//end class
diff --git a/lib/Listener/TranslationsListener.php b/lib/Listener/TranslationsListener.php
new file mode 100644
index 00000000..248932c3
--- /dev/null
+++ b/lib/Listener/TranslationsListener.php
@@ -0,0 +1,87 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Listener;
+
+use OCA\MyDash\Event\DashboardDeletedEvent;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Deletes dashboard translation rows for the deleted dashboard.
+ *
+ * @implements IEventListener
+ */
+class TranslationsListener implements IEventListener
+{
+ /**
+ * Constructor.
+ *
+ * @param LoggerInterface $logger PSR-3 logger for log-and-continue
+ * failure handling per REQ-CSC-006.
+ */
+ public function __construct(
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Handle the DashboardDeletedEvent.
+ *
+ * @param Event $event The event.
+ *
+ * @return void
+ */
+ public function handle(Event $event): void
+ {
+ if (($event instanceof DashboardDeletedEvent) === false) {
+ return;
+ }
+
+ try {
+ // TODO(dashboard-language-content): DELETE FROM
+ // oc_mydash_dash_translations WHERE dashboardUuid = ?.
+ // Stub registered for cascade scaffolding — live cleanup
+ // owned by the language-content subsystem.
+ $this->logger->debug(
+ message: sprintf(
+ 'mydash TranslationsListener: stub invoked for dashboard %s',
+ $event->getDashboardUuid()
+ ),
+ context: ['app' => 'mydash']
+ );
+ } catch (Throwable $t) {
+ $this->logger->warning(
+ message: sprintf(
+ 'mydash TranslationsListener: failed for dashboard %s: %s',
+ $event->getDashboardUuid(),
+ $t->getMessage()
+ ),
+ context: ['app' => 'mydash']
+ );
+ }//end try
+ }//end handle()
+}//end class
diff --git a/lib/Listener/TreeListener.php b/lib/Listener/TreeListener.php
new file mode 100644
index 00000000..8209f551
--- /dev/null
+++ b/lib/Listener/TreeListener.php
@@ -0,0 +1,91 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Listener;
+
+use OCA\MyDash\Event\DashboardDeletedEvent;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Recursively dispatches DashboardDeletedEvent for each child.
+ *
+ * @implements IEventListener
+ */
+class TreeListener implements IEventListener
+{
+ /**
+ * Constructor.
+ *
+ * @param LoggerInterface $logger PSR-3 logger for log-and-continue
+ * failure handling per REQ-CSC-006.
+ */
+ public function __construct(
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Handle the DashboardDeletedEvent.
+ *
+ * @param Event $event The event.
+ *
+ * @return void
+ */
+ public function handle(Event $event): void
+ {
+ if (($event instanceof DashboardDeletedEvent) === false) {
+ return;
+ }
+
+ try {
+ // TODO(dashboard-tree): query oc_mydash_dashboards for
+ // children WHERE parentUuid = $event->getDashboardUuid()
+ // and dispatch a fresh DashboardDeletedEvent for each
+ // child via IEventDispatcher. Stub registered for cascade
+ // scaffolding — see REQ-CSC-010 scenarios for the cascade
+ // guard and tree-no-op behaviour.
+ $this->logger->debug(
+ message: sprintf(
+ 'mydash TreeListener: stub invoked for dashboard %s',
+ $event->getDashboardUuid()
+ ),
+ context: ['app' => 'mydash']
+ );
+ } catch (Throwable $t) {
+ $this->logger->warning(
+ message: sprintf(
+ 'mydash TreeListener: failed for dashboard %s: %s',
+ $event->getDashboardUuid(),
+ $t->getMessage()
+ ),
+ context: ['app' => 'mydash']
+ );
+ }//end try
+ }//end handle()
+}//end class
diff --git a/lib/Listener/UserDeletedListener.php b/lib/Listener/UserDeletedListener.php
index 9a705283..9e072e8b 100644
--- a/lib/Listener/UserDeletedListener.php
+++ b/lib/Listener/UserDeletedListener.php
@@ -30,18 +30,27 @@
use OCA\MyDash\Db\DashboardShareMapper;
use OCA\MyDash\Db\WidgetPlacementMapper;
use OCA\MyDash\Service\DashboardShareService;
+use OCA\MyDash\Service\RoleService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IDBConnection;
use OCP\IGroupManager;
use OCP\IUserManager;
use OCP\User\Events\UserDeletedEvent;
+use Psr\Log\LoggerInterface;
use Throwable;
/**
* Handles user deletion: recipient cleanup + ownership transfer / cascade.
*
* @implements IEventListener
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The retention cascade
+ * legitimately spans share,
+ * dashboard, placement,
+ * group and user services
+ * in one orchestrating
+ * listener.
*/
class UserDeletedListener implements IEventListener
{
@@ -55,6 +64,11 @@ class UserDeletedListener implements IEventListener
* @param IGroupManager $groupManager The group manager.
* @param IUserManager $userManager The user manager.
* @param IDBConnection $db The DB connection.
+ * @param LoggerInterface $logger PSR-3 logger (PHP_SAPI-safe;
+ * replaces deprecated
+ * `\OC::$server->getLogger()`).
+ * @param RoleService $roleService Role-assignment cascade
+ * (REQ-ROLE-010).
*/
public function __construct(
private readonly DashboardShareMapper $shareMapper,
@@ -64,6 +78,8 @@ public function __construct(
private readonly IGroupManager $groupManager,
private readonly IUserManager $userManager,
private readonly IDBConnection $db,
+ private readonly LoggerInterface $logger,
+ private readonly RoleService $roleService,
) {
}//end __construct()
@@ -86,6 +102,21 @@ public function handle(Event $event): void
$userId = $event->getUser()->getUID();
+ // REQ-ROLE-010: cascade role-assignment cleanup. Best-effort —
+ // logged but never aborts the rest of the pipeline.
+ try {
+ $this->roleService->deleteByUserId(userId: $userId);
+ } catch (Throwable $t) {
+ $this->logger->error(
+ message: sprintf(
+ 'mydash UserDeletedListener: failed to cascade role assignment cleanup for user %s: %s',
+ $userId,
+ $t->getMessage()
+ ),
+ context: ['app' => 'mydash']
+ );
+ }
+
// Step A: remove shares granted TO the deleted user.
$this->shareMapper->deleteByRecipientUser(userId: $userId);
@@ -154,7 +185,7 @@ private function handleOwnedDashboard(
$this->db->rollBack();
// Log but do not rethrow — we want to continue processing
// the other dashboards.
- \OC::$server->getLogger()->error(
+ $this->logger->error(
message: sprintf(
'mydash UserDeletedListener: failed to handle dashboard %d: %s',
$dashboardId,
diff --git a/lib/Listener/VersionsListener.php b/lib/Listener/VersionsListener.php
new file mode 100644
index 00000000..22b27648
--- /dev/null
+++ b/lib/Listener/VersionsListener.php
@@ -0,0 +1,89 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Listener;
+
+use OCA\MyDash\Event\DashboardDeletedEvent;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Deletes version rows + GroupFolder JSON for the deleted dashboard.
+ *
+ * @implements IEventListener
+ */
+class VersionsListener implements IEventListener
+{
+ /**
+ * Constructor.
+ *
+ * @param LoggerInterface $logger PSR-3 logger for log-and-continue
+ * failure handling per REQ-CSC-006.
+ */
+ public function __construct(
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Handle the DashboardDeletedEvent.
+ *
+ * @param Event $event The event.
+ *
+ * @return void
+ */
+ public function handle(Event $event): void
+ {
+ if (($event instanceof DashboardDeletedEvent) === false) {
+ return;
+ }
+
+ try {
+ // TODO(dashboard-versioning): DELETE FROM
+ // oc_mydash_dashboard_versions WHERE dashboardUuid = ?
+ // and remove the JSON version file under GroupFolder mode.
+ // Stub registered for cascade scaffolding — see REQ-CSC-003
+ // scenario "Versions file is deleted in GroupFolder mode".
+ $this->logger->debug(
+ message: sprintf(
+ 'mydash VersionsListener: stub invoked for dashboard %s',
+ $event->getDashboardUuid()
+ ),
+ context: ['app' => 'mydash']
+ );
+ } catch (Throwable $t) {
+ $this->logger->warning(
+ message: sprintf(
+ 'mydash VersionsListener: failed for dashboard %s: %s',
+ $event->getDashboardUuid(),
+ $t->getMessage()
+ ),
+ context: ['app' => 'mydash']
+ );
+ }//end try
+ }//end handle()
+}//end class
diff --git a/lib/Listener/ViewAnalyticsListener.php b/lib/Listener/ViewAnalyticsListener.php
new file mode 100644
index 00000000..2f3df966
--- /dev/null
+++ b/lib/Listener/ViewAnalyticsListener.php
@@ -0,0 +1,87 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Listener;
+
+use OCA\MyDash\Event\DashboardDeletedEvent;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Deletes view-analytics rows for the deleted dashboard.
+ *
+ * @implements IEventListener
+ */
+class ViewAnalyticsListener implements IEventListener
+{
+ /**
+ * Constructor.
+ *
+ * @param LoggerInterface $logger PSR-3 logger for log-and-continue
+ * failure handling per REQ-CSC-006.
+ */
+ public function __construct(
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Handle the DashboardDeletedEvent.
+ *
+ * @param Event $event The event.
+ *
+ * @return void
+ */
+ public function handle(Event $event): void
+ {
+ if (($event instanceof DashboardDeletedEvent) === false) {
+ return;
+ }
+
+ try {
+ // TODO(dashboard-view-analytics): DELETE FROM
+ // oc_mydash_dashboard_views WHERE dashboardUuid = ?.
+ // Stub registered for cascade scaffolding — live cleanup
+ // owned by the view-analytics subsystem.
+ $this->logger->debug(
+ message: sprintf(
+ 'mydash ViewAnalyticsListener: stub invoked for dashboard %s',
+ $event->getDashboardUuid()
+ ),
+ context: ['app' => 'mydash']
+ );
+ } catch (Throwable $t) {
+ $this->logger->warning(
+ message: sprintf(
+ 'mydash ViewAnalyticsListener: failed for dashboard %s: %s',
+ $event->getDashboardUuid(),
+ $t->getMessage()
+ ),
+ context: ['app' => 'mydash']
+ );
+ }//end try
+ }//end handle()
+}//end class
diff --git a/lib/Listener/WidgetPlacementsListener.php b/lib/Listener/WidgetPlacementsListener.php
new file mode 100644
index 00000000..d0b20c92
--- /dev/null
+++ b/lib/Listener/WidgetPlacementsListener.php
@@ -0,0 +1,91 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Listener;
+
+use OCA\MyDash\Event\DashboardDeletedEvent;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Deletes widget placements for the deleted dashboard.
+ *
+ * @implements IEventListener
+ */
+class WidgetPlacementsListener implements IEventListener
+{
+ /**
+ * Constructor.
+ *
+ * @param LoggerInterface $logger PSR-3 logger for log-and-continue
+ * failure handling per REQ-CSC-006.
+ */
+ public function __construct(
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Handle the DashboardDeletedEvent.
+ *
+ * @param Event $event The event.
+ *
+ * @return void
+ */
+ public function handle(Event $event): void
+ {
+ if (($event instanceof DashboardDeletedEvent) === false) {
+ return;
+ }
+
+ try {
+ // TODO(widget-placements): DELETE FROM oc_mydash_widget_placements
+ // WHERE dashboardUuid = $event->getDashboardUuid().
+ // Stub registered for cascade scaffolding — live cleanup
+ // owned by the placements subsystem (REQ-CSC-003,
+ // REQ-CSC-008 idempotency).
+ $this->logger->debug(
+ message: sprintf(
+ 'mydash WidgetPlacementsListener: stub invoked for dashboard %s',
+ $event->getDashboardUuid()
+ ),
+ context: ['app' => 'mydash']
+ );
+ } catch (Throwable $t) {
+ // Failure isolation per REQ-CSC-006 — log at WARN, do not
+ // rethrow so peer listeners still execute.
+ $this->logger->warning(
+ message: sprintf(
+ 'mydash WidgetPlacementsListener: failed for dashboard %s: %s',
+ $event->getDashboardUuid(),
+ $t->getMessage()
+ ),
+ context: ['app' => 'mydash']
+ );
+ }//end try
+ }//end handle()
+}//end class
diff --git a/lib/Migration/DashboardLockTableBuilder.php b/lib/Migration/DashboardLockTableBuilder.php
new file mode 100644
index 00000000..9251d656
--- /dev/null
+++ b/lib/Migration/DashboardLockTableBuilder.php
@@ -0,0 +1,117 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+
+/**
+ * Builder for the dashboard_locks database table schema.
+ */
+class DashboardLockTableBuilder
+{
+ /**
+ * Create the mydash_dashboard_locks table.
+ *
+ * Schema follows the `dashboard-locking` design (D1, D2):
+ * - `dashboard_uuid` UNIQUE — enforces atomically that only one
+ * lock exists per dashboard at a time (REQ-LOCK-007 contention
+ * guarantee).
+ * - `created_at` is set on first acquire and never updated.
+ * - `updated_at` is bumped by every heartbeat. Expiry is computed
+ * at query time as `updated_at + 15 min`; no `expires_at` column
+ * is stored.
+ * - The `updated_at` index keeps the inline `cleanExpiredLock`
+ * DELETE fast even when many stale rows accumulate.
+ *
+ * @param ISchemaWrapper $schema The schema wrapper.
+ *
+ * @return void
+ */
+ public static function create(ISchemaWrapper $schema): void
+ {
+ if ($schema->hasTable('mydash_dashboard_locks') === true) {
+ return;
+ }
+
+ $table = $schema->createTable('mydash_dashboard_locks');
+
+ $table->addColumn(
+ 'id',
+ Types::BIGINT,
+ [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'dashboard_uuid',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 36,
+ ]
+ );
+ $table->addColumn(
+ 'user_id',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 64,
+ ]
+ );
+ $table->addColumn(
+ 'display_name',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 255,
+ ]
+ );
+ $table->addColumn(
+ 'created_at',
+ Types::DATETIME,
+ ['notnull' => true]
+ );
+ $table->addColumn(
+ 'updated_at',
+ Types::DATETIME,
+ ['notnull' => true]
+ );
+
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(
+ ['dashboard_uuid'],
+ 'mydash_lock_dash_unique'
+ );
+ $table->addIndex(
+ ['user_id'],
+ 'mydash_lock_user_idx'
+ );
+ $table->addIndex(
+ ['updated_at'],
+ 'mydash_lock_updated_idx'
+ );
+ }//end create()
+}//end class
diff --git a/lib/Migration/DashboardShareTableBuilder.php b/lib/Migration/DashboardShareTableBuilder.php
new file mode 100644
index 00000000..32a169a2
--- /dev/null
+++ b/lib/Migration/DashboardShareTableBuilder.php
@@ -0,0 +1,114 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+
+/**
+ * Builder for the dashboard_shares database table schema.
+ */
+class DashboardShareTableBuilder
+{
+ /**
+ * Create the mydash_dashboard_shares table.
+ *
+ * @param ISchemaWrapper $schema The schema wrapper.
+ *
+ * @return void
+ */
+ public static function create(ISchemaWrapper $schema): void
+ {
+ if ($schema->hasTable('mydash_dashboard_shares') === true) {
+ return;
+ }
+
+ $table = $schema->createTable('mydash_dashboard_shares');
+
+ $table->addColumn(
+ 'id',
+ Types::BIGINT,
+ [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'dashboard_id',
+ Types::BIGINT,
+ [
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'share_type',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 8,
+ ]
+ );
+ $table->addColumn(
+ 'share_with',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 64,
+ ]
+ );
+ $table->addColumn(
+ 'permission_level',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 16,
+ 'default' => 'view_only',
+ ]
+ );
+ $table->addColumn(
+ 'created_at',
+ Types::DATETIME,
+ ['notnull' => true]
+ );
+ $table->addColumn(
+ 'updated_at',
+ Types::DATETIME,
+ ['notnull' => false]
+ );
+
+ $table->setPrimaryKey(['id']);
+ $table->addIndex(
+ ['dashboard_id'],
+ 'mydash_share_dash_idx'
+ );
+ $table->addIndex(
+ ['share_type', 'share_with'],
+ 'mydash_share_recip_idx'
+ );
+ $table->addUniqueIndex(
+ ['dashboard_id', 'share_type', 'share_with'],
+ 'mydash_share_unique_idx'
+ );
+ }//end create()
+}//end class
diff --git a/lib/Migration/DashboardTableBuilder.php b/lib/Migration/DashboardTableBuilder.php
index 8fb4c05c..e6907992 100644
--- a/lib/Migration/DashboardTableBuilder.php
+++ b/lib/Migration/DashboardTableBuilder.php
@@ -35,14 +35,15 @@ class DashboardTableBuilder
*/
public static function create(ISchemaWrapper $schema): void
{
- if ($schema->hasTable(tableName: 'mydash_dashboards') === true) {
+ if ($schema->hasTable('mydash_dashboards') === true) {
return;
}
- $table = $schema->createTable(tableName: 'mydash_dashboards');
+ $table = $schema->createTable('mydash_dashboards');
self::addColumns(table: $table);
self::addIndexes(table: $table);
+ self::addTemplateDiscoveryColumns(table: $table);
}//end create()
/**
@@ -55,123 +56,146 @@ public static function create(ISchemaWrapper $schema): void
private static function addColumns($table): void
{
$table->addColumn(
- name: 'id',
- typeName: Types::BIGINT,
- options: [
+ 'id',
+ Types::BIGINT,
+ [
'autoincrement' => true,
'notnull' => true,
'unsigned' => true,
]
);
$table->addColumn(
- name: 'uuid',
- typeName: Types::STRING,
- options: [
+ 'uuid',
+ Types::STRING,
+ [
'notnull' => true,
'length' => 36,
]
);
$table->addColumn(
- name: 'name',
- typeName: Types::STRING,
- options: [
+ 'name',
+ Types::STRING,
+ [
'notnull' => true,
'length' => 255,
]
);
$table->addColumn(
- name: 'description',
- typeName: Types::TEXT,
- options: [
+ 'description',
+ Types::TEXT,
+ [
'notnull' => false,
]
);
+ // Owned by the frontend `dashboard-icons` capability — see
+ // `lib/Db/Dashboard.php::$icon` for the legal value classes.
$table->addColumn(
- name: 'type',
- typeName: Types::STRING,
- options: [
+ 'icon',
+ Types::STRING,
+ [
+ 'notnull' => false,
+ 'length' => 2000,
+ 'comment' => 'Dashboard icon: registry key (dashboard-icons) or upload URL; NULL = default.',
+ ]
+ );
+ $table->addColumn(
+ 'type',
+ Types::STRING,
+ [
'notnull' => true,
'length' => 20,
'default' => 'user',
]
);
$table->addColumn(
- name: 'user_id',
- typeName: Types::STRING,
- options: [
+ 'user_id',
+ Types::STRING,
+ [
'notnull' => false,
'length' => 64,
]
);
$table->addColumn(
- name: 'group_id',
- typeName: Types::STRING,
- options: [
+ 'group_id',
+ Types::STRING,
+ [
'notnull' => false,
'length' => 64,
]
);
$table->addColumn(
- name: 'based_on_template',
- typeName: Types::BIGINT,
- options: [
+ 'based_on_template',
+ Types::BIGINT,
+ [
'notnull' => false,
'unsigned' => true,
]
);
$table->addColumn(
- name: 'grid_columns',
- typeName: Types::INTEGER,
- options: [
+ 'grid_columns',
+ Types::INTEGER,
+ [
'notnull' => true,
'default' => 12,
]
);
$table->addColumn(
- name: 'permission_level',
- typeName: Types::STRING,
- options: [
+ 'permission_level',
+ Types::STRING,
+ [
'notnull' => true,
'length' => 20,
'default' => 'full',
]
);
$table->addColumn(
- name: 'target_groups',
- typeName: Types::TEXT,
- options: [
+ 'target_groups',
+ Types::TEXT,
+ [
'notnull' => false,
]
);
$table->addColumn(
- name: 'is_default',
- typeName: Types::SMALLINT,
- options: [
+ 'is_default',
+ Types::SMALLINT,
+ [
'notnull' => true,
'default' => 0,
'unsigned' => true,
]
);
$table->addColumn(
- name: 'is_active',
- typeName: Types::SMALLINT,
- options: [
+ 'is_active',
+ Types::SMALLINT,
+ [
'notnull' => true,
'default' => 0,
'unsigned' => true,
]
);
+ // Per-dashboard comments toggle (REQ-CMNT-007). NULL = inherit
+ // global setting; 1 = force on; 0 = force off.
+ $table->addColumn(
+ 'comments_enabled',
+ Types::SMALLINT,
+ [
+ 'notnull' => false,
+ 'default' => null,
+ 'unsigned' => true,
+ 'comment' => 'Per-dashboard comments toggle: NULL = inherit global, 1 = on, 0 = off.',
+ ]
+ );
$table->addColumn(
- name: 'created_at',
- typeName: Types::DATETIME,
- options: [
+ 'created_at',
+ Types::DATETIME,
+ [
'notnull' => true,
]
);
$table->addColumn(
- name: 'updated_at',
- typeName: Types::DATETIME,
- options: [
+ 'updated_at',
+ Types::DATETIME,
+ [
'notnull' => true,
]
);
@@ -186,26 +210,272 @@ private static function addColumns($table): void
*/
private static function addIndexes($table): void
{
- $table->setPrimaryKey(columnNames: ['id']);
+ $table->setPrimaryKey(['id']);
$table->addUniqueIndex(
- columnNames: ['uuid'],
- indexName: 'mydash_dashboard_uuid'
+ ['uuid'],
+ 'mydash_dashboard_uuid'
);
$table->addIndex(
- columnNames: ['user_id'],
- indexName: 'mydash_dashboard_user'
+ ['user_id'],
+ 'mydash_dashboard_user'
);
$table->addIndex(
- columnNames: ['type'],
- indexName: 'mydash_dashboard_type'
+ ['type'],
+ 'mydash_dashboard_type'
);
$table->addIndex(
- columnNames: ['user_id', 'is_active'],
- indexName: 'mydash_dashboard_active'
+ ['user_id', 'is_active'],
+ 'mydash_dashboard_active'
);
$table->addIndex(
- columnNames: ['type', 'group_id'],
- indexName: 'mydash_dash_type_group'
+ ['type', 'group_id'],
+ 'mydash_dash_type_group'
);
}//end addIndexes()
+
+ /**
+ * Apply the dashboard-tree hierarchy schema (REQ-DASH-023..030) to an
+ * existing `mydash_dashboards` table.
+ *
+ * Adds the three nullable columns (`parent_uuid`, `slug`, `sort_order`)
+ * plus the supporting indexes used by `DashboardTreeService` for
+ * sibling lookups, ordered traversal, and the per-parent slug
+ * uniqueness guarantee. Idempotent — every check is `hasColumn` /
+ * `hasIndex` first.
+ *
+ * @param \Doctrine\DBAL\Schema\Table $table The mydash_dashboards table.
+ *
+ * @return void
+ */
+ public static function addTreeColumns($table): void
+ {
+ if ($table->hasColumn('parent_uuid') === false) {
+ $table->addColumn(
+ 'parent_uuid',
+ Types::STRING,
+ [
+ 'notnull' => false,
+ 'length' => 36,
+ ]
+ );
+ }
+
+ if ($table->hasColumn('slug') === false) {
+ $table->addColumn(
+ 'slug',
+ Types::STRING,
+ [
+ 'notnull' => false,
+ 'length' => 128,
+ ]
+ );
+ }
+
+ if ($table->hasColumn('sort_order') === false) {
+ $table->addColumn(
+ 'sort_order',
+ Types::INTEGER,
+ [
+ 'notnull' => true,
+ 'default' => 0,
+ ]
+ );
+ }
+
+ if ($table->hasIndex('mydash_dash_parent') === false) {
+ $table->addIndex(
+ ['parent_uuid'],
+ 'mydash_dash_parent'
+ );
+ }
+
+ if ($table->hasIndex('mydash_dash_parent_slug') === false) {
+ // Composite (parent_uuid, slug) supports per-parent slug
+ // uniqueness lookups (REQ-DASH-024) and child-by-slug lookups
+ // during path resolution (REQ-DASH-027). NULL parent_uuid is
+ // accepted by both MySQL/MariaDB and PostgreSQL in non-unique
+ // composite indexes — the uniqueness rule is enforced at the
+ // service layer because composite NULL semantics differ
+ // between drivers (sqlite ⇢ NULL = NULL, postgres ⇢ NULL ≠ NULL).
+ $table->addIndex(
+ ['parent_uuid', 'slug'],
+ 'mydash_dash_parent_slug'
+ );
+ }
+
+ if ($table->hasIndex('mydash_dash_sort') === false) {
+ $table->addIndex(
+ ['parent_uuid', 'sort_order'],
+ 'mydash_dash_sort'
+ );
+ }
+ }//end addTreeColumns()
+
+ /**
+ * Apply the dashboard publication-state schema (REQ-DASH-031..037) to
+ * an existing `mydash_dashboards` table.
+ *
+ * Adds three nullable / defaulted columns (`publication_status`,
+ * `publish_at`, `published_at`) plus the `(user_id, publication_status)`
+ * composite index used by the visibility filter in
+ * `DashboardMapper`. The `publication_status` column is declared with
+ * `DEFAULT 'published'` so pre-existing rows are backfilled to
+ * `'published'` automatically by the database engine — no explicit
+ * `UPDATE` statement is required (REQ-DASH-035, design D1). New
+ * dashboards created post-migration receive `'draft'` via application
+ * logic in `DashboardFactory::create()` instead of relying on the
+ * column default. Idempotent — every check is `hasColumn` /
+ * `hasIndex` first.
+ *
+ * @param \Doctrine\DBAL\Schema\Table $table The mydash_dashboards table.
+ *
+ * @return void
+ */
+ public static function addPublicationColumns($table): void
+ {
+ if ($table->hasColumn('publication_status') === false) {
+ $table->addColumn(
+ 'publication_status',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 20,
+ 'default' => 'published',
+ 'comment' => 'Dashboard publication status (draft|published|scheduled). REQ-DASH-031.',
+ ]
+ );
+ }
+
+ if ($table->hasColumn('publish_at') === false) {
+ $table->addColumn(
+ 'publish_at',
+ Types::DATETIME,
+ [
+ 'notnull' => false,
+ 'comment' => 'Scheduled publication timestamp; used when publication_status = scheduled.',
+ ]
+ );
+ }
+
+ if ($table->hasColumn('published_at') === false) {
+ $table->addColumn(
+ 'published_at',
+ Types::DATETIME,
+ [
+ 'notnull' => false,
+ 'comment' => 'First-publication timestamp; preserved across unpublish. REQ-DASH-032.',
+ ]
+ );
+ }
+
+ if ($table->hasIndex('mydash_dash_user_pubstatus') === false) {
+ $table->addIndex(
+ ['user_id', 'publication_status'],
+ 'mydash_dash_user_pubstatus'
+ );
+ }
+ }//end addPublicationColumns()
+
+ /**
+ * Apply the per-dashboard footer-override schema (REQ-FTR-006) to an
+ * existing `mydash_dashboards` table.
+ *
+ * Adds two columns: `dashboard_footer_mode` (VARCHAR(16), default
+ * `'inherit'`) selecting one of `inherit | hidden | custom`, and
+ * `dashboard_footer_html` (TEXT, nullable) holding the
+ * dashboard-specific HTML when mode is `custom`. Pre-existing rows
+ * materialise as `inherit` automatically via the column default —
+ * no explicit backfill required (footer-customization design D2).
+ * Idempotent — every column is checked with `hasColumn` first.
+ *
+ * @param \Doctrine\DBAL\Schema\Table $table The mydash_dashboards table.
+ *
+ * @return void
+ */
+ public static function addFooterColumns($table): void
+ {
+ if ($table->hasColumn('dashboard_footer_mode') === false) {
+ $table->addColumn(
+ 'dashboard_footer_mode',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 16,
+ 'default' => 'inherit',
+ 'comment' => 'Per-dashboard footer mode (inherit|hidden|custom). REQ-FTR-006.',
+ ]
+ );
+ }
+
+ if ($table->hasColumn('dashboard_footer_html') === false) {
+ $table->addColumn(
+ 'dashboard_footer_html',
+ Types::TEXT,
+ [
+ 'notnull' => false,
+ 'comment' => 'Sanitised dashboard-specific footer HTML; only used when dashboard_footer_mode=custom.',
+ ]
+ );
+ }
+ }//end addFooterColumns()
+
+ /**
+ * Apply the template-discovery schema (REQ-TMPL-014..017) to an
+ * existing `mydash_dashboards` table.
+ *
+ * Adds three nullable metadata columns (`template_category`,
+ * `template_description`, `template_preview_image`) plus the
+ * `(type, template_category)` composite index used by the gallery
+ * filter path in `DashboardMapper::findAllTemplatesForGallery()`.
+ * The base `WHERE type = 'admin_template'` query path already
+ * benefits from the existing single-column `type` index. Idempotent —
+ * every check is `hasColumn` / `hasIndex` first.
+ *
+ * @param \Doctrine\DBAL\Schema\Table $table The mydash_dashboards table.
+ *
+ * @return void
+ */
+ public static function addTemplateDiscoveryColumns($table): void
+ {
+ if ($table->hasColumn('template_category') === false) {
+ $table->addColumn(
+ 'template_category',
+ Types::STRING,
+ [
+ 'notnull' => false,
+ 'length' => 64,
+ 'comment' => 'Free-form gallery category for admin templates. REQ-TMPL-016.',
+ ]
+ );
+ }
+
+ if ($table->hasColumn('template_description') === false) {
+ $table->addColumn(
+ 'template_description',
+ Types::TEXT,
+ [
+ 'notnull' => false,
+ 'comment' => 'Long-form gallery description for admin templates. REQ-TMPL-016.',
+ ]
+ );
+ }
+
+ if ($table->hasColumn('template_preview_image') === false) {
+ $table->addColumn(
+ 'template_preview_image',
+ Types::TEXT,
+ [
+ 'notnull' => false,
+ 'comment' => 'Preview image URL stored via the resource-uploads pattern. REQ-TMPL-017.',
+ ]
+ );
+ }
+
+ if ($table->hasIndex('mydash_tpl_type_cat') === false) {
+ $table->addIndex(
+ ['type', 'template_category'],
+ 'mydash_tpl_type_cat'
+ );
+ }
+ }//end addTemplateDiscoveryColumns()
}//end class
diff --git a/lib/Migration/DashboardTranslationTableBuilder.php b/lib/Migration/DashboardTranslationTableBuilder.php
new file mode 100644
index 00000000..54a0ca52
--- /dev/null
+++ b/lib/Migration/DashboardTranslationTableBuilder.php
@@ -0,0 +1,134 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+
+/**
+ * Builder for the dashboard_translations database table schema.
+ */
+class DashboardTranslationTableBuilder
+{
+ /**
+ * Create the mydash_dash_translations table.
+ *
+ * Idempotent — returns immediately when the table already exists.
+ *
+ * @param ISchemaWrapper $schema The schema wrapper.
+ *
+ * @return void
+ */
+ public static function create(ISchemaWrapper $schema): void
+ {
+ if ($schema->hasTable('mydash_dash_translations') === true) {
+ return;
+ }
+
+ $table = $schema->createTable(
+ 'mydash_dash_translations'
+ );
+
+ $table->addColumn(
+ 'id',
+ Types::BIGINT,
+ [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'dashboard_uuid',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 36,
+ ]
+ );
+ $table->addColumn(
+ 'language_code',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 16,
+ 'comment' => 'ISO 639-1 base code (nl, en, de, fr); normalised by mapper.',
+ ]
+ );
+ $table->addColumn(
+ 'name',
+ Types::STRING,
+ [
+ 'notnull' => false,
+ 'length' => 255,
+ ]
+ );
+ $table->addColumn(
+ 'description',
+ Types::TEXT,
+ ['notnull' => false]
+ );
+ $table->addColumn(
+ 'widget_tree_json',
+ Types::TEXT,
+ [
+ 'notnull' => false,
+ 'comment' => 'Localised widget tree JSON — mirrors oc_mydash_dashboards shape.',
+ ]
+ );
+ $table->addColumn(
+ 'is_primary',
+ Types::SMALLINT,
+ [
+ 'notnull' => true,
+ 'default' => 0,
+ 'unsigned' => true,
+ 'comment' => 'Exactly one row per dashboard MUST have is_primary=1 (service-enforced).',
+ ]
+ );
+ $table->addColumn(
+ 'created_at',
+ Types::DATETIME,
+ ['notnull' => true]
+ );
+ $table->addColumn(
+ 'updated_at',
+ Types::DATETIME,
+ ['notnull' => false]
+ );
+
+ $table->setPrimaryKey(['id']);
+ $table->addIndex(
+ ['dashboard_uuid'],
+ 'mydash_trans_dash_idx'
+ );
+ $table->addUniqueIndex(
+ ['dashboard_uuid', 'language_code'],
+ 'mydash_trans_unique_idx'
+ );
+ $table->addIndex(
+ ['dashboard_uuid', 'is_primary'],
+ 'mydash_trans_primary_idx'
+ );
+ }//end create()
+}//end class
diff --git a/lib/Migration/DashboardVersionTableBuilder.php b/lib/Migration/DashboardVersionTableBuilder.php
new file mode 100644
index 00000000..f3cc1b05
--- /dev/null
+++ b/lib/Migration/DashboardVersionTableBuilder.php
@@ -0,0 +1,126 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+
+/**
+ * Builder for the dashboard versions database table schema.
+ */
+class DashboardVersionTableBuilder
+{
+ /**
+ * Create the mydash_dashboard_versions table.
+ *
+ * Columns:
+ * - id BIGINT PK auto-increment
+ * - dashboard_uuid STRING(36) NOT NULL
+ * - version_number BIGINT NOT NULL
+ * - snapshot_json TEXT NOT NULL (MEDIUMTEXT on MySQL via the
+ * `OCP\DB\Types::TEXT` mapping)
+ * - created_by STRING(64) NOT NULL
+ * - created_at DATETIME NOT NULL
+ * - note STRING(500) NULL
+ *
+ * Indexes:
+ * - mydash_dvers_uuid_num : UNIQUE(dashboard_uuid, version_number)
+ * - mydash_dvers_uuid_ts : (dashboard_uuid, created_at)
+ *
+ * @param ISchemaWrapper $schema The schema wrapper.
+ *
+ * @return void
+ */
+ public static function create(ISchemaWrapper $schema): void
+ {
+ if ($schema->hasTable('mydash_dashboard_versions') === true) {
+ return;
+ }
+
+ $table = $schema->createTable(
+ 'mydash_dashboard_versions'
+ );
+
+ $table->addColumn(
+ 'id',
+ Types::BIGINT,
+ [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'dashboard_uuid',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 36,
+ ]
+ );
+ $table->addColumn(
+ 'version_number',
+ Types::BIGINT,
+ [
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'snapshot_json',
+ Types::TEXT,
+ ['notnull' => true]
+ );
+ $table->addColumn(
+ 'created_by',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 64,
+ ]
+ );
+ $table->addColumn(
+ 'created_at',
+ Types::DATETIME,
+ ['notnull' => true]
+ );
+ $table->addColumn(
+ 'note',
+ Types::STRING,
+ [
+ 'notnull' => false,
+ 'length' => 500,
+ ]
+ );
+
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(
+ ['dashboard_uuid', 'version_number'],
+ 'mydash_dvers_uuid_num'
+ );
+ $table->addIndex(
+ ['dashboard_uuid', 'created_at'],
+ 'mydash_dvers_uuid_ts'
+ );
+ }//end create()
+}//end class
diff --git a/lib/Migration/DashboardViewsTableBuilder.php b/lib/Migration/DashboardViewsTableBuilder.php
new file mode 100644
index 00000000..899cdb2d
--- /dev/null
+++ b/lib/Migration/DashboardViewsTableBuilder.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
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+
+/**
+ * Builder for the dashboard view-analytics database table schema.
+ */
+class DashboardViewsTableBuilder
+{
+ /**
+ * Create the `mydash_dashboard_views` table (REQ-ANLT-001).
+ *
+ * Idempotent — early-returns when the table already exists. The
+ * composite unique index on `(dashboard_uuid, view_bucket)` is the
+ * one-row-per-(dashboard, day) invariant; the secondary
+ * `view_bucket` index speeds up date-range scans for the admin
+ * top/summary endpoints.
+ *
+ * @param ISchemaWrapper $schema The schema wrapper.
+ *
+ * @return void
+ */
+ public static function create(ISchemaWrapper $schema): void
+ {
+ if ($schema->hasTable('mydash_dashboard_views') === true) {
+ return;
+ }
+
+ $table = $schema->createTable('mydash_dashboard_views');
+
+ self::addColumns(table: $table);
+ self::addIndexes(table: $table);
+ }//end create()
+
+ /**
+ * Add columns to the `mydash_dashboard_views` table.
+ *
+ * @param \Doctrine\DBAL\Schema\Table $table The table instance.
+ *
+ * @return void
+ */
+ private static function addColumns($table): void
+ {
+ $table->addColumn(
+ 'id',
+ Types::BIGINT,
+ [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'dashboard_uuid',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 36,
+ 'comment' => 'The dashboard UUID this aggregate row belongs to (REQ-ANLT-001).',
+ ]
+ );
+ $table->addColumn(
+ 'view_bucket',
+ Types::DATE,
+ [
+ 'notnull' => true,
+ 'comment' => 'Calendar date in UTC; one row per (dashboard, day).',
+ ]
+ );
+ $table->addColumn(
+ 'view_count',
+ Types::INTEGER,
+ [
+ 'notnull' => true,
+ 'default' => 0,
+ 'unsigned' => true,
+ 'comment' => 'Total number of view events on this date.',
+ ]
+ );
+ $table->addColumn(
+ 'unique_viewer_count',
+ Types::INTEGER,
+ [
+ 'notnull' => true,
+ 'default' => 0,
+ 'unsigned' => true,
+ 'comment' => 'Distinct viewers on this date (cache-deduped, REQ-ANLT-003).',
+ ]
+ );
+ }//end addColumns()
+
+ /**
+ * Add indexes to the `mydash_dashboard_views` table.
+ *
+ * Composite unique index `(dashboard_uuid, view_bucket)` enforces
+ * the "one row per dashboard per day" invariant (REQ-ANLT-001).
+ * The standalone `view_bucket` index supports the date-range
+ * predicates used by the admin top / summary / export endpoints
+ * (REQ-ANLT-006..010).
+ *
+ * @param \Doctrine\DBAL\Schema\Table $table The table instance.
+ *
+ * @return void
+ */
+ private static function addIndexes($table): void
+ {
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(
+ ['dashboard_uuid', 'view_bucket'],
+ 'mydash_anlt_uniq'
+ );
+ $table->addIndex(
+ ['view_bucket'],
+ 'mydash_anlt_bucket'
+ );
+ }//end addIndexes()
+}//end class
diff --git a/lib/Migration/FeedCacheTableBuilder.php b/lib/Migration/FeedCacheTableBuilder.php
new file mode 100644
index 00000000..0aff7386
--- /dev/null
+++ b/lib/Migration/FeedCacheTableBuilder.php
@@ -0,0 +1,131 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+
+/**
+ * Builder for the dashboard feed-cache table schema.
+ */
+class FeedCacheTableBuilder
+{
+ /**
+ * Create the mydash_feed_cache table when missing.
+ *
+ * Column layout:
+ * - id BIGINT PK auto-increment
+ * - feed_url VARCHAR(2048) NOT NULL (UNIQUE — one row per URL)
+ * - last_fetched_at DATETIME NULLABLE (every tick, even 304)
+ * - last_success_at DATETIME NULLABLE (last 200/304 hit)
+ * - last_failure_reason TEXT NULLABLE (4xx/5xx/timeout/parse)
+ * - etag VARCHAR(255) NULLABLE (conditional GET)
+ * - last_modified VARCHAR(255) NULLABLE (conditional GET)
+ * - items_json CLOB NULLABLE (capped at 50 items)
+ *
+ * NOTE on the unique index: most database engines reject a UNIQUE
+ * index on a column wider than ~767 bytes. We index `feed_url` with
+ * a 191-character key length on MySQL (default UTF8MB4) to stay safe;
+ * the `length` option is honoured by Doctrine on engines that need
+ * it and ignored by SQLite/Postgres which support full-length unique
+ * indexes natively.
+ *
+ * @param ISchemaWrapper $schema The schema wrapper.
+ *
+ * @return void
+ */
+ public static function create(ISchemaWrapper $schema): void
+ {
+ if ($schema->hasTable('mydash_feed_cache') === true) {
+ return;
+ }
+
+ $table = $schema->createTable('mydash_feed_cache');
+
+ $table->addColumn(
+ 'id',
+ Types::BIGINT,
+ [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'feed_url',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 2048,
+ ]
+ );
+ $table->addColumn(
+ 'last_fetched_at',
+ Types::DATETIME,
+ ['notnull' => false]
+ );
+ $table->addColumn(
+ 'last_success_at',
+ Types::DATETIME,
+ ['notnull' => false]
+ );
+ $table->addColumn(
+ 'last_failure_reason',
+ Types::TEXT,
+ ['notnull' => false]
+ );
+ $table->addColumn(
+ 'etag',
+ Types::STRING,
+ [
+ 'notnull' => false,
+ 'length' => 255,
+ ]
+ );
+ $table->addColumn(
+ 'last_modified',
+ Types::STRING,
+ [
+ 'notnull' => false,
+ 'length' => 255,
+ ]
+ );
+ $table->addColumn(
+ 'items_json',
+ Types::TEXT,
+ ['notnull' => false]
+ );
+
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(
+ ['feed_url'],
+ 'mydash_feed_cache_url_uq',
+ ['lengths' => [191]]
+ );
+ }//end create()
+}//end class
diff --git a/lib/Migration/FeedTokenTableBuilder.php b/lib/Migration/FeedTokenTableBuilder.php
new file mode 100644
index 00000000..a2f8d47b
--- /dev/null
+++ b/lib/Migration/FeedTokenTableBuilder.php
@@ -0,0 +1,110 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+
+/**
+ * Builder for the dashboard feed-tokens table schema.
+ */
+class FeedTokenTableBuilder
+{
+ /**
+ * Create the mydash_feed_tokens table when missing.
+ *
+ * Column layout:
+ * - id BIGINT PK auto-increment
+ * - user_id VARCHAR(64) NOT NULL (UNIQUE — one token per user)
+ * - token VARCHAR(64) NOT NULL (indexed for public lookup)
+ * - created_at DATETIME NOT NULL
+ * - last_used_at DATETIME NULLABLE (touched on every public hit)
+ * - revoked_at DATETIME NULLABLE (soft-revoke flag)
+ *
+ * @param ISchemaWrapper $schema The schema wrapper.
+ *
+ * @return void
+ */
+ public static function create(ISchemaWrapper $schema): void
+ {
+ if ($schema->hasTable('mydash_feed_tokens') === true) {
+ return;
+ }
+
+ $table = $schema->createTable('mydash_feed_tokens');
+
+ $table->addColumn(
+ 'id',
+ Types::BIGINT,
+ [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'user_id',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 64,
+ ]
+ );
+ $table->addColumn(
+ 'token',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 64,
+ ]
+ );
+ $table->addColumn(
+ 'created_at',
+ Types::DATETIME,
+ ['notnull' => true]
+ );
+ $table->addColumn(
+ 'last_used_at',
+ Types::DATETIME,
+ ['notnull' => false]
+ );
+ $table->addColumn(
+ 'revoked_at',
+ Types::DATETIME,
+ ['notnull' => false]
+ );
+
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(
+ ['user_id'],
+ 'mydash_feed_tok_user_uq'
+ );
+ $table->addIndex(
+ ['token'],
+ 'mydash_feed_tok_tok_idx'
+ );
+ }//end create()
+}//end class
diff --git a/lib/Migration/MetadataTablesBuilder.php b/lib/Migration/MetadataTablesBuilder.php
new file mode 100644
index 00000000..2daace1a
--- /dev/null
+++ b/lib/Migration/MetadataTablesBuilder.php
@@ -0,0 +1,212 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+
+/**
+ * Builders for the metadata-field registry and per-dashboard value tables.
+ */
+class MetadataTablesBuilder
+{
+ /**
+ * Create both metadata tables (idempotent — skip when present).
+ *
+ * @param ISchemaWrapper $schema The schema wrapper.
+ *
+ * @return void
+ */
+ public static function create(ISchemaWrapper $schema): void
+ {
+ self::createFieldsTable(schema: $schema);
+ self::createValuesTable(schema: $schema);
+ }//end create()
+
+ /**
+ * Create the `mydash_meta_fields` registry table.
+ *
+ * @param ISchemaWrapper $schema The schema wrapper.
+ *
+ * @return void
+ */
+ private static function createFieldsTable(ISchemaWrapper $schema): void
+ {
+ if ($schema->hasTable('mydash_meta_fields') === true) {
+ return;
+ }
+
+ $table = $schema->createTable('mydash_meta_fields');
+
+ $table->addColumn(
+ 'id',
+ Types::BIGINT,
+ [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'field_key',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 64,
+ ]
+ );
+ $table->addColumn(
+ 'label',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 255,
+ ]
+ );
+ $table->addColumn(
+ 'type',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 20,
+ ]
+ );
+ $table->addColumn(
+ 'options',
+ Types::TEXT,
+ [
+ 'notnull' => false,
+ 'comment' => 'JSON array of strings; NULL for non-select types.',
+ ]
+ );
+ $table->addColumn(
+ 'required',
+ Types::SMALLINT,
+ [
+ 'notnull' => true,
+ 'default' => 0,
+ ]
+ );
+ $table->addColumn(
+ 'sort_order',
+ Types::INTEGER,
+ [
+ 'notnull' => true,
+ 'default' => 0,
+ ]
+ );
+ $table->addColumn(
+ 'created_at',
+ Types::DATETIME,
+ [
+ 'notnull' => false,
+ ]
+ );
+ $table->addColumn(
+ 'updated_at',
+ Types::DATETIME,
+ [
+ 'notnull' => false,
+ ]
+ );
+
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(
+ ['field_key'],
+ 'mydash_meta_fkey'
+ );
+ $table->addIndex(
+ ['sort_order'],
+ 'mydash_meta_forder'
+ );
+ }//end createFieldsTable()
+
+ /**
+ * Create the `mydash_meta_values` per-dashboard value table.
+ *
+ * @param ISchemaWrapper $schema The schema wrapper.
+ *
+ * @return void
+ */
+ private static function createValuesTable(ISchemaWrapper $schema): void
+ {
+ if ($schema->hasTable('mydash_meta_values') === true) {
+ return;
+ }
+
+ $table = $schema->createTable('mydash_meta_values');
+
+ $table->addColumn(
+ 'id',
+ Types::BIGINT,
+ [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'dashboard_uuid',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 36,
+ ]
+ );
+ $table->addColumn(
+ 'field_id',
+ Types::BIGINT,
+ [
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'value',
+ Types::TEXT,
+ [
+ 'notnull' => true,
+ ]
+ );
+
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(
+ ['dashboard_uuid', 'field_id'],
+ 'mydash_meta_vunique'
+ );
+ $table->addIndex(
+ ['dashboard_uuid'],
+ 'mydash_meta_vdash'
+ );
+ $table->addIndex(
+ ['field_id'],
+ 'mydash_meta_vfield'
+ );
+ }//end createValuesTable()
+}//end class
diff --git a/lib/Migration/PlacementTableBuilder.php b/lib/Migration/PlacementTableBuilder.php
index ca3b21cc..cb15ee79 100644
--- a/lib/Migration/PlacementTableBuilder.php
+++ b/lib/Migration/PlacementTableBuilder.php
@@ -36,14 +36,14 @@ class PlacementTableBuilder
public static function create(ISchemaWrapper $schema): void
{
if ($schema->hasTable(
- tableName: 'mydash_widget_placements'
+ 'mydash_widget_placements'
) === true
) {
return;
}
$table = $schema->createTable(
- tableName: 'mydash_widget_placements'
+ 'mydash_widget_placements'
);
self::addColumns(table: $table);
@@ -60,123 +60,123 @@ public static function create(ISchemaWrapper $schema): void
private static function addColumns($table): void
{
$table->addColumn(
- name: 'id',
- typeName: Types::BIGINT,
- options: [
+ 'id',
+ Types::BIGINT,
+ [
'autoincrement' => true,
'notnull' => true,
'unsigned' => true,
]
);
$table->addColumn(
- name: 'dashboard_id',
- typeName: Types::BIGINT,
- options: [
+ 'dashboard_id',
+ Types::BIGINT,
+ [
'notnull' => true,
'unsigned' => true,
]
);
$table->addColumn(
- name: 'widget_id',
- typeName: Types::STRING,
- options: [
+ 'widget_id',
+ Types::STRING,
+ [
'notnull' => true,
'length' => 255,
]
);
$table->addColumn(
- name: 'grid_x',
- typeName: Types::INTEGER,
- options: [
+ 'grid_x',
+ Types::INTEGER,
+ [
'notnull' => true,
'default' => 0,
]
);
$table->addColumn(
- name: 'grid_y',
- typeName: Types::INTEGER,
- options: [
+ 'grid_y',
+ Types::INTEGER,
+ [
'notnull' => true,
'default' => 0,
]
);
$table->addColumn(
- name: 'grid_width',
- typeName: Types::INTEGER,
- options: [
+ 'grid_width',
+ Types::INTEGER,
+ [
'notnull' => true,
'default' => 4,
]
);
$table->addColumn(
- name: 'grid_height',
- typeName: Types::INTEGER,
- options: [
+ 'grid_height',
+ Types::INTEGER,
+ [
'notnull' => true,
'default' => 4,
]
);
$table->addColumn(
- name: 'is_compulsory',
- typeName: Types::SMALLINT,
- options: [
+ 'is_compulsory',
+ Types::SMALLINT,
+ [
'notnull' => true,
'default' => 0,
'unsigned' => true,
]
);
$table->addColumn(
- name: 'is_visible',
- typeName: Types::SMALLINT,
- options: [
+ 'is_visible',
+ Types::SMALLINT,
+ [
'notnull' => true,
'default' => 1,
'unsigned' => true,
]
);
$table->addColumn(
- name: 'style_config',
- typeName: Types::TEXT,
- options: [
+ 'style_config',
+ Types::TEXT,
+ [
'notnull' => false,
]
);
$table->addColumn(
- name: 'custom_title',
- typeName: Types::STRING,
- options: [
+ 'custom_title',
+ Types::STRING,
+ [
'notnull' => false,
'length' => 255,
]
);
$table->addColumn(
- name: 'show_title',
- typeName: Types::SMALLINT,
- options: [
+ 'show_title',
+ Types::SMALLINT,
+ [
'notnull' => true,
'default' => 1,
'unsigned' => true,
]
);
$table->addColumn(
- name: 'sort_order',
- typeName: Types::INTEGER,
- options: [
+ 'sort_order',
+ Types::INTEGER,
+ [
'notnull' => true,
'default' => 0,
]
);
$table->addColumn(
- name: 'created_at',
- typeName: Types::DATETIME,
- options: [
+ 'created_at',
+ Types::DATETIME,
+ [
'notnull' => true,
]
);
$table->addColumn(
- name: 'updated_at',
- typeName: Types::DATETIME,
- options: [
+ 'updated_at',
+ Types::DATETIME,
+ [
'notnull' => true,
]
);
@@ -191,14 +191,14 @@ private static function addColumns($table): void
*/
private static function addIndexes($table): void
{
- $table->setPrimaryKey(columnNames: ['id']);
+ $table->setPrimaryKey(['id']);
$table->addIndex(
- columnNames: ['dashboard_id'],
- indexName: 'mydash_placement_dashboard'
+ ['dashboard_id'],
+ 'mydash_placement_dashboard'
);
$table->addIndex(
- columnNames: ['widget_id'],
- indexName: 'mydash_placement_widget'
+ ['widget_id'],
+ 'mydash_placement_widget'
);
}//end addIndexes()
}//end class
diff --git a/lib/Migration/RoleAssignmentTableBuilder.php b/lib/Migration/RoleAssignmentTableBuilder.php
new file mode 100644
index 00000000..cef3079d
--- /dev/null
+++ b/lib/Migration/RoleAssignmentTableBuilder.php
@@ -0,0 +1,127 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+
+/**
+ * Builder for the role-assignments table schema (REQ-ROLE-004).
+ *
+ * The XOR constraint between user_id and group_id is enforced at the
+ * service layer (RoleService::validateTarget). Database-level CHECK
+ * constraints are not portable across the SQL dialects Nextcloud
+ * supports, so we instead enforce uniqueness via two partial-style
+ * indexes (composite (user_id, role) and (group_id, role)) plus the
+ * service-layer XOR validation. Either user_id OR group_id is set per
+ * row, never both.
+ */
+class RoleAssignmentTableBuilder
+{
+ /**
+ * Create the `mydash_role_assignments` table when missing.
+ *
+ * @param ISchemaWrapper $schema The schema wrapper.
+ *
+ * @return void
+ */
+ public static function create(ISchemaWrapper $schema): void
+ {
+ if ($schema->hasTable('mydash_role_assignments') === true) {
+ return;
+ }
+
+ $table = $schema->createTable('mydash_role_assignments');
+
+ $table->addColumn(
+ 'id',
+ Types::BIGINT,
+ [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'user_id',
+ Types::STRING,
+ [
+ 'notnull' => false,
+ 'length' => 64,
+ ]
+ );
+ $table->addColumn(
+ 'group_id',
+ Types::STRING,
+ [
+ 'notnull' => false,
+ 'length' => 64,
+ ]
+ );
+ $table->addColumn(
+ 'role',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 16,
+ ]
+ );
+ $table->addColumn(
+ 'assigned_by',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 64,
+ ]
+ );
+ $table->addColumn(
+ 'assigned_at',
+ Types::DATETIME,
+ ['notnull' => true]
+ );
+
+ $table->setPrimaryKey(['id']);
+
+ // Lookup indexes for cascade and resolution paths.
+ $table->addIndex(
+ ['user_id'],
+ 'mydash_role_user_idx'
+ );
+ $table->addIndex(
+ ['group_id'],
+ 'mydash_role_group_idx'
+ );
+
+ // Enforce one assignment per (user, role) and per (group, role).
+ // Rows are XOR — only one of user_id / group_id is populated —
+ // so a single row never participates in both unique indexes.
+ $table->addUniqueIndex(
+ ['user_id', 'role'],
+ 'mydash_role_user_uniq'
+ );
+ $table->addUniqueIndex(
+ ['group_id', 'role'],
+ 'mydash_role_group_uniq'
+ );
+ }//end create()
+}//end class
diff --git a/lib/Migration/RoleFeaturePermissionTableBuilder.php b/lib/Migration/RoleFeaturePermissionTableBuilder.php
new file mode 100644
index 00000000..d04cbeb0
--- /dev/null
+++ b/lib/Migration/RoleFeaturePermissionTableBuilder.php
@@ -0,0 +1,146 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+
+/**
+ * Schema builder for the role-feature-permissions table.
+ */
+class RoleFeaturePermissionTableBuilder
+{
+ /**
+ * Create the table when missing.
+ *
+ * @param ISchemaWrapper $schema The schema wrapper.
+ *
+ * @return void
+ */
+ public static function create(ISchemaWrapper $schema): void
+ {
+ if ($schema->hasTable(tableName: 'mydash_role_feature_perms') === true) {
+ return;
+ }
+
+ $table = $schema->createTable(tableName: 'mydash_role_feature_perms');
+
+ self::addColumns(table: $table);
+ self::addIndexes(table: $table);
+ }//end create()
+
+ /**
+ * Add columns to the table.
+ *
+ * @param \Doctrine\DBAL\Schema\Table $table The table instance.
+ *
+ * @return void
+ */
+ private static function addColumns($table): void
+ {
+ $table->addColumn(
+ name: 'id',
+ typeName: Types::BIGINT,
+ options: [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ name: 'name',
+ typeName: Types::STRING,
+ options: [
+ 'notnull' => true,
+ 'length' => 255,
+ ]
+ );
+ $table->addColumn(
+ name: 'description',
+ typeName: Types::TEXT,
+ options: [
+ 'notnull' => false,
+ ]
+ );
+ $table->addColumn(
+ name: 'group_id',
+ typeName: Types::STRING,
+ options: [
+ 'notnull' => true,
+ 'length' => 255,
+ ]
+ );
+ $table->addColumn(
+ name: 'allowed_widgets',
+ typeName: Types::TEXT,
+ options: [
+ 'notnull' => false,
+ ]
+ );
+ $table->addColumn(
+ name: 'denied_widgets',
+ typeName: Types::TEXT,
+ options: [
+ 'notnull' => false,
+ ]
+ );
+ $table->addColumn(
+ name: 'priority_weights',
+ typeName: Types::TEXT,
+ options: [
+ 'notnull' => false,
+ ]
+ );
+ $table->addColumn(
+ name: 'created_at',
+ typeName: Types::STRING,
+ options: [
+ 'notnull' => false,
+ 'length' => 32,
+ ]
+ );
+ $table->addColumn(
+ name: 'updated_at',
+ typeName: Types::STRING,
+ options: [
+ 'notnull' => false,
+ 'length' => 32,
+ ]
+ );
+ }//end addColumns()
+
+ /**
+ * Add primary key + uniqueness index on group_id.
+ *
+ * @param \Doctrine\DBAL\Schema\Table $table The table instance.
+ *
+ * @return void
+ */
+ private static function addIndexes($table): void
+ {
+ $table->setPrimaryKey(columnNames: ['id']);
+ $table->addUniqueIndex(
+ columnNames: ['group_id'],
+ indexName: 'mydash_rfp_group'
+ );
+ }//end addIndexes()
+}//end class
diff --git a/lib/Migration/RoleLayoutDefaultTableBuilder.php b/lib/Migration/RoleLayoutDefaultTableBuilder.php
new file mode 100644
index 00000000..00789fed
--- /dev/null
+++ b/lib/Migration/RoleLayoutDefaultTableBuilder.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
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+
+/**
+ * Schema builder for the role-layout-defaults table.
+ */
+class RoleLayoutDefaultTableBuilder
+{
+ /**
+ * Create the table when missing.
+ *
+ * @param ISchemaWrapper $schema The schema wrapper.
+ *
+ * @return void
+ */
+ public static function create(ISchemaWrapper $schema): void
+ {
+ if ($schema->hasTable(tableName: 'mydash_role_layout_defaults') === true) {
+ return;
+ }
+
+ $table = $schema->createTable(tableName: 'mydash_role_layout_defaults');
+
+ self::addColumns(table: $table);
+ self::addIndexes(table: $table);
+ }//end create()
+
+ /**
+ * Add columns to the table.
+ *
+ * @param \Doctrine\DBAL\Schema\Table $table The table instance.
+ *
+ * @return void
+ */
+ private static function addColumns($table): void
+ {
+ $table->addColumn(
+ name: 'id',
+ typeName: Types::BIGINT,
+ options: [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ name: 'name',
+ typeName: Types::STRING,
+ options: [
+ 'notnull' => true,
+ 'length' => 255,
+ ]
+ );
+ $table->addColumn(
+ name: 'description',
+ typeName: Types::TEXT,
+ options: [
+ 'notnull' => false,
+ ]
+ );
+ $table->addColumn(
+ name: 'group_id',
+ typeName: Types::STRING,
+ options: [
+ 'notnull' => true,
+ 'length' => 255,
+ ]
+ );
+ $table->addColumn(
+ name: 'widget_id',
+ typeName: Types::STRING,
+ options: [
+ 'notnull' => true,
+ 'length' => 255,
+ ]
+ );
+ $table->addColumn(
+ name: 'grid_x',
+ typeName: Types::INTEGER,
+ options: [
+ 'notnull' => true,
+ 'default' => 0,
+ ]
+ );
+ $table->addColumn(
+ name: 'grid_y',
+ typeName: Types::INTEGER,
+ options: [
+ 'notnull' => true,
+ 'default' => 0,
+ ]
+ );
+ $table->addColumn(
+ name: 'grid_width',
+ typeName: Types::INTEGER,
+ options: [
+ 'notnull' => true,
+ 'default' => 4,
+ ]
+ );
+ $table->addColumn(
+ name: 'grid_height',
+ typeName: Types::INTEGER,
+ options: [
+ 'notnull' => true,
+ 'default' => 4,
+ ]
+ );
+ $table->addColumn(
+ name: 'sort_order',
+ typeName: Types::INTEGER,
+ options: [
+ 'notnull' => true,
+ 'default' => 0,
+ ]
+ );
+ $table->addColumn(
+ name: 'is_compulsory',
+ typeName: Types::SMALLINT,
+ options: [
+ 'notnull' => true,
+ 'default' => 0,
+ ]
+ );
+ $table->addColumn(
+ name: 'created_at',
+ typeName: Types::STRING,
+ options: [
+ 'notnull' => false,
+ 'length' => 32,
+ ]
+ );
+ $table->addColumn(
+ name: 'updated_at',
+ typeName: Types::STRING,
+ options: [
+ 'notnull' => false,
+ 'length' => 32,
+ ]
+ );
+ }//end addColumns()
+
+ /**
+ * Add primary key + uniqueness index on (group_id, widget_id).
+ *
+ * @param \Doctrine\DBAL\Schema\Table $table The table instance.
+ *
+ * @return void
+ */
+ private static function addIndexes($table): void
+ {
+ $table->setPrimaryKey(columnNames: ['id']);
+ $table->addUniqueIndex(
+ columnNames: ['group_id', 'widget_id'],
+ indexName: 'mydash_rld_group_widget'
+ );
+ $table->addIndex(
+ columnNames: ['group_id'],
+ indexName: 'mydash_rld_group'
+ );
+ }//end addIndexes()
+}//end class
diff --git a/lib/Migration/RulesTableBuilder.php b/lib/Migration/RulesTableBuilder.php
index 720b379a..cc0f2245 100644
--- a/lib/Migration/RulesTableBuilder.php
+++ b/lib/Migration/RulesTableBuilder.php
@@ -36,14 +36,14 @@ class RulesTableBuilder
public static function create(ISchemaWrapper $schema): void
{
if ($schema->hasTable(
- tableName: 'mydash_conditional_rules'
+ 'mydash_conditional_rules'
) === true
) {
return;
}
$table = $schema->createTable(
- tableName: 'mydash_conditional_rules'
+ 'mydash_conditional_rules'
);
self::addColumns(table: $table);
@@ -60,50 +60,50 @@ public static function create(ISchemaWrapper $schema): void
private static function addColumns($table): void
{
$table->addColumn(
- name: 'id',
- typeName: Types::BIGINT,
- options: [
+ 'id',
+ Types::BIGINT,
+ [
'autoincrement' => true,
'notnull' => true,
'unsigned' => true,
]
);
$table->addColumn(
- name: 'widget_placement_id',
- typeName: Types::BIGINT,
- options: [
+ 'widget_placement_id',
+ Types::BIGINT,
+ [
'notnull' => true,
'unsigned' => true,
]
);
$table->addColumn(
- name: 'rule_type',
- typeName: Types::STRING,
- options: [
+ 'rule_type',
+ Types::STRING,
+ [
'notnull' => true,
'length' => 50,
]
);
$table->addColumn(
- name: 'rule_config',
- typeName: Types::TEXT,
- options: [
+ 'rule_config',
+ Types::TEXT,
+ [
'notnull' => false,
]
);
$table->addColumn(
- name: 'is_include',
- typeName: Types::SMALLINT,
- options: [
+ 'is_include',
+ Types::SMALLINT,
+ [
'notnull' => true,
'default' => 1,
'unsigned' => true,
]
);
$table->addColumn(
- name: 'created_at',
- typeName: Types::DATETIME,
- options: [
+ 'created_at',
+ Types::DATETIME,
+ [
'notnull' => true,
]
);
@@ -118,10 +118,10 @@ private static function addColumns($table): void
*/
private static function addIndexes($table): void
{
- $table->setPrimaryKey(columnNames: ['id']);
+ $table->setPrimaryKey(['id']);
$table->addIndex(
- columnNames: ['widget_placement_id'],
- indexName: 'mydash_rule_placement'
+ ['widget_placement_id'],
+ 'mydash_rule_placement'
);
}//end addIndexes()
}//end class
diff --git a/lib/Migration/SettingsTableBuilder.php b/lib/Migration/SettingsTableBuilder.php
index 5352cbda..32cb4ae0 100644
--- a/lib/Migration/SettingsTableBuilder.php
+++ b/lib/Migration/SettingsTableBuilder.php
@@ -36,14 +36,14 @@ class SettingsTableBuilder
public static function create(ISchemaWrapper $schema): void
{
if ($schema->hasTable(
- tableName: 'mydash_admin_settings'
+ 'mydash_admin_settings'
) === true
) {
return;
}
$table = $schema->createTable(
- tableName: 'mydash_admin_settings'
+ 'mydash_admin_settings'
);
self::addColumns(table: $table);
@@ -60,33 +60,33 @@ public static function create(ISchemaWrapper $schema): void
private static function addColumns($table): void
{
$table->addColumn(
- name: 'id',
- typeName: Types::BIGINT,
- options: [
+ 'id',
+ Types::BIGINT,
+ [
'autoincrement' => true,
'notnull' => true,
'unsigned' => true,
]
);
$table->addColumn(
- name: 'setting_key',
- typeName: Types::STRING,
- options: [
+ 'setting_key',
+ Types::STRING,
+ [
'notnull' => true,
'length' => 255,
]
);
$table->addColumn(
- name: 'setting_value',
- typeName: Types::TEXT,
- options: [
+ 'setting_value',
+ Types::TEXT,
+ [
'notnull' => false,
]
);
$table->addColumn(
- name: 'updated_at',
- typeName: Types::DATETIME,
- options: [
+ 'updated_at',
+ Types::DATETIME,
+ [
'notnull' => true,
]
);
@@ -101,10 +101,10 @@ private static function addColumns($table): void
*/
private static function addIndexes($table): void
{
- $table->setPrimaryKey(columnNames: ['id']);
+ $table->setPrimaryKey(['id']);
$table->addUniqueIndex(
- columnNames: ['setting_key'],
- indexName: 'mydash_setting_key'
+ ['setting_key'],
+ 'mydash_setting_key'
);
}//end addIndexes()
}//end class
diff --git a/lib/Migration/Version001001Date20260203000000.php b/lib/Migration/Version001001Date20260203000000.php
index 603a57f9..814b0777 100644
--- a/lib/Migration/Version001001Date20260203000000.php
+++ b/lib/Migration/Version001001Date20260203000000.php
@@ -44,47 +44,47 @@ public function changeSchema(
$schema = $schemaClosure();
// Create mydash_tiles table.
- if ($schema->hasTable(tableName: 'mydash_tiles') === false) {
- $table = $schema->createTable(tableName: 'mydash_tiles');
+ if ($schema->hasTable('mydash_tiles') === false) {
+ $table = $schema->createTable('mydash_tiles');
$table->addColumn(
- name: 'id',
- typeName: Types::BIGINT,
- options: [
+ 'id',
+ Types::BIGINT,
+ [
'autoincrement' => true,
'notnull' => true,
'unsigned' => true,
]
);
$table->addColumn(
- name: 'user_id',
- typeName: Types::STRING,
- options: [
+ 'user_id',
+ Types::STRING,
+ [
'notnull' => true,
'length' => 64,
]
);
$table->addColumn(
- name: 'title',
- typeName: Types::STRING,
- options: [
+ 'title',
+ Types::STRING,
+ [
'notnull' => true,
'length' => 255,
]
);
$table->addColumn(
- name: 'icon',
- typeName: Types::STRING,
- options: [
+ 'icon',
+ Types::STRING,
+ [
'notnull' => true,
'length' => 2000,
'comment' => 'Icon class, URL to icon image, or SVG path data.',
]
);
$table->addColumn(
- name: 'icon_type',
- typeName: Types::STRING,
- options: [
+ 'icon_type',
+ Types::STRING,
+ [
'notnull' => true,
'length' => 20,
'default' => 'class',
@@ -92,9 +92,9 @@ public function changeSchema(
]
);
$table->addColumn(
- name: 'background_color',
- typeName: Types::STRING,
- options: [
+ 'background_color',
+ Types::STRING,
+ [
'notnull' => true,
'length' => 7,
'default' => '#0082c9',
@@ -102,9 +102,9 @@ public function changeSchema(
]
);
$table->addColumn(
- name: 'text_color',
- typeName: Types::STRING,
- options: [
+ 'text_color',
+ Types::STRING,
+ [
'notnull' => true,
'length' => 7,
'default' => '#ffffff',
@@ -112,42 +112,42 @@ public function changeSchema(
]
);
$table->addColumn(
- name: 'link_type',
- typeName: Types::STRING,
- options: [
+ 'link_type',
+ Types::STRING,
+ [
'notnull' => true,
'length' => 20,
'comment' => 'Type of link: app or url.',
]
);
$table->addColumn(
- name: 'link_value',
- typeName: Types::STRING,
- options: [
+ 'link_value',
+ Types::STRING,
+ [
'notnull' => true,
'length' => 1000,
'comment' => 'App ID or URL.',
]
);
$table->addColumn(
- name: 'created_at',
- typeName: Types::DATETIME,
- options: [
+ 'created_at',
+ Types::DATETIME,
+ [
'notnull' => true,
]
);
$table->addColumn(
- name: 'updated_at',
- typeName: Types::DATETIME,
- options: [
+ 'updated_at',
+ Types::DATETIME,
+ [
'notnull' => true,
]
);
- $table->setPrimaryKey(columnNames: ['id']);
+ $table->setPrimaryKey(['id']);
$table->addIndex(
- columnNames: ['user_id'],
- indexName: 'mydash_tiles_user'
+ ['user_id'],
+ 'mydash_tiles_user'
);
}//end if
diff --git a/lib/Migration/Version001002Date20260204000000.php b/lib/Migration/Version001002Date20260204000000.php
index a570ee27..dc4f3745 100644
--- a/lib/Migration/Version001002Date20260204000000.php
+++ b/lib/Migration/Version001002Date20260204000000.php
@@ -44,13 +44,13 @@ public function changeSchema(
$schema = $schemaClosure();
// Increase icon column size to support longer SVG paths.
- if ($schema->hasTable(tableName: 'mydash_tiles') === true) {
- $table = $schema->getTable(tableName: 'mydash_tiles');
+ if ($schema->hasTable('mydash_tiles') === true) {
+ $table = $schema->getTable('mydash_tiles');
- if ($table->hasColumn(name: 'icon') === true) {
- $iconColumn = $table->getColumn(name: 'icon');
+ if ($table->hasColumn('icon') === true) {
+ $iconColumn = $table->getColumn('icon');
// Increase from 500 to 2000 characters for complex SVG paths.
- $iconColumn->setLength(length: 2000);
+ $iconColumn->setLength(2000);
}
}
diff --git a/lib/Migration/Version001003Date20260204120000.php b/lib/Migration/Version001003Date20260204120000.php
index 3938551f..4e5aae59 100644
--- a/lib/Migration/Version001003Date20260204120000.php
+++ b/lib/Migration/Version001003Date20260204120000.php
@@ -45,19 +45,19 @@ public function changeSchema(
// Add tile configuration fields to widget_placements table.
if ($schema->hasTable(
- tableName: 'mydash_widget_placements'
+ 'mydash_widget_placements'
) === true
) {
$table = $schema->getTable(
- tableName: 'mydash_widget_placements'
+ 'mydash_widget_placements'
);
// Add tile_type to distinguish between widgets and tiles.
- if ($table->hasColumn(name: 'tile_type') === false) {
+ if ($table->hasColumn('tile_type') === false) {
$table->addColumn(
- name: 'tile_type',
- typeName: Types::STRING,
- options: [
+ 'tile_type',
+ Types::STRING,
+ [
'notnull' => false,
'length' => 20,
'default' => null,
@@ -67,11 +67,11 @@ public function changeSchema(
}
// Add tile_title for custom tiles.
- if ($table->hasColumn(name: 'tile_title') === false) {
+ if ($table->hasColumn('tile_title') === false) {
$table->addColumn(
- name: 'tile_title',
- typeName: Types::STRING,
- options: [
+ 'tile_title',
+ Types::STRING,
+ [
'notnull' => false,
'length' => 255,
'default' => null,
@@ -81,11 +81,11 @@ public function changeSchema(
}
// Add tile_icon.
- if ($table->hasColumn(name: 'tile_icon') === false) {
+ if ($table->hasColumn('tile_icon') === false) {
$table->addColumn(
- name: 'tile_icon',
- typeName: Types::STRING,
- options: [
+ 'tile_icon',
+ Types::STRING,
+ [
'notnull' => false,
'length' => 2000,
'default' => null,
@@ -95,11 +95,11 @@ public function changeSchema(
}
// Add tile_icon_type.
- if ($table->hasColumn(name: 'tile_icon_type') === false) {
+ if ($table->hasColumn('tile_icon_type') === false) {
$table->addColumn(
- name: 'tile_icon_type',
- typeName: Types::STRING,
- options: [
+ 'tile_icon_type',
+ Types::STRING,
+ [
'notnull' => false,
'length' => 20,
'default' => null,
@@ -110,13 +110,13 @@ public function changeSchema(
// Add tile_background_color.
if ($table->hasColumn(
- name: 'tile_background_color'
+ 'tile_background_color'
) === false
) {
$table->addColumn(
- name: 'tile_background_color',
- typeName: Types::STRING,
- options: [
+ 'tile_background_color',
+ Types::STRING,
+ [
'notnull' => false,
'length' => 7,
'default' => null,
@@ -126,11 +126,11 @@ public function changeSchema(
}
// Add tile_text_color.
- if ($table->hasColumn(name: 'tile_text_color') === false) {
+ if ($table->hasColumn('tile_text_color') === false) {
$table->addColumn(
- name: 'tile_text_color',
- typeName: Types::STRING,
- options: [
+ 'tile_text_color',
+ Types::STRING,
+ [
'notnull' => false,
'length' => 7,
'default' => null,
@@ -140,11 +140,11 @@ public function changeSchema(
}
// Add tile_link_type.
- if ($table->hasColumn(name: 'tile_link_type') === false) {
+ if ($table->hasColumn('tile_link_type') === false) {
$table->addColumn(
- name: 'tile_link_type',
- typeName: Types::STRING,
- options: [
+ 'tile_link_type',
+ Types::STRING,
+ [
'notnull' => false,
'length' => 20,
'default' => null,
@@ -154,11 +154,11 @@ public function changeSchema(
}
// Add tile_link_value.
- if ($table->hasColumn(name: 'tile_link_value') === false) {
+ if ($table->hasColumn('tile_link_value') === false) {
$table->addColumn(
- name: 'tile_link_value',
- typeName: Types::STRING,
- options: [
+ 'tile_link_value',
+ Types::STRING,
+ [
'notnull' => false,
'length' => 1000,
'default' => null,
diff --git a/lib/Migration/Version001004Date20260204150000.php b/lib/Migration/Version001004Date20260204150000.php
index e7ca5e23..297f9cee 100644
--- a/lib/Migration/Version001004Date20260204150000.php
+++ b/lib/Migration/Version001004Date20260204150000.php
@@ -44,14 +44,14 @@ public function changeSchema(
$schema = $schemaClosure();
$table = $schema->getTable(
- tableName: 'mydash_widget_placements'
+ 'mydash_widget_placements'
);
- if ($table->hasColumn(name: 'custom_icon') === false) {
+ if ($table->hasColumn('custom_icon') === false) {
$table->addColumn(
- name: 'custom_icon',
- typeName: Types::TEXT,
- options: [
+ 'custom_icon',
+ Types::TEXT,
+ [
'notnull' => false,
'length' => 2000,
]
diff --git a/lib/Migration/Version001005Date20260430000000.php b/lib/Migration/Version001005Date20260430000000.php
index 1e9ca141..37762dc7 100644
--- a/lib/Migration/Version001005Date20260430000000.php
+++ b/lib/Migration/Version001005Date20260430000000.php
@@ -51,27 +51,27 @@ public function changeSchema(
): ?ISchemaWrapper {
$schema = $schemaClosure();
- if ($schema->hasTable(tableName: 'mydash_dashboards') === false) {
+ if ($schema->hasTable('mydash_dashboards') === false) {
return $schema;
}
- $table = $schema->getTable(tableName: 'mydash_dashboards');
+ $table = $schema->getTable('mydash_dashboards');
- if ($table->hasColumn(name: 'group_id') === false) {
+ if ($table->hasColumn('group_id') === false) {
$table->addColumn(
- name: 'group_id',
- typeName: Types::STRING,
- options: [
+ 'group_id',
+ Types::STRING,
+ [
'notnull' => false,
'length' => 64,
]
);
}
- if ($table->hasIndex(indexName: 'mydash_dash_type_group') === false) {
+ if ($table->hasIndex('mydash_dash_type_group') === false) {
$table->addIndex(
- columnNames: ['type', 'group_id'],
- indexName: 'mydash_dash_type_group'
+ ['type', 'group_id'],
+ 'mydash_dash_type_group'
);
}
diff --git a/lib/Migration/Version001005Date20260430120000.php b/lib/Migration/Version001005Date20260430120000.php
new file mode 100644
index 00000000..e06825da
--- /dev/null
+++ b/lib/Migration/Version001005Date20260430120000.php
@@ -0,0 +1,52 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+class Version001005Date20260430120000 extends SimpleMigrationStep
+{
+ /**
+ * Create the dashboard shares table.
+ *
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure The schema closure returns an ISchemaWrapper.
+ * @param array $options The migration options.
+ *
+ * @return ISchemaWrapper|null The modified schema or null.
+ */
+ public function changeSchema(
+ IOutput $output,
+ Closure $schemaClosure,
+ array $options
+ ): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+
+ DashboardShareTableBuilder::create(schema: $schema);
+
+ return $schema;
+ }//end changeSchema()
+}//end class
diff --git a/lib/Migration/Version001006Date20260430130000.php b/lib/Migration/Version001006Date20260430130000.php
index 48bbdf82..9cede20f 100644
--- a/lib/Migration/Version001006Date20260430130000.php
+++ b/lib/Migration/Version001006Date20260430130000.php
@@ -72,83 +72,83 @@ public function changeSchema(
): ?ISchemaWrapper {
$schema = $schemaClosure();
- if ($schema->hasTable(tableName: 'mydash_dashboard_shares') === true) {
+ if ($schema->hasTable('mydash_dashboard_shares') === true) {
return null;
}
- $table = $schema->createTable(tableName: 'mydash_dashboard_shares');
+ $table = $schema->createTable('mydash_dashboard_shares');
$table->addColumn(
- name: 'id',
- typeName: Types::BIGINT,
- options: [
+ 'id',
+ Types::BIGINT,
+ [
'autoincrement' => true,
'notnull' => true,
'unsigned' => true,
]
);
$table->addColumn(
- name: 'dashboard_id',
- typeName: Types::BIGINT,
- options: [
+ 'dashboard_id',
+ Types::BIGINT,
+ [
'notnull' => true,
'unsigned' => true,
]
);
$table->addColumn(
- name: 'share_type',
- typeName: Types::STRING,
- options: [
+ 'share_type',
+ Types::STRING,
+ [
'notnull' => true,
'length' => 16,
]
);
$table->addColumn(
- name: 'share_with',
- typeName: Types::STRING,
- options: [
+ 'share_with',
+ Types::STRING,
+ [
'notnull' => true,
'length' => 255,
]
);
$table->addColumn(
- name: 'permission_level',
- typeName: Types::STRING,
- options: [
+ 'permission_level',
+ Types::STRING,
+ [
'notnull' => true,
'length' => 32,
'default' => 'view_only',
]
);
$table->addColumn(
- name: 'created_at',
- typeName: Types::STRING,
- options: [
+ 'created_at',
+ Types::STRING,
+ [
'notnull' => false,
'length' => 32,
]
);
$table->addColumn(
- name: 'updated_at',
- typeName: Types::STRING,
- options: [
+ 'updated_at',
+ Types::STRING,
+ [
'notnull' => false,
'length' => 32,
]
);
- $table->setPrimaryKey(columnNames: ['id']);
+ $table->setPrimaryKey(['id']);
$table->addIndex(
- columnNames: ['dashboard_id'],
- indexName: 'mydash_shares_dashboard'
+ ['dashboard_id'],
+ 'mydash_shares_dashboard'
);
$table->addIndex(
- columnNames: ['share_type', 'share_with'],
- indexName: 'mydash_shares_recipient'
+ ['share_type', 'share_with'],
+ 'mydash_shares_recipient'
);
$table->addUniqueIndex(
- columnNames: ['dashboard_id', 'share_type', 'share_with'],
- indexName: 'mydash_shares_unique'
+ ['dashboard_id', 'share_type', 'share_with'],
+ 'mydash_shares_unique'
);
return $schema;
@@ -161,15 +161,15 @@ public function changeSchema(
* Only runs when `mydash.cleanup_orphan_shares` is explicitly `true`.
* Default is `false` to avoid surprise deletions on federated environments.
*
- * @param IOutput $output The migration output handler.
- * @param Closure $closure The schema closure.
- * @param array $options The migration options.
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure The schema closure.
+ * @param array $options The migration options.
*
* @return void
*/
public function postSchemaChange(
IOutput $output,
- Closure $closure,
+ Closure $schemaClosure,
array $options
): void {
$enabled = $this->config->getAppValue(
diff --git a/lib/Migration/Version001006Date20260502000000.php b/lib/Migration/Version001006Date20260502000000.php
new file mode 100644
index 00000000..57896eb6
--- /dev/null
+++ b/lib/Migration/Version001006Date20260502000000.php
@@ -0,0 +1,74 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+class Version001006Date20260502000000 extends SimpleMigrationStep
+{
+ /**
+ * Add the `icon` column to `mydash_dashboards`.
+ *
+ * Length 2000 mirrors `mydash_tiles.icon` so that an uploaded icon
+ * URL added by the sibling `custom-icon-upload-pattern` capability
+ * fits without a follow-up migration.
+ *
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure The schema closure returns an ISchemaWrapper.
+ * @param array $options The migration options.
+ *
+ * @return ISchemaWrapper|null The modified schema or null.
+ */
+ public function changeSchema(
+ IOutput $output,
+ Closure $schemaClosure,
+ array $options
+ ): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+
+ if ($schema->hasTable('mydash_dashboards') === false) {
+ return null;
+ }
+
+ $table = $schema->getTable('mydash_dashboards');
+ if ($table->hasColumn('icon') === false) {
+ $table->addColumn(
+ 'icon',
+ Types::STRING,
+ [
+ 'notnull' => false,
+ 'length' => 2000,
+ 'comment' => 'Dashboard icon: registry key (dashboard-icons) or upload URL; NULL = default.',
+ ]
+ );
+ }
+
+ return $schema;
+ }//end changeSchema()
+}//end class
diff --git a/lib/Migration/Version001007Date20260501120000.php b/lib/Migration/Version001007Date20260501120000.php
new file mode 100644
index 00000000..9e88ecad
--- /dev/null
+++ b/lib/Migration/Version001007Date20260501120000.php
@@ -0,0 +1,66 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Migration creating the role-feature-permissions tables.
+ */
+class Version001007Date20260501120000 extends SimpleMigrationStep
+{
+ /**
+ * Create both `mydash_role_feature_perms` and `mydash_role_layout_defaults`.
+ *
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure Returns an ISchemaWrapper.
+ * @param array $options The migration options (unused).
+ *
+ * @return ISchemaWrapper|null The modified schema or null if no changes.
+ */
+ public function changeSchema(
+ IOutput $output,
+ Closure $schemaClosure,
+ array $options
+ ): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+
+ $hadFeaturePerms = $schema->hasTable(tableName: 'mydash_role_feature_perms');
+ $hadLayoutDefs = $schema->hasTable(tableName: 'mydash_role_layout_defaults');
+
+ if ($hadFeaturePerms === true && $hadLayoutDefs === true) {
+ return null;
+ }
+
+ RoleFeaturePermissionTableBuilder::create(schema: $schema);
+ RoleLayoutDefaultTableBuilder::create(schema: $schema);
+
+ return $schema;
+ }//end changeSchema()
+}//end class
diff --git a/lib/Migration/Version001008Date20260502000000.php b/lib/Migration/Version001008Date20260502000000.php
new file mode 100644
index 00000000..70355c7b
--- /dev/null
+++ b/lib/Migration/Version001008Date20260502000000.php
@@ -0,0 +1,85 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Add group_id column + composite index to mydash_dashboards (REQ-DASH-011).
+ */
+class Version001008Date20260502000000 extends SimpleMigrationStep
+{
+ /**
+ * Add the group_id column and the composite (type, group_id) index.
+ *
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure The schema closure returns an
+ * ISchemaWrapper.
+ * @param array $options The migration options.
+ *
+ * @return ISchemaWrapper|null The modified schema or null.
+ */
+ public function changeSchema(
+ IOutput $output,
+ Closure $schemaClosure,
+ array $options
+ ): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+
+ if ($schema->hasTable('mydash_dashboards') === false) {
+ return $schema;
+ }
+
+ $table = $schema->getTable('mydash_dashboards');
+
+ if ($table->hasColumn('group_id') === false) {
+ $table->addColumn(
+ 'group_id',
+ Types::STRING,
+ [
+ 'notnull' => false,
+ 'length' => 64,
+ ]
+ );
+ }
+
+ if ($table->hasIndex('mydash_dash_type_group') === false) {
+ $table->addIndex(
+ ['type', 'group_id'],
+ 'mydash_dash_type_group'
+ );
+ }
+
+ return $schema;
+ }//end changeSchema()
+}//end class
diff --git a/lib/Migration/Version001009Date20260502120000.php b/lib/Migration/Version001009Date20260502120000.php
new file mode 100644
index 00000000..c6a412fc
--- /dev/null
+++ b/lib/Migration/Version001009Date20260502120000.php
@@ -0,0 +1,57 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Create the `mydash_role_assignments` table (REQ-ROLE-004).
+ */
+class Version001009Date20260502120000 extends SimpleMigrationStep
+{
+ /**
+ * Create the role-assignments table when it does not yet exist.
+ *
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure The schema closure (returns ISchemaWrapper).
+ * @param array $options The migration options.
+ *
+ * @return ISchemaWrapper|null The modified schema or null.
+ */
+ public function changeSchema(
+ IOutput $output,
+ Closure $schemaClosure,
+ array $options
+ ): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+
+ RoleAssignmentTableBuilder::create(schema: $schema);
+
+ return $schema;
+ }//end changeSchema()
+}//end class
diff --git a/lib/Migration/Version001010Date20260502120000.php b/lib/Migration/Version001010Date20260502120000.php
new file mode 100644
index 00000000..515df1c8
--- /dev/null
+++ b/lib/Migration/Version001010Date20260502120000.php
@@ -0,0 +1,72 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Add hierarchy columns + supporting indexes to mydash_dashboards
+ * (REQ-DASH-023..030).
+ */
+class Version001010Date20260502120000 extends SimpleMigrationStep
+{
+ /**
+ * Add the parent_uuid / slug / sort_order columns and indexes.
+ *
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure The schema closure returns an
+ * ISchemaWrapper.
+ * @param array $options The migration options.
+ *
+ * @return ISchemaWrapper|null The modified schema or null.
+ */
+ public function changeSchema(
+ IOutput $output,
+ Closure $schemaClosure,
+ array $options
+ ): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+
+ if ($schema->hasTable('mydash_dashboards') === false) {
+ return $schema;
+ }
+
+ $table = $schema->getTable('mydash_dashboards');
+
+ DashboardTableBuilder::addTreeColumns(table: $table);
+
+ return $schema;
+ }//end changeSchema()
+}//end class
diff --git a/lib/Migration/Version001011Date20260502130000.php b/lib/Migration/Version001011Date20260502130000.php
new file mode 100644
index 00000000..ab5728f5
--- /dev/null
+++ b/lib/Migration/Version001011Date20260502130000.php
@@ -0,0 +1,74 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Add publication-state columns + supporting index to mydash_dashboards
+ * (REQ-DASH-031..037).
+ */
+class Version001011Date20260502130000 extends SimpleMigrationStep
+{
+ /**
+ * Add the publication_status / publish_at / published_at columns +
+ * the (user_id, publication_status) composite index.
+ *
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure The schema closure returns an
+ * ISchemaWrapper.
+ * @param array $options The migration options.
+ *
+ * @return ISchemaWrapper|null The modified schema or null.
+ */
+ public function changeSchema(
+ IOutput $output,
+ Closure $schemaClosure,
+ array $options
+ ): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+
+ if ($schema->hasTable('mydash_dashboards') === false) {
+ return $schema;
+ }
+
+ $table = $schema->getTable('mydash_dashboards');
+
+ DashboardTableBuilder::addPublicationColumns(table: $table);
+
+ return $schema;
+ }//end changeSchema()
+}//end class
diff --git a/lib/Migration/Version001012Date20260503000000.php b/lib/Migration/Version001012Date20260503000000.php
new file mode 100644
index 00000000..a47976f5
--- /dev/null
+++ b/lib/Migration/Version001012Date20260503000000.php
@@ -0,0 +1,72 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Add template discovery columns + supporting index to mydash_dashboards
+ * (REQ-TMPL-014..017).
+ */
+class Version001012Date20260503000000 extends SimpleMigrationStep
+{
+ /**
+ * Add the template_category / template_description / template_preview_image
+ * columns and the (type, template_category) composite index.
+ *
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure The schema closure returns an
+ * ISchemaWrapper.
+ * @param array $options The migration options.
+ *
+ * @return ISchemaWrapper|null The modified schema or null.
+ */
+ public function changeSchema(
+ IOutput $output,
+ Closure $schemaClosure,
+ array $options
+ ): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+
+ if ($schema->hasTable('mydash_dashboards') === false) {
+ return $schema;
+ }
+
+ $table = $schema->getTable('mydash_dashboards');
+
+ DashboardTableBuilder::addTemplateDiscoveryColumns(table: $table);
+
+ return $schema;
+ }//end changeSchema()
+}//end class
diff --git a/lib/Migration/Version001013Date20260502120000.php b/lib/Migration/Version001013Date20260502120000.php
new file mode 100644
index 00000000..f89800d4
--- /dev/null
+++ b/lib/Migration/Version001013Date20260502120000.php
@@ -0,0 +1,78 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Add the nullable `comments_enabled` column to `oc_mydash_dashboards`
+ * (REQ-CMNT-007).
+ */
+class Version001013Date20260502120000 extends SimpleMigrationStep
+{
+ /**
+ * Add the column when it does not yet exist.
+ *
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure The schema closure (returns ISchemaWrapper).
+ * @param array $options The migration options.
+ *
+ * @return ISchemaWrapper|null The modified schema or null.
+ */
+ public function changeSchema(
+ IOutput $output,
+ Closure $schemaClosure,
+ array $options
+ ): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+
+ if ($schema->hasTable('mydash_dashboards') === false) {
+ return $schema;
+ }
+
+ $table = $schema->getTable('mydash_dashboards');
+
+ if ($table->hasColumn('comments_enabled') === false) {
+ $table->addColumn(
+ 'comments_enabled',
+ Types::SMALLINT,
+ [
+ 'notnull' => false,
+ 'default' => null,
+ 'unsigned' => true,
+ 'comment' => 'Per-dashboard comments toggle: NULL = inherit global, 1 = on, 0 = off.',
+ ]
+ );
+ }
+
+ return $schema;
+ }//end changeSchema()
+}//end class
diff --git a/lib/Migration/Version001014Date20260502120000.php b/lib/Migration/Version001014Date20260502120000.php
new file mode 100644
index 00000000..82c3580a
--- /dev/null
+++ b/lib/Migration/Version001014Date20260502120000.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
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Add `mydash_dashboard_reactions` table + per-dashboard
+ * `reactions_enabled` toggle column. REQ-RXN-001..009.
+ */
+class Version001014Date20260502120000 extends SimpleMigrationStep
+{
+ /**
+ * Create the reactions table and add the per-dashboard toggle column.
+ *
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure The schema closure returns an
+ * ISchemaWrapper.
+ * @param array $options The migration options.
+ *
+ * @return ISchemaWrapper|null The modified schema or null.
+ */
+ public function changeSchema(
+ IOutput $output,
+ Closure $schemaClosure,
+ array $options
+ ): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+
+ // 1. Create `mydash_dashboard_reactions` table.
+ if ($schema->hasTable('mydash_dashboard_reactions') === false) {
+ $table = $schema->createTable('mydash_dashboard_reactions');
+
+ $table->addColumn(
+ 'id',
+ Types::BIGINT,
+ [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'dashboard_uuid',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 36,
+ ]
+ );
+ $table->addColumn(
+ 'user_id',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 64,
+ ]
+ );
+ $table->addColumn(
+ 'emoji',
+ Types::STRING,
+ [
+ 'notnull' => true,
+ 'length' => 32,
+ ]
+ );
+ $table->addColumn(
+ 'reacted_at',
+ Types::DATETIME,
+ ['notnull' => true]
+ );
+
+ $table->setPrimaryKey(['id']);
+
+ // Composite uniqueness — REQ-RXN-001 idempotency guarantee.
+ $table->addUniqueIndex(
+ ['dashboard_uuid', 'user_id', 'emoji'],
+ 'mydash_react_uuid_user_emoji'
+ );
+
+ // Aggregation lookups — REQ-RXN-003 / REQ-RXN-004.
+ $table->addIndex(
+ ['dashboard_uuid'],
+ 'mydash_react_uuid'
+ );
+ $table->addIndex(
+ ['emoji'],
+ 'mydash_react_emoji'
+ );
+ }//end if
+
+ // 2. Per-dashboard toggle column on the existing dashboards table.
+ if ($schema->hasTable('mydash_dashboards') === true) {
+ $dashTable = $schema->getTable('mydash_dashboards');
+ if ($dashTable->hasColumn('reactions_enabled') === false) {
+ $dashTable->addColumn(
+ 'reactions_enabled',
+ Types::SMALLINT,
+ ['notnull' => false]
+ );
+ }
+ }
+
+ return $schema;
+ }//end changeSchema()
+}//end class
diff --git a/lib/Migration/Version001015Date20260502130000.php b/lib/Migration/Version001015Date20260502130000.php
new file mode 100644
index 00000000..0ac88342
--- /dev/null
+++ b/lib/Migration/Version001015Date20260502130000.php
@@ -0,0 +1,62 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Add the mydash_dashboard_versions table (REQ-VERS-001..009).
+ */
+class Version001015Date20260502130000 extends SimpleMigrationStep
+{
+ /**
+ * Create the dashboard versions table.
+ *
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure The schema closure (returns
+ * ISchemaWrapper).
+ * @param array $options The migration options.
+ *
+ * @return ISchemaWrapper|null The modified schema or null.
+ */
+ public function changeSchema(
+ IOutput $output,
+ Closure $schemaClosure,
+ array $options
+ ): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+
+ DashboardVersionTableBuilder::create(schema: $schema);
+
+ return $schema;
+ }//end changeSchema()
+}//end class
diff --git a/lib/Migration/Version001016Date20260502130000.php b/lib/Migration/Version001016Date20260502130000.php
new file mode 100644
index 00000000..ca5e6f79
--- /dev/null
+++ b/lib/Migration/Version001016Date20260502130000.php
@@ -0,0 +1,70 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Create metadata-field registry + per-dashboard value tables
+ * (REQ-MDFL-001..008).
+ */
+class Version001016Date20260502130000 extends SimpleMigrationStep
+{
+ /**
+ * Create the `mydash_meta_fields` and `mydash_meta_values` tables.
+ *
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure The schema closure returns an
+ * ISchemaWrapper.
+ * @param array $options The migration options.
+ *
+ * @return ISchemaWrapper|null The modified schema or null.
+ */
+ public function changeSchema(
+ IOutput $output,
+ Closure $schemaClosure,
+ array $options
+ ): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+
+ MetadataTablesBuilder::create(schema: $schema);
+
+ return $schema;
+ }//end changeSchema()
+}//end class
diff --git a/lib/Migration/Version001017Date20260502130000.php b/lib/Migration/Version001017Date20260502130000.php
new file mode 100644
index 00000000..4cc48ded
--- /dev/null
+++ b/lib/Migration/Version001017Date20260502130000.php
@@ -0,0 +1,64 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Add the per-language dashboard translations table (REQ-DASH-038..044).
+ */
+class Version001017Date20260502130000 extends SimpleMigrationStep
+{
+ /**
+ * Create the dashboard translations table.
+ *
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure The schema closure returning an
+ * ISchemaWrapper.
+ * @param array $options The migration options.
+ *
+ * @return ISchemaWrapper|null The modified schema or null.
+ */
+ public function changeSchema(
+ IOutput $output,
+ Closure $schemaClosure,
+ array $options
+ ): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+
+ DashboardTranslationTableBuilder::create(schema: $schema);
+
+ return $schema;
+ }//end changeSchema()
+}//end class
diff --git a/lib/Migration/Version001019Date20260502130000.php b/lib/Migration/Version001019Date20260502130000.php
new file mode 100644
index 00000000..235827df
--- /dev/null
+++ b/lib/Migration/Version001019Date20260502130000.php
@@ -0,0 +1,62 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Create the `mydash_dashboard_views` aggregate table
+ * (REQ-ANLT-001..011).
+ */
+class Version001019Date20260502130000 extends SimpleMigrationStep
+{
+ /**
+ * Create the daily-aggregate views table.
+ *
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure The schema closure returns an
+ * ISchemaWrapper.
+ * @param array $options The migration options.
+ *
+ * @return ISchemaWrapper|null The modified schema or null.
+ */
+ public function changeSchema(
+ IOutput $output,
+ Closure $schemaClosure,
+ array $options
+ ): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+
+ DashboardViewsTableBuilder::create(schema: $schema);
+
+ return $schema;
+ }//end changeSchema()
+}//end class
diff --git a/lib/Migration/Version001020Date20260502130000.php b/lib/Migration/Version001020Date20260502130000.php
new file mode 100644
index 00000000..0ff32471
--- /dev/null
+++ b/lib/Migration/Version001020Date20260502130000.php
@@ -0,0 +1,63 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Add the feed-tokens table backing the dashboard RSS/Atom capability
+ * (REQ-FEED-001..009).
+ */
+class Version001020Date20260502130000 extends SimpleMigrationStep
+{
+ /**
+ * Create the feed-tokens table.
+ *
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure The schema closure returns an
+ * ISchemaWrapper.
+ * @param array $options The migration options.
+ *
+ * @return ISchemaWrapper|null The modified schema or null.
+ */
+ public function changeSchema(
+ IOutput $output,
+ Closure $schemaClosure,
+ array $options
+ ): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+
+ FeedTokenTableBuilder::create(schema: $schema);
+
+ return $schema;
+ }//end changeSchema()
+}//end class
diff --git a/lib/Migration/Version001021Date20260502130000.php b/lib/Migration/Version001021Date20260502130000.php
new file mode 100644
index 00000000..2b5a6a12
--- /dev/null
+++ b/lib/Migration/Version001021Date20260502130000.php
@@ -0,0 +1,60 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Add the `mydash_dashboard_locks` table (REQ-LOCK-001..008).
+ */
+class Version001021Date20260502130000 extends SimpleMigrationStep
+{
+ /**
+ * Create the dashboard locks table via DashboardLockTableBuilder.
+ *
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure The schema closure returns an
+ * ISchemaWrapper.
+ * @param array $options The migration options.
+ *
+ * @return ISchemaWrapper|null The modified schema or null.
+ */
+ public function changeSchema(
+ IOutput $output,
+ Closure $schemaClosure,
+ array $options
+ ): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+
+ DashboardLockTableBuilder::create(schema: $schema);
+
+ return $schema;
+ }//end changeSchema()
+}//end class
diff --git a/lib/Migration/Version001023Date20260502130000.php b/lib/Migration/Version001023Date20260502130000.php
new file mode 100644
index 00000000..544b2c9e
--- /dev/null
+++ b/lib/Migration/Version001023Date20260502130000.php
@@ -0,0 +1,63 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Add the feed-cache table backing the background-job feed-refresh
+ * capability (REQ-FRJ-001..012).
+ */
+class Version001023Date20260502130000 extends SimpleMigrationStep
+{
+ /**
+ * Create the feed-cache table.
+ *
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure The schema closure returns an
+ * ISchemaWrapper.
+ * @param array $options The migration options.
+ *
+ * @return ISchemaWrapper|null The modified schema or null.
+ */
+ public function changeSchema(
+ IOutput $output,
+ Closure $schemaClosure,
+ array $options
+ ): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+
+ FeedCacheTableBuilder::create(schema: $schema);
+
+ return $schema;
+ }//end changeSchema()
+}//end class
diff --git a/lib/Migration/Version001024Date20260503130000.php b/lib/Migration/Version001024Date20260503130000.php
new file mode 100644
index 00000000..cd7e0471
--- /dev/null
+++ b/lib/Migration/Version001024Date20260503130000.php
@@ -0,0 +1,71 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Add footer-override columns to mydash_dashboards (REQ-FTR-006).
+ */
+class Version001024Date20260503130000 extends SimpleMigrationStep
+{
+ /**
+ * Add the dashboard_footer_mode / dashboard_footer_html columns.
+ *
+ * @param IOutput $output The migration output handler.
+ * @param Closure $schemaClosure The schema closure returns an
+ * ISchemaWrapper.
+ * @param array $options The migration options.
+ *
+ * @return ISchemaWrapper|null The modified schema or null.
+ */
+ public function changeSchema(
+ IOutput $output,
+ Closure $schemaClosure,
+ array $options
+ ): ?ISchemaWrapper {
+ $schema = $schemaClosure();
+
+ if ($schema->hasTable('mydash_dashboards') === false) {
+ return $schema;
+ }
+
+ $table = $schema->getTable('mydash_dashboards');
+
+ DashboardTableBuilder::addFooterColumns(table: $table);
+
+ return $schema;
+ }//end changeSchema()
+}//end class
diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php
index e5e63e5b..51522462 100644
--- a/lib/Notification/Notifier.php
+++ b/lib/Notification/Notifier.php
@@ -27,6 +27,7 @@
use OCA\MyDash\AppInfo\Application;
use OCA\MyDash\Db\DashboardMapper;
use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\L10N\IFactory;
use OCP\Notification\INotification;
@@ -36,12 +37,16 @@
/**
* MyDash notification renderer.
*
- * Handles two subjects:
+ * Handles three subjects:
* - `dashboard_shared` — published when a dashboard is shared with a user or
* when a share's permission_level is upgraded (REQ-SHARE-008).
* - `dashboard_ownership_transferred` — published when a UserDeletedEvent
* causes ownership of a dashboard to be transferred to a new owner
* (REQ-SHARE-013).
+ * - `mentioned_in_comment` — published when a user is `@-mentioned` inside
+ * a dashboard comment (REQ-CMNT-006). For this subject the
+ * `INotification::getObjectId()` is the dashboard UUID directly (not
+ * the DB id), so the URL builder takes a different branch.
*/
class Notifier implements INotifier
{
@@ -103,16 +108,31 @@ public function prepare(
);
}
- $l = $this->l10nFactory->get(
+ $l = $this->l10nFactory->get(
app: Application::APP_ID,
lang: $languageCode
);
+ $subject = $notification->getSubject();
+
+ // The mention subject ships the dashboard UUID directly as the
+ // notification's objectId; the share / transfer subjects ship the
+ // dashboard DB id (legacy contract). REQ-CMNT-006.
+ if ($subject === 'mentioned_in_comment') {
+ $url = $this->buildDashboardUrlFromUuid(
+ uuid: $notification->getObjectId()
+ );
+
+ return $this->prepareMentionedInComment(
+ notification: $notification,
+ l: $l,
+ url: $url
+ );
+ }
+
$url = $this->buildDashboardUrl(
objectId: $notification->getObjectId()
);
- $subject = $notification->getSubject();
-
if ($subject === 'dashboard_shared') {
return $this->prepareDashboardShared(
notification: $notification,
@@ -134,20 +154,61 @@ public function prepare(
);
}//end prepare()
+ /**
+ * Prepare a `mentioned_in_comment` notification (REQ-CMNT-006).
+ *
+ * Subject parameters: [authorUserId, dashboardUuid].
+ *
+ * @param INotification $notification The notification.
+ * @param IL10N $l The L10N instance.
+ * @param string $url The deep-link URL.
+ *
+ * @return INotification The prepared notification.
+ */
+ private function prepareMentionedInComment(
+ INotification $notification,
+ IL10N $l,
+ string $url
+ ): INotification {
+ $params = $notification->getSubjectParameters();
+ $author = (string) ($params[0] ?? '');
+
+ $richSubject = $l->t(
+ '%1$s mentioned you in a dashboard comment',
+ [$author]
+ );
+ $notification->setRichSubject(
+ subject: $richSubject,
+ parameters: []
+ );
+ $notification->setParsedSubject(subject: $richSubject);
+
+ $message = $l->t('Open the dashboard to read the comment');
+ $notification->setRichMessage(
+ message: $message,
+ parameters: []
+ );
+ $notification->setParsedMessage(message: $message);
+
+ $notification->setLink(link: $url);
+
+ return $notification;
+ }//end prepareMentionedInComment()
+
/**
* Prepare a `dashboard_shared` notification.
*
* Subject parameters: [sharerUserId, dashboardName, permissionLevel].
*
* @param INotification $notification The notification.
- * @param \OCP\IL10N $l The L10N instance.
+ * @param IL10N $l The L10N instance.
* @param string $url The deep-link URL.
*
* @return INotification The prepared notification.
*/
private function prepareDashboardShared(
INotification $notification,
- \OCP\IL10N $l,
+ IL10N $l,
string $url
): INotification {
$params = $notification->getSubjectParameters();
@@ -188,14 +249,14 @@ private function prepareDashboardShared(
* Subject parameters: [dashboardName].
*
* @param INotification $notification The notification.
- * @param \OCP\IL10N $l The L10N instance.
+ * @param IL10N $l The L10N instance.
* @param string $url The deep-link URL.
*
* @return INotification The prepared notification.
*/
private function prepareOwnershipTransferred(
INotification $notification,
- \OCP\IL10N $l,
+ IL10N $l,
string $url
): INotification {
$params = $notification->getSubjectParameters();
@@ -258,16 +319,37 @@ private function buildDashboardUrl(string $objectId): string
return $base;
}//end buildDashboardUrl()
+ /**
+ * Build a deep-link URL when the objectId is already the dashboard
+ * UUID — used by the comment-mention notification (REQ-CMNT-006).
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return string The URL.
+ */
+ private function buildDashboardUrlFromUuid(string $uuid): string
+ {
+ $base = $this->urlGenerator->linkToRouteAbsolute(
+ routeName: 'mydash.page.index'
+ );
+
+ if ($uuid === '') {
+ return $base;
+ }
+
+ return $base.'?dashboard='.urlencode(string: $uuid);
+ }//end buildDashboardUrlFromUuid()
+
/**
* Return the human-readable label for a permission level.
*
- * @param \OCP\IL10N $l The L10N instance.
- * @param string $level The permission level identifier.
+ * @param IL10N $l The L10N instance.
+ * @param string $level The permission level identifier.
*
* @return string The translated label.
*/
private function permissionLabel(
- \OCP\IL10N $l,
+ IL10N $l,
string $level
): string {
return match ($level) {
diff --git a/lib/Search/MyDashSearchProvider.php b/lib/Search/MyDashSearchProvider.php
new file mode 100644
index 00000000..d913cca5
--- /dev/null
+++ b/lib/Search/MyDashSearchProvider.php
@@ -0,0 +1,593 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Search;
+
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Db\Dashboard;
+use OCA\MyDash\Db\WidgetPlacement;
+use OCA\MyDash\Db\WidgetPlacementMapper;
+use OCA\MyDash\Service\DashboardService;
+use OCA\MyDash\Service\MetadataService;
+use OCP\IURLGenerator;
+use OCP\IUser;
+use OCP\L10N\IFactory;
+use OCP\Search\IProvider;
+use OCP\Search\ISearchQuery;
+use OCP\Search\SearchResult;
+use OCP\Search\SearchResultEntry;
+use Throwable;
+
+/**
+ * Unified-search provider for MyDash content.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Search needs the
+ * visible-set boundary, the placement mapper for widget content,
+ * the metadata facade, and the URL/IL10N factories. The provider is
+ * the single integration point with NC's search dispatcher.
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The provider
+ * orchestrates three independent match dimensions (dashboard,
+ * widget content, metadata) plus result-entry building and URL
+ * construction. Splitting would scatter the per-dimension match
+ * invariants across helpers without reducing total complexity.
+ */
+class MyDashSearchProvider implements IProvider
+{
+ /**
+ * Cap per result bucket — REQ-SRCH-007.
+ *
+ * @var int
+ */
+ private const PER_BUCKET_LIMIT = 10;
+
+ /**
+ * Discriminator for the text-display widget shape stored in the
+ * `styleConfig` JSON column (text-display-widget spec).
+ *
+ * @var string
+ */
+ private const TEXT_WIDGET_TYPE = 'text';
+
+ /**
+ * Constructor.
+ *
+ * @param DashboardService $dashboardService The dashboard service.
+ * @param WidgetPlacementMapper $placementMapper The placement mapper.
+ * @param MetadataService $metadataService The metadata service.
+ * @param IFactory $l10nFactory The L10N factory.
+ * @param IURLGenerator $urlGenerator The URL generator.
+ */
+ public function __construct(
+ private readonly DashboardService $dashboardService,
+ private readonly WidgetPlacementMapper $placementMapper,
+ private readonly MetadataService $metadataService,
+ private readonly IFactory $l10nFactory,
+ private readonly IURLGenerator $urlGenerator,
+ ) {
+ }//end __construct()
+
+ /**
+ * Provider id — REQ-SRCH-001.
+ *
+ * @return string The provider id.
+ */
+ public function getId(): string
+ {
+ return Application::APP_ID;
+ }//end getId()
+
+ /**
+ * Translated display name — REQ-SRCH-001 / REQ-SRCH-008.
+ *
+ * @return string The translated name.
+ */
+ public function getName(): string
+ {
+ return $this->l10nFactory->get(app: Application::APP_ID)->t('Dashboards');
+ }//end getName()
+
+ /**
+ * Provider order — REQ-SRCH-012. Mid-range so admin-search providers
+ * (typical order 5-10) come first, contacts/files (100+) come after.
+ *
+ * @param string $route The current frontend route.
+ * @param array $routeParameters The current route parameters.
+ *
+ * @return int|null The provider order.
+ *
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter) Required by interface.
+ */
+ public function getOrder(string $route, array $routeParameters): ?int
+ {
+ // Boost MyDash to the top when the user is already in the app.
+ $inApp = str_starts_with(haystack: $route, needle: Application::APP_ID.'.');
+ if ($inApp === true) {
+ return 5;
+ }
+
+ return 50;
+ }//end getOrder()
+
+ /**
+ * Run the search across dashboards, widget content, and metadata.
+ *
+ * Permission boundary delegates to {@see DashboardService::getVisibleToUser()}
+ * which already enforces the `permissions` capability and the
+ * `dashboards` publication-state filter (REQ-SRCH-005).
+ *
+ * @param IUser $user The acting user.
+ * @param ISearchQuery $query The unified-search query.
+ *
+ * @return SearchResult The search result for the `mydash` group.
+ *
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity) Match-tier dispatch
+ * drives the branch count; splitting would scatter the result-entry
+ * shape across helpers for no readability gain.
+ * @SuppressWarnings(PHPMD.NPathComplexity)
+ */
+ public function search(IUser $user, ISearchQuery $query): SearchResult
+ {
+ $providerName = $this->getName();
+ $term = trim(string: $query->getTerm());
+
+ // REQ-SRCH-009: empty term yields an empty (non-error) result.
+ if ($term === '') {
+ return SearchResult::complete(name: $providerName, entries: []);
+ }
+
+ try {
+ $visible = $this->dashboardService->getVisibleToUser(
+ userId: $user->getUID()
+ );
+ } catch (Throwable) {
+ // REQ-SRCH-005: fail safe — never leak when the permission
+ // boundary cannot resolve.
+ return SearchResult::complete(name: $providerName, entries: []);
+ }
+
+ if (count($visible) === 0) {
+ return SearchResult::complete(name: $providerName, entries: []);
+ }
+
+ $needle = mb_strtolower(string: $term);
+ $l10n = $this->l10nFactory->get(app: Application::APP_ID);
+
+ $dashboardEntries = [];
+ $widgetEntries = [];
+ $metadataEntries = [];
+
+ foreach ($visible as $entry) {
+ $dashboard = $entry['dashboard'];
+
+ // --- Dashboard name + description (REQ-SRCH-002) ---
+ if (count($dashboardEntries) < self::PER_BUCKET_LIMIT) {
+ if ($this->matchesDashboard(dashboard: $dashboard, needle: $needle) === true) {
+ $dashboardEntries[] = $this->buildDashboardEntry(
+ dashboard: $dashboard
+ );
+ }
+ }
+
+ // --- Widget content (REQ-SRCH-003) ---
+ if (count($widgetEntries) < self::PER_BUCKET_LIMIT) {
+ $matchedPlacements = $this->findMatchingTextPlacements(
+ dashboard: $dashboard,
+ needle: $needle
+ );
+ foreach ($matchedPlacements as $placement) {
+ if (count($widgetEntries) >= self::PER_BUCKET_LIMIT) {
+ break;
+ }
+
+ $widgetEntries[] = $this->buildWidgetEntry(
+ dashboard: $dashboard,
+ placement: $placement,
+ l10n: $l10n
+ );
+ }
+ }
+
+ // --- Metadata-field values (REQ-SRCH-004) ---
+ if (count($metadataEntries) < self::PER_BUCKET_LIMIT) {
+ $matchedMetadata = $this->findMatchingMetadata(
+ dashboard: $dashboard,
+ needle: $needle
+ );
+ foreach ($matchedMetadata as $key => $value) {
+ if (count($metadataEntries) >= self::PER_BUCKET_LIMIT) {
+ break;
+ }
+
+ $metadataEntries[] = $this->buildMetadataEntry(
+ dashboard: $dashboard,
+ fieldKey: (string) $key,
+ fieldValue: $value,
+ l10n: $l10n
+ );
+ }
+ }
+ }//end foreach
+
+ // Rank tiers: title/description first, then widget body, then
+ // metadata. REQ-SRCH-006 / REQ-SRCH-011 (UI grouping).
+ $entries = array_merge($dashboardEntries, $widgetEntries, $metadataEntries);
+
+ return SearchResult::complete(name: $providerName, entries: $entries);
+ }//end search()
+
+ /**
+ * Whether a dashboard's name or description contains the needle
+ * (case-insensitive substring match).
+ *
+ * @param Dashboard $dashboard The dashboard.
+ * @param string $needle The lower-cased search term.
+ *
+ * @return bool Whether the dashboard matches.
+ */
+ private function matchesDashboard(Dashboard $dashboard, string $needle): bool
+ {
+ $name = mb_strtolower(string: (string) $dashboard->getName());
+ if ($name !== '' && str_contains(haystack: $name, needle: $needle) === true) {
+ return true;
+ }
+
+ $description = mb_strtolower(string: (string) $dashboard->getDescription());
+ if ($description !== ''
+ && str_contains(haystack: $description, needle: $needle) === true
+ ) {
+ return true;
+ }
+
+ return false;
+ }//end matchesDashboard()
+
+ /**
+ * Find text-display placements on the dashboard whose `text`
+ * content matches the needle.
+ *
+ * @param Dashboard $dashboard The dashboard.
+ * @param string $needle The lower-cased search term.
+ *
+ * @return WidgetPlacement[] The matching placements.
+ */
+ private function findMatchingTextPlacements(
+ Dashboard $dashboard,
+ string $needle
+ ): array {
+ $dashboardId = (int) $dashboard->getId();
+ if ($dashboardId === 0) {
+ return [];
+ }
+
+ try {
+ $placements = $this->placementMapper->findByDashboardId(
+ dashboardId: $dashboardId
+ );
+ } catch (Throwable) {
+ return [];
+ }
+
+ $matches = [];
+ foreach ($placements as $placement) {
+ $text = $this->extractTextWidgetContent(placement: $placement);
+ if ($text === null) {
+ continue;
+ }
+
+ $haystack = mb_strtolower(string: $this->stripTags(html: $text));
+ if (str_contains(haystack: $haystack, needle: $needle) === true) {
+ $matches[] = $placement;
+ }
+ }
+
+ return $matches;
+ }//end findMatchingTextPlacements()
+
+ /**
+ * Decode the text-display content from a placement's `styleConfig`
+ * JSON column. Returns `null` when the placement is not a text
+ * widget or the column is malformed.
+ *
+ * @param WidgetPlacement $placement The placement entity.
+ *
+ * @return string|null The raw `content.text` value, or null.
+ */
+ private function extractTextWidgetContent(WidgetPlacement $placement): ?string
+ {
+ $rawConfig = $placement->getStyleConfig();
+ if ($rawConfig === null || $rawConfig === '') {
+ return null;
+ }
+
+ try {
+ $decoded = json_decode(
+ json: $rawConfig,
+ associative: true,
+ depth: 32,
+ flags: JSON_THROW_ON_ERROR
+ );
+ } catch (Throwable) {
+ return null;
+ }
+
+ if (is_array(value: $decoded) === false) {
+ return null;
+ }
+
+ $type = ($decoded['type'] ?? null);
+ if ($type !== self::TEXT_WIDGET_TYPE) {
+ return null;
+ }
+
+ $content = ($decoded['content'] ?? null);
+ if (is_array(value: $content) === false) {
+ return null;
+ }
+
+ $text = ($content['text'] ?? null);
+ if (is_string(value: $text) === false || $text === '') {
+ return null;
+ }
+
+ return $text;
+ }//end extractTextWidgetContent()
+
+ /**
+ * Strip HTML tags so search matches survive markup
+ * (REQ-SRCH-003 scenario "Text widget with HTML rendering").
+ *
+ * @param string $html The widget body — may contain HTML.
+ *
+ * @return string The plain-text body.
+ */
+ private function stripTags(string $html): string
+ {
+ $stripped = strip_tags(string: $html);
+ // Decode entities so `&` matches `&`.
+ return html_entity_decode(
+ string: $stripped,
+ flags: (ENT_QUOTES | ENT_HTML5),
+ encoding: 'UTF-8'
+ );
+ }//end stripTags()
+
+ /**
+ * Find metadata key/value pairs on the dashboard that match the
+ * needle (REQ-SRCH-004). Degrades silently on any storage error
+ * — the metadata capability is optional from the search provider's
+ * perspective.
+ *
+ * @param Dashboard $dashboard The dashboard.
+ * @param string $needle The lower-cased search term.
+ *
+ * @return array Matching key → value pairs.
+ */
+ private function findMatchingMetadata(
+ Dashboard $dashboard,
+ string $needle
+ ): array {
+ $uuid = (string) $dashboard->getUuid();
+ if ($uuid === '') {
+ return [];
+ }
+
+ try {
+ $metadata = $this->metadataService->getMetadataForDashboard(
+ dashboardUuid: $uuid
+ );
+ } catch (Throwable) {
+ return [];
+ }
+
+ $matches = [];
+ foreach ($metadata as $key => $value) {
+ $stringValue = (string) $value;
+ if ($stringValue === '') {
+ continue;
+ }
+
+ $haystack = mb_strtolower(string: $stringValue);
+ if (str_contains(haystack: $haystack, needle: $needle) === true) {
+ $matches[(string) $key] = $stringValue;
+ }
+ }
+
+ return $matches;
+ }//end findMatchingMetadata()
+
+ /**
+ * Build a search-result entry for a dashboard match.
+ *
+ * @param Dashboard $dashboard The matching dashboard.
+ *
+ * @return SearchResultEntry The result entry.
+ */
+ private function buildDashboardEntry(Dashboard $dashboard): SearchResultEntry
+ {
+ $l10n = $this->l10nFactory->get(app: Application::APP_ID);
+ $name = (string) $dashboard->getName();
+ $description = trim(string: (string) $dashboard->getDescription());
+ $subline = $description;
+ if ($subline === '') {
+ $subline = $l10n->t('MyDash dashboard');
+ }
+
+ return new SearchResultEntry(
+ thumbnailUrl: $this->iconUrl(),
+ title: $name,
+ subline: $subline,
+ resourceUrl: $this->dashboardUrl(uuid: (string) $dashboard->getUuid()),
+ icon: $this->iconUrl(),
+ rounded: false
+ );
+ }//end buildDashboardEntry()
+
+ /**
+ * Build a search-result entry for a widget-content match.
+ *
+ * @param Dashboard $dashboard The parent dashboard.
+ * @param WidgetPlacement $placement The matching placement.
+ * @param \OCP\IL10N $l10n The localizer.
+ *
+ * @return SearchResultEntry The result entry.
+ */
+ private function buildWidgetEntry(
+ Dashboard $dashboard,
+ WidgetPlacement $placement,
+ \OCP\IL10N $l10n
+ ): SearchResultEntry {
+ $dashboardName = (string) $dashboard->getName();
+ $subline = $l10n->t('Widget content on %s', [$dashboardName]);
+
+ return new SearchResultEntry(
+ thumbnailUrl: $this->iconUrl(),
+ title: $dashboardName,
+ subline: $subline,
+ resourceUrl: $this->widgetUrl(
+ uuid: (string) $dashboard->getUuid(),
+ placementId: (int) $placement->getId()
+ ),
+ icon: $this->iconUrl(),
+ rounded: false
+ );
+ }//end buildWidgetEntry()
+
+ /**
+ * Build a search-result entry for a metadata-value match.
+ *
+ * @param Dashboard $dashboard The parent dashboard.
+ * @param string $fieldKey The metadata field key.
+ * @param string $fieldValue The matching value.
+ * @param \OCP\IL10N $l10n The localizer.
+ *
+ * @return SearchResultEntry The result entry.
+ */
+ private function buildMetadataEntry(
+ Dashboard $dashboard,
+ string $fieldKey,
+ string $fieldValue,
+ \OCP\IL10N $l10n
+ ): SearchResultEntry {
+ $dashboardName = (string) $dashboard->getName();
+ $subline = $l10n->t(
+ 'Metadata: %1$s = %2$s',
+ [$fieldKey, $fieldValue]
+ );
+
+ return new SearchResultEntry(
+ thumbnailUrl: $this->iconUrl(),
+ title: $dashboardName,
+ subline: $subline,
+ resourceUrl: $this->dashboardUrl(uuid: (string) $dashboard->getUuid()),
+ icon: $this->iconUrl(),
+ rounded: false
+ );
+ }//end buildMetadataEntry()
+
+ /**
+ * Build the dashboard deep-link URL. The hash fragment lets the SPA
+ * focus the matching dashboard without a separate redirect route
+ * (REQ-SRCH-006 design D6).
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return string The absolute deep-link URL.
+ */
+ private function dashboardUrl(string $uuid): string
+ {
+ $base = $this->urlGenerator->linkToRouteAbsolute(
+ routeName: Application::APP_ID.'.page.index'
+ );
+
+ if ($uuid === '') {
+ return $base;
+ }
+
+ return $base.'#dashboard/'.rawurlencode(string: $uuid);
+ }//end dashboardUrl()
+
+ /**
+ * Build the widget deep-link URL. The placement id is appended to
+ * the dashboard hash fragment so the SPA can scroll/highlight the
+ * specific widget on mount (REQ-SRCH-003 / REQ-SRCH-011).
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param int $placementId The placement id.
+ *
+ * @return string The absolute deep-link URL.
+ */
+ private function widgetUrl(string $uuid, int $placementId): string
+ {
+ $base = $this->dashboardUrl(uuid: $uuid);
+ if ($placementId === 0) {
+ return $base;
+ }
+
+ $separator = '?';
+ if (str_contains(haystack: $base, needle: '#') === true) {
+ // Hash already present — append widget hint inside the fragment.
+ return $base.';widget='.$placementId;
+ }
+
+ return $base.$separator.'widget='.$placementId;
+ }//end widgetUrl()
+
+ /**
+ * Resolve the absolute MyDash icon URL used for every result entry.
+ *
+ * @return string The absolute icon URL.
+ */
+ private function iconUrl(): string
+ {
+ return $this->urlGenerator->getAbsoluteURL(
+ url: $this->urlGenerator->imagePath(
+ appName: Application::APP_ID,
+ file: 'app.svg'
+ )
+ );
+ }//end iconUrl()
+}//end class
diff --git a/lib/Service/AdminSettingsService.php b/lib/Service/AdminSettingsService.php
index 1c9eabf2..5f8530a2 100644
--- a/lib/Service/AdminSettingsService.php
+++ b/lib/Service/AdminSettingsService.php
@@ -39,10 +39,30 @@ public function __construct(
) {
}//end __construct()
+ /**
+ * Default link-button-widget createFile extension allow-list
+ * (REQ-LBN-004). Mirrored on
+ * {@see \OCA\MyDash\Service\FileService::DEFAULT_ALLOWED_EXTENSIONS}
+ * so the admin UI can render the default before
+ * {@see FileService::getAllowedExtensions()} is consulted.
+ *
+ * @var array
+ */
+ public const DEFAULT_LINK_CREATE_FILE_EXTENSIONS = [
+ 'txt',
+ 'md',
+ 'docx',
+ 'xlsx',
+ 'csv',
+ 'odt',
+ ];
+
/**
* Get all admin settings with defaults.
*
* @return array The settings array.
+ *
+ * @spec admin-settings:REQ-ASET-001
*/
public function getSettings(): array
{
@@ -53,30 +73,47 @@ public function getSettings(): array
$userKey = AdminSetting::KEY_ALLOW_USER_DASHBOARDS;
$multiKey = AdminSetting::KEY_ALLOW_MULTIPLE_DASHBOARDS;
$gridKey = AdminSetting::KEY_DEFAULT_GRID_COLUMNS;
+ $extKey = AdminSetting::KEY_LINK_CREATE_FILE_EXTENSIONS;
+
+ $storedExt = ($settings[$extKey] ?? null);
+ if (is_array($storedExt) === false || count($storedExt) === 0) {
+ $storedExt = self::DEFAULT_LINK_CREATE_FILE_EXTENSIONS;
+ }
return [
- 'defaultPermissionLevel' => $settings[$permKey] ?? $permDef,
- 'allowUserDashboards' => $settings[$userKey] ?? true,
- 'allowMultipleDashboards' => $settings[$multiKey] ?? true,
- 'defaultGridColumns' => $settings[$gridKey] ?? 12,
+ 'defaultPermissionLevel' => $settings[$permKey] ?? $permDef,
+ // REQ-ASET-003 (extended): default `false` — admins MUST opt in
+ // to personal dashboard creation.
+ 'allowUserDashboards' => $settings[$userKey] ?? false,
+ 'allowMultipleDashboards' => $settings[$multiKey] ?? true,
+ 'defaultGridColumns' => $settings[$gridKey] ?? 12,
+ 'linkCreateFileExtensions' => $storedExt,
];
}//end getSettings()
/**
* Update admin settings.
*
- * @param string|null $defaultPermLevel Default permission level.
- * @param bool|null $allowUserDash Allow user dashboards.
- * @param bool|null $allowMultiDash Allow multiple dashboards.
- * @param int|null $defaultGridCols Default grid columns.
+ * @param string|null $defaultPermLevel Default permission level.
+ * @param bool|null $allowUserDash Allow user dashboards.
+ * @param bool|null $allowMultiDash Allow multiple dashboards.
+ * @param int|null $defaultGridCols Default grid columns.
+ * @param array|null $linkCreateFileExts link-button-widget
+ * createFile
+ * extension
+ * allow-list
+ * (REQ-LBN-004).
*
* @return void
+ *
+ * @spec admin-settings:REQ-ASET-002
*/
public function updateSettings(
?string $defaultPermLevel=null,
?bool $allowUserDash=null,
?bool $allowMultiDash=null,
- ?int $defaultGridCols=null
+ ?int $defaultGridCols=null,
+ ?array $linkCreateFileExts=null
): void {
if ($defaultPermLevel !== null) {
$this->settingMapper->setSetting(
@@ -105,101 +142,133 @@ public function updateSettings(
value: $defaultGridCols
);
}
+
+ if ($linkCreateFileExts !== null) {
+ $this->settingMapper->setSetting(
+ key: AdminSetting::KEY_LINK_CREATE_FILE_EXTENSIONS,
+ value: $this->normaliseExtensions(input: $linkCreateFileExts)
+ );
+ }
}//end updateSettings()
/**
- * Get the persisted ordered list of "active" Nextcloud group IDs.
- *
- * Reads the global `group_order` admin setting (REQ-ASET-012). The value
- * is persisted as a JSON-encoded `string[]`. This method is intentionally
- * defensive: a missing row, a `NULL` value, an empty string, corrupt JSON,
- * or any non-array decoded value MUST resolve to `[]` without throwing,
- * so the resolver and admin UI never see a fatal error from a malformed
- * value (REQ-ASET-012 "Corrupt DB JSON falls back to empty array"
- * scenario).
+ * Get the persisted admin-chosen group priority order (REQ-ASET-012).
*
- * Non-string elements that slip through the persisted value (defence in
- * depth — `setGroupOrder` already rejects them) are filtered out.
+ * Defensive read: returns `[]` when the row is missing, when the value
+ * is not a JSON-encoded list of strings, or when JSON decoding fails.
+ * MUST never throw — corrupt data in the database MUST resolve to the
+ * factory default so admin UI and downstream resolvers stay alive.
*
- * @return array The admin-chosen group IDs in priority
- * order; `[]` when unset or unparseable.
+ * @return string[] The ordered list of group IDs, or `[]` when missing
+ * or corrupt.
*/
public function getGroupOrder(): array
{
- try {
- $entity = $this->settingMapper->findByKey(
- key: AdminSetting::KEY_GROUP_ORDER
- );
- } catch (DoesNotExistException) {
- return [];
- }
-
- $raw = $entity->getSettingValue();
- if ($raw === null || $raw === '') {
- return [];
- }
-
- $decoded = json_decode(
- json: $raw,
- associative: true
+ $raw = $this->settingMapper->getValue(
+ key: AdminSetting::KEY_GROUP_ORDER,
+ default: null
);
- if (is_array($decoded) === false) {
+ if (is_array($raw) === false) {
+ // Corrupt or missing — fall back to empty list (REQ-ASET-012).
return [];
}
- $clean = [];
- foreach ($decoded as $value) {
- if (is_string($value) === true && $value !== '') {
- $clean[] = $value;
+ $result = [];
+ foreach ($raw as $entry) {
+ // Drop any element that isn't a non-empty string. The persist
+ // path validates this, but a hand-edited DB row could still
+ // contain garbage.
+ if (is_string($entry) === true && $entry !== '') {
+ $result[] = $entry;
}
}
- return array_values(array_unique($clean));
+ return array_values(array_unique($result));
}//end getGroupOrder()
/**
- * Persist the ordered list of "active" Nextcloud group IDs.
+ * Persist the admin-chosen group priority order (REQ-ASET-012,
+ * REQ-ASET-014).
*
- * Implements REQ-ASET-012 / REQ-ASET-014:
- * - Every element MUST be a non-empty string; otherwise an
- * `InvalidArgumentException` is thrown and nothing is persisted.
- * - Duplicate IDs are deduplicated, first occurrence wins, order is
- * preserved.
- * - The result is persisted as a JSON-encoded `string[]` under the
- * `group_order` key (REPLACE-WHOLESALE — no merge with previous).
- * - Unknown (not currently in Nextcloud) IDs are NOT validated here;
- * they are tolerated per REQ-ASET-014 "Unknown IDs accepted".
+ * Replaces the persisted value wholesale — no merge semantics. Every
+ * element MUST be a non-empty string; throws
+ * {@see \InvalidArgumentException} otherwise so the controller can
+ * surface HTTP 400. Duplicates are deduplicated (first occurrence
+ * wins, preserving order). Unknown (not-currently-in-Nextcloud) IDs
+ * are tolerated by design (REQ-ASET-014) — the runtime resolver in
+ * `group-routing` drops them.
*
- * @param array $groupIds The new ordered list of group IDs.
+ * @param array $groupIds The ordered group IDs (mixed so the
+ * runtime defensive check has work
+ * to do; static analysis cannot
+ * reach the IGroupManager → service
+ * payload origin).
*
* @return void
*
- * @throws InvalidArgumentException When any element is not a non-empty
- * string.
+ * @throws InvalidArgumentException When any element is not a
+ * non-empty string.
*/
public function setGroupOrder(array $groupIds): void
{
- $deduped = [];
- $seen = [];
- foreach ($groupIds as $id) {
- if (is_string($id) === false || $id === '') {
+ $deduplicated = [];
+ foreach ($groupIds as $entry) {
+ if (is_string($entry) === false || $entry === '') {
throw new InvalidArgumentException(
- message: 'Every group ID must be a non-empty string.'
+ message: 'group_order entries must be non-empty strings'
);
}
- if (isset($seen[$id]) === true) {
+ if (in_array(needle: $entry, haystack: $deduplicated, strict: true) === true) {
continue;
}
- $seen[$id] = true;
- $deduped[] = $id;
+ $deduplicated[] = $entry;
}
$this->settingMapper->setSetting(
key: AdminSetting::KEY_GROUP_ORDER,
- value: $deduped
+ value: $deduplicated
);
}//end setGroupOrder()
+
+ /**
+ * Normalise an admin-supplied extension allow-list.
+ *
+ * Lowercases each entry, strips leading dots, drops anything that
+ * is not a bare alphanumeric token, de-duplicates, and falls back
+ * to the default allow-list when the input collapses to empty —
+ * the admin cannot brick the feature by saving an empty list.
+ *
+ * @param array $input Raw admin input.
+ *
+ * @return array The normalised allow-list.
+ */
+ private function normaliseExtensions(array $input): array
+ {
+ $normalised = [];
+ foreach ($input as $value) {
+ if (is_string($value) === false) {
+ continue;
+ }
+
+ $token = strtolower(string: ltrim(string: trim(string: $value), characters: '.'));
+ if ($token === '') {
+ continue;
+ }
+
+ if (preg_match(pattern: '/^[a-z0-9]+$/', subject: $token) !== 1) {
+ continue;
+ }
+
+ $normalised[$token] = $token;
+ }
+
+ if (count($normalised) === 0) {
+ return self::DEFAULT_LINK_CREATE_FILE_EXTENSIONS;
+ }
+
+ return array_values(array: $normalised);
+ }//end normaliseExtensions()
}//end class
diff --git a/lib/Service/AdminTemplateService.php b/lib/Service/AdminTemplateService.php
index e9e3adf8..1d73a911 100644
--- a/lib/Service/AdminTemplateService.php
+++ b/lib/Service/AdminTemplateService.php
@@ -3,7 +3,17 @@
/**
* AdminTemplateService
*
- * Service for admin template CRUD operations.
+ * Service for admin template CRUD operations and the canonical
+ * primary-group routing resolver (REQ-TMPL-012, REQ-TMPL-013).
+ *
+ * This class is the single source of truth for:
+ * - Walking the admin-configured `group_order` priority list to pick the
+ * user's primary workspace group (`resolvePrimaryGroup`).
+ * - Reading the user's Nextcloud group memberships
+ * (`getUserGroupIdsFor` — the only place in `lib/` that calls
+ * `IGroupManager::getUserGroupIds`). The grep-based test
+ * {@see \Unit\Service\AdminTemplateServiceGrepGuardTest} enforces this
+ * invariant.
*
* @category Service
* @package OCA\MyDash\Service
@@ -20,14 +30,26 @@
use DateTime;
use Exception;
+use InvalidArgumentException;
use OCA\MyDash\Db\Dashboard;
use OCA\MyDash\Db\DashboardMapper;
use OCA\MyDash\Db\WidgetPlacementMapper;
+use OCA\MyDash\Exception\ForbiddenException;
+use OCA\MyDash\Exception\InvalidDataUrlException;
+use OCA\MyDash\Exception\InvalidImageFormatException;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IDBConnection;
use OCP\IGroupManager;
use OCP\IUserManager;
+use RuntimeException;
+use Throwable;
/**
- * Service for admin template CRUD operations.
+ * Service for admin template CRUD operations and primary-group routing.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Routing resolver
+ * intentionally lives here so REQ-TMPL-013's single-source-of-truth
+ * invariant is statically enforceable by the grep guard.
*/
class AdminTemplateService
{
@@ -36,23 +58,173 @@ class AdminTemplateService
*
* @param DashboardMapper $dashboardMapper Dashboard mapper.
* @param WidgetPlacementMapper $placementMapper Widget placement mapper.
- * @param AdminSettingsService $adminSettings Admin settings reader (for `group_order`).
- * @param IGroupManager $groupManager Nextcloud group membership lookup.
- * @param IUserManager $userManager Nextcloud user lookup (resolve user ID to IUser).
+ * @param AdminSettingsService $settingsService Admin settings reader
+ * (provides the
+ * `group_order` list).
+ * @param IGroupManager $groupManager Nextcloud group manager.
+ * @param IUserManager $userManager Nextcloud user manager.
+ * @param ResourceService|null $resourceService Resource-uploads
+ * pipeline reused
+ * for
+ * preview-image
+ * storage
+ * (REQ-TMPL-017).
+ * Optional so
+ * existing wiring
+ * + tests stay
+ * valid.
+ * @param IDBConnection|null $db DB connection used to
+ * wrap save-as-template
+ * in a transaction
+ * (REQ-TMPL-015).
+ * Optional so test
+ * wiring without a real
+ * DB still works.
*/
public function __construct(
private readonly DashboardMapper $dashboardMapper,
private readonly WidgetPlacementMapper $placementMapper,
- private readonly AdminSettingsService $adminSettings,
+ private readonly AdminSettingsService $settingsService,
private readonly IGroupManager $groupManager,
private readonly IUserManager $userManager,
+ private readonly ?ResourceService $resourceService=null,
+ private readonly ?IDBConnection $db=null,
) {
}//end __construct()
+ /**
+ * Resolve the Nextcloud group ID whose `group_shared` dashboards the
+ * given user should see (REQ-TMPL-012).
+ *
+ * Pure read: walks the admin-configured ordered list of group IDs from
+ * `admin_settings.group_order`, intersects with the user's actual group
+ * memberships (`IGroupManager::getUserGroupIds`), and returns the first
+ * match. When no group matches — or when `group_order` is empty / the
+ * user has no groups — returns the literal {@see Dashboard::DEFAULT_GROUP_ID}
+ * sentinel. Stale group IDs in `group_order` (groups that no longer
+ * exist in Nextcloud) are tolerated: they simply never match a real
+ * user membership and are silently skipped.
+ *
+ * MUST be deterministic and idempotent — never writes.
+ *
+ * @param string $userId The user ID.
+ *
+ * @return string The resolved primary group ID, or
+ * {@see Dashboard::DEFAULT_GROUP_ID} when no match is found.
+ */
+ public function resolvePrimaryGroup(string $userId): string
+ {
+ $orderedGroups = $this->settingsService->getGroupOrder();
+ $userGroups = $this->getUserGroupIdsFor(userId: $userId);
+
+ $match = self::pickFirstMatch(
+ orderedGroups: $orderedGroups,
+ userGroups: $userGroups
+ );
+
+ if ($match === null) {
+ return Dashboard::DEFAULT_GROUP_ID;
+ }
+
+ return $match;
+ }//end resolvePrimaryGroup()
+
+ /**
+ * Pure helper: return the first element of `$orderedGroups` that also
+ * appears in `$userGroups`, or `null` when there is no overlap.
+ *
+ * Extracted from {@see self::resolvePrimaryGroup()} so the algorithm
+ * itself is unit-testable without an `IGroupManager` /
+ * `AdminSettingsService` round-trip. The method is `static` because it
+ * has no instance state — kept on the class for discoverability.
+ *
+ * @param string[] $orderedGroups The admin-configured priority list.
+ * @param string[] $userGroups The user's actual group memberships.
+ *
+ * @return string|null The first matching group ID, or `null` when no
+ * element of `$orderedGroups` is present in
+ * `$userGroups`.
+ */
+ public static function pickFirstMatch(
+ array $orderedGroups,
+ array $userGroups
+ ): ?string {
+ if ($orderedGroups === [] || $userGroups === []) {
+ return null;
+ }
+
+ $userIndex = array_flip(array: $userGroups);
+
+ foreach ($orderedGroups as $groupId) {
+ if (isset($userIndex[$groupId]) === true) {
+ return $groupId;
+ }
+ }
+
+ return null;
+ }//end pickFirstMatch()
+
+ /**
+ * Resolve the user's Nextcloud group IDs (REQ-TMPL-013).
+ *
+ * Single-source-of-truth wrapper around `IGroupManager::getUserGroupIds`.
+ * Every other service that needs the user's group memberships MUST
+ * consume this helper instead of injecting `IGroupManager` directly —
+ * the {@see \Unit\Service\AdminTemplateServiceGrepGuardTest} grep guard
+ * enforces the rule. Returns `[]` when the user is unknown so callers
+ * can treat "no user" the same as "no groups".
+ *
+ * @param string $userId The user ID.
+ *
+ * @return string[] The user's group IDs, or `[]` when the user does
+ * not exist.
+ */
+ public function getUserGroupIdsFor(string $userId): array
+ {
+ $user = $this->userManager->get(uid: $userId);
+ if ($user === null) {
+ return [];
+ }
+
+ return $this->groupManager->getUserGroupIds(user: $user);
+ }//end getUserGroupIdsFor()
+
+ /**
+ * Resolve the human-readable display name for a primary group ID.
+ *
+ * Used by the workspace renderer (REQ-TMPL-012) so the frontend can
+ * label the dashboard switcher with the friendly name. The
+ * {@see Dashboard::DEFAULT_GROUP_ID} sentinel resolves to the literal
+ * string `'Default'` (translated client-side); a real group ID is
+ * looked up via `IGroupManager::get()` and its display name returned —
+ * falling back to the group ID itself when the group has been deleted
+ * since the resolver ran (rare race).
+ *
+ * @param string $groupId The group ID returned by
+ * {@see self::resolvePrimaryGroup()}.
+ *
+ * @return string The display name to surface to the frontend.
+ */
+ public function resolvePrimaryGroupDisplayName(string $groupId): string
+ {
+ if ($groupId === Dashboard::DEFAULT_GROUP_ID) {
+ return 'Default';
+ }
+
+ $group = $this->groupManager->get(gid: $groupId);
+ if ($group === null) {
+ return $groupId;
+ }
+
+ return $group->getDisplayName();
+ }//end resolvePrimaryGroupDisplayName()
+
/**
* List all admin dashboard templates.
*
* @return Dashboard[] The list of admin templates.
+ *
+ * @spec admin-templates:REQ-TMPL-002
*/
public function listTemplates(): array
{
@@ -96,6 +268,8 @@ public function getTemplateWithPlacements(int $id): array
* @param bool $isDefault Whether this is the default.
*
* @return Dashboard The created template.
+ *
+ * @spec admin-templates:REQ-TMPL-001
*/
public function createTemplate(
string $name,
@@ -108,6 +282,7 @@ public function createTemplate(
$this->dashboardMapper->clearDefaultTemplates();
}
+ $now = (new DateTime())->format(format: 'Y-m-d H:i:s');
$template = new Dashboard();
$template->setUuid($this->generateUuid());
$template->setName($name);
@@ -121,13 +296,29 @@ public function createTemplate(
$template->setTargetGroupsArray(
$targetGroups ?? []
);
- $template->setIsDefault($isDefault);
- $template->setCreatedAt(new DateTime());
- $template->setUpdatedAt(new DateTime());
+ $template->setIsDefault((int) $isDefault);
+ $template->setCreatedAt($now);
+ $template->setUpdatedAt($now);
return $this->dashboardMapper->insert(entity: $template);
}//end createTemplate()
+ /**
+ * Generate a v4 UUID using random_bytes (no external dependency).
+ *
+ * @return string A v4 UUID.
+ */
+ private function generateUuid(): string
+ {
+ $data = random_bytes(length: 16);
+ $data[6] = chr((ord($data[6]) & 0x0F) | 0x40);
+ $data[8] = chr((ord($data[8]) & 0x3F) | 0x80);
+ return vsprintf(
+ format: '%s%s-%s-%s-%s-%s%s%s',
+ values: str_split(string: bin2hex(string: $data), length: 4)
+ );
+ }//end generateUuid()
+
/**
* Update an admin template.
*
@@ -137,6 +328,8 @@ public function createTemplate(
* @return Dashboard The updated template.
*
* @throws Exception If the dashboard is not an admin template.
+ *
+ * @spec admin-templates:REQ-TMPL-003
*/
public function updateTemplate(int $id, array $data): Dashboard
{
@@ -151,7 +344,9 @@ public function updateTemplate(int $id, array $data): Dashboard
data: $data
);
- $template->setUpdatedAt(new DateTime());
+ $template->setUpdatedAt(
+ (new DateTime())->format(format: 'Y-m-d H:i:s')
+ );
return $this->dashboardMapper->update(entity: $template);
}//end updateTemplate()
@@ -164,6 +359,8 @@ public function updateTemplate(int $id, array $data): Dashboard
* @return void
*
* @throws Exception If the dashboard is not an admin template.
+ *
+ * @spec admin-templates:REQ-TMPL-004
*/
public function deleteTemplate(int $id): void
{
@@ -229,118 +426,248 @@ private function applyTemplateUpdates(
$data['gridColumns']
);
}
+
+ if (array_key_exists(key: 'templateCategory', array: $data) === true) {
+ $template->setTemplateCategory(
+ $data['templateCategory']
+ );
+ }
+
+ if (array_key_exists(key: 'templateDescription', array: $data) === true) {
+ $template->setTemplateDescription(
+ $data['templateDescription']
+ );
+ }
+
+ if (array_key_exists(key: 'templatePreviewImage', array: $data) === true) {
+ $template->setTemplatePreviewImage(
+ $data['templatePreviewImage']
+ );
+ }
}//end applyTemplateUpdates()
/**
- * Resolve the user's primary workspace group (REQ-TMPL-012).
- *
- * Pure read-only function — performs no writes. Implements the single
- * routing authority for primary-group resolution (REQ-TMPL-013): every
- * workspace-rendering and dashboard-resolution code path MUST consult
- * this method instead of inlining the algorithm.
- *
- * Algorithm:
- * 1. Read the admin-configured ordered list of group IDs from
- * `admin_settings.group_order` (JSON `string[]`, default `[]`).
- * 2. Read the user's Nextcloud group memberships via
- * `IGroupManager::getUserGroupIds`.
- * 3. Walk `group_order` left-to-right and return the first group ID
- * that also appears in the user's memberships.
- * 4. If no match (including empty list, user in no configured group,
- * or every configured ID is stale), return the literal sentinel
- * `Dashboard::DEFAULT_GROUP_ID` (`'default'`).
- *
- * Stale (deleted) group IDs in `group_order` are tolerated — cleanup is
- * the admin UI's responsibility and the resolver MUST NOT throw on
- * them.
- *
- * @param string $userId The Nextcloud user ID.
- *
- * @return string The matched Nextcloud group ID, or
- * {@see Dashboard::DEFAULT_GROUP_ID} when no match
- * exists.
+ * List admin templates for the discovery gallery (REQ-TMPL-014).
+ *
+ * Returns a serialised list of `{uuid, name, description, category,
+ * previewImage, gridColumns, widgetCount, lastUpdatedAt}` entries
+ * suitable for direct return to the frontend. Widget bodies are not
+ * fetched — `widgetCount` comes from a single COUNT query per
+ * template via {@see WidgetPlacementMapper::countByDashboardId()}.
+ *
+ * @param string|null $category Optional exact-match category filter.
+ * @param string $sortBy `'name'` (default) or `'updatedAt'`.
+ *
+ * @return array> The gallery entries.
*/
- public function resolvePrimaryGroup(string $userId): string
- {
- $orderedGroups = $this->adminSettings->getGroupOrder();
+ public function getGallery(
+ ?string $category=null,
+ string $sortBy='name'
+ ): array {
+ $templates = $this->dashboardMapper->findAllTemplatesForGallery(
+ category: $category,
+ sortBy: $sortBy
+ );
- // Short-circuit: if no groups are configured the answer is always
- // the default sentinel — no need to look the user up.
- if ($orderedGroups === []) {
- return Dashboard::DEFAULT_GROUP_ID;
+ $result = [];
+ foreach ($templates as $template) {
+ $id = (int) $template->getId();
+ $result[] = [
+ 'uuid' => $template->getUuid(),
+ 'name' => $template->getName(),
+ 'description' => $template->getTemplateDescription() ?? $template->getDescription(),
+ 'category' => $template->getTemplateCategory(),
+ 'previewImage' => $template->getTemplatePreviewImage(),
+ 'gridColumns' => $template->getGridColumns(),
+ 'widgetCount' => $this->placementMapper->countByDashboardId(
+ dashboardId: $id
+ ),
+ 'lastUpdatedAt' => $template->getUpdatedAt(),
+ ];
}
- $user = $this->userManager->get($userId);
- if ($user === null) {
- // Stale / unknown user ID — tolerate silently per
- // REQ-TMPL-012 spirit (resolver MUST NOT throw on bad input).
- return Dashboard::DEFAULT_GROUP_ID;
+ return $result;
+ }//end getGallery()
+
+ /**
+ * Save an existing personal dashboard as a new admin template
+ * (REQ-TMPL-015).
+ *
+ * Owner-only deep-copy: validates that `$dashboardUuid` belongs to
+ * `$userId` and is a `type = 'user'` row, then creates a new
+ * `type = 'admin_template'` row with a fresh UUID, inherited
+ * `gridColumns`, the supplied `{name, description, category,
+ * previewImage}` metadata, `userId = null`, `isActive = 0`,
+ * `isDefault = 0`, and `basedOnTemplate = null` (templates do NOT
+ * chain — REQ-TMPL-015 D3). All widget placements are byte-for-byte
+ * cloned via {@see WidgetPlacementMapper::cloneToDashboard()} inside
+ * a single transaction.
+ *
+ * @param string $userId The acting user (must own the source).
+ * @param string $dashboardUuid The source personal dashboard UUID.
+ * @param array $metadata Required: `name`. Optional:
+ * `description`, `category`,
+ * `previewImage`.
+ *
+ * @return Dashboard The newly created admin template.
+ *
+ * @throws ForbiddenException When the source is not owned by
+ * `$userId` or is not a `user` row.
+ * @throws DoesNotExistException When the source UUID does not exist
+ * at all (caller still maps to 403 to
+ * avoid leaking existence).
+ * @throws Throwable On any DB error — the transaction is
+ * rolled back before rethrowing.
+ */
+ public function saveAsTemplate(
+ string $userId,
+ string $dashboardUuid,
+ array $metadata
+ ): Dashboard {
+ $name = trim((string) ($metadata['name'] ?? ''));
+ if ($name === '') {
+ throw new InvalidArgumentException(
+ message: 'Template name is required'
+ );
}
- $userGroups = $this->groupManager->getUserGroupIds(user: $user);
+ $source = $this->dashboardMapper->findOwnedByUserAndUuid(
+ userId: $userId,
+ uuid: $dashboardUuid
+ );
+ if ($source === null) {
+ throw new ForbiddenException(
+ message: 'You can only save your own dashboards as templates'
+ );
+ }
- $match = self::pickFirstMatch(
- orderedGroups: $orderedGroups,
- userGroups: $userGroups
+ $now = (new DateTime())->format(format: 'Y-m-d H:i:s');
+ $template = new Dashboard();
+ $template->setUuid($this->generateUuid());
+ $template->setName($name);
+ $template->setDescription($metadata['description'] ?? null);
+ $template->setType(Dashboard::TYPE_ADMIN_TEMPLATE);
+ $template->setUserId(null);
+ $template->setGridColumns((int) $source->getGridColumns());
+ $template->setPermissionLevel(Dashboard::PERMISSION_ADD_ONLY);
+ $template->setTargetGroupsArray([]);
+ $template->setIsDefault(0);
+ $template->setIsActive(0);
+ $template->setBasedOnTemplate(null);
+ $template->setTemplateCategory(
+ $this->normaliseStringMeta(value: $metadata['category'] ?? null)
);
+ $template->setTemplateDescription(
+ $this->normaliseStringMeta(value: $metadata['description'] ?? null)
+ );
+ $template->setTemplatePreviewImage(
+ $this->normaliseStringMeta(value: $metadata['previewImage'] ?? null)
+ );
+ $template->setCreatedAt($now);
+ $template->setUpdatedAt($now);
- return ($match ?? Dashboard::DEFAULT_GROUP_ID);
- }//end resolvePrimaryGroup()
+ $useTransaction = ($this->db !== null);
+ if ($useTransaction === true) {
+ $this->db->beginTransaction();
+ }
+
+ try {
+ $persisted = $this->dashboardMapper->insert(entity: $template);
+
+ $this->placementMapper->cloneToDashboard(
+ sourceDashboardId: (int) $source->getId(),
+ targetDashboardId: (int) $persisted->getId()
+ );
+
+ if ($useTransaction === true) {
+ $this->db->commit();
+ }
+
+ return $persisted;
+ } catch (Throwable $t) {
+ if ($useTransaction === true) {
+ $this->db->rollBack();
+ }
+
+ throw $t;
+ }
+ }//end saveAsTemplate()
/**
- * Pick the first ordered group that the user is a member of.
+ * Upload a preview image for an admin template (REQ-TMPL-017).
+ *
+ * Reuses the resource-uploads pipeline (the "custom-icon-upload
+ * pattern") for storage: a base64 data URL `data:image/;base64,
+ * ` is decoded, validated (PNG, JPG, GIF, WebP, SVG only),
+ * sanitised in the SVG case, and persisted via
+ * `IAppData::getFolder('resources')`. The returned URL is written
+ * back to the template's `templatePreviewImage` column.
*
- * Internal pure helper extracted for direct unit-testability of the
- * intersection logic, independent of any Nextcloud/DI dependencies.
- * Walks `$orderedGroups` left-to-right and returns the first entry that
- * also appears in `$userGroups`. Returns `null` when no overlap exists
- * (including either argument being empty).
+ * Admin-only: callers MUST gate via the controller before invoking
+ * — this service trusts its inputs because the resource-uploads
+ * pipeline is itself admin-only at the controller layer.
*
- * Tolerates stale entries in `$orderedGroups` — entries that are not in
- * `$userGroups` are simply skipped, never raise an error.
+ * @param string $templateUuid The template UUID.
+ * @param string $base64DataUrl The `data:image/...;base64,...` URL.
*
- * @param array $orderedGroups The admin-configured ordered
- * list of group IDs.
- * @param array $userGroups The user's actual Nextcloud
- * group memberships.
+ * @return string The persisted preview-image URL.
*
- * @return string|null The first matching group ID, or `null` when the
- * two lists do not intersect.
+ * @throws DoesNotExistException When the template UUID is
+ * unknown or refers to a non-
+ * template row.
+ * @throws InvalidDataUrlException Bad data URL prefix.
+ * @throws InvalidImageFormatException Disallowed image type.
*/
- public static function pickFirstMatch(
- array $orderedGroups,
- array $userGroups
- ): ?string {
- if ($orderedGroups === [] || $userGroups === []) {
- return null;
+ public function uploadPreviewImage(
+ string $templateUuid,
+ string $base64DataUrl
+ ): string {
+ if ($this->resourceService === null) {
+ throw new RuntimeException(
+ message: 'ResourceService is not wired in'
+ );
}
- // O(n) lookup: hash the user's groups for constant-time matching.
- $userGroupSet = array_flip($userGroups);
-
- foreach ($orderedGroups as $groupId) {
- if (isset($userGroupSet[$groupId]) === true) {
- return $groupId;
- }
+ $template = $this->dashboardMapper->findByUuid(uuid: $templateUuid);
+ if ($template->getType() !== Dashboard::TYPE_ADMIN_TEMPLATE) {
+ throw new DoesNotExistException(msg: 'Not an admin template');
}
- return null;
- }//end pickFirstMatch()
+ $resource = $this->resourceService->upload(
+ base64DataUrl: $base64DataUrl
+ );
+
+ $template->setTemplatePreviewImage($resource['url']);
+ $template->setUpdatedAt(
+ (new DateTime())->format(format: 'Y-m-d H:i:s')
+ );
+ $this->dashboardMapper->update(entity: $template);
+
+ return (string) $resource['url'];
+ }//end uploadPreviewImage()
/**
- * Generate a UUID v4.
+ * Coerce an incoming metadata field to a clean nullable string.
+ *
+ * Empty strings collapse to `null` so the stored value is unambiguous
+ * — gallery filters compare on equality and an empty-string category
+ * would create a phantom group.
+ *
+ * @param mixed $value The raw incoming value.
*
- * @return string The generated UUID.
+ * @return string|null The cleaned value.
*/
- private function generateUuid(): string
+ private function normaliseStringMeta(mixed $value): ?string
{
- $data = random_bytes(length: 16);
- $data[6] = chr(codepoint: ord(character: $data[6]) & 0x0f | 0x40);
- $data[8] = chr(codepoint: ord(character: $data[8]) & 0x3f | 0x80);
+ if ($value === null) {
+ return null;
+ }
- return vsprintf(
- format: '%s%s-%s-%s-%s-%s%s%s',
- values: str_split(string: bin2hex(string: $data), length: 4)
- );
- }//end generateUuid()
+ $trimmed = trim(string: (string) $value);
+ if ($trimmed === '') {
+ return null;
+ }
+
+ return $trimmed;
+ }//end normaliseStringMeta()
}//end class
diff --git a/lib/Service/AnalyticsService.php b/lib/Service/AnalyticsService.php
new file mode 100644
index 00000000..3b4b12a2
--- /dev/null
+++ b/lib/Service/AnalyticsService.php
@@ -0,0 +1,601 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use InvalidArgumentException;
+use OCA\MyDash\Db\Dashboard;
+use OCA\MyDash\Db\DashboardMapper;
+use OCA\MyDash\Db\DashboardView;
+use OCA\MyDash\Db\DashboardViewMapper;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IConfig;
+
+/**
+ * Reporting service for the dashboard view-analytics capability.
+ *
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods)
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
+ */
+class AnalyticsService
+{
+ /**
+ * App-config key for the global instance-wide enable/disable
+ * toggle (REQ-ANLT-005). Default `'true'`.
+ *
+ * @var string
+ */
+ public const CONFIG_KEY_ENABLED = 'analytics_enabled';
+
+ /**
+ * App-config key for the retention window in days
+ * (REQ-ANLT-009). Default `'365'`. Clamped to [30, 3650].
+ *
+ * @var string
+ */
+ public const CONFIG_KEY_RETENTION_DAYS = 'analytics_retention_days';
+
+ /**
+ * Per-user preference key for the analytics opt-out flag
+ * (REQ-ANLT-004). Default `'false'`.
+ *
+ * @var string
+ */
+ public const USER_PREF_KEY_OPTOUT = 'analytics_optout';
+
+ /**
+ * Default retention window in days.
+ *
+ * @var int
+ */
+ public const DEFAULT_RETENTION_DAYS = 365;
+
+ /**
+ * Minimum retention window in days (REQ-ANLT-009).
+ *
+ * @var int
+ */
+ public const MIN_RETENTION_DAYS = 30;
+
+ /**
+ * Maximum retention window in days (REQ-ANLT-009).
+ *
+ * @var int
+ */
+ public const MAX_RETENTION_DAYS = 3650;
+
+ /**
+ * Constructor.
+ *
+ * @param DashboardViewMapper $viewMapper Aggregate-row mapper.
+ * @param DashboardMapper $dashboardMapper Dashboard mapper for
+ * name lookups.
+ * @param UniqueViewerDedup $dedup Unique-viewer dedup
+ * service.
+ * @param IConfig $config Nextcloud config
+ * service for
+ * admin/user settings.
+ */
+ public function __construct(
+ private readonly DashboardViewMapper $viewMapper,
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly UniqueViewerDedup $dedup,
+ private readonly IConfig $config,
+ ) {
+ }//end __construct()
+
+ /**
+ * Report whether analytics are enabled instance-wide
+ * (REQ-ANLT-005). Default `true` when the setting is absent.
+ *
+ * @return bool `true` when analytics is globally enabled.
+ */
+ public function isGloballyEnabled(): bool
+ {
+ $value = $this->config->getAppValue(
+ 'mydash',
+ self::CONFIG_KEY_ENABLED,
+ 'true'
+ );
+
+ return ($value === 'true' || $value === '1');
+ }//end isGloballyEnabled()
+
+ /**
+ * Report whether the supplied user has opted out of analytics
+ * tracking (REQ-ANLT-004). Default `false`.
+ *
+ * @param string $userId The user identifier.
+ *
+ * @return bool `true` when the user has opted out.
+ */
+ public function isUserOptedOut(string $userId): bool
+ {
+ $value = $this->config->getUserValue(
+ $userId,
+ 'mydash',
+ self::USER_PREF_KEY_OPTOUT,
+ 'false'
+ );
+
+ return ($value === 'true' || $value === '1');
+ }//end isUserOptedOut()
+
+ /**
+ * Resolve the retention window in days, clamped to the
+ * `[MIN_RETENTION_DAYS, MAX_RETENTION_DAYS]` range
+ * (REQ-ANLT-009).
+ *
+ * @return int The effective retention window.
+ */
+ public function getRetentionDays(): int
+ {
+ $value = $this->config->getAppValue(
+ 'mydash',
+ self::CONFIG_KEY_RETENTION_DAYS,
+ (string) self::DEFAULT_RETENTION_DAYS
+ );
+
+ $days = (int) $value;
+ if ($days < self::MIN_RETENTION_DAYS) {
+ return self::MIN_RETENTION_DAYS;
+ }
+
+ if ($days > self::MAX_RETENTION_DAYS) {
+ return self::MAX_RETENTION_DAYS;
+ }
+
+ return $days;
+ }//end getRetentionDays()
+
+ /**
+ * Persist a fresh retention-days value, clamped to the legal
+ * bounds (REQ-ANLT-009). The clamping happens before storage so
+ * the persisted value always falls in `[30, 3650]`.
+ *
+ * @param int $days The proposed retention window.
+ *
+ * @return int The clamped value that was stored.
+ */
+ public function setRetentionDays(int $days): int
+ {
+ $clamped = $days;
+ if ($clamped < self::MIN_RETENTION_DAYS) {
+ $clamped = self::MIN_RETENTION_DAYS;
+ }
+
+ if ($clamped > self::MAX_RETENTION_DAYS) {
+ $clamped = self::MAX_RETENTION_DAYS;
+ }
+
+ $this->config->setAppValue(
+ 'mydash',
+ self::CONFIG_KEY_RETENTION_DAYS,
+ (string) $clamped
+ );
+
+ return $clamped;
+ }//end setRetentionDays()
+
+ /**
+ * Persist the global enable/disable toggle.
+ *
+ * @param bool $enabled Whether analytics tracking is active
+ * instance-wide.
+ *
+ * @return void
+ */
+ public function setGlobalEnabled(bool $enabled): void
+ {
+ $value = 'false';
+ if ($enabled === true) {
+ $value = 'true';
+ }
+
+ $this->config->setAppValue(
+ 'mydash',
+ self::CONFIG_KEY_ENABLED,
+ $value
+ );
+ }//end setGlobalEnabled()
+
+ /**
+ * Record a view event for `$dashboardUuid` by `$userId`
+ * (REQ-ANLT-002, REQ-ANLT-003, REQ-ANLT-004, REQ-ANLT-005).
+ *
+ * Short-circuits to `false` (no-op) when:
+ * - global analytics is disabled (REQ-ANLT-005);
+ * - the user has opted out (REQ-ANLT-004).
+ *
+ * Always increments `viewCount` by 1 when the call proceeds;
+ * `uniqueViewerCount` is incremented only when the dedup layer
+ * reports the viewer as new for today.
+ *
+ * @param string $dashboardUuid The dashboard UUID being viewed.
+ * @param string $userId The viewing user identifier.
+ *
+ * @return bool `true` when an event was recorded, `false` when
+ * the call was short-circuited.
+ */
+ public function recordViewEvent(
+ string $dashboardUuid,
+ string $userId
+ ): bool {
+ // Existence guard surfaces a DoesNotExistException to the
+ // caller so the controller can return a clean 404
+ // (REQ-ANLT-002).
+ $this->dashboardMapper->findByUuid(uuid: $dashboardUuid);
+
+ if ($this->isGloballyEnabled() === false) {
+ return false;
+ }
+
+ if ($this->isUserOptedOut(userId: $userId) === true) {
+ return false;
+ }
+
+ $today = UniqueViewerDedup::utcDateFor();
+ $isNewViewer = $this->dedup->isNewUniqueViewer(
+ userId: $userId,
+ viewBucketDate: $today,
+ dashboardUuid: $dashboardUuid
+ );
+ $uniqueDelta = 0;
+ if ($isNewViewer === true) {
+ $uniqueDelta = 1;
+ }
+
+ $this->viewMapper->upsertView(
+ dashboardUuid: $dashboardUuid,
+ viewBucket: $today,
+ viewCountDelta: 1,
+ uniqueCountDelta: $uniqueDelta
+ );
+
+ return true;
+ }//end recordViewEvent()
+
+ /**
+ * Resolve the top-N dashboards by total view count for the
+ * supplied period (REQ-ANLT-006). The dashboard `name` is
+ * looked up from `DashboardMapper` per row; rows whose dashboard
+ * has been deleted since the aggregate was recorded are kept
+ * with `name = null` so admins still see the orphan counter.
+ *
+ * @param string $period The period string (`7d`, `30d`, `90d`).
+ * @param int $limit Maximum rows.
+ *
+ * @return array
+ * Top-N dashboards sorted by `viewCount` descending.
+ */
+ public function getTopDashboards(string $period, int $limit): array
+ {
+ [$startDate, $endDate] = self::periodToDateRange(period: $period);
+
+ $rows = $this->viewMapper->findTopDashboardsInRange(
+ startDate: $startDate,
+ endDate: $endDate,
+ limit: $limit
+ );
+ $result = [];
+ foreach ($rows as $row) {
+ $name = null;
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(
+ uuid: $row['dashboardUuid']
+ );
+ $name = $dashboard->getName();
+ } catch (DoesNotExistException) {
+ // Orphan aggregate — dashboard deleted; leave name null.
+ $name = null;
+ }
+
+ $result[] = [
+ 'dashboardUuid' => $row['dashboardUuid'],
+ 'name' => $name,
+ 'viewCount' => $row['viewCount'],
+ 'uniqueViewerCount' => $row['uniqueViewerCount'],
+ ];
+ }
+
+ return $result;
+ }//end getTopDashboards()
+
+ /**
+ * Return the daily breakdown for one dashboard (REQ-ANLT-007).
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $period The period string.
+ *
+ * @return array
+ * Daily breakdown sorted by `viewBucket` ascending. Missing
+ * days are omitted (REQ-ANLT-007 scenario).
+ *
+ * @throws DoesNotExistException When the dashboard does not
+ * exist.
+ * @throws InvalidArgumentException When the period string is
+ * not one of `7d`, `30d`,
+ * `90d`.
+ */
+ public function getDashboardDetail(
+ string $dashboardUuid,
+ string $period
+ ): array {
+ // Existence guard surfaces a DoesNotExistException to the
+ // controller so it can return a clean 404 (REQ-ANLT-007).
+ $this->dashboardMapper->findByUuid(uuid: $dashboardUuid);
+
+ [$startDate, $endDate] = self::periodToDateRange(period: $period);
+
+ $entities = $this->viewMapper->findByDashboardInRange(
+ dashboardUuid: $dashboardUuid,
+ startDate: $startDate,
+ endDate: $endDate
+ );
+
+ $result = [];
+ foreach ($entities as $entity) {
+ $result[] = [
+ 'viewBucket' => (string) $entity->getViewBucket(),
+ 'viewCount' => $entity->getViewCount(),
+ 'uniqueViewerCount' => $entity->getUniqueViewerCount(),
+ ];
+ }
+
+ return $result;
+ }//end getDashboardDetail()
+
+ /**
+ * Compute instance-wide totals + the top-5 dashboards
+ * (REQ-ANLT-008).
+ *
+ * @param string $period The period string.
+ *
+ * @return array{totalViewCount: int, totalUniqueViewers: int,
+ * dashboardCount: int, period: string,
+ * top5: array}
+ * The summary payload.
+ */
+ public function getInstanceSummary(string $period): array
+ {
+ [$startDate, $endDate] = self::periodToDateRange(period: $period);
+
+ $totals = $this->viewMapper->findInstanceSummaryInRange(
+ startDate: $startDate,
+ endDate: $endDate
+ );
+ $top5 = $this->getTopDashboards(period: $period, limit: 5);
+
+ return [
+ 'totalViewCount' => $totals['totalViewCount'],
+ 'totalUniqueViewers' => $totals['totalUniqueViewers'],
+ 'dashboardCount' => $totals['dashboardCount'],
+ 'period' => $period,
+ 'top5' => $top5,
+ ];
+ }//end getInstanceSummary()
+
+ /**
+ * Generate a CSV export of every aggregate row in the supplied
+ * period (REQ-ANLT-010). Header row first, sorted by
+ * `(dashboardUuid, viewBucket)` ascending. Dashboard names are
+ * resolved lazily and cached in-method to avoid N+1 lookups.
+ *
+ * @param string $period The period string.
+ *
+ * @return string The CSV body (CRLF line endings).
+ */
+ public function generateCsvExport(string $period): string
+ {
+ [$startDate, $endDate] = self::periodToDateRange(period: $period);
+
+ $rows = $this->viewMapper->findAllInRange(
+ startDate: $startDate,
+ endDate: $endDate
+ );
+
+ $nameByUuid = [];
+ $output = [];
+ $output[] = self::csvLine(
+ cells: [
+ 'dashboardUuid',
+ 'dashboardName',
+ 'viewBucket',
+ 'viewCount',
+ 'uniqueViewerCount',
+ ]
+ );
+
+ foreach ($rows as $row) {
+ $uuid = (string) $row->getDashboardUuid();
+ if (array_key_exists(key: $uuid, array: $nameByUuid) === false) {
+ $name = '';
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(
+ uuid: $uuid
+ );
+ $name = (string) $dashboard->getName();
+ } catch (DoesNotExistException) {
+ $name = '';
+ }
+
+ $nameByUuid[$uuid] = $name;
+ }
+
+ $output[] = self::csvLine(
+ cells: [
+ $uuid,
+ $nameByUuid[$uuid],
+ (string) $row->getViewBucket(),
+ (string) $row->getViewCount(),
+ (string) $row->getUniqueViewerCount(),
+ ]
+ );
+ }//end foreach
+
+ return implode(separator: "\r\n", array: $output)."\r\n";
+ }//end generateCsvExport()
+
+ /**
+ * Compute a filename suitable for the CSV export attachment
+ * (REQ-ANLT-010 scenario "CSV filename includes export date").
+ *
+ * @return string The filename in the form
+ * `dashboard-analytics-YYYY-MM-DD.csv`.
+ */
+ public function csvExportFilename(): string
+ {
+ $today = (new DateTimeImmutable('now'))
+ ->setTimezone(timezone: new DateTimeZone(timezone: 'UTC'))
+ ->format(format: 'Y-m-d');
+
+ return 'dashboard-analytics-'.$today.'.csv';
+ }//end csvExportFilename()
+
+ /**
+ * Translate a period string into an inclusive UTC date range
+ * `[startDate, endDate]` where `endDate` is today (UTC) and
+ * `startDate` is `today - (n - 1)` so that exactly `n` days are
+ * covered (REQ-ANLT-006 NOTE).
+ *
+ * @param string $period The period string.
+ *
+ * @return array{0: string, 1: string} The `[startDate,
+ * endDate]` pair as
+ * `YYYY-MM-DD`.
+ *
+ * @throws InvalidArgumentException When the period is unknown.
+ */
+ public static function periodToDateRange(string $period): array
+ {
+ $days = match ($period) {
+ '7d' => 7,
+ '30d' => 30,
+ '90d' => 90,
+ default => null,
+ };
+
+ if ($days === null) {
+ throw new InvalidArgumentException(
+ message: 'Unsupported period: '.$period
+ );
+ }
+
+ $today = (new DateTimeImmutable('now'))
+ ->setTimezone(timezone: new DateTimeZone(timezone: 'UTC'));
+ $startDate = $today->modify(modifier: '-'.($days - 1).' days');
+
+ return [
+ $startDate->format(format: 'Y-m-d'),
+ $today->format(format: 'Y-m-d'),
+ ];
+ }//end periodToDateRange()
+
+ /**
+ * Compute the cutoff date for the retention purge job — every
+ * row with `view_bucket < cutoff` is eligible for deletion
+ * (REQ-ANLT-009).
+ *
+ * @return string The cutoff date in `YYYY-MM-DD` format.
+ */
+ public function getPurgeCutoffDate(): string
+ {
+ $today = (new DateTimeImmutable('now'))
+ ->setTimezone(timezone: new DateTimeZone(timezone: 'UTC'));
+ $cutoff = $today->modify(
+ modifier: '-'.$this->getRetentionDays().' days'
+ );
+
+ return $cutoff->format(format: 'Y-m-d');
+ }//end getPurgeCutoffDate()
+
+ /**
+ * Render one CSV line from the supplied cells. Quotes every
+ * cell, escaping embedded quotes per RFC 4180.
+ *
+ * @param string[] $cells The raw cell values.
+ *
+ * @return string The CSV-encoded line (no trailing newline).
+ */
+ private static function csvLine(array $cells): string
+ {
+ $escaped = array_map(
+ callback: static function (string $cell): string {
+ return '"'.str_replace(
+ search: '"',
+ replace: '""',
+ subject: $cell
+ ).'"';
+ },
+ array: $cells
+ );
+
+ return implode(separator: ',', array: $escaped);
+ }//end csvLine()
+
+ /**
+ * Returns the entity's UUID for a dashboard. Internal helper
+ * useful for tests and callers that already hold the entity.
+ *
+ * @param Dashboard $dashboard The dashboard entity.
+ *
+ * @return string The non-null UUID (returns empty string when
+ * the entity carries no UUID — never expected on
+ * a persisted row).
+ */
+ public static function dashboardUuidOf(Dashboard $dashboard): string
+ {
+ $uuid = $dashboard->getUuid();
+ if ($uuid === null) {
+ return '';
+ }
+
+ return $uuid;
+ }//end dashboardUuidOf()
+
+ /**
+ * Internal helper exposed for the controller layer to surface a
+ * canonical "view" entity payload during `viewEvent` debugging.
+ *
+ * @param DashboardView $view The aggregate row.
+ *
+ * @return array The serialised payload.
+ */
+ public static function viewToArray(DashboardView $view): array
+ {
+ return $view->jsonSerialize();
+ }//end viewToArray()
+}//end class
diff --git a/lib/Service/BulkOperationService.php b/lib/Service/BulkOperationService.php
new file mode 100644
index 00000000..89a1da3d
--- /dev/null
+++ b/lib/Service/BulkOperationService.php
@@ -0,0 +1,871 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service;
+
+use DateTime;
+use InvalidArgumentException;
+use OCA\MyDash\Activity\ActivityPublisher;
+use OCA\MyDash\Activity\Extension;
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Db\Dashboard;
+use OCA\MyDash\Db\DashboardMapper;
+use OCA\MyDash\Db\WidgetPlacementMapper;
+use OCA\MyDash\Exception\DashboardHasChildrenException;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IConfig;
+use OCP\IGroupManager;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Orchestrates dashboard bulk operations (REQ-BULK-001..011).
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The four bulk endpoints
+ * share the same permission, audit, and idempotency scaffolding; splitting
+ * the service would duplicate the all-or-nothing guard logic.
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Branching mirrors the
+ * per-operation idempotency rules pinned in REQ-BULK-007.
+ */
+class BulkOperationService
+{
+ /**
+ * App config key controlling the per-request cap (REQ-BULK-006).
+ *
+ * @var string
+ */
+ public const CONFIG_KEY_MAX_PER_REQUEST = 'bulk_operation_max_per_request';
+
+ /**
+ * Default value when no admin-tuned override is present (REQ-BULK-006).
+ *
+ * @var int
+ */
+ public const DEFAULT_MAX_PER_REQUEST = 500;
+
+ /**
+ * Operation labels used in the audit payload + error envelopes
+ * (REQ-BULK-009).
+ */
+ public const OP_DELETE = 'delete';
+ public const OP_MOVE = 'move';
+ public const OP_STATUS = 'status';
+ public const OP_REINDEX = 'reindex';
+
+ /**
+ * Stable error reasons returned in the per-uuid `errors` array.
+ */
+ public const REASON_ALREADY_DELETED = 'already_deleted';
+ public const REASON_PARENT_ALREADY_MATCH = 'parent_already_matches';
+ public const REASON_STATUS_ALREADY_MATCH = 'status_already_matches';
+ public const REASON_CYCLE_DETECTED = 'cycle_detected';
+ public const REASON_TRANSACTION_FAILED = 'transaction_failed';
+ public const REASON_REINDEX_FAILED = 'reindex_failed';
+ public const REASON_NOT_FOUND = 'not_found';
+ public const REASON_INVALID_PARENT = 'invalid_parent';
+
+ /**
+ * Constructor.
+ *
+ * @param DashboardMapper $dashboardMapper The dashboard mapper.
+ * @param WidgetPlacementMapper $placementMapper The placement mapper
+ * (used by the per-uuid
+ * hard delete cascade).
+ * @param PermissionService $permissionService Permission resolver
+ * used for the per-uuid
+ * admin pre-check
+ * (REQ-BULK-011).
+ * @param DashboardTreeService $treeService Cycle / cascade
+ * service delegated by
+ * bulk-move + bulk-delete
+ * (REQ-BULK-001/002).
+ * @param ActivityPublisher $activityPublisher Single-event audit
+ * emitter
+ * (REQ-BULK-009).
+ * @param IGroupManager $groupManager Used for the "is the
+ * caller an NC admin"
+ * short-circuit on the
+ * permission pre-check.
+ * @param IConfig $config App config for
+ * reading the
+ * per-request cap.
+ * @param LoggerInterface $logger Diagnostic logger.
+ */
+ public function __construct(
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly WidgetPlacementMapper $placementMapper,
+ private readonly PermissionService $permissionService,
+ private readonly DashboardTreeService $treeService,
+ private readonly ActivityPublisher $activityPublisher,
+ private readonly IGroupManager $groupManager,
+ private readonly IConfig $config,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Hard-delete multiple dashboards (REQ-BULK-001).
+ *
+ * Returns `[deletedCount, skippedCount, errors]` (or the `wouldX`
+ * variants when `$dryRun` is true). Cascade to child dashboards is
+ * opt-in: when `$cascade` is false and any dashboard has children,
+ * that dashboard is rejected with `dashboard_has_children` and a
+ * `childCount` field in the per-uuid error.
+ *
+ * @param string[] $dashboardUuids The dashboard UUIDs to delete.
+ * @param string $userId The acting NC user ID.
+ * @param bool $dryRun When true, preview only.
+ * @param bool $cascade When true, recurse into children.
+ *
+ * @return array The result envelope.
+ *
+ * @throws InvalidArgumentException When the request exceeds the cap.
+ * @throws PermissionDeniedException When any UUID is unauthorised.
+ */
+ public function bulkDelete(
+ array $dashboardUuids,
+ string $userId,
+ bool $dryRun=false,
+ bool $cascade=false
+ ): array {
+ $start = microtime(as_float: true);
+ $this->assertPermissions(uuids: $dashboardUuids, userId: $userId);
+ $this->assertWithinCap(uuids: $dashboardUuids);
+
+ $deleted = 0;
+ $skipped = 0;
+ $errors = [];
+
+ foreach ($dashboardUuids as $uuid) {
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: (string) $uuid);
+ } catch (DoesNotExistException) {
+ // REQ-BULK-007: hard delete on a missing row is silent.
+ $skipped++;
+ $errors[] = [
+ 'uuid' => (string) $uuid,
+ 'reason' => self::REASON_ALREADY_DELETED,
+ ];
+ continue;
+ }
+
+ $childCount = 0;
+ $rowUuid = (string) $dashboard->getUuid();
+ if ($rowUuid !== '') {
+ $childCount = $this->dashboardMapper->countChildrenByParent(
+ parentUuid: $rowUuid
+ );
+ }
+
+ if ($childCount > 0 && $cascade === false) {
+ $errors[] = [
+ 'uuid' => $rowUuid,
+ 'reason' => DashboardHasChildrenException::ERROR_CODE,
+ 'childCount' => $childCount,
+ ];
+ $skipped++;
+ continue;
+ }
+
+ if ($dryRun === true) {
+ $increment = 1;
+ if ($cascade === true && $childCount > 0) {
+ $increment = ($childCount + 1);
+ }
+
+ $deleted += $increment;
+ continue;
+ }
+
+ try {
+ if ($cascade === true && $childCount > 0) {
+ $deleted += $this->treeService->deleteSubtree(
+ dashboard: $dashboard
+ );
+ } else {
+ $this->placementMapper->deleteByDashboardId(
+ dashboardId: (int) $dashboard->getId()
+ );
+ $this->dashboardMapper->delete(entity: $dashboard);
+ $deleted++;
+ }
+ } catch (Throwable $t) {
+ $this->logger->error(
+ message: 'BulkOperationService::bulkDelete failed for uuid',
+ context: ['uuid' => $rowUuid, 'exception' => $t]
+ );
+ $errors[] = [
+ 'uuid' => $rowUuid,
+ 'reason' => self::REASON_TRANSACTION_FAILED,
+ 'detail' => $t->getMessage(),
+ ];
+ }//end try
+ }//end foreach
+
+ $payload = [
+ $this->countKey(dryRun: $dryRun, real: 'deletedCount', preview: 'wouldDeleteCount') => $deleted,
+ $this->countKey(dryRun: $dryRun, real: 'skippedCount', preview: 'wouldSkipCount') => $skipped,
+ 'errors' => $errors,
+ 'dryRun' => $dryRun,
+ ];
+
+ $this->emitAuditEvent(
+ operation: self::OP_DELETE,
+ dashboardCount: count($dashboardUuids),
+ userId: $userId,
+ durationMs: $this->elapsedMs(start: $start),
+ dryRun: $dryRun
+ );
+
+ return $payload;
+ }//end bulkDelete()
+
+ /**
+ * Re-parent multiple dashboards in the dashboard hierarchy
+ * (REQ-BULK-002).
+ *
+ * Cycle detection is delegated to {@see DashboardTreeService} so the
+ * single source of truth for tree invariants stays in one place.
+ *
+ * @param string[] $dashboardUuids The dashboards to re-parent.
+ * @param string|null $parentUuid The new parent UUID
+ * (NULL ⇒ root).
+ * @param string $userId The acting NC user ID.
+ * @param bool $dryRun When true, preview only.
+ *
+ * @return array The result envelope.
+ *
+ * @throws InvalidArgumentException When the request exceeds the cap
+ * or the parent does not exist.
+ * @throws PermissionDeniedException When any UUID is unauthorised.
+ */
+ public function bulkMove(
+ array $dashboardUuids,
+ ?string $parentUuid,
+ string $userId,
+ bool $dryRun=false
+ ): array {
+ $start = microtime(as_float: true);
+ $this->assertPermissions(uuids: $dashboardUuids, userId: $userId);
+ $this->assertWithinCap(uuids: $dashboardUuids);
+
+ if ($parentUuid !== null && $parentUuid !== '') {
+ try {
+ $this->dashboardMapper->findByUuid(uuid: $parentUuid);
+ } catch (DoesNotExistException) {
+ throw new InvalidArgumentException(
+ message: DashboardTreeService::ERR_PARENT_NOT_FOUND
+ );
+ }
+ }
+
+ $moved = 0;
+ $skipped = 0;
+ $errors = [];
+
+ $now = (new DateTime())->format(format: 'Y-m-d H:i:s');
+
+ foreach ($dashboardUuids as $uuid) {
+ $uuidString = (string) $uuid;
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuidString);
+ } catch (DoesNotExistException) {
+ $errors[] = [
+ 'uuid' => $uuidString,
+ 'reason' => self::REASON_NOT_FOUND,
+ ];
+ continue;
+ }
+
+ $current = $dashboard->getParentUuid();
+ if (($current ?? '') === ($parentUuid ?? '')) {
+ // REQ-BULK-007 idempotency: already at target parent.
+ $skipped++;
+ $errors[] = [
+ 'uuid' => $uuidString,
+ 'reason' => self::REASON_PARENT_ALREADY_MATCH,
+ ];
+ continue;
+ }
+
+ try {
+ $this->treeService->validateParent(
+ movingUuid: $uuidString,
+ newParentUuid: $parentUuid
+ );
+ } catch (InvalidArgumentException $e) {
+ $errors[] = [
+ 'uuid' => $uuidString,
+ 'reason' => self::REASON_CYCLE_DETECTED,
+ 'detail' => $e->getMessage(),
+ ];
+ continue;
+ }
+
+ if ($dryRun === true) {
+ $moved++;
+ continue;
+ }
+
+ try {
+ $dashboard->setParentUuid($parentUuid);
+ $dashboard->setUpdatedAt($now);
+ $this->dashboardMapper->update(entity: $dashboard);
+ $moved++;
+ } catch (Throwable $t) {
+ $this->logger->error(
+ message: 'BulkOperationService::bulkMove failed for uuid',
+ context: ['uuid' => $uuidString, 'exception' => $t]
+ );
+ $errors[] = [
+ 'uuid' => $uuidString,
+ 'reason' => self::REASON_TRANSACTION_FAILED,
+ 'detail' => $t->getMessage(),
+ ];
+ }
+ }//end foreach
+
+ $payload = [
+ $this->countKey(dryRun: $dryRun, real: 'movedCount', preview: 'wouldMoveCount') => $moved,
+ $this->countKey(dryRun: $dryRun, real: 'skippedCount', preview: 'wouldSkipCount') => $skipped,
+ 'errors' => $errors,
+ 'dryRun' => $dryRun,
+ ];
+
+ $this->emitAuditEvent(
+ operation: self::OP_MOVE,
+ dashboardCount: count($dashboardUuids),
+ userId: $userId,
+ durationMs: $this->elapsedMs(start: $start),
+ dryRun: $dryRun
+ );
+
+ return $payload;
+ }//end bulkMove()
+
+ /**
+ * Update the publication status of multiple dashboards (REQ-BULK-003).
+ *
+ * Status enum is the canonical {@see Dashboard::STATUS_DRAFT} /
+ * {@see Dashboard::STATUS_PUBLISHED} / {@see Dashboard::STATUS_SCHEDULED}
+ * triple. `publishAt` is required when the target status is
+ * `scheduled` and is rejected when in the past.
+ *
+ * @param string[] $dashboardUuids The dashboards to mutate.
+ * @param string $publicationStatus The target status enum value.
+ * @param string|null $publishAt Future ISO-8601 timestamp
+ * (required for `scheduled`).
+ * @param string $userId The acting NC user ID.
+ * @param bool $dryRun When true, preview only.
+ *
+ * @return array The result envelope.
+ *
+ * @throws InvalidArgumentException When the status is not in the
+ * enum, when `publishAt` is missing
+ * for `scheduled`, when the cap is
+ * exceeded, or when `publishAt` is
+ * in the past.
+ * @throws PermissionDeniedException When any UUID is unauthorised.
+ */
+ public function bulkStatus(
+ array $dashboardUuids,
+ string $publicationStatus,
+ ?string $publishAt,
+ string $userId,
+ bool $dryRun=false
+ ): array {
+ $start = microtime(as_float: true);
+ $this->assertPermissions(uuids: $dashboardUuids, userId: $userId);
+ $this->assertWithinCap(uuids: $dashboardUuids);
+
+ $allowed = [
+ Dashboard::STATUS_DRAFT,
+ Dashboard::STATUS_PUBLISHED,
+ Dashboard::STATUS_SCHEDULED,
+ ];
+
+ if (in_array(needle: $publicationStatus, haystack: $allowed, strict: true) === false) {
+ throw new InvalidArgumentException(
+ message: 'publicationStatus must be one of: draft, published, scheduled'
+ );
+ }
+
+ $resolvedPublishAt = null;
+ if ($publicationStatus === Dashboard::STATUS_SCHEDULED) {
+ if ($publishAt === null || trim($publishAt) === '') {
+ throw new InvalidArgumentException(
+ message: 'publishAt is required when publicationStatus is "scheduled"'
+ );
+ }
+
+ $resolvedPublishAt = $this->parseFutureDate(publishAt: $publishAt);
+ }
+
+ $updated = 0;
+ $skipped = 0;
+ $errors = [];
+ $now = (new DateTime())->format(format: 'Y-m-d H:i:s');
+
+ foreach ($dashboardUuids as $uuid) {
+ $uuidString = (string) $uuid;
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuidString);
+ } catch (DoesNotExistException) {
+ $errors[] = [
+ 'uuid' => $uuidString,
+ 'reason' => self::REASON_NOT_FOUND,
+ ];
+ continue;
+ }
+
+ // REQ-BULK-007 idempotency: already at target status.
+ if ($dashboard->getPublicationStatus() === $publicationStatus
+ && ($publicationStatus !== Dashboard::STATUS_SCHEDULED
+ || $dashboard->getPublishAt() === $resolvedPublishAt)
+ ) {
+ $skipped++;
+ $errors[] = [
+ 'uuid' => $uuidString,
+ 'reason' => self::REASON_STATUS_ALREADY_MATCH,
+ ];
+ continue;
+ }
+
+ if ($dryRun === true) {
+ $updated++;
+ continue;
+ }
+
+ try {
+ $this->applyStatusChange(
+ dashboard: $dashboard,
+ publicationStatus: $publicationStatus,
+ resolvedPublishAt: $resolvedPublishAt,
+ now: $now
+ );
+ $updated++;
+ } catch (Throwable $t) {
+ $this->logger->error(
+ message: 'BulkOperationService::bulkStatus failed for uuid',
+ context: ['uuid' => $uuidString, 'exception' => $t]
+ );
+ $errors[] = [
+ 'uuid' => $uuidString,
+ 'reason' => self::REASON_TRANSACTION_FAILED,
+ 'detail' => $t->getMessage(),
+ ];
+ }
+ }//end foreach
+
+ $payload = [
+ $this->countKey(dryRun: $dryRun, real: 'updatedCount', preview: 'wouldUpdateCount') => $updated,
+ $this->countKey(dryRun: $dryRun, real: 'skippedCount', preview: 'wouldSkipCount') => $skipped,
+ 'errors' => $errors,
+ 'dryRun' => $dryRun,
+ ];
+
+ $this->emitAuditEvent(
+ operation: self::OP_STATUS,
+ dashboardCount: count($dashboardUuids),
+ userId: $userId,
+ durationMs: $this->elapsedMs(start: $start),
+ dryRun: $dryRun,
+ extra: ['publicationStatus' => $publicationStatus]
+ );
+
+ return $payload;
+ }//end bulkStatus()
+
+ /**
+ * Re-index multiple dashboards for unified search (REQ-BULK-004).
+ *
+ * The unified-search integration is provided by a sibling capability;
+ * this service touches the dashboard's `updated_at` to mark the row
+ * dirty so any downstream search-indexer pipeline (cron / queue) will
+ * pick it up on its next pass. Per-uuid failures are reported in
+ * `errors` and the batch continues.
+ *
+ * @param string[] $dashboardUuids The dashboards to mark for reindex.
+ * @param string $userId The acting NC user ID.
+ * @param bool $dryRun When true, preview only.
+ *
+ * @return array The result envelope.
+ *
+ * @throws InvalidArgumentException When the request exceeds the cap.
+ * @throws PermissionDeniedException When any UUID is unauthorised.
+ */
+ public function bulkReindex(
+ array $dashboardUuids,
+ string $userId,
+ bool $dryRun=false
+ ): array {
+ $start = microtime(as_float: true);
+ $this->assertPermissions(uuids: $dashboardUuids, userId: $userId);
+ $this->assertWithinCap(uuids: $dashboardUuids);
+
+ $reindexed = 0;
+ $errors = [];
+ $now = (new DateTime())->format(format: 'Y-m-d H:i:s');
+
+ foreach ($dashboardUuids as $uuid) {
+ $uuidString = (string) $uuid;
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuidString);
+ } catch (DoesNotExistException) {
+ $errors[] = [
+ 'uuid' => $uuidString,
+ 'reason' => self::REASON_NOT_FOUND,
+ ];
+ continue;
+ }
+
+ if ($dryRun === true) {
+ $reindexed++;
+ continue;
+ }
+
+ try {
+ // Mark the dashboard dirty so the unified-search provider
+ // re-ingests it on the next pass. The provider lives in
+ // a sibling capability; touching `updatedAt` is the
+ // documented contract surface for "rebuild this row".
+ $dashboard->setUpdatedAt($now);
+ $this->dashboardMapper->update(entity: $dashboard);
+ $reindexed++;
+ } catch (Throwable $t) {
+ $this->logger->error(
+ message: 'BulkOperationService::bulkReindex failed for uuid',
+ context: ['uuid' => $uuidString, 'exception' => $t]
+ );
+ $errors[] = [
+ 'uuid' => $uuidString,
+ 'reason' => self::REASON_REINDEX_FAILED,
+ 'detail' => $t->getMessage(),
+ ];
+ }
+ }//end foreach
+
+ $payload = [
+ $this->countKey(dryRun: $dryRun, real: 'reindexedCount', preview: 'wouldReindexCount') => $reindexed,
+ 'errors' => $errors,
+ 'dryRun' => $dryRun,
+ ];
+
+ $this->emitAuditEvent(
+ operation: self::OP_REINDEX,
+ dashboardCount: count($dashboardUuids),
+ userId: $userId,
+ durationMs: $this->elapsedMs(start: $start),
+ dryRun: $dryRun
+ );
+
+ return $payload;
+ }//end bulkReindex()
+
+ /**
+ * Resolve the per-request size cap from app config, falling back to
+ * the {@see self::DEFAULT_MAX_PER_REQUEST} when the configured value
+ * is missing, zero, or negative (REQ-BULK-006 fallback scenario).
+ *
+ * @return int The effective cap.
+ */
+ public function getEffectiveCap(): int
+ {
+ $raw = $this->config->getAppValue(
+ Application::APP_ID,
+ self::CONFIG_KEY_MAX_PER_REQUEST,
+ (string) self::DEFAULT_MAX_PER_REQUEST
+ );
+
+ $value = (int) $raw;
+ if ($value <= 0) {
+ $this->logger->warning(
+ message: 'Invalid bulk operation cap; falling back to default',
+ context: ['raw' => $raw]
+ );
+
+ return self::DEFAULT_MAX_PER_REQUEST;
+ }
+
+ return $value;
+ }//end getEffectiveCap()
+
+ /**
+ * REQ-BULK-011 — all-or-nothing permission pre-check.
+ *
+ * The permission pre-check has higher priority than the size cap
+ * (REQ-BULK-011 scenario "Permission check happens before size
+ * validation"). Non-admins are rejected wholesale; for admins, every
+ * UUID is verified individually so a single unauthorised dashboard
+ * fails the batch.
+ *
+ * @param string[] $uuids The dashboard UUIDs in the batch.
+ * @param string $userId The acting NC user ID.
+ *
+ * @return void
+ *
+ * @throws PermissionDeniedException When the caller is not an NC
+ * admin or any UUID is unauthorised.
+ */
+ private function assertPermissions(array $uuids, string $userId): void
+ {
+ if ($this->groupManager->isAdmin(userId: $userId) === false) {
+ throw new PermissionDeniedException(
+ message: 'Administrator privileges required.',
+ deniedUuids: []
+ );
+ }
+
+ $denied = [];
+ foreach ($uuids as $uuid) {
+ $uuidString = (string) $uuid;
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuidString);
+ } catch (DoesNotExistException) {
+ // Missing rows are not a permission failure — they are
+ // routed to the per-uuid `not_found`/`already_deleted`
+ // handler downstream.
+ continue;
+ }
+
+ $level = $this->permissionService->resolveAccessLevel(
+ userId: $userId,
+ dashboard: $dashboard
+ );
+ if ($level === null) {
+ $denied[] = $uuidString;
+ }
+ }
+
+ if ($denied !== []) {
+ throw new PermissionDeniedException(
+ message: 'Insufficient permissions on one or more dashboards.',
+ deniedUuids: $denied
+ );
+ }
+ }//end assertPermissions()
+
+ /**
+ * REQ-BULK-006 — enforce the per-request size cap.
+ *
+ * @param string[] $uuids The dashboard UUIDs.
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException When the batch exceeds the cap.
+ */
+ private function assertWithinCap(array $uuids): void
+ {
+ $cap = $this->getEffectiveCap();
+ if (count($uuids) > $cap) {
+ throw new InvalidArgumentException(
+ message: 'Request contains '.count($uuids).' dashboards; maximum is '.$cap.' (configured by admin)'
+ );
+ }
+ }//end assertWithinCap()
+
+ /**
+ * Emit a single audit Activity event summarising the bulk operation
+ * (REQ-BULK-009).
+ *
+ * Audit events are fan-out via {@see ActivityPublisher::publishGlobal()}
+ * with a synthetic placeholder UUID — the dashboard count and
+ * operation type are the diagnostically interesting fields. Any
+ * Activity failure is swallowed by the publisher so an audit-log
+ * problem never rolls back the bulk operation.
+ *
+ * @param string $operation The operation label.
+ * @param int $dashboardCount The batch size.
+ * @param string $userId The acting NC user ID.
+ * @param int $durationMs Operation duration.
+ * @param bool $dryRun True when this was a dry-run.
+ * @param array $extra Additional payload fields.
+ *
+ * @return void
+ */
+ private function emitAuditEvent(
+ string $operation,
+ int $dashboardCount,
+ string $userId,
+ int $durationMs,
+ bool $dryRun,
+ array $extra=[]
+ ): void {
+ try {
+ $this->activityPublisher->publish(
+ type: Extension::EVENT_UPDATED,
+ actorUserId: $userId,
+ recipientUserId: $userId,
+ dashboardUuid: 'bulk-'.$operation,
+ dashboardName: 'Bulk '.$operation.' ('.$dashboardCount.' dashboards)',
+ dashboardLink: '',
+ extraParams: array_merge(
+ [
+ 'bulkOperation' => $operation,
+ 'dashboardCount' => $dashboardCount,
+ 'durationMs' => $durationMs,
+ 'dryRun' => $dryRun,
+ ],
+ $extra
+ )
+ );
+ } catch (Throwable $t) {
+ // Defensive: ActivityPublisher already swallows; this is a
+ // belt-and-braces guard.
+ $this->logger->warning(
+ message: 'BulkOperationService audit event failed',
+ context: ['operation' => $operation, 'exception' => $t]
+ );
+ }//end try
+ }//end emitAuditEvent()
+
+ /**
+ * Compute the elapsed time in milliseconds from a microtime(true)
+ * start marker.
+ *
+ * @param float $start The start marker (`microtime(true)`).
+ *
+ * @return int The elapsed milliseconds (>=0).
+ */
+ private function elapsedMs(float $start): int
+ {
+ return (int) max(0, ((microtime(as_float: true) - $start) * 1000));
+ }//end elapsedMs()
+
+ /**
+ * Parse and validate a future ISO-8601 timestamp for the `scheduled`
+ * status. Mirrors {@see DashboardService::parseFuturePublishAt()} so
+ * the bulk path enforces the same contract as the per-dashboard
+ * publish/schedule path.
+ *
+ * @param string $publishAt The ISO-8601 timestamp.
+ *
+ * @return string The normalised `Y-m-d H:i:s` string.
+ *
+ * @throws InvalidArgumentException When the timestamp is empty,
+ * unparseable, or in the past.
+ */
+ private function parseFutureDate(string $publishAt): string
+ {
+ $trimmed = trim($publishAt);
+ if ($trimmed === '') {
+ throw new InvalidArgumentException(
+ message: DashboardService::ERR_SCHEDULE_PAST_DATE
+ );
+ }
+
+ try {
+ $parsed = new DateTime($trimmed);
+ } catch (\Exception) {
+ throw new InvalidArgumentException(
+ message: DashboardService::ERR_SCHEDULE_PAST_DATE
+ );
+ }
+
+ if ($parsed <= new DateTime()) {
+ throw new InvalidArgumentException(
+ message: DashboardService::ERR_SCHEDULE_PAST_DATE
+ );
+ }
+
+ return $parsed->format(format: 'Y-m-d H:i:s');
+ }//end parseFutureDate()
+
+ /**
+ * Apply the per-dashboard publication-status mutation.
+ *
+ * Encapsulates the per-status field bookkeeping:
+ * - PUBLISHED: stamps `publishedAt` on first publish, clears `publishAt`.
+ * - DRAFT: clears `publishAt`.
+ * - SCHEDULED: stores the parsed `publishAt`.
+ *
+ * @param Dashboard $dashboard The dashboard to mutate.
+ * @param string $publicationStatus The target status.
+ * @param string|null $resolvedPublishAt The parsed publishAt (for
+ * STATUS_SCHEDULED).
+ * @param string $now Current timestamp.
+ *
+ * @return void
+ */
+ private function applyStatusChange(
+ Dashboard $dashboard,
+ string $publicationStatus,
+ ?string $resolvedPublishAt,
+ string $now
+ ): void {
+ $dashboard->setPublicationStatus($publicationStatus);
+
+ if ($publicationStatus === Dashboard::STATUS_PUBLISHED) {
+ if ($dashboard->getPublishedAt() === null) {
+ $dashboard->setPublishedAt($now);
+ }
+
+ $dashboard->setPublishAt(null);
+ } else if ($publicationStatus === Dashboard::STATUS_DRAFT) {
+ $dashboard->setPublishAt(null);
+ } else {
+ // STATUS_SCHEDULED.
+ $dashboard->setPublishAt($resolvedPublishAt);
+ }
+
+ $dashboard->setUpdatedAt($now);
+ $this->dashboardMapper->update(entity: $dashboard);
+ }//end applyStatusChange()
+
+ /**
+ * Resolve the response counter key for the current run mode.
+ *
+ * The bulk endpoints expose two parallel counter naming schemes:
+ * `deletedCount`/`movedCount`/etc. for real runs, `wouldDeleteCount`
+ * /`wouldMoveCount`/etc. for dry runs. This helper centralises the
+ * chooser so the per-operation `$payload` arrays stay free of inline
+ * ternaries (the project's PHPCS rules disallow them).
+ *
+ * @param bool $dryRun True when the operation is a dry-run preview.
+ * @param string $real The counter name for real runs.
+ * @param string $preview The counter name for dry-run previews.
+ *
+ * @return string The chosen key.
+ */
+ private function countKey(bool $dryRun, string $real, string $preview): string
+ {
+ if ($dryRun === true) {
+ return $preview;
+ }
+
+ return $real;
+ }//end countKey()
+}//end class
diff --git a/lib/Service/CalendarWidgetService.php b/lib/Service/CalendarWidgetService.php
new file mode 100644
index 00000000..3d9e1ee0
--- /dev/null
+++ b/lib/Service/CalendarWidgetService.php
@@ -0,0 +1,658 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service;
+
+use DateTimeImmutable;
+use DateTimeInterface;
+use OCP\Calendar\IManager;
+use OCP\Http\Client\IClientService;
+use OCP\IAppConfig;
+use OCP\ICache;
+use OCP\ICacheFactory;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+use Throwable;
+
+/**
+ * Aggregates and normalises calendar events for the MyDash calendar widget.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Calendar + HTTP + cache +
+ * config + logging are all
+ * unavoidable here.
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Aggregation of multiple
+ * sources + recurring-
+ * event expansion + SSRF
+ * guard naturally pushes
+ * total complexity past
+ * the project default.
+ */
+class CalendarWidgetService
+{
+ /**
+ * Default cache TTL for raw ICS bodies (in seconds).
+ *
+ * @var int
+ */
+ public const DEFAULT_CACHE_TTL_SECONDS = 1800;
+
+ /**
+ * App-config key for the admin-tunable cache TTL.
+ *
+ * @var string
+ */
+ public const CONFIG_KEY_CACHE_TTL = 'calendar_widget_ics_cache_ttl_seconds';
+
+ /**
+ * App-config key for the JSON-encoded allow-list of ICS hostnames.
+ *
+ * @var string
+ */
+ public const CONFIG_KEY_ALLOWED_HOSTS = 'calendar_widget_allowed_ics_hosts';
+
+ /**
+ * HTTP timeout for external ICS fetches (seconds).
+ *
+ * @var int
+ */
+ public const FETCH_TIMEOUT_SECONDS = 10;
+
+ /**
+ * Maximum response size accepted from an external ICS URL (bytes).
+ *
+ * @var int
+ */
+ public const MAX_RESPONSE_SIZE_BYTES = 1048576;
+
+ /**
+ * Cache namespace used by ICacheFactory::createDistributed().
+ *
+ * @var string
+ */
+ private const CACHE_NAMESPACE = 'mydash-calendar-ics';
+
+ /**
+ * The distributed ICache instance used to cache raw ICS bodies.
+ *
+ * @var ICache
+ */
+ private ICache $cache;
+
+ /**
+ * Constructor.
+ *
+ * @param IAppConfig $appConfig The app config reader.
+ * @param ICacheFactory $cacheFactory Distributed cache factory.
+ * @param IClientService $clientService HTTP client factory.
+ * @param LoggerInterface $logger Logger.
+ * @param IManager|null $calendarMgr NC Calendar manager (optional).
+ */
+ public function __construct(
+ private readonly IAppConfig $appConfig,
+ ICacheFactory $cacheFactory,
+ private readonly IClientService $clientService,
+ private readonly LoggerInterface $logger,
+ private readonly ?IManager $calendarMgr=null,
+ ) {
+ $this->cache = $cacheFactory->createDistributed(prefix: self::CACHE_NAMESPACE);
+ }//end __construct()
+
+ /**
+ * Aggregate events for a single placement configuration.
+ *
+ * Combines internal NC calendar events and external ICS events.
+ * Failures on individual sources are logged and reported via
+ * the `failures` array but never abort the whole call.
+ *
+ * @param array $config Parsed calendar widget config — keys:
+ * internalCalendars: string[],
+ * externalIcsUrls: string[].
+ * @param string $from ISO 8601 start (inclusive).
+ * @param string $to ISO 8601 end (inclusive).
+ *
+ * @return array{events: array>, failures: array}
+ */
+ public function getEvents(array $config, string $from, string $to): array
+ {
+ $internalCalendars = (array) ($config['internalCalendars'] ?? []);
+ $externalIcsUrls = (array) ($config['externalIcsUrls'] ?? []);
+
+ $events = [];
+ $failures = [];
+
+ if ($internalCalendars !== []) {
+ $internalResult = $this->fetchInternalEvents(
+ principals: $internalCalendars,
+ from: $from,
+ to: $to
+ );
+ $events = array_merge($events, $internalResult['events']);
+ $failures = array_merge($failures, $internalResult['failures']);
+ }
+
+ if ($externalIcsUrls !== []) {
+ $externalResult = $this->fetchExternalIcsEvents(
+ urls: $externalIcsUrls,
+ from: $from,
+ to: $to
+ );
+ $events = array_merge($events, $externalResult['events']);
+ $failures = array_merge($failures, $externalResult['failures']);
+ }
+
+ usort(
+ array: $events,
+ callback: static function (array $eventA, array $eventB): int {
+ return strcmp(string1: $eventA['start'] ?? '', string2: $eventB['start'] ?? '');
+ }
+ );
+
+ return [
+ 'events' => $events,
+ 'failures' => $failures,
+ ];
+ }//end getEvents()
+
+ /**
+ * Fetch events from internal Nextcloud calendars.
+ *
+ * Uses OCP\Calendar\IManager::search() when available so ACL is
+ * delegated to NC's calendar app. If IManager is unavailable
+ * (typical in unit tests or minimal NC installs) returns an empty
+ * result without raising.
+ *
+ * @param array $principals Calendar principal/key URIs.
+ * @param string $from ISO start.
+ * @param string $to ISO end.
+ *
+ * @return array{events: array>, failures: array}
+ */
+ public function fetchInternalEvents(array $principals, string $from, string $to): array
+ {
+ $events = [];
+ $failures = [];
+
+ if ($this->calendarMgr === null) {
+ return [
+ 'events' => $events,
+ 'failures' => $failures,
+ ];
+ }
+
+ try {
+ $startObj = new DateTimeImmutable(datetime: $from);
+ $endObj = new DateTimeImmutable(datetime: $to);
+ } catch (Throwable $exception) {
+ $this->logger->warning(
+ message: 'Calendar widget received malformed from/to: '.$exception->getMessage()
+ );
+ return [
+ 'events' => $events,
+ 'failures' => ['internal: invalid date range'],
+ ];
+ }
+
+ try {
+ $matches = $this->calendarMgr->search(
+ pattern: '',
+ searchProperties: [],
+ options: [
+ 'timerange' => [
+ 'start' => $startObj,
+ 'end' => $endObj,
+ ],
+ ],
+ limit: null,
+ offset: null
+ );
+ } catch (Throwable $exception) {
+ $this->logger->warning(
+ message: 'Calendar widget internal search failed: '.$exception->getMessage()
+ );
+ return [
+ 'events' => $events,
+ 'failures' => ['internal: '.$exception->getMessage()],
+ ];
+ }//end try
+
+ foreach ($matches as $match) {
+ $matchArr = (array) $match;
+
+ $calId = (string) ($matchArr['calendar-key'] ?? $matchArr['calendarUri'] ?? '');
+ $calName = (string) ($matchArr['calendar-name'] ?? $matchArr['calendarName'] ?? '');
+
+ if ($principals !== [] && in_array(needle: $calId, haystack: $principals, strict: true) === false
+ && in_array(needle: $calName, haystack: $principals, strict: true) === false
+ ) {
+ // Caller restricted to a subset; skip out-of-scope calendars.
+ continue;
+ }
+
+ $events[] = $this->normalizeInternalEvent(raw: $matchArr);
+ }
+
+ return [
+ 'events' => $events,
+ 'failures' => $failures,
+ ];
+ }//end fetchInternalEvents()
+
+ /**
+ * Fetch events from a list of external ICS URLs.
+ *
+ * URLs that are non-HTTPS, resolve to private IPs, exceed the size
+ * cap, or are not in the (optional) admin allow-list are silently
+ * skipped with a logged warning. Each successfully fetched body is
+ * cached for the configured TTL and re-parsed on every call.
+ *
+ * @param array $urls External ICS URLs.
+ * @param string $from ISO start.
+ * @param string $to ISO end.
+ *
+ * @return array{events: array>, failures: array}
+ */
+ public function fetchExternalIcsEvents(array $urls, string $from, string $to): array
+ {
+ $events = [];
+ $failures = [];
+
+ foreach ($urls as $url) {
+ $url = (string) $url;
+
+ if ($this->validateUrl(url: $url) === false) {
+ $failures[] = 'external: rejected URL '.$url;
+ $this->logger->info(message: 'Calendar widget rejected external URL: '.$url);
+ continue;
+ }
+
+ if ($this->checkAllowList(url: $url) === false) {
+ $failures[] = 'external: not in allow-list '.$url;
+ $this->logger->info(message: 'Calendar widget URL not in allow-list: '.$url);
+ continue;
+ }
+
+ try {
+ $body = $this->fetchIcsBody(url: $url);
+ } catch (Throwable $exception) {
+ $failures[] = 'external: fetch failed '.$url;
+ $this->logger->warning(
+ message: 'Calendar widget fetch failed for '.$url.': '.$exception->getMessage()
+ );
+ continue;
+ }
+
+ try {
+ $expanded = $this->parseAndExpandIcs(body: $body, from: $from, to: $to);
+ } catch (Throwable $exception) {
+ $failures[] = 'external: parse failed '.$url;
+ $this->logger->warning(
+ message: 'Calendar widget parse failed for '.$url.': '.$exception->getMessage()
+ );
+ continue;
+ }
+
+ foreach ($expanded as $event) {
+ $events[] = $event;
+ }
+ }//end foreach
+
+ return [
+ 'events' => $events,
+ 'failures' => $failures,
+ ];
+ }//end fetchExternalIcsEvents()
+
+ /**
+ * Validate an external ICS URL.
+ *
+ * Accepts only HTTPS URLs whose hostname resolves to a public IP
+ * (no private/reserved ranges). Returns false on any deviation.
+ *
+ * @param string $url The URL to validate.
+ *
+ * @return bool True when the URL passes all checks.
+ */
+ public function validateUrl(string $url): bool
+ {
+ $parts = parse_url(url: $url);
+ if (is_array(value: $parts) === false) {
+ return false;
+ }
+
+ if (($parts['scheme'] ?? '') !== 'https') {
+ return false;
+ }
+
+ $host = (string) ($parts['host'] ?? '');
+ if ($host === '') {
+ return false;
+ }
+
+ // Resolve and reject any private/reserved IPs (SSRF guard).
+ $ips = gethostbynamel(hostname: $host);
+ if ($ips === false || $ips === []) {
+ return false;
+ }
+
+ foreach ($ips as $ip) {
+ $publicIp = filter_var(
+ value: $ip,
+ filter: FILTER_VALIDATE_IP,
+ options: (FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)
+ );
+ if ($publicIp === false) {
+ return false;
+ }
+ }
+
+ return true;
+ }//end validateUrl()
+
+ /**
+ * Check whether a URL's host appears in the admin allow-list.
+ *
+ * Empty/missing allow-list means all hosts are allowed. Comparison
+ * is case-insensitive and exact (no wildcard subdomain expansion).
+ *
+ * @param string $url The URL to check.
+ *
+ * @return bool True when the host passes the allow-list.
+ */
+ public function checkAllowList(string $url): bool
+ {
+ $raw = $this->appConfig->getValueString(
+ app: 'mydash',
+ key: self::CONFIG_KEY_ALLOWED_HOSTS,
+ default: ''
+ );
+
+ if ($raw === '') {
+ return true;
+ }
+
+ $decoded = json_decode(json: $raw, associative: true);
+ if (is_array(value: $decoded) === false || $decoded === []) {
+ return true;
+ }
+
+ $host = (string) parse_url(url: $url, component: PHP_URL_HOST);
+ if ($host === '') {
+ return false;
+ }
+
+ $needle = strtolower(string: $host);
+ $allowList = array_map(callback: 'strtolower', array: array_map(callback: 'strval', array: $decoded));
+
+ return in_array(needle: $needle, haystack: $allowList, strict: true);
+ }//end checkAllowList()
+
+ /**
+ * Fetch the raw ICS body for a URL, using ICache when fresh.
+ *
+ * @param string $url The URL to fetch.
+ *
+ * @return string The raw ICS body.
+ *
+ * @throws \RuntimeException When the response is too large or non-2xx.
+ */
+ public function fetchIcsBody(string $url): string
+ {
+ $cacheKey = 'ics_'.md5(string: $url);
+ $cached = $this->cache->get(key: $cacheKey);
+ if (is_string(value: $cached) === true && $cached !== '') {
+ return $cached;
+ }
+
+ $client = $this->clientService->newClient();
+ $response = $client->get(
+ uri: $url,
+ options: [
+ 'timeout' => self::FETCH_TIMEOUT_SECONDS,
+ 'connect_timeout' => self::FETCH_TIMEOUT_SECONDS,
+ ]
+ );
+
+ $body = (string) $response->getBody();
+ if (strlen(string: $body) > self::MAX_RESPONSE_SIZE_BYTES) {
+ throw new RuntimeException(
+ message: 'ICS response exceeds 1MB cap'
+ );
+ }
+
+ $ttl = $this->appConfig->getValueInt(
+ app: 'mydash',
+ key: self::CONFIG_KEY_CACHE_TTL,
+ default: self::DEFAULT_CACHE_TTL_SECONDS
+ );
+ if ($ttl <= 0) {
+ $ttl = self::DEFAULT_CACHE_TTL_SECONDS;
+ }
+
+ $this->cache->set(key: $cacheKey, value: $body, ttl: $ttl);
+
+ return $body;
+ }//end fetchIcsBody()
+
+ /**
+ * Parse a raw ICS body and expand recurring events into instances.
+ *
+ * Uses sabre/vobject's bundled `\Sabre\VObject\Reader::read()` and
+ * `VCalendar::expand()`. Each VEVENT becomes one event object.
+ *
+ * @param string $body The raw ICS body.
+ * @param string $from ISO start (inclusive).
+ * @param string $to ISO end (inclusive).
+ *
+ * @return array> Normalised event objects.
+ */
+ public function parseAndExpandIcs(string $body, string $from, string $to): array
+ {
+ if (class_exists(class: \Sabre\VObject\Reader::class) === false) {
+ $this->logger->warning(message: 'sabre/vobject is not available; cannot parse ICS');
+ return [];
+ }
+
+ $startObj = new DateTimeImmutable(datetime: $from);
+ $endObj = new DateTimeImmutable(datetime: $to);
+
+ // Returns a sabre VCalendar component which we expand server-side.
+ $vcal = \Sabre\VObject\Reader::read(data: $body);
+
+ try {
+ $vcal->expand(start: $startObj, end: $endObj);
+ } catch (Throwable $exception) {
+ $this->logger->info(
+ message: 'sabre/vobject expansion failed: '.$exception->getMessage()
+ );
+ }
+
+ $events = [];
+ if (isset($vcal->VEVENT) === false) {
+ return $events;
+ }
+
+ foreach ($vcal->VEVENT as $vevent) {
+ $events[] = $this->normalizeVevent(vevent: $vevent);
+ }
+
+ return $events;
+ }//end parseAndExpandIcs()
+
+ /**
+ * Normalise a sabre VEVENT into the canonical event object shape.
+ *
+ * @param object $vevent The sabre VEVENT component.
+ *
+ * @return array
+ */
+ public function normalizeVevent(object $vevent): array
+ {
+ $uid = (string) ($vevent->UID ?? '');
+ $summary = (string) ($vevent->SUMMARY ?? '');
+ $loc = (string) ($vevent->LOCATION ?? '');
+ $desc = (string) ($vevent->DESCRIPTION ?? '');
+
+ $startIso = $this->veventDateToIso(prop: $vevent->DTSTART ?? null);
+ $endIso = $this->veventDateToIso(prop: $vevent->DTEND ?? null);
+ $allDay = $this->veventIsAllDay(prop: $vevent->DTSTART ?? null);
+
+ $title = 'Untitled';
+ if ($summary !== '') {
+ $title = $summary;
+ }
+
+ $location = null;
+ if ($loc !== '') {
+ $location = $loc;
+ }
+
+ $description = null;
+ if ($desc !== '') {
+ $description = $desc;
+ }
+
+ return [
+ 'uid' => $uid,
+ 'title' => $title,
+ 'start' => $startIso,
+ 'end' => $endIso,
+ 'allDay' => $allDay,
+ 'location' => $location,
+ 'description' => $description,
+ 'calendarId' => '',
+ 'calendarName' => '',
+ 'color' => null,
+ 'source' => 'external',
+ ];
+ }//end normalizeVevent()
+
+ /**
+ * Extract an ISO 8601 string from a sabre VEVENT date property.
+ *
+ * @param object|null $prop The DTSTART/DTEND property (or null).
+ *
+ * @return string ISO 8601 string or empty string.
+ */
+ private function veventDateToIso(?object $prop): string
+ {
+ if ($prop === null) {
+ return '';
+ }
+
+ try {
+ if (method_exists(object_or_class: $prop, method: 'getDateTime') === true) {
+ // The DateTime instance returned implements DateTimeInterface.
+ $dateTime = $prop->getDateTime();
+ return $dateTime->format(format: \DATE_ATOM);
+ }
+ } catch (Throwable $exception) {
+ // Fall through to string cast on any extraction error.
+ unset($exception);
+ }
+
+ return (string) $prop;
+ }//end veventDateToIso()
+
+ /**
+ * Detect whether a VEVENT DTSTART represents an all-day event.
+ *
+ * @param object|null $prop The DTSTART property.
+ *
+ * @return bool True when VALUE=DATE (no time component).
+ */
+ private function veventIsAllDay(?object $prop): bool
+ {
+ if ($prop === null) {
+ return false;
+ }
+
+ try {
+ if (method_exists(object_or_class: $prop, method: 'hasTime') === true) {
+ return $prop->hasTime() === false;
+ }
+ } catch (Throwable $exception) {
+ unset($exception);
+ }
+
+ return false;
+ }//end veventIsAllDay()
+
+ /**
+ * Normalise a NC IManager::search() result into the canonical shape.
+ *
+ * @param array $raw Raw event from IManager.
+ *
+ * @return array
+ */
+ public function normalizeInternalEvent(array $raw): array
+ {
+ // NC Calendar search results vary across versions; do best-effort mapping.
+ $obj = $raw['objects'][0] ?? $raw;
+ $title = (string) ($raw['SUMMARY'] ?? $obj['SUMMARY'] ?? $raw['title'] ?? 'Untitled');
+ $start = (string) ($raw['DTSTART'] ?? $obj['DTSTART'] ?? $raw['start'] ?? '');
+ $end = (string) ($raw['DTEND'] ?? $obj['DTEND'] ?? $raw['end'] ?? '');
+ $location = $raw['LOCATION'] ?? $obj['LOCATION'] ?? $raw['location'] ?? null;
+ $desc = $raw['DESCRIPTION'] ?? $obj['DESCRIPTION'] ?? $raw['description'] ?? null;
+
+ $resolvedTitle = 'Untitled';
+ if ($title !== '') {
+ $resolvedTitle = $title;
+ }
+
+ $resolvedLocation = null;
+ if ($location !== null) {
+ $resolvedLocation = (string) $location;
+ }
+
+ $resolvedDescription = null;
+ if ($desc !== null) {
+ $resolvedDescription = (string) $desc;
+ }
+
+ return [
+ 'uid' => (string) ($raw['UID'] ?? $obj['UID'] ?? $raw['uid'] ?? ''),
+ 'title' => $resolvedTitle,
+ 'start' => $start,
+ 'end' => $end,
+ 'allDay' => (bool) ($raw['allDay'] ?? false),
+ 'location' => $resolvedLocation,
+ 'description' => $resolvedDescription,
+ 'calendarId' => (string) ($raw['calendar-key'] ?? $raw['calendarUri'] ?? ''),
+ 'calendarName' => (string) ($raw['calendar-name'] ?? $raw['calendarName'] ?? ''),
+ 'color' => $raw['calendar-color'] ?? null,
+ 'source' => 'internal',
+ ];
+ }//end normalizeInternalEvent()
+}//end class
diff --git a/lib/Service/Cleanup/CategoryRegistryService.php b/lib/Service/Cleanup/CategoryRegistryService.php
new file mode 100644
index 00000000..f83291eb
--- /dev/null
+++ b/lib/Service/Cleanup/CategoryRegistryService.php
@@ -0,0 +1,138 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service\Cleanup;
+
+/**
+ * Lookup registry over the cleanup categories.
+ */
+class CategoryRegistryService
+{
+
+ /**
+ * The registered categories, keyed by their stable name.
+ *
+ * @var array
+ */
+ private array $categories;
+
+ /**
+ * Constructor.
+ *
+ * Each category is injected explicitly so the registry stays
+ * compatible with installs that don't have Nextcloud DI tag
+ * support. New categories MUST be appended to both the parameter
+ * list and the keyed-by-name map.
+ *
+ * @param ExpiredLocksCategory $expiredLocks Tier-A.
+ * @param OrphanedSharesCategory $orphanedShares Tier-A.
+ * @param OrphanedWidgetPlacementsCategory $orphanedPlacements Tier-B.
+ * @param OrphanedConditionalRulesCategory $orphanedRules Tier-B.
+ */
+ public function __construct(
+ ExpiredLocksCategory $expiredLocks,
+ OrphanedSharesCategory $orphanedShares,
+ OrphanedWidgetPlacementsCategory $orphanedPlacements,
+ OrphanedConditionalRulesCategory $orphanedRules,
+ ) {
+ // Order is significant: it determines the row order of the
+ // CLI scan table and the JSON object key order in API
+ // responses. Group Tier-A first so the most common entries
+ // appear at the top of admin previews.
+ $this->categories = [
+ $expiredLocks->getName() => $expiredLocks,
+ $orphanedShares->getName() => $orphanedShares,
+ $orphanedPlacements->getName() => $orphanedPlacements,
+ $orphanedRules->getName() => $orphanedRules,
+ ];
+ }//end __construct()
+
+ /**
+ * All registered categories, keyed by their stable name.
+ *
+ * @return array The categories.
+ */
+ public function getCategories(): array
+ {
+ return $this->categories;
+ }//end getCategories()
+
+ /**
+ * The names of all registered categories, in registration order.
+ *
+ * @return array The names.
+ */
+ public function getCategoryNames(): array
+ {
+ return array_keys(array: $this->categories);
+ }//end getCategoryNames()
+
+ /**
+ * Look up a category by its stable name.
+ *
+ * Returns `null` when the name is unknown so callers (CLI,
+ * controller) can surface a deterministic 400 / "Unknown
+ * category" message.
+ *
+ * @param string $name The category name.
+ *
+ * @return CleanupCategoryInterface|null The category or null.
+ */
+ public function getCategoryByName(string $name): ?CleanupCategoryInterface
+ {
+ return ($this->categories[$name] ?? null);
+ }//end getCategoryByName()
+
+ /**
+ * The names of every category whose
+ * `getSafeToPurgeAutomatically()` returns `true` (REQ-CLN-008).
+ *
+ * Used as the factory default for the daily background job's
+ * auto-purge config and as the "checked by default" set in the
+ * admin UI.
+ *
+ * @return array The Tier-A category names.
+ */
+ public function getAutoSafeCategoryNames(): array
+ {
+ $names = [];
+ foreach ($this->categories as $name => $category) {
+ if ($category->getSafeToPurgeAutomatically() === true) {
+ $names[] = $name;
+ }
+ }
+
+ return $names;
+ }//end getAutoSafeCategoryNames()
+}//end class
diff --git a/lib/Service/Cleanup/CleanupCategoryInterface.php b/lib/Service/Cleanup/CleanupCategoryInterface.php
new file mode 100644
index 00000000..5b3d7439
--- /dev/null
+++ b/lib/Service/Cleanup/CleanupCategoryInterface.php
@@ -0,0 +1,126 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service\Cleanup;
+
+/**
+ * One detector + purger for a single orphan category.
+ */
+interface CleanupCategoryInterface
+{
+ /**
+ * Stable category identifier (snake_case).
+ *
+ * Used in CLI `--category=` filters, API request bodies,
+ * cached scan keys and the auto-purge config list. MUST stay
+ * unchanged across releases or admin configurations referencing
+ * this category will silently fall through.
+ *
+ * @return string The identifier (e.g. "expired_locks").
+ */
+ public function getName(): string;
+
+ /**
+ * Human-readable label for the admin UI.
+ *
+ * @return string The display label.
+ */
+ public function getDisplayName(): string;
+
+ /**
+ * Whether this category is in the Tier-A "safe to auto-purge"
+ * default set (REQ-CLN-007, REQ-CLN-008).
+ *
+ * Tier-A categories are pre-checked in the daily background job's
+ * default config; Tier-B/C return `false` and require an explicit
+ * admin opt-in.
+ *
+ * @return bool True for Tier-A categories.
+ */
+ public function getSafeToPurgeAutomatically(): bool;
+
+ /**
+ * Whether the underlying feature / table is currently available.
+ *
+ * Categories tied to optional features (RSS feeds, role
+ * assignments, language variants, ...) MUST return `false` when
+ * the relevant feature has not been provisioned, so the registry
+ * can mark them as `skipped` instead of erroring with a missing
+ * table SQL fault. REQ-CLN-001 "Scan handles missing tables
+ * gracefully".
+ *
+ * Always-on categories tied to existing core tables MUST return
+ * `true` unconditionally.
+ *
+ * @return bool True when the category can be scanned/purged.
+ */
+ public function isAvailable(): bool;
+
+ /**
+ * Count orphaned rows for this category.
+ *
+ * MUST NOT delete or modify any data; the scan path is the input
+ * to dry-run previews. SHOULD return 0 (rather than throwing) when
+ * there are no orphans.
+ *
+ * @return int The orphaned row count (>= 0).
+ */
+ public function scan(): int;
+
+ /**
+ * Delete orphaned rows for this category.
+ *
+ * When `$dryRun` is `true`, the implementation MUST NOT change any
+ * persisted state — the orchestrator wraps the call in a
+ * transaction and rolls back, so a category that performs
+ * non-DB side effects (file deletion, external calls) MUST gate
+ * those side effects on `$dryRun === false`.
+ *
+ * Returns the number of rows that were deleted (or that would
+ * have been deleted, in dry-run).
+ *
+ * @param bool $dryRun True for simulation, false for real delete.
+ *
+ * @return int The number of rows affected (>= 0).
+ */
+ public function purge(bool $dryRun=false): int;
+}//end interface
diff --git a/lib/Service/Cleanup/ExpiredLocksCategory.php b/lib/Service/Cleanup/ExpiredLocksCategory.php
new file mode 100644
index 00000000..5c51b946
--- /dev/null
+++ b/lib/Service/Cleanup/ExpiredLocksCategory.php
@@ -0,0 +1,126 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service\Cleanup;
+
+use OCA\MyDash\Db\DashboardLockMapper;
+
+/**
+ * Sweeps expired dashboard editing locks.
+ */
+class ExpiredLocksCategory implements CleanupCategoryInterface
+{
+ /**
+ * Stable category identifier (REQ-CLN-008 default auto-purge list).
+ *
+ * @var string
+ */
+ public const NAME = 'expired_locks';
+
+ /**
+ * Constructor.
+ *
+ * @param DashboardLockMapper $lockMapper The lock mapper exposing
+ * `countAllExpired()` and
+ * `deleteAllExpired()`.
+ */
+ public function __construct(
+ private readonly DashboardLockMapper $lockMapper,
+ ) {
+ }//end __construct()
+
+ /**
+ * Stable identifier (REQ-CLN-008).
+ *
+ * @return string The identifier.
+ */
+ public function getName(): string
+ {
+ return self::NAME;
+ }//end getName()
+
+ /**
+ * Human-readable label for the admin UI.
+ *
+ * @return string The label.
+ */
+ public function getDisplayName(): string
+ {
+ return 'Expired dashboard locks';
+ }//end getDisplayName()
+
+ /**
+ * Tier-A — always safe to auto-purge. The holding session is by
+ * definition gone (TTL exceeded), so no user is ever affected.
+ *
+ * @return bool True.
+ */
+ public function getSafeToPurgeAutomatically(): bool
+ {
+ return true;
+ }//end getSafeToPurgeAutomatically()
+
+ /**
+ * Always available — `mydash_dashboard_locks` ships in the core
+ * schema (Version001005…).
+ *
+ * @return bool True.
+ */
+ public function isAvailable(): bool
+ {
+ return true;
+ }//end isAvailable()
+
+ /**
+ * Count stale lock rows across all dashboards.
+ *
+ * @return int The orphan count.
+ */
+ public function scan(): int
+ {
+ return $this->lockMapper->countAllExpired();
+ }//end scan()
+
+ /**
+ * Delete stale lock rows (or simulate under transaction rollback).
+ *
+ * The orchestrator wraps the call in a transaction when
+ * `$dryRun=true`, so this implementation can use the same
+ * delete-everything path for both modes — the rollback handles the
+ * "do nothing" promise.
+ *
+ * @param bool $dryRun True for dry-run.
+ *
+ * @return int The number of rows deleted (or that would be).
+ */
+ public function purge(bool $dryRun=false): int
+ {
+ return $this->lockMapper->deleteAllExpired();
+ }//end purge()
+}//end class
diff --git a/lib/Service/Cleanup/OrphanedConditionalRulesCategory.php b/lib/Service/Cleanup/OrphanedConditionalRulesCategory.php
new file mode 100644
index 00000000..a81ef9df
--- /dev/null
+++ b/lib/Service/Cleanup/OrphanedConditionalRulesCategory.php
@@ -0,0 +1,118 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service\Cleanup;
+
+use OCA\MyDash\Db\ConditionalRuleMapper;
+
+/**
+ * Sweeps conditional-rule rows whose placement no longer exists.
+ */
+class OrphanedConditionalRulesCategory implements CleanupCategoryInterface
+{
+ /**
+ * Stable category identifier.
+ *
+ * @var string
+ */
+ public const NAME = 'orphaned_conditional_rules';
+
+ /**
+ * Constructor.
+ *
+ * @param ConditionalRuleMapper $ruleMapper The rule mapper.
+ */
+ public function __construct(
+ private readonly ConditionalRuleMapper $ruleMapper,
+ ) {
+ }//end __construct()
+
+ /**
+ * Stable identifier.
+ *
+ * @return string The identifier.
+ */
+ public function getName(): string
+ {
+ return self::NAME;
+ }//end getName()
+
+ /**
+ * Human-readable label.
+ *
+ * @return string The label.
+ */
+ public function getDisplayName(): string
+ {
+ return 'Conditional rules without a parent placement';
+ }//end getDisplayName()
+
+ /**
+ * Tier-B — admin-authored visibility logic is potentially worth
+ * recovering, so this category is opt-in only.
+ *
+ * @return bool False.
+ */
+ public function getSafeToPurgeAutomatically(): bool
+ {
+ return false;
+ }//end getSafeToPurgeAutomatically()
+
+ /**
+ * Always available — `mydash_conditional_rules` ships in the core
+ * schema.
+ *
+ * @return bool True.
+ */
+ public function isAvailable(): bool
+ {
+ return true;
+ }//end isAvailable()
+
+ /**
+ * Count orphan conditional-rule rows.
+ *
+ * @return int The orphan count.
+ */
+ public function scan(): int
+ {
+ return $this->ruleMapper->countOrphaned();
+ }//end scan()
+
+ /**
+ * Delete orphan conditional-rule rows.
+ *
+ * @param bool $dryRun True for dry-run.
+ *
+ * @return int The number of rows deleted.
+ */
+ public function purge(bool $dryRun=false): int
+ {
+ return $this->ruleMapper->deleteOrphaned();
+ }//end purge()
+}//end class
diff --git a/lib/Service/Cleanup/OrphanedSharesCategory.php b/lib/Service/Cleanup/OrphanedSharesCategory.php
new file mode 100644
index 00000000..9d7869e5
--- /dev/null
+++ b/lib/Service/Cleanup/OrphanedSharesCategory.php
@@ -0,0 +1,129 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service\Cleanup;
+
+use OCA\MyDash\Db\DashboardShareMapper;
+
+/**
+ * Sweeps share rows whose dashboard no longer exists.
+ */
+class OrphanedSharesCategory implements CleanupCategoryInterface
+{
+ /**
+ * Stable category identifier (REQ-CLN-008 default auto-purge list).
+ *
+ * Named `expired_share_tokens` to match the spec vocabulary even
+ * though the current schema has no expiry/revocation columns —
+ * the dangling-FK orphan is the only orphan kind shares can
+ * currently produce.
+ *
+ * @var string
+ */
+ public const NAME = 'expired_share_tokens';
+
+ /**
+ * Constructor.
+ *
+ * @param DashboardShareMapper $shareMapper The share mapper.
+ */
+ public function __construct(
+ private readonly DashboardShareMapper $shareMapper,
+ ) {
+ }//end __construct()
+
+ /**
+ * Stable identifier.
+ *
+ * @return string The identifier.
+ */
+ public function getName(): string
+ {
+ return self::NAME;
+ }//end getName()
+
+ /**
+ * Human-readable label for the admin UI.
+ *
+ * @return string The label.
+ */
+ public function getDisplayName(): string
+ {
+ return 'Expired or orphaned share tokens';
+ }//end getDisplayName()
+
+ /**
+ * Tier-A — always safe to auto-purge. The share is unreachable
+ * because its dashboard is gone.
+ *
+ * @return bool True.
+ */
+ public function getSafeToPurgeAutomatically(): bool
+ {
+ return true;
+ }//end getSafeToPurgeAutomatically()
+
+ /**
+ * Always available — `mydash_dashboard_shares` ships in the core
+ * schema.
+ *
+ * @return bool True.
+ */
+ public function isAvailable(): bool
+ {
+ return true;
+ }//end isAvailable()
+
+ /**
+ * Count orphaned share rows.
+ *
+ * @return int The orphan count.
+ */
+ public function scan(): int
+ {
+ return $this->shareMapper->countOrphaned();
+ }//end scan()
+
+ /**
+ * Delete orphaned share rows.
+ *
+ * Dry-run safety is provided by the orchestrator wrapping this
+ * call in a transaction and rolling back; this implementation
+ * runs the same delete in either mode.
+ *
+ * @param bool $dryRun True for dry-run.
+ *
+ * @return int The number of rows deleted.
+ */
+ public function purge(bool $dryRun=false): int
+ {
+ return $this->shareMapper->deleteOrphaned();
+ }//end purge()
+}//end class
diff --git a/lib/Service/Cleanup/OrphanedWidgetPlacementsCategory.php b/lib/Service/Cleanup/OrphanedWidgetPlacementsCategory.php
new file mode 100644
index 00000000..95da653c
--- /dev/null
+++ b/lib/Service/Cleanup/OrphanedWidgetPlacementsCategory.php
@@ -0,0 +1,117 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service\Cleanup;
+
+use OCA\MyDash\Db\WidgetPlacementMapper;
+
+/**
+ * Sweeps widget placement rows whose dashboard no longer exists.
+ */
+class OrphanedWidgetPlacementsCategory implements CleanupCategoryInterface
+{
+ /**
+ * Stable category identifier.
+ *
+ * @var string
+ */
+ public const NAME = 'orphaned_widget_placements';
+
+ /**
+ * Constructor.
+ *
+ * @param WidgetPlacementMapper $placementMapper The placement mapper.
+ */
+ public function __construct(
+ private readonly WidgetPlacementMapper $placementMapper,
+ ) {
+ }//end __construct()
+
+ /**
+ * Stable identifier.
+ *
+ * @return string The identifier.
+ */
+ public function getName(): string
+ {
+ return self::NAME;
+ }//end getName()
+
+ /**
+ * Human-readable label.
+ *
+ * @return string The label.
+ */
+ public function getDisplayName(): string
+ {
+ return 'Widget placements without a parent dashboard';
+ }//end getDisplayName()
+
+ /**
+ * Tier-B — placements may carry recoverable user configuration.
+ *
+ * @return bool False.
+ */
+ public function getSafeToPurgeAutomatically(): bool
+ {
+ return false;
+ }//end getSafeToPurgeAutomatically()
+
+ /**
+ * Always available — `mydash_widget_placements` ships in the core
+ * schema.
+ *
+ * @return bool True.
+ */
+ public function isAvailable(): bool
+ {
+ return true;
+ }//end isAvailable()
+
+ /**
+ * Count orphan placement rows.
+ *
+ * @return int The orphan count.
+ */
+ public function scan(): int
+ {
+ return $this->placementMapper->countOrphaned();
+ }//end scan()
+
+ /**
+ * Delete orphan placement rows.
+ *
+ * @param bool $dryRun True for dry-run.
+ *
+ * @return int The number of rows deleted.
+ */
+ public function purge(bool $dryRun=false): int
+ {
+ return $this->placementMapper->deleteOrphaned();
+ }//end purge()
+}//end class
diff --git a/lib/Service/CommandService.php b/lib/Service/CommandService.php
new file mode 100644
index 00000000..162cd498
--- /dev/null
+++ b/lib/Service/CommandService.php
@@ -0,0 +1,263 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service;
+
+use Psr\Log\LoggerInterface;
+
+/**
+ * Shared CLI helpers — exit-code constants, JSON envelope, audit log.
+ *
+ * The class is intentionally stateless: it holds only the platform
+ * logger and exposes pure helpers so individual commands stay thin.
+ */
+class CommandService
+{
+ /**
+ * Exit code for successful execution (REQ-CLI-006).
+ *
+ * @var integer
+ */
+ public const EXIT_SUCCESS = 0;
+
+ /**
+ * Exit code for unhandled exceptions / generic errors (REQ-CLI-006).
+ *
+ * @var integer
+ */
+ public const EXIT_ERROR = 1;
+
+ /**
+ * Exit code for invalid / malformed CLI arguments (REQ-CLI-006).
+ *
+ * @var integer
+ */
+ public const EXIT_INVALID_ARGS = 2;
+
+ /**
+ * Exit code for permission / authorization failures (REQ-CLI-006).
+ *
+ * @var integer
+ */
+ public const EXIT_PERMISSION_DENIED = 3;
+
+ /**
+ * Exit code for "resource not found" lookups (REQ-CLI-006).
+ *
+ * @var integer
+ */
+ public const EXIT_NOT_FOUND = 4;
+
+ /**
+ * Exit code for batch operations that partially succeeded
+ * (REQ-CLI-006).
+ *
+ * @var integer
+ */
+ public const EXIT_PARTIAL_SUCCESS = 5;
+
+ /**
+ * Maximum number of characters of the args string written to the
+ * audit log line (REQ-CLI-010).
+ *
+ * @var integer
+ */
+ public const AUDIT_ARGS_MAX = 100;
+
+ /**
+ * Constructor.
+ *
+ * @param LoggerInterface $logger Platform logger used for audit
+ * lines (REQ-CLI-010).
+ */
+ public function __construct(private readonly LoggerInterface $logger)
+ {
+ }//end __construct()
+
+ /**
+ * Build a successful JSON envelope (REQ-CLI-007).
+ *
+ * @param array|list|null $data Command payload.
+ *
+ * @return array
+ */
+ public function envelopeSuccess(array|null $data): array
+ {
+ return [
+ 'success' => true,
+ 'exitCode' => self::EXIT_SUCCESS,
+ 'data' => $data,
+ 'errors' => [],
+ ];
+ }//end envelopeSuccess()
+
+ /**
+ * Build a partial-success JSON envelope (REQ-CLI-007).
+ *
+ * @param array|list|null $data Payload.
+ * @param list> $errors Per-item errors.
+ *
+ * @return array
+ */
+ public function envelopePartial(array|null $data, array $errors): array
+ {
+ return [
+ 'success' => false,
+ 'exitCode' => self::EXIT_PARTIAL_SUCCESS,
+ 'data' => $data,
+ 'errors' => $errors,
+ ];
+ }//end envelopePartial()
+
+ /**
+ * Build an error JSON envelope (REQ-CLI-007).
+ *
+ * @param int $exitCode Command exit code.
+ * @param string $code Stable error identifier.
+ * @param string $message Human-readable text.
+ * @param array|null $context Optional metadata.
+ *
+ * @return array
+ */
+ public function envelopeError(
+ int $exitCode,
+ string $code,
+ string $message,
+ array|null $context=null
+ ): array {
+ $error = [
+ 'code' => $code,
+ 'message' => $message,
+ ];
+ if ($context !== null) {
+ $error['context'] = $context;
+ }
+
+ return [
+ 'success' => false,
+ 'exitCode' => $exitCode,
+ 'data' => null,
+ 'errors' => [$error],
+ ];
+ }//end envelopeError()
+
+ /**
+ * Encode an envelope to a single JSON line (REQ-CLI-007).
+ *
+ * @param array $envelope The envelope to encode.
+ *
+ * @return string
+ */
+ public function encodeEnvelope(array $envelope): string
+ {
+ return (string) json_encode(
+ value: $envelope,
+ flags: (JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
+ );
+ }//end encodeEnvelope()
+
+ /**
+ * Format an audit-log line per REQ-CLI-010.
+ *
+ * Format: `[mydash] cli exitCode=
+ * durationMs= byUser=`
+ *
+ * @param string $command Full command name without the
+ * `mydash:` prefix (e.g.
+ * `dashboard:list`).
+ * @param string $args Space-joined argv tail. May be
+ * truncated to {@see AUDIT_ARGS_MAX}.
+ * @param int $exitCode Final exit code.
+ * @param int $durationMs Wall-clock execution time.
+ * @param string|null $byUser Caller user id or null for `cli`.
+ *
+ * @return string
+ */
+ public function formatAuditLine(
+ string $command,
+ string $args,
+ int $exitCode,
+ int $durationMs,
+ string|null $byUser
+ ): string {
+ $argsClean = trim(string: $args);
+ if (mb_strlen(string: $argsClean) > self::AUDIT_ARGS_MAX) {
+ $argsClean = mb_substr(
+ string: $argsClean,
+ start: 0,
+ length: (self::AUDIT_ARGS_MAX - 3)
+ ).'...';
+ }
+
+ $user = $byUser;
+ if ($user === null || $user === '') {
+ $user = 'cli';
+ }
+
+ return sprintf(
+ '[mydash] cli %s %s exitCode=%d durationMs=%d byUser=%s',
+ $command,
+ $argsClean,
+ $exitCode,
+ $durationMs,
+ $user
+ );
+ }//end formatAuditLine()
+
+ /**
+ * Write the audit-log line to the platform logger (REQ-CLI-010).
+ *
+ * @param string $command Command name.
+ * @param string $args Argument string.
+ * @param int $exitCode Exit code.
+ * @param int $durationMs Wall-clock duration.
+ * @param string|null $byUser Caller user id.
+ *
+ * @return void
+ */
+ public function audit(
+ string $command,
+ string $args,
+ int $exitCode,
+ int $durationMs,
+ string|null $byUser
+ ): void {
+ $this->logger->info(
+ message: $this->formatAuditLine(
+ command: $command,
+ args: $args,
+ exitCode: $exitCode,
+ durationMs: $durationMs,
+ byUser: $byUser
+ ),
+ context: [
+ 'app' => 'mydash',
+ 'command' => $command,
+ 'exitCode' => $exitCode,
+ 'durationMs' => $durationMs,
+ 'byUser' => ($byUser ?? 'cli'),
+ ]
+ );
+ }//end audit()
+}//end class
diff --git a/lib/Service/CommentService.php b/lib/Service/CommentService.php
new file mode 100644
index 00000000..4c0b38ef
--- /dev/null
+++ b/lib/Service/CommentService.php
@@ -0,0 +1,646 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service;
+
+use DateTime;
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Db\AdminSetting;
+use OCA\MyDash\Db\AdminSettingMapper;
+use OCA\MyDash\Db\Dashboard;
+use OCA\MyDash\Exception\CommentForbiddenException;
+use OCA\MyDash\Exception\CommentNotFoundException;
+use OCA\MyDash\Exception\InvalidCommentException;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\Comments\IComment;
+use OCP\Comments\ICommentsManager;
+use OCP\Comments\NotFoundException as CommentManagerNotFoundException;
+use OCP\IGroupManager;
+use OCP\IURLGenerator;
+use OCP\IUserManager;
+use OCP\Notification\IManager as INotificationManager;
+
+/**
+ * Comment CRUD service backed by `ICommentsManager` (REQ-CMNT-001..009).
+ *
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods) Validation, CRUD,
+ * serialisation and
+ * mention parsing belong
+ * on a single cohesive
+ * comment service.
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The comment service
+ * legitimately spans
+ * the comments manager,
+ * user manager,
+ * notification manager,
+ * admin settings, and
+ * URL generator — each
+ * represents a different
+ * Nextcloud subsystem
+ * the comment workflow
+ * must integrate with.
+ */
+class CommentService
+{
+ /**
+ * Object type string used to scope comments to MyDash dashboards
+ * (REQ-CMNT-001 / D2 in design.md).
+ *
+ * @var string
+ */
+ public const OBJECT_TYPE = 'mydash_dashboard';
+
+ /**
+ * Notification subject for the @mention event (REQ-CMNT-006).
+ *
+ * @var string
+ */
+ public const NOTIFICATION_SUBJECT = 'mentioned_in_comment';
+
+ /**
+ * Regex used to extract `@username` mentions from message text.
+ *
+ * Matches a leading `@` followed by Nextcloud-friendly user-id
+ * characters (letters, digits, `_`, `.`, `-`). The username is
+ * deduplicated and case-normalised by the caller.
+ *
+ * @var string
+ */
+ private const MENTION_REGEX = '/@([a-zA-Z0-9_.-]+)/';
+
+ /**
+ * Constructor
+ *
+ * @param ICommentsManager $commentsManager Nextcloud comments backend.
+ * @param IUserManager $userManager Nextcloud user manager.
+ * @param INotificationManager $notificationManager Nextcloud notification manager.
+ * @param IGroupManager $groupManager Used only for the
+ * `isAdmin` check
+ * on edit/delete
+ * authorisation.
+ * @param IURLGenerator $urlGenerator URL generator for the
+ * deep-link payload
+ * sent with mention
+ * notifications.
+ * @param AdminSettingMapper $settingMapper Reads the global
+ * `comments_enabled_default`
+ * admin setting.
+ */
+ public function __construct(
+ private readonly ICommentsManager $commentsManager,
+ private readonly IUserManager $userManager,
+ private readonly INotificationManager $notificationManager,
+ private readonly IGroupManager $groupManager,
+ private readonly IURLGenerator $urlGenerator,
+ private readonly AdminSettingMapper $settingMapper,
+ ) {
+ }//end __construct()
+
+ /**
+ * Read the global `mydash.comments_enabled_default` setting
+ * (REQ-CMNT-008).
+ *
+ * Defaults to `true` when the setting is missing — fresh installs
+ * have comments enabled by design.
+ *
+ * @return bool True when comments are globally enabled.
+ */
+ public function isCommentsEnabledGlobally(): bool
+ {
+ try {
+ $setting = $this->settingMapper->findByKey(
+ key: AdminSetting::KEY_COMMENTS_ENABLED_DEFAULT
+ );
+ } catch (DoesNotExistException) {
+ return true;
+ }
+
+ $decoded = $setting->getValueDecoded();
+ if (is_bool($decoded) === true) {
+ return $decoded;
+ }
+
+ // Tolerate string `"true"` / `"1"` for hand-edited rows.
+ if (is_string($decoded) === true) {
+ return in_array(
+ needle: strtolower($decoded),
+ haystack: ['true', '1', 'yes', 'on'],
+ strict: true
+ );
+ }
+
+ if (is_int($decoded) === true) {
+ return $decoded !== 0;
+ }
+
+ return true;
+ }//end isCommentsEnabledGlobally()
+
+ /**
+ * Whether comments are effectively enabled on a dashboard
+ * (REQ-CMNT-007, REQ-CMNT-008).
+ *
+ * @param Dashboard $dashboard The dashboard.
+ *
+ * @return bool True when comments are effectively enabled.
+ */
+ public function isEnabledFor(Dashboard $dashboard): bool
+ {
+ return $dashboard->isCommentsEffectivelyEnabled(
+ globalDefault: $this->isCommentsEnabledGlobally()
+ );
+ }//end isEnabledFor()
+
+ /**
+ * Return the threaded comment list for a dashboard (REQ-CMNT-001).
+ *
+ * Top-level comments are ordered newest first; replies are grouped
+ * underneath their parent in chronological order. Each entry is the
+ * serialised array shape returned by {@see self::serialiseComment()}.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return array> Top-level comments with
+ * a nested `replies` array.
+ */
+ public function getCommentsForDashboard(string $dashboardUuid): array
+ {
+ $raw = $this->commentsManager->getForObject(
+ objectType: self::OBJECT_TYPE,
+ objectId: $dashboardUuid,
+ limit: 0,
+ offset: 0
+ );
+
+ $topLevel = [];
+ $replies = [];
+
+ foreach ($raw as $comment) {
+ $parentId = (int) $comment->getParentId();
+ if ($parentId === 0) {
+ $topLevel[] = $comment;
+ continue;
+ }
+
+ if (isset($replies[$parentId]) === false) {
+ $replies[$parentId] = [];
+ }
+
+ $replies[$parentId][] = $comment;
+ }
+
+ // Newest first for top-level (REQ-CMNT-001 ordering).
+ usort(
+ $topLevel,
+ static function (IComment $left, IComment $right): int {
+ return $right->getCreationDateTime()->getTimestamp() <=> $left->getCreationDateTime()->getTimestamp();
+ }
+ );
+
+ $result = [];
+ foreach ($topLevel as $top) {
+ $serialised = $this->serialiseComment(comment: $top);
+ $childPayload = [];
+ $children = $replies[(int) $top->getId()] ?? [];
+
+ // Replies stay chronological so the conversation reads top-down.
+ usort(
+ $children,
+ static function (IComment $left, IComment $right): int {
+ return $left->getCreationDateTime()->getTimestamp() <=> $right->getCreationDateTime()->getTimestamp();
+ }
+ );
+
+ foreach ($children as $child) {
+ $childPayload[] = $this->serialiseComment(comment: $child);
+ }
+
+ $serialised['replies'] = $childPayload;
+ $result[] = $serialised;
+ }//end foreach
+
+ return $result;
+ }//end getCommentsForDashboard()
+
+ /**
+ * Create a new top-level comment or one-level-deep reply
+ * (REQ-CMNT-002, REQ-CMNT-003).
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $userId The author user ID.
+ * @param string $message The non-empty message text.
+ * @param int|null $parentId Optional parent comment ID for replies.
+ *
+ * @return IComment The persisted comment.
+ *
+ * @throws InvalidCommentException On empty message or nested-reply violation.
+ * @throws CommentNotFoundException When `$parentId` does not resolve.
+ */
+ public function createComment(
+ string $dashboardUuid,
+ string $userId,
+ string $message,
+ ?int $parentId=null
+ ): IComment {
+ $trimmed = trim(string: $message);
+ if ($trimmed === '') {
+ throw new InvalidCommentException(
+ message: 'Comment message must not be empty',
+ errorCode: 'comment_empty_message'
+ );
+ }
+
+ $resolvedParentId = 0;
+ if ($parentId !== null && $parentId > 0) {
+ $parent = $this->fetchComment(commentId: $parentId);
+
+ // Ensure parent belongs to the same dashboard so callers
+ // cannot cross-thread between dashboards.
+ if ($parent->getObjectType() !== self::OBJECT_TYPE
+ || $parent->getObjectId() !== $dashboardUuid
+ ) {
+ throw new CommentNotFoundException(
+ message: 'Parent comment not found'
+ );
+ }
+
+ // One-level-deep enforcement (REQ-CMNT-003 / D4).
+ if ((int) $parent->getParentId() !== 0) {
+ throw new InvalidCommentException(
+ message: 'Comments can only be replied to once',
+ errorCode: 'comment_nested_reply'
+ );
+ }
+
+ $resolvedParentId = (int) $parent->getId();
+ }//end if
+
+ $comment = $this->commentsManager->create(
+ actorType: 'users',
+ actorId: $userId,
+ objectType: self::OBJECT_TYPE,
+ objectId: $dashboardUuid
+ );
+ $comment->setMessage(message: $trimmed);
+ $comment->setVerb(verb: 'comment');
+ if ($resolvedParentId > 0) {
+ $comment->setParentId(parentId: (string) $resolvedParentId);
+ }
+
+ $this->commentsManager->save(comment: $comment);
+
+ $this->parseAndResolveMentions(
+ message: $trimmed,
+ dashboardUuid: $dashboardUuid,
+ authorUserId: $userId
+ );
+
+ return $comment;
+ }//end createComment()
+
+ /**
+ * Update the message of a comment (REQ-CMNT-004).
+ *
+ * Only the original author or a Nextcloud admin may edit. Sets the
+ * `wasEdited` marker via the comment's `latestChildDateTime` semantics
+ * is not used here — instead `ICommentsManager::save()` updates the
+ * `latest_child_message` and the comment's serialisation surface
+ * `wasEdited = true` whenever the persisted entity carries an
+ * updated-at timestamp.
+ *
+ * @param int $commentId The comment ID.
+ * @param string $newMessage The new message text.
+ * @param string $currentUserId The acting user's ID.
+ *
+ * @return IComment The updated comment.
+ *
+ * @throws CommentNotFoundException When the comment does not exist.
+ * @throws CommentForbiddenException When the user is neither author
+ * nor admin.
+ * @throws InvalidCommentException When the message is empty.
+ */
+ public function updateComment(
+ int $commentId,
+ string $newMessage,
+ string $currentUserId
+ ): IComment {
+ $trimmed = trim(string: $newMessage);
+ if ($trimmed === '') {
+ throw new InvalidCommentException(
+ message: 'Comment message must not be empty',
+ errorCode: 'comment_empty_message'
+ );
+ }
+
+ $comment = $this->fetchComment(commentId: $commentId);
+ $this->assertAuthorOrAdmin(
+ comment: $comment,
+ userId: $currentUserId
+ );
+
+ $comment->setMessage(message: $trimmed);
+ // Marker for the serialiser — the underlying comment row stores
+ // the original timestamp on the `creation_timestamp` column and
+ // a separate `latest_child_message` field; we re-stamp the
+ // comment's own `latestChildDateTime` to a fresh timestamp so
+ // the serialiser can deterministically expose `wasEdited`.
+ $comment->setLatestChildDateTime(dateTime: new DateTime());
+
+ $this->commentsManager->save(comment: $comment);
+
+ $this->parseAndResolveMentions(
+ message: $trimmed,
+ dashboardUuid: (string) $comment->getObjectId(),
+ authorUserId: $currentUserId
+ );
+
+ return $comment;
+ }//end updateComment()
+
+ /**
+ * Soft-delete a comment, cascading to replies for top-level deletes
+ * (REQ-CMNT-005).
+ *
+ * @param int $commentId The comment ID.
+ * @param string $currentUserId The acting user's ID.
+ *
+ * @return void
+ *
+ * @throws CommentNotFoundException When the comment does not exist.
+ * @throws CommentForbiddenException When the user is neither author
+ * nor admin.
+ */
+ public function deleteComment(
+ int $commentId,
+ string $currentUserId
+ ): void {
+ $comment = $this->fetchComment(commentId: $commentId);
+ $this->assertAuthorOrAdmin(
+ comment: $comment,
+ userId: $currentUserId
+ );
+
+ // Cascade to direct children when deleting a top-level comment.
+ if ((int) $comment->getParentId() === 0) {
+ $children = $this->commentsManager->getForObject(
+ objectType: self::OBJECT_TYPE,
+ objectId: (string) $comment->getObjectId(),
+ limit: 0,
+ offset: 0
+ );
+
+ foreach ($children as $candidate) {
+ if ((int) $candidate->getParentId() === (int) $comment->getId()) {
+ $this->commentsManager->delete(
+ id: (string) $candidate->getId()
+ );
+ }
+ }
+ }
+
+ $this->commentsManager->delete(id: (string) $comment->getId());
+ }//end deleteComment()
+
+ /**
+ * Cascade entry point invoked when a dashboard is deleted (D6).
+ *
+ * Delegates to `ICommentsManager::deleteCommentsAtObject()` so all
+ * comments and replies associated with the dashboard are removed in
+ * a single backend call.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return void
+ */
+ public function deleteAllForDashboard(string $dashboardUuid): void
+ {
+ $this->commentsManager->deleteCommentsAtObject(
+ objectType: self::OBJECT_TYPE,
+ objectId: $dashboardUuid
+ );
+ }//end deleteAllForDashboard()
+
+ /**
+ * Parse `@username` mentions from a message, resolve them to
+ * Nextcloud user IDs, and dispatch mention notifications
+ * (REQ-CMNT-006).
+ *
+ * Returns the resolved mention list — `[{userId, displayName}, ...]`.
+ * Unresolved mentions are silently dropped (no error, no
+ * notification) so a typo in `@nonexistent_user` never breaks the
+ * comment write path.
+ *
+ * @param string $message The message text to parse.
+ * @param string $dashboardUuid The dashboard UUID for the deep-link
+ * attached to the notification.
+ * @param string $authorUserId The author's user ID — used as the
+ * notification's "actor" so the
+ * recipient knows who mentioned them.
+ *
+ * @return array
+ */
+ public function parseAndResolveMentions(
+ string $message,
+ string $dashboardUuid,
+ string $authorUserId
+ ): array {
+ $matches = [];
+ $count = preg_match_all(
+ pattern: self::MENTION_REGEX,
+ subject: $message,
+ matches: $matches
+ );
+
+ if ($count === false) {
+ return [];
+ }
+
+ if ($count < 1) {
+ return [];
+ }
+
+ $unique = [];
+ foreach ($matches[1] as $candidate) {
+ // The regex group `([a-zA-Z0-9_.-]+)` guarantees one or more
+ // characters, so the trimmed lowercase form is never empty
+ // here — no length guard required.
+ $normalised = strtolower(string: (string) $candidate);
+ $unique[$normalised] = true;
+ }
+
+ $resolved = [];
+ foreach (array_keys($unique) as $username) {
+ $user = $this->userManager->get(uid: (string) $username);
+ if ($user === null) {
+ continue;
+ }
+
+ $resolvedId = (string) $user->getUID();
+ $displayName = (string) $user->getDisplayName();
+
+ $resolved[] = [
+ 'userId' => $resolvedId,
+ 'displayName' => $displayName,
+ ];
+
+ // Skip self-mentions — no value in pinging yourself.
+ if ($resolvedId === $authorUserId) {
+ continue;
+ }
+
+ $this->dispatchMentionNotification(
+ recipientUserId: $resolvedId,
+ authorUserId: $authorUserId,
+ dashboardUuid: $dashboardUuid
+ );
+ }//end foreach
+
+ return $resolved;
+ }//end parseAndResolveMentions()
+
+ /**
+ * Serialise a single comment to the JSON envelope contract
+ * (REQ-CMNT-001 / REQ-CMNT-002).
+ *
+ * @param IComment $comment The comment to serialise.
+ *
+ * @return array The wire-format envelope.
+ */
+ public function serialiseComment(IComment $comment): array
+ {
+ $createdAt = $comment->getCreationDateTime()->format(format: 'c');
+ $latest = $comment->getLatestChildDateTime();
+
+ $updatedAt = null;
+ if ($latest !== null) {
+ $updatedAt = $latest->format(format: 'c');
+ }
+
+ $wasEdited = ($updatedAt !== null);
+
+ $parentId = (int) $comment->getParentId();
+ $parent = null;
+ if ($parentId !== 0) {
+ $parent = $parentId;
+ }
+
+ return [
+ 'id' => (int) $comment->getId(),
+ 'parentId' => $parent,
+ 'author' => (string) $comment->getActorId(),
+ 'message' => (string) $comment->getMessage(),
+ 'createdAt' => $createdAt,
+ 'updatedAt' => $updatedAt,
+ 'wasEdited' => $wasEdited,
+ 'mentions' => $this->parseAndResolveMentions(
+ message: (string) $comment->getMessage(),
+ dashboardUuid: (string) $comment->getObjectId(),
+ authorUserId: (string) $comment->getActorId()
+ ),
+ ];
+ }//end serialiseComment()
+
+ /**
+ * Look up a comment by ID, raising the local 404 exception on miss.
+ *
+ * @param int $commentId The comment ID.
+ *
+ * @return IComment The resolved comment.
+ *
+ * @throws CommentNotFoundException When the comment does not exist.
+ */
+ private function fetchComment(int $commentId): IComment
+ {
+ try {
+ return $this->commentsManager->get(id: (string) $commentId);
+ } catch (CommentManagerNotFoundException) {
+ throw new CommentNotFoundException();
+ }
+ }//end fetchComment()
+
+ /**
+ * Enforce the author-or-admin guard on edit / delete (REQ-CMNT-004,
+ * REQ-CMNT-005).
+ *
+ * @param IComment $comment The comment.
+ * @param string $userId The acting user's ID.
+ *
+ * @return void
+ *
+ * @throws CommentForbiddenException When the user is neither author
+ * nor a Nextcloud admin.
+ */
+ private function assertAuthorOrAdmin(
+ IComment $comment,
+ string $userId
+ ): void {
+ if ((string) $comment->getActorId() === $userId) {
+ return;
+ }
+
+ if ($this->groupManager->isAdmin(userId: $userId) === true) {
+ return;
+ }
+
+ throw new CommentForbiddenException(
+ message: 'Only the author or an admin may modify this comment'
+ );
+ }//end assertAuthorOrAdmin()
+
+ /**
+ * Dispatch a single @mention notification (REQ-CMNT-006).
+ *
+ * @param string $recipientUserId The mentioned user.
+ * @param string $authorUserId The comment author.
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return void
+ */
+ private function dispatchMentionNotification(
+ string $recipientUserId,
+ string $authorUserId,
+ string $dashboardUuid
+ ): void {
+ $notification = $this->notificationManager->createNotification();
+ $notification->setApp(app: Application::APP_ID)
+ ->setUser(user: $recipientUserId)
+ ->setDateTime(dateTime: new DateTime())
+ ->setObject(
+ type: self::OBJECT_TYPE,
+ id: $dashboardUuid
+ )
+ ->setSubject(
+ subject: self::NOTIFICATION_SUBJECT,
+ parameters: [$authorUserId, $dashboardUuid]
+ )
+ ->setLink(
+ link: $this->urlGenerator->linkToRouteAbsolute(
+ routeName: 'mydash.page.index'
+ ).'?dashboard='.urlencode(string: $dashboardUuid)
+ );
+
+ $this->notificationManager->notify(notification: $notification);
+ }//end dispatchMentionNotification()
+}//end class
diff --git a/lib/Service/ConditionalService.php b/lib/Service/ConditionalService.php
index 75e8e52b..f192508b 100644
--- a/lib/Service/ConditionalService.php
+++ b/lib/Service/ConditionalService.php
@@ -54,7 +54,7 @@ public function isWidgetVisible(
WidgetPlacement $placement,
string $userId
): bool {
- if ($placement->getIsVisible() === false) {
+ if ($placement->getIsVisible() === 0) {
return false;
}
@@ -96,6 +96,8 @@ public function evaluateRule(
* @param int $placementId The placement ID.
*
* @return ConditionalRule[] The list of rules.
+ *
+ * @spec conditional-visibility:REQ-VIS-002
*/
public function getRules(int $placementId): array
{
@@ -113,6 +115,8 @@ public function getRules(int $placementId): array
* @param bool $isInclude Whether this is an include rule.
*
* @return ConditionalRule The created rule.
+ *
+ * @spec conditional-visibility:REQ-VIS-001
*/
public function addRule(
int $placementId,
@@ -139,6 +143,8 @@ public function addRule(
* @param array $data The data to update.
*
* @return ConditionalRule The updated rule.
+ *
+ * @spec conditional-visibility:REQ-VIS-003
*/
public function updateRule(int $ruleId, array $data): ConditionalRule
{
@@ -165,6 +171,8 @@ public function updateRule(int $ruleId, array $data): ConditionalRule
* @param int $ruleId The rule ID.
*
* @return void
+ *
+ * @spec conditional-visibility:REQ-VIS-004
*/
public function deleteRule(int $ruleId): void
{
diff --git a/lib/Service/Confluence/ArchiveParser.php b/lib/Service/Confluence/ArchiveParser.php
new file mode 100644
index 00000000..4079b250
--- /dev/null
+++ b/lib/Service/Confluence/ArchiveParser.php
@@ -0,0 +1,520 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service\Confluence;
+
+use DOMDocument;
+use DOMXPath;
+use InvalidArgumentException;
+use ZipArchive;
+
+/**
+ * Confluence HTML export ZIP reader.
+ *
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
+ * Per-stage parsing kept in one class so the importer has a single
+ * collaborator with a flat surface (`parse()` returns everything).
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.UndefinedVariable)
+ * `preg_match` populates its by-ref `$matches` argument; PHPMD's
+ * flow analysis does not follow PHP by-reference semantics.
+ */
+class ArchiveParser
+{
+
+ /**
+ * XPath selectors tried in order to extract the page body.
+ *
+ * Mirrors REQ-CFLI-003 scenario "Text-display widget captures page
+ * body" — the first non-empty match wins. The fallback is regex
+ * extraction of the `` element, then the raw HTML.
+ *
+ * @var array
+ */
+ private const BODY_SELECTORS = [
+ "//div[@id='main-content']",
+ "//div[contains(@class, 'wiki-content')]",
+ "//div[contains(@class, 'page-content')]",
+ "//div[@id='content']",
+ '//main',
+ '//article',
+ ];
+
+ /**
+ * Selectors for navigation chrome that MUST be removed from the
+ * extracted body before further processing (REQ-CFLI-003).
+ *
+ * @var array
+ */
+ private const STRIP_SELECTORS = [
+ "//div[@id='pagetreesearch']",
+ "//div[contains(@class, 'breadcrumbs')]",
+ "//div[contains(@class, 'pageSection')]",
+ "//form[@name='pagetreesearchform']",
+ "//form[contains(@class, 'aui')]",
+ '//nav',
+ "//div[contains(@class, 'page-metadata')]",
+ ];
+
+ /**
+ * Parse a Confluence HTML export ZIP at the given path.
+ *
+ * @param string $zipPath Absolute path to the ZIP file.
+ *
+ * @return ParsedArchive Structured representation of the archive.
+ *
+ * @throws InvalidArgumentException When the ZIP is missing,
+ * unreadable, or has no `index.html`.
+ */
+ public function parse(string $zipPath): ParsedArchive
+ {
+ if (file_exists(filename: $zipPath) === false) {
+ throw new InvalidArgumentException(
+ message: 'Confluence export not found: '.$zipPath
+ );
+ }
+
+ $zip = new ZipArchive();
+ if ($zip->open(filename: $zipPath, flags: ZipArchive::RDONLY) !== true) {
+ throw new InvalidArgumentException(
+ message: 'Uploaded file is not a valid ZIP archive'
+ );
+ }
+
+ try {
+ $entries = $this->collectEntries(zip: $zip);
+
+ if ($entries['indexHtml'] === null) {
+ throw new InvalidArgumentException(
+ message: 'index.html not found in archive'
+ );
+ }
+
+ $orderMap = $this->extractPageOrderFromIndex(html: $entries['indexHtml']);
+ $pages = [];
+ $warnings = [];
+
+ foreach ($entries['htmlFiles'] as $relPath => $html) {
+ $page = $this->parsePage(
+ relPath: $relPath,
+ html: $html,
+ orderMap: $orderMap
+ );
+ if ($page === null) {
+ $warnings[] = 'Failed to parse '.$relPath;
+ continue;
+ }
+
+ $pages[] = $page;
+ }
+
+ $this->applyDirectoryHierarchy(pages: $pages);
+
+ return new ParsedArchive(
+ pages: $pages,
+ attachments: $entries['attachments'],
+ images: $entries['images'],
+ warnings: $warnings
+ );
+ } finally {
+ $zip->close();
+ }//end try
+ }//end parse()
+
+ /**
+ * Read every relevant entry out of the ZIP into memory.
+ *
+ * Confluence exports of any practical size (a few MB to a few
+ * hundred MB) fit comfortably; the importer is admin-triggered and
+ * documented as synchronous for ≤100 pages.
+ *
+ * @param ZipArchive $zip The opened archive.
+ *
+ * @return array{
+ * indexHtml:?string,
+ * htmlFiles:array,
+ * attachments:array,
+ * images:array
+ * }
+ */
+ private function collectEntries(ZipArchive $zip): array
+ {
+ $indexHtml = null;
+ $htmlFiles = [];
+ $attachments = [];
+ $images = [];
+
+ for ($i = 0; $i < $zip->numFiles; $i++) {
+ $name = (string) $zip->getNameIndex(index: $i);
+ if ($name === '' || str_ends_with(haystack: $name, needle: '/') === true) {
+ continue;
+ }
+
+ // Reject directory traversal entries up front (defence in
+ // depth — the importer never writes anything to disk via the
+ // entry name, but we still guard for safety).
+ if (str_contains(haystack: $name, needle: '..') === true) {
+ continue;
+ }
+
+ $lower = strtolower(string: $name);
+ if ($lower === 'index.html') {
+ $raw = $zip->getFromIndex(index: $i);
+ if ($raw === false) {
+ $raw = '';
+ }
+
+ $indexHtml = $raw;
+ continue;
+ }
+
+ if (str_ends_with(haystack: $lower, needle: '.html') === true) {
+ $base = strtolower(string: basename(path: $name));
+ if ($base === 'index.html' || $base === 'overview.html' || $base === 'toc.html') {
+ continue;
+ }
+
+ $raw = $zip->getFromIndex(index: $i);
+ if ($raw === false) {
+ $raw = '';
+ }
+
+ $htmlFiles[$name] = $raw;
+ continue;
+ }
+
+ if (str_starts_with(haystack: $lower, needle: 'attachments/') === true) {
+ $attachments[] = $name;
+ continue;
+ }
+
+ if (str_starts_with(haystack: $lower, needle: 'images/') === true) {
+ $images[] = $name;
+ continue;
+ }
+ }//end for
+
+ return [
+ 'indexHtml' => $indexHtml,
+ 'htmlFiles' => $htmlFiles,
+ 'attachments' => $attachments,
+ 'images' => $images,
+ ];
+ }//end collectEntries()
+
+ /**
+ * Extract the position-based ordering hint from `index.html`.
+ *
+ * Per REQ-CFLI-002 the index drives sibling order only — never
+ * parent-child relationships. The map is `pageId => 0-based index`
+ * keyed by the basename of every `` link in the file.
+ *
+ * @param string $html The index.html contents.
+ *
+ * @return array The per-pageId sibling order.
+ */
+ private function extractPageOrderFromIndex(string $html): array
+ {
+ $matches = [];
+ if (preg_match_all(
+ pattern: '/href\s*=\s*"([^"#?]+\.html)(?:[#?][^"]*)?"/i',
+ subject: $html,
+ matches: $matches
+ ) === false
+ ) {
+ return [];
+ }
+
+ $order = [];
+ foreach ($matches[1] as $idx => $href) {
+ $base = strtolower(string: basename(path: $href));
+ $base = pathinfo(path: $base, flags: PATHINFO_FILENAME);
+ if ($base === 'index' || $base === 'overview' || $base === 'toc') {
+ continue;
+ }
+
+ if (isset($order[$base]) === false) {
+ $order[$base] = $idx;
+ }
+ }
+
+ return $order;
+ }//end extractPageOrderFromIndex()
+
+ /**
+ * Parse a single Confluence HTML page into a {@see ParsedPage}.
+ *
+ * @param string $relPath The in-archive path.
+ * @param string $html The raw HTML.
+ * @param array $orderMap pageId → sibling order.
+ *
+ * @return ParsedPage|null The parsed page, or NULL on hard failure.
+ */
+ private function parsePage(
+ string $relPath,
+ string $html,
+ array $orderMap
+ ): ?ParsedPage {
+ if (trim($html) === '') {
+ return null;
+ }
+
+ $pageId = pathinfo(path: $relPath, flags: PATHINFO_FILENAME);
+ if ($pageId === '') {
+ return null;
+ }
+
+ $title = $this->extractTitle(html: $html);
+ if ($title === '') {
+ $title = $pageId;
+ }
+
+ $body = $this->extractBody(html: $html);
+ $breadcrumbParent = $this->extractParentFromBreadcrumb(html: $html);
+
+ $sortOrder = (int) ($orderMap[strtolower(string: $pageId)] ?? 0);
+
+ return new ParsedPage(
+ relPath: $relPath,
+ pageId: $pageId,
+ title: $title,
+ body: $body,
+ sortOrder: $sortOrder,
+ parentPageId: $breadcrumbParent,
+ );
+ }//end parsePage()
+
+ /**
+ * Extract the page title.
+ *
+ * @param string $html The page HTML.
+ *
+ * @return string The trimmed title, or empty string when absent.
+ */
+ private function extractTitle(string $html): string
+ {
+ if (preg_match(
+ pattern: '/]*>(.*?)<\/title>/is',
+ subject: $html,
+ matches: $matches
+ ) === 1
+ ) {
+ $raw = trim((string) $matches[1]);
+ // Confluence titles are often `Space : Page Name` — strip
+ // the space prefix when present.
+ if (str_contains(haystack: $raw, needle: ' : ') === true) {
+ $raw = trim(substr(string: $raw, offset: ((int) strpos(haystack: $raw, needle: ' : ') + 3)));
+ }
+
+ return html_entity_decode(string: $raw, flags: (ENT_QUOTES | ENT_HTML5), encoding: 'UTF-8');
+ }
+
+ return '';
+ }//end extractTitle()
+
+ /**
+ * Apply the body-selector waterfall + nav-strip pass.
+ *
+ * @param string $html The page HTML.
+ *
+ * @return string The cleaned body HTML (possibly empty).
+ */
+ private function extractBody(string $html): string
+ {
+ $doc = new DOMDocument();
+ $previous = libxml_use_internal_errors(use_errors: true);
+ $loaded = $doc->loadHTML(
+ source: ''.$html,
+ options: LIBXML_NONET
+ );
+ libxml_clear_errors();
+ libxml_use_internal_errors(use_errors: $previous);
+
+ if ($loaded === false) {
+ return $this->extractBodyFallback(html: $html);
+ }
+
+ $xpath = new DOMXPath(document: $doc);
+
+ foreach (self::BODY_SELECTORS as $selector) {
+ $nodes = $xpath->query(expression: $selector);
+ if ($nodes === false || $nodes->length === 0) {
+ continue;
+ }
+
+ $node = $nodes->item(index: 0);
+ if ($node === null) {
+ continue;
+ }
+
+ $this->stripUnwanted(xpath: $xpath, root: $node);
+
+ $out = '';
+ foreach ($node->childNodes as $child) {
+ $out .= $doc->saveHTML(node: $child);
+ }
+
+ $trimmed = trim($out);
+ if ($trimmed !== '') {
+ return $trimmed;
+ }
+ }//end foreach
+
+ return $this->extractBodyFallback(html: $html);
+ }//end extractBody()
+
+ /**
+ * Regex fallback for body extraction when DOMXPath finds nothing.
+ *
+ * @param string $html The page HTML.
+ *
+ * @return string The body fragment, or the raw HTML as last resort.
+ */
+ private function extractBodyFallback(string $html): string
+ {
+ if (preg_match(
+ pattern: '/ ]*>(.*?)<\/body>/is',
+ subject: $html,
+ matches: $matches
+ ) === 1
+ ) {
+ return trim((string) $matches[1]);
+ }
+
+ return trim($html);
+ }//end extractBodyFallback()
+
+ /**
+ * Remove navigation chrome from a node in place.
+ *
+ * @param DOMXPath $xpath The XPath.
+ * @param \DOMNode $root The root.
+ *
+ * @return void
+ */
+ private function stripUnwanted(DOMXPath $xpath, \DOMNode $root): void
+ {
+ foreach (self::STRIP_SELECTORS as $selector) {
+ $matches = $xpath->query(expression: '.'.$selector, contextNode: $root);
+ if ($matches === false) {
+ continue;
+ }
+
+ // Snapshot — removeChild during iteration mutates the list.
+ $remove = [];
+ foreach ($matches as $node) {
+ $remove[] = $node;
+ }
+
+ foreach ($remove as $node) {
+ if ($node->parentNode !== null) {
+ $node->parentNode->removeChild(child: $node);
+ }
+ }
+ }
+ }//end stripUnwanted()
+
+ /**
+ * Pull the parent pageId out of the page's breadcrumb navigation.
+ *
+ * @param string $html The page HTML.
+ *
+ * @return string|null Parent pageId, or NULL when the breadcrumb is
+ * absent / malformed / lists only the page itself.
+ */
+ private function extractParentFromBreadcrumb(string $html): ?string
+ {
+ $pattern = '/]*(?:class="[^"]*breadcrumbs[^"]*"|id="breadcrumbs")[^>]*>(.*?)<\/ol>/is';
+ if (preg_match(pattern: $pattern, subject: $html, matches: $match) !== 1) {
+ return null;
+ }
+
+ $hrefMatches = [];
+ if (preg_match_all(
+ pattern: '/href\s*=\s*"([^"#?]+\.html)(?:[#?][^"]*)?"/i',
+ subject: (string) $match[1],
+ matches: $hrefMatches
+ ) === false
+ ) {
+ return null;
+ }
+
+ $hrefs = $hrefMatches[1];
+ if (count($hrefs) === 0) {
+ return null;
+ }
+
+ // The last href in the breadcrumb is the page's immediate
+ // parent (the page itself is rendered as plain text, no link).
+ $parentHref = (string) end($hrefs);
+ $base = pathinfo(path: $parentHref, flags: PATHINFO_FILENAME);
+ if ($base === '' || $base === 'index' || $base === 'overview') {
+ return null;
+ }
+
+ return $base;
+ }//end extractParentFromBreadcrumb()
+
+ /**
+ * Apply directory-nesting hierarchy to pages that have no
+ * breadcrumb-derived parent.
+ *
+ * REQ-CFLI-002 establishes directory nesting as the initial parent
+ * source, with breadcrumb as the override. This pass fills in the
+ * directory-derived parent for any page where breadcrumb extraction
+ * returned NULL.
+ *
+ * @param ParsedPage[] $pages The parsed pages (mutated in place).
+ *
+ * @return void
+ */
+ private function applyDirectoryHierarchy(array $pages): void
+ {
+ $byPath = [];
+ foreach ($pages as $page) {
+ $byPath[$page->relPath] = $page;
+ }
+
+ foreach ($pages as $page) {
+ if ($page->parentPageId !== null) {
+ continue;
+ }
+
+ $dir = dirname(path: $page->relPath);
+ if ($dir === '.' || $dir === '/' || $dir === '') {
+ continue;
+ }
+
+ // Look for an `index.html` sibling in the parent dir of the
+ // current dir (typical Confluence layout: `SPACE/page.html`
+ // children + `SPACE.html` parent at the same level).
+ $candidate = $dir.'.html';
+ if (isset($byPath[$candidate]) === true) {
+ $page->parentPageId = $byPath[$candidate]->pageId;
+ }
+ }
+ }//end applyDirectoryHierarchy()
+}//end class
diff --git a/lib/Service/Confluence/HtmlSanitizer.php b/lib/Service/Confluence/HtmlSanitizer.php
new file mode 100644
index 00000000..216539a2
--- /dev/null
+++ b/lib/Service/Confluence/HtmlSanitizer.php
@@ -0,0 +1,300 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service\Confluence;
+
+use DOMDocument;
+use DOMElement;
+use DOMNode;
+
+/**
+ * Allow-list HTML sanitiser tuned for Confluence body content.
+ */
+class HtmlSanitizer
+{
+
+ /**
+ * Tags preserved verbatim by {@see HtmlSanitizer::sanitize()}.
+ *
+ * Mirrors REQ-CFLI-012: the list is intentionally narrow so the
+ * result is safe to render via Vue's `v-html` (the text-display
+ * widget already runs the result through its own renderer).
+ *
+ * @var array
+ */
+ private const ALLOWED_TAGS = [
+ 'p',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'a',
+ 'strong',
+ 'em',
+ 'b',
+ 'i',
+ 'ul',
+ 'ol',
+ 'li',
+ 'img',
+ 'table',
+ 'tr',
+ 'td',
+ 'th',
+ 'thead',
+ 'tbody',
+ 'blockquote',
+ 'pre',
+ 'code',
+ 'br',
+ 'span',
+ 'div',
+ 'details',
+ 'summary',
+ ];
+
+ /**
+ * Tags whose content (not just the wrapper) MUST be discarded.
+ *
+ * Unwrapping ` bodies entirely (content + tags).
+ $stripped = preg_replace(
+ pattern: '##is',
+ replacement: '',
+ subject: $html
+ );
+
+ if ($stripped === null) {
+ return '';
+ }
+
+ // Replace any tag with a sanitised version or empty string. The
+ // callback inspects each match, validates the tag name, drops
+ // disallowed attributes, and force-tags external elements
+ // with rel/target.
+ $result = preg_replace_callback(
+ pattern: '#<(/?)\s*([a-zA-Z0-9]+)\b([^>]*)>#s',
+ callback: function (array $matches): string {
+ $closing = ($matches[1] === '/');
+ $tag = strtolower(string: $matches[2]);
+ $attrs = $matches[3];
+
+ if (in_array(needle: $tag, haystack: self::ALLOWED_TAGS, strict: true) === false) {
+ return '';
+ }
+
+ if ($closing === true) {
+ return ''.$tag.'>';
+ }
+
+ $allowedAttrs = (self::ALLOWED_ATTRIBUTES[$tag] ?? []);
+ $kept = [];
+ if ($allowedAttrs !== [] && trim(string: $attrs) !== '') {
+ if (preg_match_all(
+ pattern: '#([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*("([^"]*)"|\'([^\']*)\'|([^\s"\'>]+))#',
+ subject: $attrs,
+ matches: $found,
+ flags: PREG_SET_ORDER
+ ) > 0
+ ) {
+ foreach ($found as $attr) {
+ $name = strtolower(string: $attr[1]);
+ if (in_array(needle: $name, haystack: $allowedAttrs, strict: true) === false) {
+ continue;
+ }
+
+ $value = ($attr[3] ?? ($attr[4] ?? ($attr[5] ?? '')));
+ // Reject data: / javascript: schemes on URL
+ // attributes (href / src). The allow-list
+ // already restricted us to those names so a
+ // scheme check is enough.
+ if (preg_match(
+ pattern: '#^\s*(javascript|data|vbscript):#i',
+ subject: $value
+ ) === 1
+ ) {
+ continue;
+ }
+
+ // Strip any control char or newline; keep
+ // simple value escaping for HTML context.
+ $cleanValue = htmlspecialchars(
+ string: $value,
+ flags: (ENT_QUOTES | ENT_HTML5),
+ encoding: 'UTF-8'
+ );
+ $kept[$name] = $cleanValue;
+ }//end foreach
+ }//end if
+ }//end if
+
+ $rendered = '<'.$tag;
+ foreach ($kept as $name => $value) {
+ $rendered .= ' '.$name.'="'.$value.'"';
+ }
+
+ // External elements get rel + target automatically
+ // (REQ-FTR-002 external-link scenario).
+ if ($tag === 'a'
+ && isset($kept['href']) === true
+ && preg_match(
+ pattern: '#^https?://#i',
+ subject: $kept['href']
+ ) === 1
+ ) {
+ $rendered .= ' rel="noopener noreferrer" target="_blank"';
+ }
+
+ if (in_array(needle: $tag, haystack: ['br', 'img'], strict: true) === true) {
+ $rendered .= ' />';
+ } else {
+ $rendered .= '>';
+ }
+
+ return $rendered;
+ },
+ subject: $stripped
+ );
+
+ if ($result === null) {
+ return '';
+ }
+
+ return $result;
+ }//end sanitiseHtml()
+
+ /**
+ * Validate a structured-mode config payload against the documented
+ * schema (REQ-FTR-003). Throws on extra keys, malformed `links`,
+ * or an unknown `layoutMode`.
+ *
+ * @param array $config The structured config.
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException On schema mismatch.
+ */
+ public function validateStructuredConfig(array $config): void
+ {
+ foreach (array_keys(array: $config) as $key) {
+ if (in_array(needle: $key, haystack: self::STRUCTURED_CONFIG_KEYS, strict: true) === false) {
+ throw new InvalidArgumentException(
+ message: 'footerConfig contains unknown key: '.(string) $key
+ );
+ }
+ }
+
+ if (array_key_exists(key: 'layoutMode', array: $config) === true) {
+ $mode = $config['layoutMode'];
+ if (is_string($mode) === false
+ || in_array(needle: $mode, haystack: self::LAYOUT_MODES, strict: true) === false
+ ) {
+ throw new InvalidArgumentException(
+ message: 'footerConfig.layoutMode must be one of: '.implode(separator: ', ', array: self::LAYOUT_MODES)
+ );
+ }
+ }
+
+ if (array_key_exists(key: 'links', array: $config) === true) {
+ $links = $config['links'];
+ if (is_array($links) === false) {
+ throw new InvalidArgumentException(
+ message: 'footerConfig.links must be an array'
+ );
+ }
+
+ foreach ($links as $entry) {
+ if (is_array($entry) === false
+ || isset($entry['label']) === false
+ || isset($entry['url']) === false
+ || is_string($entry['label']) === false
+ || is_string($entry['url']) === false
+ ) {
+ throw new InvalidArgumentException(
+ message: 'footerConfig.links entries must be {label, url} string pairs'
+ );
+ }
+ }
+ }//end if
+ }//end validateStructuredConfig()
+
+ /**
+ * Resolve the effective footer payload for a single dashboard
+ * (REQ-FTR-004, REQ-FTR-006). Returns NULL when no footer should
+ * render, or an associative array suitable for serialising as
+ * the dashboard API response's `effectiveFooter` field.
+ *
+ * Resolution order (matches DashboardService.resolveFooterForDashboard
+ * tasks 6.5):
+ * - dashboard mode = `hidden` → NULL.
+ * - dashboard mode = `custom` → render the dashboard HTML.
+ * - dashboard mode = `inherit` → check global `footerEnabled`;
+ * if false → NULL; otherwise render the global footer HTML or
+ * structured config.
+ *
+ * @param Dashboard $dashboard The dashboard whose footer to resolve.
+ *
+ * @return array|null Effective footer payload keyed
+ * by `mode`, `html`, `config`,
+ * `backgroundColor`, `textColor`.
+ */
+ public function resolveFooterForDashboard(Dashboard $dashboard): ?array
+ {
+ $rawMode = $dashboard->getDashboardFooterMode();
+ if ($rawMode === '') {
+ $mode = Dashboard::FOOTER_MODE_INHERIT;
+ } else {
+ $mode = $rawMode;
+ }
+
+ if ($mode === Dashboard::FOOTER_MODE_HIDDEN) {
+ return null;
+ }
+
+ $globals = $this->getGlobalSettings();
+
+ if ($mode === Dashboard::FOOTER_MODE_CUSTOM) {
+ $html = $dashboard->getDashboardFooterHtml();
+ if ($html === null || trim(string: $html) === '') {
+ return null;
+ }
+
+ return [
+ 'mode' => Dashboard::FOOTER_MODE_CUSTOM,
+ 'html' => $html,
+ 'config' => null,
+ 'backgroundColor' => $globals['footerBackgroundColor'],
+ 'textColor' => $globals['footerTextColor'],
+ ];
+ }
+
+ // Inherit branch — must consult the global toggle.
+ if ($globals['footerEnabled'] !== true) {
+ return null;
+ }
+
+ $html = $globals['footerHtml'];
+ $htmlValue = null;
+ if (is_string($html) === true && $html !== '') {
+ $htmlValue = $html;
+ } else if (is_array($html) === true && $html !== []) {
+ // Language-variant map — the frontend selects the locale.
+ $htmlValue = $html;
+ }
+
+ $config = $globals['footerConfig'];
+ $configValue = null;
+ if (is_array($config) === true && $config !== []) {
+ $configValue = $config;
+ }
+
+ if ($htmlValue === null && $configValue === null) {
+ // Footer enabled but nothing configured — skip rendering.
+ return null;
+ }
+
+ return [
+ 'mode' => 'global',
+ 'html' => $htmlValue,
+ 'config' => $configValue,
+ 'backgroundColor' => $globals['footerBackgroundColor'],
+ 'textColor' => $globals['footerTextColor'],
+ ];
+ }//end resolveFooterForDashboard()
+
+ /**
+ * Validate that a value is a `#rgb` / `#rrggbb` hex colour string
+ * (REQ-FTR-009). Throws on anything else.
+ *
+ * @param mixed $value The value to validate.
+ * @param string $fieldName The field name for the error message.
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException When the value is not a hex string.
+ */
+ private function assertHexColour(mixed $value, string $fieldName): void
+ {
+ if (is_string($value) === false
+ || preg_match(pattern: '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', subject: $value) !== 1
+ ) {
+ throw new InvalidArgumentException(
+ message: $fieldName.' must be a hex colour string (e.g. #1a1a1a)'
+ );
+ }
+ }//end assertHexColour()
+}//end class
diff --git a/lib/Service/ImageMimeValidator.php b/lib/Service/ImageMimeValidator.php
index d694c0d9..88baecf2 100644
--- a/lib/Service/ImageMimeValidator.php
+++ b/lib/Service/ImageMimeValidator.php
@@ -106,7 +106,7 @@ public function validate(string $declaredType, string $bytes): void
throw new CorruptImageException();
}
- $detectedMime = $info['mime'];
+ $detectedMime = ($info['mime'] ?? '');
if ($detectedMime !== $expectedMime) {
throw new MimeMismatchException();
}
diff --git a/lib/Service/ImportService.php b/lib/Service/ImportService.php
new file mode 100644
index 00000000..ff2e5534
--- /dev/null
+++ b/lib/Service/ImportService.php
@@ -0,0 +1,633 @@
+
+ * @copyright 2024 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2024 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service;
+
+use InvalidArgumentException;
+use OCA\MyDash\Db\Dashboard;
+use OCA\MyDash\Db\DashboardMapper;
+use OCA\MyDash\Db\WidgetPlacement;
+use OCA\MyDash\Db\WidgetPlacementMapper;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IDBConnection;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+use Throwable;
+use ZipArchive;
+
+/**
+ * Imports MyDash export ZIP archives.
+ *
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
+ * Validation + remap + transactional restore is intentionally cohesive.
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.NPathComplexity)
+ */
+class ImportService
+{
+ /**
+ * Maximum manifest schema version this service can read.
+ *
+ * @var integer
+ */
+ public const SCHEMA_VERSION = ExportService::SCHEMA_VERSION;
+
+ /**
+ * Public marker for a UUID-collision result so the controller can
+ * map it to HTTP 409.
+ *
+ * @var string
+ */
+ public const ERR_UUID_COLLISION = 'uuidCollision';
+
+ /**
+ * Public marker for a metadata-field type mismatch.
+ *
+ * @var string
+ */
+ public const ERR_FIELD_TYPE_MISMATCH = 'metadataFieldTypeMismatch';
+
+ /**
+ * Public marker for an invalid dashboard payload.
+ *
+ * @var string
+ */
+ public const ERR_INVALID_DASHBOARD = 'invalidDashboard';
+
+ /**
+ * Constructor.
+ *
+ * @param DashboardMapper $dashboardMapper Dashboard data mapper.
+ * @param WidgetPlacementMapper $placementMapper Widget placement mapper.
+ * @param IDBConnection $db Database connection.
+ * @param LoggerInterface $logger PSR-3 logger.
+ */
+ public function __construct(
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly WidgetPlacementMapper $placementMapper,
+ private readonly IDBConnection $db,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Import a ZIP archive.
+ *
+ * @param string $zipPath Path to the uploaded ZIP file.
+ * @param bool $preserveUuids When true, fail on UUID collision.
+ * @param string $currentUserId The importing user's UID.
+ *
+ * @return array{importedDashboardCount:int, skippedDashboardCount:int,
+ * errors:array>,
+ * manifest:array,
+ * status:string}
+ *
+ * @throws InvalidArgumentException When the archive is invalid or the
+ * schema version is unsupported.
+ */
+ public function import(
+ string $zipPath,
+ bool $preserveUuids,
+ string $currentUserId
+ ): array {
+ if (file_exists(filename: $zipPath) === false) {
+ throw new InvalidArgumentException(
+ message: 'Uploaded file is not a valid ZIP archive'
+ );
+ }
+
+ $zip = new ZipArchive();
+ if ($zip->open(filename: $zipPath, flags: ZipArchive::RDONLY) !== true) {
+ throw new InvalidArgumentException(
+ message: 'Uploaded file is not a valid ZIP archive'
+ );
+ }
+
+ try {
+ $manifest = $this->validateZipStructure(zip: $zip);
+ $dashboards = $this->readDashboards(zip: $zip);
+ $collisions = $this->detectUuidCollisions(dashboards: $dashboards);
+
+ if ($preserveUuids === true && $collisions !== []) {
+ return [
+ 'status' => self::ERR_UUID_COLLISION,
+ 'manifest' => $manifest,
+ 'importedDashboardCount' => 0,
+ 'skippedDashboardCount' => 0,
+ 'errors' => $collisions,
+ ];
+ }
+
+ $remapped = $this->remapUuids(
+ dashboards: $dashboards,
+ preserveUuids: $preserveUuids
+ );
+
+ return [
+ 'status' => 'ok',
+ 'manifest' => $manifest,
+ ] + $this->importDashboardBatch(
+ dashboards: $remapped,
+ currentUserId: $currentUserId,
+ preserveUuids: $preserveUuids
+ );
+ } finally {
+ $zip->close();
+ }//end try
+ }//end import()
+
+ /**
+ * Validate the manifest and return its decoded payload.
+ *
+ * @param ZipArchive $zip The opened archive.
+ *
+ * @return array The decoded manifest.
+ *
+ * @throws InvalidArgumentException When the manifest is missing or invalid.
+ */
+ public function validateZipStructure(ZipArchive $zip): array
+ {
+ $raw = $zip->getFromName(name: 'manifest.json');
+ if ($raw === false) {
+ throw new InvalidArgumentException(
+ message: 'manifest.json not found in archive'
+ );
+ }
+
+ $decoded = json_decode(json: $raw, associative: true);
+ if (is_array($decoded) === false) {
+ throw new InvalidArgumentException(
+ message: 'manifest.json is not valid JSON'
+ );
+ }
+
+ $required = ['schemaVersion', 'scope'];
+ $missing = [];
+ foreach ($required as $field) {
+ if (array_key_exists(key: $field, array: $decoded) === false) {
+ $missing[] = $field;
+ }
+ }
+
+ if ($missing !== []) {
+ throw new InvalidArgumentException(
+ message: 'Manifest missing required field(s): '.implode(separator: ', ', array: $missing)
+ );
+ }
+
+ $version = $decoded['schemaVersion'];
+ if (is_int($version) === false || $version !== self::SCHEMA_VERSION) {
+ $head = 'Unsupported manifest schema version: '.(string) $version.'.';
+ $tail = ' Only version '.(string) self::SCHEMA_VERSION.' is supported.';
+ throw new InvalidArgumentException(message: ($head.$tail));
+ }
+
+ return $decoded;
+ }//end validateZipStructure()
+
+ /**
+ * Re-map UUIDs across dashboards when `preserveUuids=false`.
+ *
+ * @param array> $dashboards The dashboards.
+ * @param bool $preserveUuids Preserve flag.
+ *
+ * @return array> The (possibly remapped) dashboards.
+ */
+ public function remapUuids(array $dashboards, bool $preserveUuids): array
+ {
+ if ($preserveUuids === true) {
+ return $dashboards;
+ }
+
+ $uuidMap = [];
+ foreach ($dashboards as $dashboard) {
+ $original = (string) ($dashboard['uuid'] ?? '');
+ if ($original === '') {
+ continue;
+ }
+
+ $uuidMap[$original] = $this->generateUuidV4();
+ }
+
+ $result = [];
+ foreach ($dashboards as $dashboard) {
+ $original = (string) ($dashboard['uuid'] ?? '');
+ if ($original !== '' && isset($uuidMap[$original]) === true) {
+ $dashboard['uuid'] = $uuidMap[$original];
+ }
+
+ $parent = $dashboard['parentUuid'] ?? null;
+ if (is_string($parent) === true && isset($uuidMap[$parent]) === true) {
+ $dashboard['parentUuid'] = $uuidMap[$parent];
+ }
+
+ $result[] = $dashboard;
+ }
+
+ return $result;
+ }//end remapUuids()
+
+ /**
+ * Persist a batch of remapped dashboards.
+ *
+ * Each dashboard runs in its own DB transaction so a single bad
+ * record cannot poison the batch (REQ-EXIM-011).
+ *
+ * @param array> $dashboards Decoded dashboards.
+ * @param string $currentUserId Importing UID.
+ * @param bool $preserveUuids Preserve flag.
+ *
+ * @return array{importedDashboardCount:int, skippedDashboardCount:int,
+ * errors:array>}
+ */
+ private function importDashboardBatch(
+ array $dashboards,
+ string $currentUserId,
+ bool $preserveUuids
+ ): array {
+ $imported = 0;
+ $skipped = 0;
+ $errors = [];
+
+ foreach ($dashboards as $payload) {
+ $uuid = (string) ($payload['uuid'] ?? '');
+ $missing = $this->validateDashboardPayload(payload: $payload);
+ if ($missing !== null) {
+ $skipped++;
+ $errors[] = [
+ 'type' => self::ERR_INVALID_DASHBOARD,
+ 'uuid' => $uuid,
+ 'message' => 'Missing required field: '.$missing,
+ ];
+ continue;
+ }
+
+ $this->db->beginTransaction();
+ try {
+ $dashboard = $this->buildEntity(
+ payload: $payload,
+ currentUserId: $currentUserId,
+ preserveUuids: $preserveUuids
+ );
+
+ $persisted = $this->dashboardMapper->insert(entity: $dashboard);
+
+ $widgets = $payload['widgets'] ?? [];
+ if (is_array($widgets) === true) {
+ foreach ($widgets as $widgetPayload) {
+ if (is_array($widgetPayload) === false) {
+ continue;
+ }
+
+ $placement = $this->buildPlacement(
+ dashboardId: (int) $persisted->getId(),
+ payload: $widgetPayload
+ );
+ $this->placementMapper->insert(entity: $placement);
+ }
+ }
+
+ $this->db->commit();
+ $imported++;
+ } catch (Throwable $e) {
+ $this->db->rollBack();
+ $skipped++;
+ $errors[] = [
+ 'type' => self::ERR_INVALID_DASHBOARD,
+ 'uuid' => $uuid,
+ 'message' => 'Failed to import dashboard: '.$e->getMessage(),
+ ];
+ $this->logger->warning(
+ message: 'Skipped dashboard during import',
+ context: ['uuid' => $uuid, 'exception' => $e]
+ );
+ }//end try
+ }//end foreach
+
+ return [
+ 'importedDashboardCount' => $imported,
+ 'skippedDashboardCount' => $skipped,
+ 'errors' => $errors,
+ ];
+ }//end importDashboardBatch()
+
+ /**
+ * Read decoded dashboards from the archive.
+ *
+ * @param ZipArchive $zip The open archive.
+ *
+ * @return array>
+ */
+ private function readDashboards(ZipArchive $zip): array
+ {
+ $dashboards = [];
+ for ($i = 0; $i < $zip->numFiles; $i++) {
+ $name = (string) $zip->getNameIndex(index: $i);
+ if ($this->isDashboardEntry(name: $name) === false) {
+ continue;
+ }
+
+ $raw = $zip->getFromIndex(index: $i);
+ if ($raw === false) {
+ continue;
+ }
+
+ $decoded = json_decode(json: $raw, associative: true);
+ if (is_array($decoded) === false) {
+ $dashboards[] = [
+ '__corrupt__' => true,
+ '__entry__' => $name,
+ ];
+ continue;
+ }
+
+ $dashboards[] = $decoded;
+ }//end for
+
+ return $dashboards;
+ }//end readDashboards()
+
+ /**
+ * Detect UUID collisions against the live database.
+ *
+ * @param array> $dashboards Decoded dashboards.
+ *
+ * @return array> Collisions formatted for the response.
+ */
+ private function detectUuidCollisions(array $dashboards): array
+ {
+ $collisions = [];
+ foreach ($dashboards as $dashboard) {
+ $uuid = (string) ($dashboard['uuid'] ?? '');
+ if ($uuid === '') {
+ continue;
+ }
+
+ try {
+ $this->dashboardMapper->findByUuid(uuid: $uuid);
+ $msg = 'Dashboard with UUID '.$uuid.' already exists. Use preserveUuids=false to assign new UUIDs.';
+ $collisions[] = [
+ 'type' => self::ERR_UUID_COLLISION,
+ 'dashboard' => $uuid,
+ 'message' => $msg,
+ ];
+ } catch (DoesNotExistException) {
+ // No collision — happy path.
+ continue;
+ }
+ }
+
+ return $collisions;
+ }//end detectUuidCollisions()
+
+ /**
+ * Determine whether a ZIP entry is a per-dashboard JSON file.
+ *
+ * Rejects entries with directory-traversal segments to satisfy the
+ * REQ-EXIM-005 / non-functional security requirement.
+ *
+ * @param string $name The ZIP entry name.
+ *
+ * @return bool True when the entry is a dashboard JSON file.
+ */
+ private function isDashboardEntry(string $name): bool
+ {
+ if (str_starts_with(haystack: $name, needle: 'dashboards/') === false) {
+ return false;
+ }
+
+ if (str_ends_with(haystack: $name, needle: '.json') === false) {
+ return false;
+ }
+
+ if (str_contains(haystack: $name, needle: '..') === true) {
+ return false;
+ }
+
+ return true;
+ }//end isDashboardEntry()
+
+ /**
+ * Validate that a dashboard payload has the minimum required fields.
+ *
+ * @param array $payload The decoded payload.
+ *
+ * @return string|null The first missing field name, or NULL when valid.
+ */
+ private function validateDashboardPayload(array $payload): ?string
+ {
+ if (isset($payload['__corrupt__']) === true) {
+ return 'corrupt JSON payload';
+ }
+
+ foreach (['uuid', 'name', 'widgets'] as $required) {
+ if (array_key_exists(key: $required, array: $payload) === false) {
+ return $required;
+ }
+ }
+
+ return null;
+ }//end validateDashboardPayload()
+
+ /**
+ * Hydrate a Dashboard entity from a payload.
+ *
+ * @param array $payload The dashboard payload.
+ * @param string $currentUserId The importing user.
+ * @param bool $preserveUuids Preserve the source UUID.
+ *
+ * @return Dashboard The new entity (not yet persisted).
+ *
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
+ * Field-by-field guards are clearer than a map-driven setter.
+ * @SuppressWarnings(PHPMD.NPathComplexity)
+ */
+ private function buildEntity(
+ array $payload,
+ string $currentUserId,
+ bool $preserveUuids
+ ): Dashboard {
+ $dashboard = new Dashboard();
+
+ $uuid = (string) $payload['uuid'];
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setUuid($uuid);
+
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setName((string) $payload['name']);
+
+ if (array_key_exists(key: 'description', array: $payload) === true) {
+ $description = null;
+ if ($payload['description'] !== null) {
+ $description = (string) $payload['description'];
+ }
+
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setDescription($description);
+ }
+
+ if (array_key_exists(key: 'icon', array: $payload) === true) {
+ $icon = null;
+ if ($payload['icon'] !== null) {
+ $icon = (string) $payload['icon'];
+ }
+
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setIcon($icon);
+ }
+
+ $type = (string) ($payload['type'] ?? Dashboard::TYPE_USER);
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setType($type);
+
+ // Imported personal dashboards are owned by the current user
+ // unless we are preserving identity for a same-instance restore.
+ $userId = (string) ($payload['userId'] ?? $currentUserId);
+ if ($preserveUuids === false && $type === Dashboard::TYPE_USER) {
+ $userId = $currentUserId;
+ }
+
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setUserId($userId);
+
+ if (array_key_exists(key: 'gridColumns', array: $payload) === true) {
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setGridColumns((int) $payload['gridColumns']);
+ }
+
+ if (array_key_exists(key: 'permissionLevel', array: $payload) === true) {
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setPermissionLevel((string) $payload['permissionLevel']);
+ }
+
+ if (array_key_exists(key: 'targetGroups', array: $payload) === true
+ && is_array($payload['targetGroups']) === true
+ ) {
+ $dashboard->setTargetGroupsArray(groups: $payload['targetGroups']);
+ }
+
+ if (array_key_exists(key: 'parentUuid', array: $payload) === true) {
+ $parent = null;
+ if ($payload['parentUuid'] !== null) {
+ $parent = (string) $payload['parentUuid'];
+ }
+
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setParentUuid($parent);
+ }
+
+ if (array_key_exists(key: 'slug', array: $payload) === true) {
+ $slug = null;
+ if ($payload['slug'] !== null) {
+ $slug = (string) $payload['slug'];
+ }
+
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setSlug($slug);
+ }
+
+ if (array_key_exists(key: 'sortOrder', array: $payload) === true) {
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setSortOrder((int) $payload['sortOrder']);
+ }
+
+ if (array_key_exists(key: 'publicationStatus', array: $payload) === true) {
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setPublicationStatus((string) $payload['publicationStatus']);
+ }
+
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setIsActive(0);
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setIsDefault(0);
+
+ return $dashboard;
+ }//end buildEntity()
+
+ /**
+ * Hydrate a WidgetPlacement entity from a payload.
+ *
+ * @param int $dashboardId The freshly-inserted dashboard ID.
+ * @param array $payload The widget payload.
+ *
+ * @return WidgetPlacement The placement entity (not yet persisted).
+ */
+ private function buildPlacement(
+ int $dashboardId,
+ array $payload
+ ): WidgetPlacement {
+ $placement = new WidgetPlacement();
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setDashboardId($dashboardId);
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setWidgetId((string) ($payload['widgetId'] ?? ''));
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setGridX((int) ($payload['gridX'] ?? 0));
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setGridY((int) ($payload['gridY'] ?? 0));
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setGridWidth((int) ($payload['gridWidth'] ?? 4));
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setGridHeight((int) ($payload['gridHeight'] ?? 4));
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setIsVisible((int) ($payload['isVisible'] ?? 1));
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setShowTitle((int) ($payload['showTitle'] ?? 1));
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setSortOrder((int) ($payload['sortOrder'] ?? 0));
+
+ if (isset($payload['styleConfig']) === true && is_array($payload['styleConfig']) === true) {
+ $placement->setStyleConfigArray(config: $payload['styleConfig']);
+ }
+
+ if (isset($payload['customTitle']) === true) {
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setCustomTitle((string) $payload['customTitle']);
+ }
+
+ return $placement;
+ }//end buildPlacement()
+
+ /**
+ * Generate a v4 UUID for re-mapped imports.
+ *
+ * @return string The UUID.
+ */
+ private function generateUuidV4(): string
+ {
+ $bytes = random_bytes(length: 16);
+ $bytes[6] = chr(codepoint: ord(character: $bytes[6]) & 0x0f | 0x40);
+ $bytes[8] = chr(codepoint: ord(character: $bytes[8]) & 0x3f | 0x80);
+ $hex = bin2hex(string: $bytes);
+ return sprintf(
+ '%s-%s-%s-%s-%s',
+ substr(string: $hex, offset: 0, length: 8),
+ substr(string: $hex, offset: 8, length: 4),
+ substr(string: $hex, offset: 12, length: 4),
+ substr(string: $hex, offset: 16, length: 4),
+ substr(string: $hex, offset: 20, length: 12),
+ );
+ }//end generateUuidV4()
+}//end class
diff --git a/lib/Service/InitialState/Page.php b/lib/Service/InitialState/Page.php
new file mode 100644
index 00000000..5f408eb1
--- /dev/null
+++ b/lib/Service/InitialState/Page.php
@@ -0,0 +1,38 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service\InitialState;
+
+/**
+ * Page identifier for the initial-state contract (REQ-INIT-001).
+ */
+enum Page: string
+{
+ case WORKSPACE = 'workspace';
+ case ADMIN = 'admin';
+}//end enum
diff --git a/lib/Service/InitialStateBuilder.php b/lib/Service/InitialStateBuilder.php
index ac24dafb..f1ef4f0d 100644
--- a/lib/Service/InitialStateBuilder.php
+++ b/lib/Service/InitialStateBuilder.php
@@ -3,42 +3,55 @@
/**
* InitialStateBuilder
*
- * Typed builder for the per-page initial-state payload that PHP pushes to the
- * Vue mounts. Every key declared in REQ-INIT-002 of the
- * `initial-state-contract` spec MUST be set through this builder; controllers
- * MUST NOT call IInitialState::provideInitialState directly.
+ * Centralises the per-page initial-state contract for MyDash. This is the
+ * only class allowed to call {@see \OCP\AppFramework\Services\IInitialState::provideInitialState()};
+ * a CI grep lint enforces that against `lib/Controller/` and `lib/Settings/`.
*
- * Workspace page (#mydash-app) keys:
- * - widgets (array) default []
- * - layout (array) default []
- * - primaryGroup (string) default 'default'
- * - primaryGroupName (string) default ''
- * - isAdmin (bool) default false
- * - activeDashboardId (string) default ''
- * - dashboardSource (string) default 'group' (user|group|default)
- * - groupDashboards (array) default []
- * - userDashboards (array) default []
- * - allowUserDashboards (bool) default false
+ * Construct one builder per render with the destination page, call the
+ * typed setters, then `apply()`. `apply()` validates that every required
+ * key for the chosen page has been set, throwing
+ * {@see \OCA\MyDash\Exception\MissingInitialStateException} when any are
+ * missing — the page never renders with a partial payload.
*
- * Admin page (#mydash-admin-settings) keys:
- * - allGroups (array) default []
- * - configuredGroups (array) default []
- * - widgets (array) default []
- * - allowUserDashboards (bool) default false
+ * The contract version is stamped onto every payload under the key
+ * `_schemaVersion`. Bumping {@see self::INITIAL_STATE_SCHEMA_VERSION}
+ * MUST accompany any change to the per-page key sets, and MUST be paired
+ * with the matching constant in `src/utils/loadInitialState.js`. See
+ * REQ-INIT-002 for the rules.
*
- * Every payload is also stamped with `_schemaVersion`
- * (see {@see self::INITIAL_STATE_SCHEMA_VERSION}). The matching JS reader
- * lives in `src/utils/loadInitialState.js`.
+ * Workspace page (`#mydash-app`) keys:
+ * - widgets — array of dashboard widget descriptors
+ * - layout — array of WidgetPlacement rows for the active dashboard
+ * - primaryGroup — group id of the user's resolved primary group
+ * - primaryGroupName — human-readable display name for the primary group
+ * - isAdmin — boolean, true when the user is in the admin group
+ * - activeDashboardId — id of the currently active dashboard for the user
+ * - dashboardSource — 'user' | 'group' | 'default' (drives canEdit)
+ * - groupDashboards — list of group-scope dashboards visible to the user
+ * - userDashboards — list of user-scope (personal) dashboards
+ * - allowUserDashboards — admin flag toggling the personal-dashboards UI
+ *
+ * Admin page (`#mydash-admin-settings`) keys:
+ * - allGroups — every Nextcloud group, for the ordering UI
+ * - configuredGroups — group ids the admin has explicitly ordered
+ * - widgets — every available dashboard widget descriptor
+ * - allowUserDashboards — current value of the personal-dashboards admin flag
+ * - linkCreateFileExtensions — admin-configurable allow-list for the
+ * link-button-widget createFile flow (REQ-LBN-004)
+ *
+ * Both payloads always carry `_schemaVersion`.
*
* @category Service
* @package OCA\MyDash\Service
* @author Conduction b.v.
- * @copyright 2024 Conduction b.v.
+ * @copyright 2026 Conduction b.v.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2024 MyDash Contributors
+ * @link https://conduction.nl/openspec/initial-state-contract REQ-INIT-002
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
@@ -47,39 +60,44 @@
namespace OCA\MyDash\Service;
use OCA\MyDash\Exception\MissingInitialStateException;
+use OCA\MyDash\Service\InitialState\Page;
use OCP\AppFramework\Services\IInitialState;
/**
- * Centralised typed builder for MyDash initial-state payloads.
+ * Typed builder for the per-page initial-state payload (REQ-INIT-001, REQ-INIT-002).
*
- * See REQ-INIT-001..REQ-INIT-005 in the `initial-state-contract` spec.
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods) Twelve typed setters mirror the contract.
+ * @SuppressWarnings(PHPMD.TooManyMethods) Same.
*/
class InitialStateBuilder
{
/**
- * Schema version stamped on every payload under key `_schemaVersion`.
+ * Schema version stamped onto every payload under `_schemaVersion`.
*
- * Bumping this value is a deliberate spec change per REQ-INIT-002 and
- * MUST be coordinated with the JS reader's compiled-in constant.
+ * Bump in the same commit that changes any per-page key set. The JS
+ * reader in `src/utils/loadInitialState.js` MUST keep its constant in
+ * lockstep — version drift surfaces as a console warning at runtime
+ * (REQ-INIT-002).
*
- * @var int
+ * @var integer
*/
- public const INITIAL_STATE_SCHEMA_VERSION = 1;
+ public const INITIAL_STATE_SCHEMA_VERSION = 2;
/**
- * Reserved key used to stamp the schema version on every payload.
+ * Reserved payload key carrying the schema version.
*
* @var string
*/
- public const SCHEMA_VERSION_KEY = '_schemaVersion';
+ public const KEY_SCHEMA_VERSION = '_schemaVersion';
/**
- * Required key sets per page. Keep in sync with REQ-INIT-002 Data Model.
+ * Required key set per page. Keys MUST exactly match the spec's Data
+ * Model — adding or removing a key is a spec change (REQ-INIT-002).
*
- * @var array>
+ * @var array>
*/
private const REQUIRED_KEYS = [
- 'workspace' => [
+ Page::WORKSPACE->value => [
'widgets',
'layout',
'primaryGroup',
@@ -90,27 +108,31 @@ class InitialStateBuilder
'groupDashboards',
'userDashboards',
'allowUserDashboards',
+ // REQ-RFP-010: list of widget IDs the caller is permitted to see.
+ // null = no restriction configured (backwards-compat).
+ 'allowedWidgets',
],
- 'admin' => [
+ Page::ADMIN->value => [
'allGroups',
'configuredGroups',
'widgets',
'allowUserDashboards',
+ 'linkCreateFileExtensions',
],
];
/**
- * Accumulated key/value pairs to push on apply().
+ * Buffered key/value pairs awaiting apply().
*
* @var array
*/
private array $values = [];
/**
- * Construct a new InitialStateBuilder.
+ * Constructor.
*
* @param IInitialState $initialState The Nextcloud initial-state service.
- * @param Page $page The page this builder targets.
+ * @param Page $page Destination page.
*/
public function __construct(
private readonly IInitialState $initialState,
@@ -119,11 +141,11 @@ public function __construct(
}//end __construct()
/**
- * Set the dashboard widgets descriptor list (workspace + admin).
+ * Set the dashboard widgets list (workspace + admin).
*
- * @param array $widgets Array of {id,title,iconClass,iconUrl,url}.
+ * @param array $widgets Widget descriptors `[{id, title, iconClass, iconUrl, url}, ...]`.
*
- * @return self
+ * @return self Fluent.
*/
public function setWidgets(array $widgets): self
{
@@ -132,11 +154,11 @@ public function setWidgets(array $widgets): self
}//end setWidgets()
/**
- * Set the workspace layout grid (workspace).
+ * Set the active-dashboard layout (workspace).
*
- * @param array $layout Array of WorkspaceWidget descriptors.
+ * @param array $layout WidgetPlacement rows for the active dashboard.
*
- * @return self
+ * @return self Fluent.
*/
public function setLayout(array $layout): self
{
@@ -145,11 +167,11 @@ public function setLayout(array $layout): self
}//end setLayout()
/**
- * Set the primary group identifier (workspace).
+ * Set the primary group id (workspace).
*
- * @param string $primaryGroup The group identifier.
+ * @param string $primaryGroup Resolved primary group id (e.g. 'default').
*
- * @return self
+ * @return self Fluent.
*/
public function setPrimaryGroup(string $primaryGroup): self
{
@@ -160,9 +182,9 @@ public function setPrimaryGroup(string $primaryGroup): self
/**
* Set the primary group display name (workspace).
*
- * @param string $primaryGroupName The display name.
+ * @param string $primaryGroupName Human-readable primary group name.
*
- * @return self
+ * @return self Fluent.
*/
public function setPrimaryGroupName(string $primaryGroupName): self
{
@@ -171,11 +193,11 @@ public function setPrimaryGroupName(string $primaryGroupName): self
}//end setPrimaryGroupName()
/**
- * Set whether the current user is an admin (workspace).
+ * Set the is-admin flag (workspace).
*
- * @param bool $isAdmin True if admin.
+ * @param bool $isAdmin True when the user belongs to the admin group.
*
- * @return self
+ * @return self Fluent.
*/
public function setIsAdmin(bool $isAdmin): self
{
@@ -184,11 +206,11 @@ public function setIsAdmin(bool $isAdmin): self
}//end setIsAdmin()
/**
- * Set the active dashboard identifier (workspace).
+ * Set the active dashboard id (workspace).
*
- * @param string $activeDashboardId The dashboard id.
+ * @param string $activeDashboardId Id of the currently active dashboard.
*
- * @return self
+ * @return self Fluent.
*/
public function setActiveDashboardId(string $activeDashboardId): self
{
@@ -197,11 +219,14 @@ public function setActiveDashboardId(string $activeDashboardId): self
}//end setActiveDashboardId()
/**
- * Set the source of the active dashboard (workspace).
+ * Set the dashboard source (workspace).
*
- * @param string $dashboardSource One of 'user'|'group'|'default'.
+ * Valid values: 'user' | 'group' | 'default'. Drives canEdit on the
+ * runtime shell (REQ-RTS-006).
*
- * @return self
+ * @param string $dashboardSource One of 'user', 'group', 'default'.
+ *
+ * @return self Fluent.
*/
public function setDashboardSource(string $dashboardSource): self
{
@@ -210,11 +235,11 @@ public function setDashboardSource(string $dashboardSource): self
}//end setDashboardSource()
/**
- * Set the available group dashboards (workspace).
+ * Set the visible group dashboards (workspace).
*
- * @param array $groupDashboards Array of {id,name,icon,source?}.
+ * @param array $groupDashboards List of group-scope dashboards visible to the user.
*
- * @return self
+ * @return self Fluent.
*/
public function setGroupDashboards(array $groupDashboards): self
{
@@ -223,11 +248,11 @@ public function setGroupDashboards(array $groupDashboards): self
}//end setGroupDashboards()
/**
- * Set the available user dashboards (workspace).
+ * Set the user (personal) dashboards (workspace).
*
- * @param array $userDashboards Array of {id,name,icon}.
+ * @param array $userDashboards List of personal dashboards owned by the user.
*
- * @return self
+ * @return self Fluent.
*/
public function setUserDashboards(array $userDashboards): self
{
@@ -236,11 +261,11 @@ public function setUserDashboards(array $userDashboards): self
}//end setUserDashboards()
/**
- * Set whether user-owned dashboards are permitted (workspace + admin).
+ * Set the allow-user-dashboards flag (workspace + admin).
*
- * @param bool $allowUserDashboards True if allowed.
+ * @param bool $allowUserDashboards Current value of the admin flag.
*
- * @return self
+ * @return self Fluent.
*/
public function setAllowUserDashboards(bool $allowUserDashboards): self
{
@@ -249,12 +274,47 @@ public function setAllowUserDashboards(bool $allowUserDashboards): self
}//end setAllowUserDashboards()
/**
- * Set the list of all Nextcloud groups (admin).
+ * Set the list of widget IDs the caller is permitted to see (workspace).
+ * `null` means no restriction is configured — legacy behaviour where the
+ * full widget catalogue is shown (REQ-RFP-009 / REQ-RFP-010).
*
- * @param array $allGroups Array of {id,displayName}.
+ * @param array|null $allowedWidgets List of widget IDs, or null.
*
* @return self
*/
+ public function setAllowedWidgets(?array $allowedWidgets): self
+ {
+ $this->values['allowedWidgets'] = $allowedWidgets;
+ return $this;
+ }//end setAllowedWidgets()
+
+ /**
+ * Set the canonical slug-chain path for the active dashboard
+ * (workspace).
+ *
+ * Read by the frontend on mount to bring `window.location.pathname`
+ * in line with whichever dashboard was actually rendered — handles
+ * the renamed-parent / stale-bookmark cases by replacing the URL
+ * in-place via `history.replaceState`. Empty string when no
+ * dashboard is active (the page renders the empty state).
+ *
+ * @param string $deepLinkPath Canonical path or '' when none.
+ *
+ * @return self
+ */
+ public function setDeepLinkPath(string $deepLinkPath): self
+ {
+ $this->values['deepLinkPath'] = $deepLinkPath;
+ return $this;
+ }//end setDeepLinkPath()
+
+ /**
+ * Set every Nextcloud group (admin).
+ *
+ * @param array $allGroups List of `{id, displayName}` pairs.
+ *
+ * @return self Fluent.
+ */
public function setAllGroups(array $allGroups): self
{
$this->values['allGroups'] = $allGroups;
@@ -262,11 +322,11 @@ public function setAllGroups(array $allGroups): self
}//end setAllGroups()
/**
- * Set the list of MyDash-configured group ids (admin).
+ * Set the configured (ordered) group ids (admin).
*
- * @param array $configuredGroups Array of group id strings.
+ * @param array $configuredGroups Ordered list of group ids.
*
- * @return self
+ * @return self Fluent.
*/
public function setConfiguredGroups(array $configuredGroups): self
{
@@ -275,12 +335,32 @@ public function setConfiguredGroups(array $configuredGroups): self
}//end setConfiguredGroups()
/**
- * Validate the required key set for the current page and push every
- * accumulated value (plus `_schemaVersion`) to the IInitialState service.
+ * Set the link-button-widget createFile extension allow-list (admin).
+ *
+ * Backed by the `link_create_file_extensions` admin setting
+ * (REQ-LBN-004). When the admin has not customised the list the
+ * caller passes the default values, so the renderer always sees
+ * a non-empty array.
+ *
+ * @param array $extensions Lowercase extensions without dots,
+ * e.g. `["txt","md","docx"]`.
+ *
+ * @return self Fluent.
+ */
+ public function setLinkCreateFileExtensions(array $extensions): self
+ {
+ $this->values['linkCreateFileExtensions'] = $extensions;
+ return $this;
+ }//end setLinkCreateFileExtensions()
+
+ /**
+ * Validate required keys then push every buffered pair plus the
+ * schema version to {@see IInitialState::provideInitialState()}.
*
* @return void
*
- * @throws MissingInitialStateException When a required key was not set.
+ * @throws MissingInitialStateException When any required key for the
+ * page was not set.
*/
public function apply(): void
{
@@ -296,12 +376,12 @@ public function apply(): void
}
foreach ($this->values as $key => $value) {
- $this->initialState->provideInitialState(key: $key, data: $value);
+ $this->initialState->provideInitialState($key, $value);
}
$this->initialState->provideInitialState(
- key: self::SCHEMA_VERSION_KEY,
- data: self::INITIAL_STATE_SCHEMA_VERSION
+ self::KEY_SCHEMA_VERSION,
+ self::INITIAL_STATE_SCHEMA_VERSION
);
}//end apply()
}//end class
diff --git a/lib/Service/MenuService.php b/lib/Service/MenuService.php
new file mode 100644
index 00000000..04e42797
--- /dev/null
+++ b/lib/Service/MenuService.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
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service;
+
+use InvalidArgumentException;
+
+/**
+ * Validates `menu` widget content before placement save.
+ *
+ * REQ-MENU-002 mandates a hard cap of 3 levels of nesting (top-level + 2
+ * child levels) and closed-enum validation of `style`, `orientation`, and
+ * `activeItemHighlight`. The matching renderer never recurses past 3 levels,
+ * so violating placements MUST be rejected at save time rather than letting
+ * unrenderable rows persist.
+ */
+class MenuService
+{
+ /**
+ * Maximum item nesting depth permitted (top + 2 child levels).
+ *
+ * @var integer
+ */
+ public const MAX_DEPTH = 3;
+
+ /**
+ * Allowed values for the `style` field.
+ *
+ * @var string[]
+ */
+ public const ALLOWED_STYLES = ['dropdown', 'megamenu', 'tree'];
+
+ /**
+ * Allowed values for the `orientation` field.
+ *
+ * @var string[]
+ */
+ public const ALLOWED_ORIENTATIONS = ['horizontal', 'vertical'];
+
+ /**
+ * Allowed values for the `activeItemHighlight` field.
+ *
+ * @var string[]
+ */
+ public const ALLOWED_HIGHLIGHTS = ['background', 'underline', 'left-bar', 'none'];
+
+ /**
+ * Validate the `items` array recursively.
+ *
+ * REQ-MENU-002: rejects any tree where an item's `children` list pushes
+ * the depth beyond 3 levels. The error message is fixed so the API
+ * response (HTTP 400) and form-side error remain in lock-step.
+ *
+ * @param array $items Top-level menu items.
+ *
+ * @return void
+ * @throws InvalidArgumentException When depth exceeds 3 or an item is malformed.
+ */
+ public function validateMenuItems(array $items): void
+ {
+ $this->validateLevel(items: $items, depth: 1);
+ }//end validateMenuItems()
+
+ /**
+ * Validate the closed-enum fields on the menu content blob.
+ *
+ * Each call short-circuits on the first illegal value so the caller
+ * sees the most specific error possible. Defaults supplied by the
+ * renderer are accepted unconditionally — only explicit overrides are
+ * checked.
+ *
+ * @param array $content Full widget content (`style`, `orientation`,
+ * `activeItemHighlight`, ...).
+ *
+ * @return void
+ * @throws InvalidArgumentException When a field value is outside its allowed set.
+ */
+ public function validateMenuConfig(array $content): void
+ {
+ if (isset($content['style']) === true
+ && in_array(needle: $content['style'], haystack: self::ALLOWED_STYLES, strict: true) === false
+ ) {
+ throw new InvalidArgumentException(
+ message: 'Menu style must be one of: dropdown, megamenu, tree'
+ );
+ }
+
+ if (isset($content['orientation']) === true
+ && in_array(
+ needle: $content['orientation'],
+ haystack: self::ALLOWED_ORIENTATIONS,
+ strict: true
+ ) === false
+ ) {
+ throw new InvalidArgumentException(
+ message: 'Menu orientation must be one of: horizontal, vertical'
+ );
+ }
+
+ if (isset($content['activeItemHighlight']) === true
+ && in_array(
+ needle: $content['activeItemHighlight'],
+ haystack: self::ALLOWED_HIGHLIGHTS,
+ strict: true
+ ) === false
+ ) {
+ throw new InvalidArgumentException(
+ message: 'Menu activeItemHighlight must be one of: background, underline, left-bar, none'
+ );
+ }
+ }//end validateMenuConfig()
+
+ /**
+ * Validate one level of the items tree.
+ *
+ * @param array $items Items at the current depth.
+ * @param integer $depth Current depth (1-indexed).
+ *
+ * @return void
+ * @throws InvalidArgumentException When the depth cap is exceeded.
+ */
+ private function validateLevel(array $items, int $depth): void
+ {
+ if ($depth > self::MAX_DEPTH) {
+ throw new InvalidArgumentException(
+ message: 'Menu items can nest at most 3 levels deep'
+ );
+ }
+
+ foreach ($items as $item) {
+ if (is_array($item) === false) {
+ throw new InvalidArgumentException(
+ message: 'Menu item must be an object'
+ );
+ }
+
+ if (isset($item['children']) === true && is_array($item['children']) === true
+ && count($item['children']) > 0
+ ) {
+ $this->validateLevel(items: $item['children'], depth: ($depth + 1));
+ }
+ }
+ }//end validateLevel()
+}//end class
diff --git a/lib/Service/MetadataService.php b/lib/Service/MetadataService.php
new file mode 100644
index 00000000..2767a548
--- /dev/null
+++ b/lib/Service/MetadataService.php
@@ -0,0 +1,754 @@
+=…` filter resolution.
+ *
+ * Pure facade — never touches HTTP. The controllers translate the
+ * thrown exceptions into status codes (400, 403, 409).
+ *
+ * @category Service
+ * @package OCA\MyDash\Service
+ * @author Conduction b.v.
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service;
+
+use DateTime;
+use OCA\MyDash\Db\MetadataField;
+use OCA\MyDash\Db\MetadataFieldMapper;
+use OCA\MyDash\Db\MetadataValue;
+use OCA\MyDash\Db\MetadataValueMapper;
+use OCA\MyDash\Exception\InvalidMetadataFieldException;
+use OCA\MyDash\Exception\MetadataFieldHasValuesException;
+use OCP\AppFramework\Db\DoesNotExistException;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Coordinator for the dashboard-metadata-fields capability.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Orchestrates two
+ * mappers + the validation service + the logger; this is the
+ * single facade for the capability.
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods) The full CRUD +
+ * read/write/filter surface is intentionally co-located here so
+ * controllers stay thin.
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Per-type
+ * filter dispatch + multi-shape patch handling drive complexity;
+ * splitting would scatter the capability's invariants across
+ * multiple classes for no readability gain.
+ */
+class MetadataService
+{
+ /**
+ * Maximum field-key length (matches schema column).
+ *
+ * @var int
+ */
+ private const MAX_KEY_LENGTH = 64;
+
+ /**
+ * Maximum label length (matches schema column).
+ *
+ * @var int
+ */
+ private const MAX_LABEL_LENGTH = 255;
+
+ /**
+ * Constructor.
+ *
+ * @param MetadataFieldMapper $fieldMapper The field mapper.
+ * @param MetadataValueMapper $valueMapper The value mapper.
+ * @param MetadataValidationService $validationService The validator.
+ * @param LoggerInterface $logger PSR logger.
+ */
+ public function __construct(
+ private readonly MetadataFieldMapper $fieldMapper,
+ private readonly MetadataValueMapper $valueMapper,
+ private readonly MetadataValidationService $validationService,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Return all field definitions ordered by `sortOrder`.
+ *
+ * @return MetadataField[] The sorted field list.
+ */
+ public function listFields(): array
+ {
+ return $this->fieldMapper->findAll();
+ }//end listFields()
+
+ /**
+ * Look up a field definition by id or throw.
+ *
+ * @param int $id The field id.
+ *
+ * @return MetadataField The matching field.
+ *
+ * @throws DoesNotExistException When missing.
+ */
+ public function getField(int $id): MetadataField
+ {
+ return $this->fieldMapper->findById(id: $id);
+ }//end getField()
+
+ /**
+ * Create a new field definition with full validation
+ * (REQ-MDFL-001).
+ *
+ * @param string $key The slugified key.
+ * @param string $label The display label.
+ * @param string $type One of {@see MetadataField::VALID_TYPES}.
+ * @param array|null $options Option set (select types only).
+ * @param int $required 0 / 1.
+ * @param int $sortOrder UI sort order.
+ *
+ * @return MetadataField The persisted entity.
+ *
+ * @throws InvalidMetadataFieldException When validation fails.
+ */
+ public function createFieldDefinition(
+ string $key,
+ string $label,
+ string $type,
+ ?array $options=null,
+ int $required=0,
+ int $sortOrder=0
+ ): MetadataField {
+ self::assertKeyShape(key: $key);
+ self::assertLabelShape(label: $label);
+ self::assertTypeShape(type: $type);
+ self::assertOptionsShape(type: $type, options: $options);
+
+ try {
+ $this->fieldMapper->findByKey(key: $key);
+ throw new InvalidMetadataFieldException(
+ message: "Field key '".$key."' already exists"
+ );
+ } catch (DoesNotExistException) {
+ // Expected — key is free.
+ }
+
+ $now = (new DateTime())->format(format: 'c');
+ $field = new MetadataField();
+ $requiredFlag = 0;
+ if ($required === 1) {
+ $requiredFlag = 1;
+ }
+
+ // Entity __call routes setter args via $args[0]; named params would
+ // land in the wrong slot. Per-line phpcs ignore avoids the
+ // codebase-wide named-args sniff that fires on every setter call.
+ // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $field->setFieldKey($key);
+ $field->setLabel($label);
+ $field->setType($type);
+ $field->setRequired($requiredFlag);
+ $field->setSortOrder($sortOrder);
+ $field->setCreatedAt($now);
+ $field->setUpdatedAt($now);
+ // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+
+ if ($options !== null) {
+ $field->setOptionsArray(options: $options);
+ } else {
+ $field->setOptionsArray(options: null);
+ }
+
+ return $this->fieldMapper->insert(entity: $field);
+ }//end createFieldDefinition()
+
+ /**
+ * Update an existing field definition (REQ-MDFL-002).
+ *
+ * The `key` slug is immutable — supplying it triggers a 400.
+ * Allowed patch keys: `label`, `sortOrder`, `required`, `options`.
+ *
+ * @param int $id The field id.
+ * @param array $patch The shallow patch.
+ *
+ * @return MetadataField The persisted entity.
+ *
+ * @throws DoesNotExistException When the field is missing.
+ * @throws InvalidMetadataFieldException When validation fails.
+ */
+ public function updateFieldDefinition(int $id, array $patch): MetadataField
+ {
+ if (array_key_exists(key: 'key', array: $patch) === true) {
+ throw new InvalidMetadataFieldException(
+ message: 'Field key cannot be renamed'
+ );
+ }
+
+ $field = $this->fieldMapper->findById(id: $id);
+
+ // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ if (array_key_exists(key: 'label', array: $patch) === true) {
+ $label = (string) $patch['label'];
+ self::assertLabelShape(label: $label);
+ $field->setLabel($label);
+ }
+
+ if (array_key_exists(key: 'sortOrder', array: $patch) === true) {
+ $field->setSortOrder((int) $patch['sortOrder']);
+ }
+
+ if (array_key_exists(key: 'required', array: $patch) === true) {
+ $required = 0;
+ if ((int) $patch['required'] === 1) {
+ $required = 1;
+ }
+
+ $field->setRequired($required);
+ }
+
+ if (array_key_exists(key: 'options', array: $patch) === true) {
+ $options = $patch['options'];
+ if ($options !== null && is_array($options) === false) {
+ throw new InvalidMetadataFieldException(
+ message: 'Options must be an array of strings or null'
+ );
+ }
+
+ self::assertOptionsShape(type: $field->getType(), options: $options);
+ $field->setOptionsArray(options: $options);
+ }
+
+ $field->setUpdatedAt((new DateTime())->format(format: 'c'));
+ // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+
+ return $this->fieldMapper->update(entity: $field);
+ }//end updateFieldDefinition()
+
+ /**
+ * Delete a field definition (REQ-MDFL-003).
+ *
+ * Soft-by-default: when the field has dependent value rows the
+ * caller MUST opt in via `$cascade = true`, otherwise a 409 is
+ * raised.
+ *
+ * @param int $id The field id.
+ * @param bool $cascade Whether to cascade-delete dependent values.
+ *
+ * @return bool True on success.
+ *
+ * @throws DoesNotExistException When the field is missing.
+ * @throws MetadataFieldHasValuesException When values exist and
+ * cascade is false.
+ */
+ public function deleteFieldDefinition(int $id, bool $cascade=false): bool
+ {
+ $field = $this->fieldMapper->findById(id: $id);
+ $valueCount = $this->fieldMapper->countValuesForField(fieldId: $id);
+
+ if ($valueCount > 0 && $cascade === false) {
+ throw new MetadataFieldHasValuesException(valueCount: $valueCount);
+ }
+
+ if ($cascade === true) {
+ return $this->fieldMapper->deleteWithCascade(fieldId: $id);
+ }
+
+ $this->fieldMapper->delete(entity: $field);
+ return true;
+ }//end deleteFieldDefinition()
+
+ /**
+ * Read every metadata value for the dashboard, as a flat key→value
+ * object (REQ-MDFL-004). Orphan rows referencing a deleted field
+ * are silently skipped (and logged at warning level) so the
+ * dashboard load never crashes.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return array The flat key→encoded-value map.
+ */
+ public function getMetadataForDashboard(string $dashboardUuid): array
+ {
+ $rows = $this->valueMapper->findByDashboard(dashboardUuid: $dashboardUuid);
+ if (count($rows) === 0) {
+ return [];
+ }
+
+ $fieldIds = [];
+ foreach ($rows as $row) {
+ $fieldIds[] = (int) $row->getFieldId();
+ }
+
+ $fieldsById = $this->fieldMapper->findByIds(ids: $fieldIds);
+
+ $result = [];
+ foreach ($rows as $row) {
+ $fieldId = (int) $row->getFieldId();
+ if (array_key_exists(key: $fieldId, array: $fieldsById) === false) {
+ $this->logger->warning(
+ message: 'Orphaned dashboard metadata value (no field definition)',
+ context: [
+ 'dashboardUuid' => $dashboardUuid,
+ 'fieldId' => $fieldId,
+ ]
+ );
+ continue;
+ }
+
+ $field = $fieldsById[$fieldId];
+ $result[$field->getFieldKey()] = $row->getValue();
+ }
+
+ return $result;
+ }//end getMetadataForDashboard()
+
+ /**
+ * Upsert each (key → value) entry for the dashboard
+ * (REQ-MDFL-005, REQ-MDFL-006). Unknown keys raise 400. Omitted
+ * keys are NOT removed — only keys present in the payload are
+ * touched.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param array $keyValues The patch payload.
+ *
+ * @return array The full updated metadata object.
+ *
+ * @throws InvalidMetadataFieldException When any key is unknown
+ * or value invalid.
+ */
+ public function setMetadataForDashboard(
+ string $dashboardUuid,
+ array $keyValues
+ ): array {
+ foreach ($keyValues as $key => $value) {
+ $stringKey = (string) $key;
+ if ($stringKey === '') {
+ throw new InvalidMetadataFieldException(
+ message: 'Metadata keys must be non-empty strings'
+ );
+ }
+
+ try {
+ $field = $this->fieldMapper->findByKey(key: $stringKey);
+ } catch (DoesNotExistException) {
+ throw new InvalidMetadataFieldException(
+ message: "Unknown metadata field '".$stringKey."'"
+ );
+ }
+
+ $encoded = $this->validationService->validateValue(
+ value: $value,
+ field: $field
+ );
+
+ if ($encoded === '' && $field->getRequired() === 0) {
+ // Empty optional value: remove the row so the read
+ // payload omits the key (matches scenario "omitted
+ // keys are not deleted" by allowing explicit empty
+ // to clear the value — keeps client UX coherent).
+ $existing = $this->valueMapper->findOne(
+ dashboardUuid: $dashboardUuid,
+ fieldId: (int) $field->getId()
+ );
+ if ($existing !== null) {
+ $this->valueMapper->delete(entity: $existing);
+ }
+
+ continue;
+ }
+
+ $this->valueMapper->upsert(
+ dashboardUuid: $dashboardUuid,
+ fieldId: (int) $field->getId(),
+ value: $encoded
+ );
+ }//end foreach
+
+ return $this->getMetadataForDashboard(dashboardUuid: $dashboardUuid);
+ }//end setMetadataForDashboard()
+
+ /**
+ * Apply `?metadata.=…` filters to a dashboard list
+ * (REQ-MDFL-007). Filter keys not registered as fields are
+ * ignored (so a stale URL never silently empties the list when
+ * a field is deleted).
+ *
+ * Recognised filter shapes per type:
+ * - text / select / boolean — exact-match string
+ * - multi-select — substring of JSON-encoded value
+ * - number — `"min"` / `"max"` keys (inclusive)
+ * - date — `"after"` / `"before"` keys (inclusive)
+ *
+ * @param array $dashboards The candidate dashboards
+ * (each MUST expose
+ * `getUuid()`).
+ * @param array $metadataFilters The filter set
+ * (raw
+ * `metadata.`
+ * map).
+ *
+ * @return array The filtered subset.
+ */
+ public function filterDashboards(
+ array $dashboards,
+ array $metadataFilters
+ ): array {
+ if (count($metadataFilters) === 0 || count($dashboards) === 0) {
+ return $dashboards;
+ }
+
+ $resolved = [];
+ foreach ($metadataFilters as $key => $criterion) {
+ try {
+ $field = $this->fieldMapper->findByKey(key: (string) $key);
+ } catch (DoesNotExistException) {
+ continue;
+ }
+
+ $resolved[] = ['field' => $field, 'criterion' => $criterion];
+ }
+
+ if (count($resolved) === 0) {
+ return $dashboards;
+ }
+
+ $matchingByField = [];
+ foreach ($resolved as $entry) {
+ $field = $entry['field'];
+ $criterion = $entry['criterion'];
+
+ $matching = [];
+ foreach ($this->valueMapper->findByField(fieldId: (int) $field->getId()) as $row) {
+ if (self::matchesCriterion(
+ field: $field,
+ storedValue: $row->getValue(),
+ criterion: $criterion
+ ) === true
+ ) {
+ $matching[$row->getDashboardUuid()] = true;
+ }
+ }
+
+ $matchingByField[] = $matching;
+ }
+
+ // AND the per-filter sets together.
+ $intersection = $matchingByField[0];
+ $matchingCount = count($matchingByField);
+ for ($i = 1; $i < $matchingCount; $i++) {
+ $intersection = array_intersect_key(
+ $intersection,
+ $matchingByField[$i]
+ );
+ }
+
+ $filtered = [];
+ foreach ($dashboards as $dashboard) {
+ $uuid = self::extractUuid(dashboard: $dashboard);
+ if ($uuid === null) {
+ continue;
+ }
+
+ if (array_key_exists(key: $uuid, array: $intersection) === true) {
+ $filtered[] = $dashboard;
+ }
+ }
+
+ return $filtered;
+ }//end filterDashboards()
+
+ /**
+ * Pull a UUID off a dashboard entity OR a serialised array row.
+ *
+ * @param mixed $dashboard The candidate dashboard.
+ *
+ * @return string|null The UUID or null when unresolvable.
+ */
+ private static function extractUuid(mixed $dashboard): ?string
+ {
+ if (is_object($dashboard) === true && method_exists($dashboard, 'getUuid') === true) {
+ $uuid = $dashboard->getUuid();
+ if ($uuid === null) {
+ return null;
+ }
+
+ return (string) $uuid;
+ }
+
+ if (is_array($dashboard) === true && array_key_exists(key: 'uuid', array: $dashboard) === true) {
+ return (string) $dashboard['uuid'];
+ }
+
+ return null;
+ }//end extractUuid()
+
+ /**
+ * Per-criterion match logic.
+ *
+ * @param MetadataField $field The field definition.
+ * @param string $storedValue The persisted value string.
+ * @param mixed $criterion The raw filter value.
+ *
+ * @return bool True when the value satisfies the criterion.
+ */
+ private static function matchesCriterion(
+ MetadataField $field,
+ string $storedValue,
+ mixed $criterion
+ ): bool {
+ return match ($field->getType()) {
+ MetadataField::TYPE_NUMBER => self::matchesNumberRange(
+ stored: $storedValue,
+ criterion: $criterion
+ ),
+ MetadataField::TYPE_DATE => self::matchesDateRange(
+ stored: $storedValue,
+ criterion: $criterion
+ ),
+ MetadataField::TYPE_MULTI_SELECT => self::matchesMultiSelect(
+ stored: $storedValue,
+ criterion: $criterion
+ ),
+ default => self::matchesExact(
+ stored: $storedValue,
+ criterion: $criterion
+ ),
+ };
+ }//end matchesCriterion()
+
+ /**
+ * Exact-string match for text / select / boolean.
+ *
+ * @param string $stored The persisted value.
+ * @param mixed $criterion The filter value.
+ *
+ * @return bool True on equality.
+ */
+ private static function matchesExact(string $stored, mixed $criterion): bool
+ {
+ if (is_array($criterion) === true) {
+ return false;
+ }
+
+ return ($stored === (string) $criterion);
+ }//end matchesExact()
+
+ /**
+ * Numeric range filter — supports `min` / `max` (inclusive) or
+ * scalar exact-match.
+ *
+ * @param string $stored The persisted decimal string.
+ * @param mixed $criterion The filter value.
+ *
+ * @return bool True on match.
+ */
+ private static function matchesNumberRange(string $stored, mixed $criterion): bool
+ {
+ if (is_numeric(value: $stored) === false) {
+ return false;
+ }
+
+ $value = (float) $stored;
+
+ if (is_array($criterion) === true) {
+ if (array_key_exists(key: 'min', array: $criterion) === true
+ && $value < (float) $criterion['min']
+ ) {
+ return false;
+ }
+
+ if (array_key_exists(key: 'max', array: $criterion) === true
+ && $value > (float) $criterion['max']
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ if (is_numeric(value: $criterion) === false) {
+ return false;
+ }
+
+ return ($value === (float) $criterion);
+ }//end matchesNumberRange()
+
+ /**
+ * Date range filter — supports `after` / `before` (inclusive) or
+ * exact-match.
+ *
+ * @param string $stored The persisted ISO-8601 date.
+ * @param mixed $criterion The filter value.
+ *
+ * @return bool True on match.
+ */
+ private static function matchesDateRange(string $stored, mixed $criterion): bool
+ {
+ if (is_array($criterion) === true) {
+ if (array_key_exists(key: 'after', array: $criterion) === true
+ && strcmp(
+ string1: $stored,
+ string2: (string) $criterion['after']
+ ) < 0
+ ) {
+ return false;
+ }
+
+ if (array_key_exists(key: 'before', array: $criterion) === true
+ && strcmp(
+ string1: $stored,
+ string2: (string) $criterion['before']
+ ) > 0
+ ) {
+ return false;
+ }
+
+ return true;
+ }//end if
+
+ return ($stored === (string) $criterion);
+ }//end matchesDateRange()
+
+ /**
+ * Multi-select containment: the stored value is a JSON array;
+ * the criterion is matched as substring of the JSON encoding so
+ * `?metadata.tags=news` matches an array containing `"news"`.
+ *
+ * Documented limitation in design.md (D6 risks): the substring
+ * match is sufficient for the MVP; v2 will switch to JSON_CONTAINS
+ * or PHP-side array containment.
+ *
+ * @param string $stored The persisted JSON-array string.
+ * @param mixed $criterion The filter value.
+ *
+ * @return bool True on match.
+ */
+ private static function matchesMultiSelect(string $stored, mixed $criterion): bool
+ {
+ if (is_array($criterion) === true) {
+ return false;
+ }
+
+ $needle = (string) $criterion;
+ $decoded = json_decode(json: $stored, associative: true);
+ if (is_array($decoded) === true) {
+ return in_array(needle: $needle, haystack: $decoded, strict: true);
+ }
+
+ return str_contains(haystack: $stored, needle: $needle);
+ }//end matchesMultiSelect()
+
+ /**
+ * Validate the slug shape: lowercase alphanumeric + underscore,
+ * 1..MAX_KEY_LENGTH characters.
+ *
+ * @param string $key The candidate slug.
+ *
+ * @return void
+ *
+ * @throws InvalidMetadataFieldException When malformed.
+ */
+ private static function assertKeyShape(string $key): void
+ {
+ if ($key === '' || strlen(string: $key) > self::MAX_KEY_LENGTH) {
+ throw new InvalidMetadataFieldException(
+ message: 'Field key must be 1..'.self::MAX_KEY_LENGTH.' characters'
+ );
+ }
+
+ if (preg_match(pattern: '/^[a-z0-9_]+$/', subject: $key) !== 1) {
+ throw new InvalidMetadataFieldException(
+ message: 'Field key must be lowercase alphanumeric with underscores only'
+ );
+ }
+ }//end assertKeyShape()
+
+ /**
+ * Validate label length.
+ *
+ * @param string $label The candidate label.
+ *
+ * @return void
+ *
+ * @throws InvalidMetadataFieldException When malformed.
+ */
+ private static function assertLabelShape(string $label): void
+ {
+ if ($label === '' || strlen(string: $label) > self::MAX_LABEL_LENGTH) {
+ throw new InvalidMetadataFieldException(
+ message: 'Field label must be 1..'.self::MAX_LABEL_LENGTH.' characters'
+ );
+ }
+ }//end assertLabelShape()
+
+ /**
+ * Validate that the type is in the supported enum.
+ *
+ * @param string $type The candidate type.
+ *
+ * @return void
+ *
+ * @throws InvalidMetadataFieldException When unsupported.
+ */
+ private static function assertTypeShape(string $type): void
+ {
+ if (in_array(needle: $type, haystack: MetadataField::VALID_TYPES, strict: true) === false) {
+ throw new InvalidMetadataFieldException(
+ message: "Unsupported field type '".$type."'"
+ );
+ }
+ }//end assertTypeShape()
+
+ /**
+ * Validate that `options` matches the type: select types REQUIRE
+ * a non-empty string array; non-select types REQUIRE NULL.
+ *
+ * @param string $type The field type.
+ * @param array|null $options The candidate options (validated entry-by-entry).
+ *
+ * @return void
+ *
+ * @throws InvalidMetadataFieldException When mismatched.
+ */
+ private static function assertOptionsShape(string $type, ?array $options): void
+ {
+ $isSelect = ($type === MetadataField::TYPE_SELECT
+ || $type === MetadataField::TYPE_MULTI_SELECT);
+
+ if ($isSelect === true) {
+ if ($options === null || count($options) === 0) {
+ throw new InvalidMetadataFieldException(
+ message: 'Select type requires non-empty options array'
+ );
+ }
+
+ // Defensive runtime check: callers may pass non-string entries.
+ foreach ($options as $option) {
+ if (is_string($option) === false || $option === '') {
+ throw new InvalidMetadataFieldException(
+ message: 'Select options must be non-empty strings'
+ );
+ }
+ }
+
+ return;
+ }
+
+ if ($options !== null && count($options) > 0) {
+ throw new InvalidMetadataFieldException(
+ message: "Type '".$type."' does not support options"
+ );
+ }
+ }//end assertOptionsShape()
+}//end class
diff --git a/lib/Service/MetadataValidationService.php b/lib/Service/MetadataValidationService.php
new file mode 100644
index 00000000..fbe97a4b
--- /dev/null
+++ b/lib/Service/MetadataValidationService.php
@@ -0,0 +1,279 @@
+getOptionsArray()`
+ * - multi-select JSON array of strings, each in `field->getOptionsArray()`
+ * - boolean literal `"0"` or `"1"`
+ *
+ * @category Service
+ * @package OCA\MyDash\Service
+ * @author Conduction b.v.
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service;
+
+use OCA\MyDash\Db\MetadataField;
+use OCA\MyDash\Exception\InvalidMetadataFieldException;
+
+/**
+ * Per-type value validation for the dashboard-metadata-fields capability.
+ */
+class MetadataValidationService
+{
+ /**
+ * Validate `$value` against the field's type and required flag.
+ *
+ * @param mixed $value The raw incoming value (string, array,
+ * null, etc.).
+ * @param MetadataField $field The field definition.
+ *
+ * @return string The canonical encoded string ready for persistence.
+ *
+ * @throws InvalidMetadataFieldException When the value is invalid.
+ */
+ public function validateValue(mixed $value, MetadataField $field): string
+ {
+ if (self::isEmpty(value: $value) === true) {
+ if ($field->getRequired() === 1) {
+ throw new InvalidMetadataFieldException(
+ message: "Field '".$field->getLabel()."' is required"
+ );
+ }
+
+ return '';
+ }
+
+ return match ($field->getType()) {
+ MetadataField::TYPE_TEXT => self::asString(value: $value),
+ MetadataField::TYPE_NUMBER => self::validateNumber(value: $value, field: $field),
+ MetadataField::TYPE_DATE => self::validateDate(value: $value, field: $field),
+ MetadataField::TYPE_SELECT => self::validateSelect(value: $value, field: $field),
+ MetadataField::TYPE_MULTI_SELECT => self::validateMultiSelect(value: $value, field: $field),
+ MetadataField::TYPE_BOOLEAN => self::validateBoolean(value: $value, field: $field),
+ default => throw new InvalidMetadataFieldException(
+ message: "Field '".$field->getLabel()."' has unknown type '".$field->getType()."'"
+ ),
+ };
+ }//end validateValue()
+
+ /**
+ * Returns true when the value is null, empty string, or empty array.
+ *
+ * @param mixed $value The candidate value.
+ *
+ * @return bool True when treated as missing.
+ */
+ private static function isEmpty(mixed $value): bool
+ {
+ if ($value === null) {
+ return true;
+ }
+
+ if (is_string($value) === true && $value === '') {
+ return true;
+ }
+
+ if (is_array($value) === true && count($value) === 0) {
+ return true;
+ }
+
+ return false;
+ }//end isEmpty()
+
+ /**
+ * Cast scalar values to string (text type fall-through).
+ *
+ * @param mixed $value The candidate value.
+ *
+ * @return string The string form.
+ */
+ private static function asString(mixed $value): string
+ {
+ if (is_string($value) === true) {
+ return $value;
+ }
+
+ if (is_int($value) === true || is_float($value) === true) {
+ return (string) $value;
+ }
+
+ if (is_bool($value) === true) {
+ if ($value === true) {
+ return '1';
+ }
+
+ return '0';
+ }
+
+ return '';
+ }//end asString()
+
+ /**
+ * Number-type validation: numeric string only.
+ *
+ * @param mixed $value The candidate value.
+ * @param MetadataField $field The field definition.
+ *
+ * @return string The canonical numeric string.
+ *
+ * @throws InvalidMetadataFieldException When non-numeric.
+ */
+ private static function validateNumber(mixed $value, MetadataField $field): string
+ {
+ $candidate = self::asString(value: $value);
+ if (is_numeric(value: $candidate) === false) {
+ throw new InvalidMetadataFieldException(
+ message: "Field '".$field->getLabel()."' must be a valid number"
+ );
+ }
+
+ return $candidate;
+ }//end validateNumber()
+
+ /**
+ * Date-type validation: strict YYYY-MM-DD.
+ *
+ * @param mixed $value The candidate value.
+ * @param MetadataField $field The field definition.
+ *
+ * @return string The validated date string.
+ *
+ * @throws InvalidMetadataFieldException When malformed.
+ */
+ private static function validateDate(mixed $value, MetadataField $field): string
+ {
+ $candidate = self::asString(value: $value);
+ if (preg_match(pattern: '/^\d{4}-\d{2}-\d{2}$/', subject: $candidate) !== 1) {
+ throw new InvalidMetadataFieldException(
+ message: "Field '".$field->getLabel()."' must be a valid date (YYYY-MM-DD)"
+ );
+ }
+
+ $parts = explode(separator: '-', string: $candidate);
+ $check = checkdate(
+ month: (int) $parts[1],
+ day: (int) $parts[2],
+ year: (int) $parts[0]
+ );
+ if ($check === false) {
+ throw new InvalidMetadataFieldException(
+ message: "Field '".$field->getLabel()."' must be a valid date (YYYY-MM-DD)"
+ );
+ }
+
+ return $candidate;
+ }//end validateDate()
+
+ /**
+ * Select-type validation: value MUST be in the option set.
+ *
+ * @param mixed $value The candidate value.
+ * @param MetadataField $field The field definition.
+ *
+ * @return string The validated option string.
+ *
+ * @throws InvalidMetadataFieldException When out of set.
+ */
+ private static function validateSelect(mixed $value, MetadataField $field): string
+ {
+ $candidate = self::asString(value: $value);
+ $options = $field->getOptionsArray();
+ if (in_array(needle: $candidate, haystack: $options, strict: true) === false) {
+ throw new InvalidMetadataFieldException(
+ message: "Field '".$field->getLabel()."' value '".$candidate."' not in allowed options"
+ );
+ }
+
+ return $candidate;
+ }//end validateSelect()
+
+ /**
+ * Multi-select validation: JSON array of strings, each in option set.
+ *
+ * Accepts either a PHP array directly or a JSON-encoded array string.
+ *
+ * @param mixed $value The candidate value.
+ * @param MetadataField $field The field definition.
+ *
+ * @return string The canonical JSON-array string.
+ *
+ * @throws InvalidMetadataFieldException When malformed or out of set.
+ */
+ private static function validateMultiSelect(mixed $value, MetadataField $field): string
+ {
+ $items = $value;
+ if (is_string($items) === true) {
+ $decoded = json_decode(json: $items, associative: true);
+ if (is_array($decoded) === true) {
+ $items = $decoded;
+ }
+ }
+
+ if (is_array($items) === false) {
+ throw new InvalidMetadataFieldException(
+ message: "Field '".$field->getLabel()."' must be an array of options"
+ );
+ }
+
+ $options = $field->getOptionsArray();
+ foreach ($items as $entry) {
+ if (is_string($entry) === false
+ || in_array(needle: $entry, haystack: $options, strict: true) === false
+ ) {
+ $rendered = '';
+ if (is_string($entry) === true) {
+ $rendered = $entry;
+ }
+
+ throw new InvalidMetadataFieldException(
+ message: "Field '".$field->getLabel()."' value '".$rendered."' not in allowed options"
+ );
+ }
+ }
+
+ return (string) json_encode($items);
+ }//end validateMultiSelect()
+
+ /**
+ * Boolean validation: only literal `"0"` or `"1"` accepted.
+ *
+ * @param mixed $value The candidate value.
+ * @param MetadataField $field The field definition.
+ *
+ * @return string The canonical `"0"` / `"1"`.
+ *
+ * @throws InvalidMetadataFieldException When malformed.
+ */
+ private static function validateBoolean(mixed $value, MetadataField $field): string
+ {
+ if ($value === true || $value === 1 || $value === '1') {
+ return '1';
+ }
+
+ if ($value === false || $value === 0 || $value === '0') {
+ return '0';
+ }
+
+ throw new InvalidMetadataFieldException(
+ message: "Field '".$field->getLabel()."' must be boolean (\"0\" or \"1\")"
+ );
+ }//end validateBoolean()
+}//end class
diff --git a/lib/Service/MetricsCollector.php b/lib/Service/MetricsCollector.php
index 75df97cd..8ac2b5fd 100644
--- a/lib/Service/MetricsCollector.php
+++ b/lib/Service/MetricsCollector.php
@@ -45,6 +45,8 @@ public function __construct(
* Collect all metrics lines for Prometheus exposition.
*
* @return array The lines of Prometheus metrics output.
+ *
+ * @spec prometheus-metrics:REQ-PROM-001
*/
public function collectAll(): array
{
diff --git a/lib/Service/MetricsQueryService.php b/lib/Service/MetricsQueryService.php
index 7f1995f4..283d574f 100644
--- a/lib/Service/MetricsQueryService.php
+++ b/lib/Service/MetricsQueryService.php
@@ -44,6 +44,8 @@ public function __construct(
* @return array Map of type to count.
*
* @throws \Exception When the database query fails.
+ *
+ * @spec prometheus-metrics:REQ-PROM-004
*/
public function queryDashboardCounts(): array
{
diff --git a/lib/Service/NewsWidgetService.php b/lib/Service/NewsWidgetService.php
new file mode 100644
index 00000000..8f45357d
--- /dev/null
+++ b/lib/Service/NewsWidgetService.php
@@ -0,0 +1,1010 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 EUPL-1.2
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service;
+
+use DOMDocument;
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Db\WidgetPlacement;
+use OCA\MyDash\Db\WidgetPlacementMapper;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\Http\Client\IClientService;
+use OCP\IAppConfig;
+use OCP\ICache;
+use OCP\ICacheFactory;
+use Psr\Log\LoggerInterface;
+use SimpleXMLElement;
+use Throwable;
+
+/**
+ * Service for fetching, parsing, merging, deduplicating, sanitising, and
+ * filtering RSS / Atom feed items for the news widget.
+ *
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Combines feed
+ * fetching, parsing (RSS + Atom), allow-listing, sanitisation,
+ * deduplication, sorting, caching and graceful-degradation logic in
+ * one cohesive unit. Splitting into multiple services would
+ * fragment the REQ-NEWS-001..011 contract across files without
+ * reducing the surface that the news-widget capability has to
+ * present to callers.
+ */
+class NewsWidgetService
+{
+
+ /**
+ * Default per-feed cache TTL in seconds (60 minutes). Overridden at
+ * runtime by the app-config key
+ * `mydash.news_widget_feed_cache_ttl_seconds`.
+ */
+ private const DEFAULT_CACHE_TTL = 3600;
+
+ /**
+ * Hard ceiling on the per-request item count. Server-side cap that
+ * enforces a safety rail regardless of client-supplied `limit` —
+ * see design D4 in `openspec/changes/news-widget/design.md`.
+ */
+ private const HARD_ITEM_CEILING = 200;
+
+ /**
+ * HTML tags retained by the summary sanitiser (REQ-NEWS-005).
+ *
+ * @var array
+ */
+ private const ALLOWED_SUMMARY_TAGS = [
+ 'p',
+ 'a',
+ 'strong',
+ 'em',
+ 'br',
+ 'ul',
+ 'ol',
+ 'li',
+ ];
+
+ /**
+ * Per-feed HTTP fetch timeout in seconds (REQ-NEWS-008).
+ */
+ private const FETCH_TIMEOUT_SECONDS = 10;
+
+ /**
+ * Lazily resolved {@see ICache} backing the per-feed payload cache.
+ * Created on first use so unit tests can construct the service
+ * with a stub `ICacheFactory` that never gets called.
+ *
+ * @var ICache|null
+ */
+ private ?ICache $cache = null;
+
+ /**
+ * Constructor.
+ *
+ * @param WidgetPlacementMapper $placementMapper Resolves placements by id.
+ * @param IClientService $clientService Builds the HTTP client used
+ * for synchronous on-demand
+ * feed fetches.
+ * @param IAppConfig $appConfig Reads admin settings:
+ * `news_widget_feed_cache_ttl_seconds`
+ * and
+ * `news_widget_allowed_feed_hosts`.
+ * @param ICacheFactory $cacheFactory Backing factory for the
+ * distributed cache used to
+ * hold raw feed payloads.
+ * @param LoggerInterface $logger PSR logger.
+ */
+ public function __construct(
+ private readonly WidgetPlacementMapper $placementMapper,
+ private readonly IClientService $clientService,
+ private readonly IAppConfig $appConfig,
+ private readonly ICacheFactory $cacheFactory,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Resolve a placement and return the merged + sanitised feed items
+ * for it. Honours the placement's metadata filter — when the filter
+ * does not match, an empty array is returned without performing any
+ * HTTP fetch (REQ-NEWS-007).
+ *
+ * @param integer $placementId Placement entity id.
+ * @param integer $limit Max items to return; clamped to
+ * [1, HARD_ITEM_CEILING].
+ *
+ * @return array{
+ * items: array>,
+ * feedsFailed: int,
+ * failedUrls: array
+ * }
+ */
+ public function getItemsForPlacement(int $placementId, int $limit=10): array
+ {
+ $clamped = $this->clampLimit(limit: $limit);
+
+ try {
+ $placement = $this->placementMapper->find(id: $placementId);
+ } catch (DoesNotExistException $e) {
+ return $this->emptyResponse();
+ } catch (Throwable $e) {
+ $this->logger->warning(
+ message: 'NewsWidget: failed to resolve placement '.$placementId,
+ context: ['exception' => $e]
+ );
+ return $this->emptyResponse();
+ }
+
+ $config = $this->extractNewsConfig(placement: $placement);
+
+ // REQ-NEWS-007: if a metadata filter is configured and does not
+ // match the dashboard, short-circuit before any feed fetch.
+ if ($config['metadataFilter'] !== null
+ && $this->checkMetadataFilter(
+ dashboardId: (int) $placement->getDashboardId(),
+ metadataFilter: $config['metadataFilter']
+ ) === false
+ ) {
+ return $this->emptyResponse();
+ }
+
+ return $this->fetchAndMergeFeeds(
+ feedUrls: $config['feedUrls'],
+ limit: $clamped
+ );
+ }//end getItemsForPlacement()
+
+ /**
+ * Parse the placement's persisted JSON content into the canonical
+ * news config shape, applying defaults where fields are missing or
+ * malformed (REQ-NEWS-002).
+ *
+ * @param WidgetPlacement $placement The placement whose
+ * `styleConfig` JSON column carries
+ * the news widget configuration.
+ *
+ * @return array{
+ * feedUrls: array,
+ * layout: string,
+ * itemLimit: int,
+ * showThumbnails: bool,
+ * showSummary: bool,
+ * summaryMaxChars: int,
+ * dateFormat: string,
+ * metadataFilter: array{fieldKey: string, value: string}|null
+ * }
+ */
+ public function extractNewsConfig(WidgetPlacement $placement): array
+ {
+ $decoded = $this->decodeStyleConfigBlob(raw: $placement->getStyleConfig());
+
+ return [
+ 'feedUrls' => $this->extractFeedUrls(decoded: $decoded),
+ 'layout' => $this->extractLayout(decoded: $decoded),
+ 'itemLimit' => $this->extractIntInRange(
+ decoded: $decoded,
+ key: 'itemLimit',
+ default: 10,
+ min: 1,
+ max: 50
+ ),
+ 'showThumbnails' => $this->extractBool(decoded: $decoded, key: 'showThumbnails', default: true),
+ 'showSummary' => $this->extractBool(decoded: $decoded, key: 'showSummary', default: true),
+ 'summaryMaxChars' => $this->extractIntInRange(
+ decoded: $decoded,
+ key: 'summaryMaxChars',
+ default: 200,
+ min: 0,
+ max: 5000
+ ),
+ 'dateFormat' => $this->extractDateFormat(decoded: $decoded),
+ 'metadataFilter' => $this->extractMetadataFilter(decoded: $decoded),
+ ];
+ }//end extractNewsConfig()
+
+ /**
+ * Parse the widget's persisted JSON blob and unwrap the outer
+ * `{type, content}` envelope when present so callers always get
+ * the flat content map.
+ *
+ * @param string|null $raw Raw JSON string from the placement.
+ *
+ * @return array Decoded content map (empty when invalid).
+ */
+ private function decodeStyleConfigBlob(?string $raw): array
+ {
+ if (is_string(value: $raw) === false || $raw === '') {
+ return [];
+ }
+
+ $parsed = json_decode(json: $raw, associative: true);
+ if (is_array(value: $parsed) === false) {
+ return [];
+ }
+
+ if (isset($parsed['content']) === true && is_array(value: $parsed['content']) === true) {
+ return $parsed['content'];
+ }
+
+ return $parsed;
+ }//end decodeStyleConfigBlob()
+
+ /**
+ * Extract the configured feed URL list, dropping non-HTTP(S) entries.
+ *
+ * @param array $decoded Decoded config map.
+ *
+ * @return array
+ */
+ private function extractFeedUrls(array $decoded): array
+ {
+ $candidates = $decoded['feedUrls'] ?? null;
+ if (is_array(value: $candidates) === false) {
+ return [];
+ }
+
+ $out = [];
+ foreach ($candidates as $candidate) {
+ if (is_string(value: $candidate) === false || $candidate === '') {
+ continue;
+ }
+
+ $lower = strtolower(string: $candidate);
+ if (str_starts_with(haystack: $lower, needle: 'http://') === true
+ || str_starts_with(haystack: $lower, needle: 'https://') === true
+ ) {
+ $out[] = $candidate;
+ }
+ }
+
+ return $out;
+ }//end extractFeedUrls()
+
+ /**
+ * Extract the layout enum (`list` | `grid` | `carousel`).
+ *
+ * @param array $decoded Decoded config map.
+ *
+ * @return string
+ */
+ private function extractLayout(array $decoded): string
+ {
+ $value = $decoded['layout'] ?? null;
+ if (is_string(value: $value) === true
+ && in_array(needle: $value, haystack: ['list', 'grid', 'carousel'], strict: true) === true
+ ) {
+ return $value;
+ }
+
+ return 'list';
+ }//end extractLayout()
+
+ /**
+ * Extract a clamped int field.
+ *
+ * @param array $decoded Decoded config map.
+ * @param string $key Field key.
+ * @param integer $default Default value when missing/invalid.
+ * @param integer $min Lower bound.
+ * @param integer $max Upper bound.
+ *
+ * @return integer
+ */
+ private function extractIntInRange(array $decoded, string $key, int $default, int $min, int $max): int
+ {
+ $value = $decoded[$key] ?? null;
+ if (is_int(value: $value) === false) {
+ return $default;
+ }
+
+ return max($min, min($max, $value));
+ }//end extractIntInRange()
+
+ /**
+ * Extract a boolean field with default fallback.
+ *
+ * @param array $decoded Decoded config map.
+ * @param string $key Field key.
+ * @param boolean $default Default value when missing.
+ *
+ * @return boolean
+ */
+ private function extractBool(array $decoded, string $key, bool $default): bool
+ {
+ if (array_key_exists(key: $key, array: $decoded) === false) {
+ return $default;
+ }
+
+ return $decoded[$key] === true;
+ }//end extractBool()
+
+ /**
+ * Extract the date-format enum, defaulting to `relative`.
+ *
+ * @param array $decoded Decoded config map.
+ *
+ * @return string `relative` or `absolute`.
+ */
+ private function extractDateFormat(array $decoded): string
+ {
+ if (($decoded['dateFormat'] ?? null) === 'absolute') {
+ return 'absolute';
+ }
+
+ return 'relative';
+ }//end extractDateFormat()
+
+ /**
+ * Extract the optional metadata filter `{fieldKey, value}` shape.
+ *
+ * @param array $decoded Decoded config map.
+ *
+ * @return array{fieldKey: string, value: string}|null
+ */
+ private function extractMetadataFilter(array $decoded): ?array
+ {
+ $filter = $decoded['metadataFilter'] ?? null;
+ if (is_array(value: $filter) === false) {
+ return null;
+ }
+
+ $key = $filter['fieldKey'] ?? null;
+ $val = $filter['value'] ?? null;
+ if (is_string(value: $key) === false || $key === '' || is_string(value: $val) === false) {
+ return null;
+ }
+
+ return ['fieldKey' => $key, 'value' => $val];
+ }//end extractMetadataFilter()
+
+ /**
+ * Fetch each URL (cache-first), parse, merge, deduplicate, sort, and
+ * cap at $limit items. Failures of individual URLs are tolerated;
+ * the response carries a count + list of failed URLs so the UI can
+ * surface a corner badge (REQ-NEWS-008 / REQ-NEWS-010).
+ *
+ * @param array $feedUrls List of feed URLs (already
+ * sanitised against http(s) by
+ * {@see extractNewsConfig()}).
+ * @param integer $limit Hard cap on returned item count
+ * (already clamped).
+ *
+ * @return array{
+ * items: array>,
+ * feedsFailed: int,
+ * failedUrls: array
+ * }
+ */
+ public function fetchAndMergeFeeds(array $feedUrls, int $limit=10): array
+ {
+ if ($feedUrls === []) {
+ return $this->emptyResponse();
+ }
+
+ $allItems = [];
+ $failedUrls = [];
+
+ foreach ($feedUrls as $url) {
+ if ($this->checkAllowList(url: $url) === false) {
+ $this->logger->warning(
+ message: 'NewsWidget: URL skipped by allow-list',
+ context: ['url' => $url]
+ );
+ $failedUrls[] = $url;
+ continue;
+ }
+
+ $payload = $this->fetchFeedPayload(url: $url);
+ if ($payload === null) {
+ $failedUrls[] = $url;
+ continue;
+ }
+
+ try {
+ $parsed = $this->parseRssFeed(
+ feedContent: $payload,
+ sourceUrl: $url,
+ sourceTitle: $url
+ );
+ } catch (Throwable $e) {
+ $this->logger->warning(
+ message: 'NewsWidget: feed parse failed for '.$url,
+ context: ['exception' => $e]
+ );
+ $failedUrls[] = $url;
+ continue;
+ }
+
+ foreach ($parsed as $item) {
+ $allItems[] = $item;
+ }
+ }//end foreach
+
+ $deduped = $this->deduplicateItems(items: $allItems);
+ $sorted = $this->sortItemsByDate(items: $deduped);
+ $sliced = array_slice(array: $sorted, offset: 0, length: $limit);
+
+ return [
+ 'items' => $sliced,
+ 'feedsFailed' => count(value: $failedUrls),
+ 'failedUrls' => array_values(array: $failedUrls),
+ ];
+ }//end fetchAndMergeFeeds()
+
+ /**
+ * Parse a raw feed payload (RSS 2.0 or Atom 1.0) into the canonical
+ * item shape. Items without a guid receive a synthetic
+ * `sha1(title|pubDate|sourceUrl)` identifier (REQ-NEWS-003).
+ *
+ * @param string $feedContent Raw XML payload.
+ * @param string $sourceUrl URL the payload was fetched from.
+ * @param string $sourceTitle Display name for the feed; replaced by
+ * the channel/feed title when present.
+ *
+ * @return array> Parsed items.
+ */
+ public function parseRssFeed(string $feedContent, string $sourceUrl, string $sourceTitle): array
+ {
+ if (trim(string: $feedContent) === '') {
+ return [];
+ }
+
+ $previousErrors = libxml_use_internal_errors(use_errors: true);
+ $xml = simplexml_load_string(
+ data: $feedContent,
+ class_name: SimpleXMLElement::class,
+ options: (LIBXML_NONET | LIBXML_NOENT)
+ );
+ libxml_clear_errors();
+ libxml_use_internal_errors(use_errors: $previousErrors);
+
+ if ($xml === false) {
+ $this->logger->info(
+ message: 'NewsWidget: could not parse feed XML for '.$sourceUrl
+ );
+ return [];
+ }
+
+ $items = [];
+ $rootName = $xml->getName();
+
+ // Atom 1.0: … .
+ if ($rootName === 'feed') {
+ $resolvedTitle = $sourceTitle;
+ if (isset($xml->title) === true) {
+ $resolvedTitle = (string) $xml->title;
+ }
+
+ foreach ($xml->entry as $entry) {
+ $items[] = $this->normaliseAtomEntry(
+ entry: $entry,
+ sourceUrl: $sourceUrl,
+ sourceTitle: $resolvedTitle
+ );
+ }
+
+ return $items;
+ }
+
+ // RSS 2.0: … .
+ if ($rootName === 'rss' && isset($xml->channel) === true) {
+ $channel = $xml->channel;
+ $resolvedTitle = $sourceTitle;
+ if (isset($channel->title) === true) {
+ $resolvedTitle = (string) $channel->title;
+ }
+
+ foreach ($channel->item as $item) {
+ $items[] = $this->normaliseRssItem(
+ item: $item,
+ sourceUrl: $sourceUrl,
+ sourceTitle: $resolvedTitle
+ );
+ }
+
+ return $items;
+ }
+
+ // Permit a bare wrapper (some publishers ship without ).
+ if ($rootName === 'channel') {
+ $resolvedTitle = $sourceTitle;
+ if (isset($xml->title) === true) {
+ $resolvedTitle = (string) $xml->title;
+ }
+
+ foreach ($xml->item as $item) {
+ $items[] = $this->normaliseRssItem(
+ item: $item,
+ sourceUrl: $sourceUrl,
+ sourceTitle: $resolvedTitle
+ );
+ }
+
+ return $items;
+ }
+
+ $this->logger->info(
+ message: 'NewsWidget: unsupported feed root <'.$rootName.'> for '.$sourceUrl
+ );
+ return [];
+ }//end parseRssFeed()
+
+ /**
+ * Deduplicate parsed items by `guid`, keeping the first occurrence
+ * (REQ-NEWS-003).
+ *
+ * @param array> $items Items to dedupe.
+ *
+ * @return array> Deduped items.
+ */
+ public function deduplicateItems(array $items): array
+ {
+ $seen = [];
+ $out = [];
+ foreach ($items as $item) {
+ $guid = '';
+ if (isset($item['guid']) === true) {
+ $guid = (string) $item['guid'];
+ }
+
+ if ($guid === '' || isset($seen[$guid]) === true) {
+ if ($guid === '') {
+ // Items missing a guid bypass the dedupe map but
+ // still flow through so we don't lose them; the
+ // synthetic guid in normaliseRssItem / normaliseAtomEntry
+ // ensures this path is rarely hit.
+ $out[] = $item;
+ }
+
+ continue;
+ }
+
+ $seen[$guid] = true;
+ $out[] = $item;
+ }//end foreach
+
+ return $out;
+ }//end deduplicateItems()
+
+ /**
+ * Sort items by ISO 8601 `pubDate`, descending (newest first).
+ * Items with an invalid or missing pubDate sink to the end, in
+ * input order, so the visible feed never silently drops them.
+ *
+ * @param array> $items Items to sort.
+ *
+ * @return array> Sorted items.
+ */
+ public function sortItemsByDate(array $items): array
+ {
+ $sortable = $items;
+ usort(
+ array: $sortable,
+ callback: function (array $a, array $b): int {
+ $aTs = false;
+ if (isset($a['pubDate']) === true) {
+ $aTs = strtotime(datetime: (string) $a['pubDate']);
+ }
+
+ $bTs = false;
+ if (isset($b['pubDate']) === true) {
+ $bTs = strtotime(datetime: (string) $b['pubDate']);
+ }
+
+ if ($aTs === false && $bTs === false) {
+ return 0;
+ }
+
+ if ($aTs === false) {
+ return 1;
+ }
+
+ if ($bTs === false) {
+ return -1;
+ }
+
+ return ($bTs <=> $aTs);
+ }
+ );
+
+ return $sortable;
+ }//end sortItemsByDate()
+
+ /**
+ * Allow-list the small set of inline tags safe to render inside a
+ * feed item summary, strip everything else, and force `rel` on
+ * surviving anchor tags (REQ-NEWS-005).
+ *
+ * @param string $html Raw summary HTML from a feed item.
+ *
+ * @return string Sanitised summary HTML.
+ */
+ public function sanitiseSummaryHtml(string $html): string
+ {
+ if ($html === '') {
+ return '';
+ }
+
+ $allowed = '<'.implode(separator: '><', array: self::ALLOWED_SUMMARY_TAGS).'>';
+ $stripped = strip_tags(string: $html, allowed_tags: $allowed);
+
+ // After tag stripping, any `javascript:` href that survives must
+ // be neutralised; reuse PHP's HTML parser via DOMDocument so we
+ // can rewrite href attributes safely without false-positives on
+ // body text containing the substring.
+ $previousErrors = libxml_use_internal_errors(use_errors: true);
+ $document = new DOMDocument();
+ $document->loadHTML(
+ source: ''.$stripped.'
',
+ options: (LIBXML_NONET | LIBXML_NOENT)
+ );
+ libxml_clear_errors();
+ libxml_use_internal_errors(use_errors: $previousErrors);
+
+ $anchors = $document->getElementsByTagName(qualifiedName: 'a');
+ foreach ($anchors as $anchor) {
+ $href = $anchor->getAttribute(qualifiedName: 'href');
+ $normalised = strtolower(string: trim(string: $href));
+ if (str_starts_with(haystack: $normalised, needle: 'javascript:') === true
+ || str_starts_with(haystack: $normalised, needle: 'data:') === true
+ ) {
+ $anchor->setAttribute(qualifiedName: 'href', value: '#');
+ }
+
+ $anchor->setAttribute(qualifiedName: 'rel', value: 'noopener noreferrer');
+ }
+
+ // Re-serialise just the wrapper's children so we don't leak
+ // from DOMDocument's auto-wrapping.
+ $body = $document->getElementsByTagName(qualifiedName: 'div')->item(index: 0);
+ if ($body === null) {
+ return $stripped;
+ }
+
+ $out = '';
+ foreach ($body->childNodes as $child) {
+ $serialised = $document->saveHTML(node: $child);
+ if (is_string(value: $serialised) === true) {
+ $out .= $serialised;
+ }
+ }
+
+ return $out;
+ }//end sanitiseSummaryHtml()
+
+ /**
+ * Whether the supplied URL's hostname is permitted by the admin
+ * allow-list. Empty / unset list ⇒ every host allowed
+ * (REQ-NEWS-006).
+ *
+ * @param string $url Candidate feed URL.
+ *
+ * @return boolean True when the host is allowed.
+ */
+ public function checkAllowList(string $url): bool
+ {
+ $raw = $this->appConfig->getValueString(
+ app: Application::APP_ID,
+ key: 'news_widget_allowed_feed_hosts',
+ default: ''
+ );
+
+ if (trim(string: $raw) === '') {
+ return true;
+ }
+
+ $decoded = json_decode(json: $raw, associative: true);
+ if (is_array(value: $decoded) === false || $decoded === []) {
+ return true;
+ }
+
+ $host = parse_url(url: $url, component: PHP_URL_HOST);
+ if (is_string(value: $host) === false || $host === '') {
+ return false;
+ }
+
+ $needle = strtolower(string: $host);
+ foreach ($decoded as $allowed) {
+ if (is_string(value: $allowed) === true && strtolower(string: $allowed) === $needle) {
+ return true;
+ }
+ }
+
+ return false;
+ }//end checkAllowList()
+
+ /**
+ * Hook for the (out-of-branch) `dashboard-metadata-fields` capability.
+ * Until that capability ships, the metadata store is unavailable and
+ * the filter result is "no metadata defined" — which means a
+ * configured filter never matches and the widget returns no items
+ * (REQ-NEWS-007 scenarios "Metadata field missing" and "spec not
+ * yet implemented").
+ *
+ * @param integer $dashboardId The dashboard whose
+ * metadata to consult.
+ * @param array{fieldKey: string, value: string} $metadataFilter The filter to apply.
+ *
+ * @return boolean True when the filter passes (allow fetch); false otherwise.
+ */
+ public function checkMetadataFilter(int $dashboardId, array $metadataFilter): bool
+ {
+ unset($dashboardId);
+ unset($metadataFilter);
+
+ // REQ-NEWS-007: gracefully treat missing dashboard-metadata-fields
+ // implementation as "no fields defined", which causes any
+ // configured equality filter to fail.
+ return false;
+ }//end checkMetadataFilter()
+
+ /**
+ * Clamp a caller-requested `limit` to the supported range [1, 200].
+ * The hard ceiling guards against runaway feeds. Out-of-range values
+ * collapse to the closer bound rather than returning an error so the
+ * widget always renders something.
+ *
+ * @param integer $limit Caller-requested limit.
+ *
+ * @return integer Clamped limit.
+ */
+ private function clampLimit(int $limit): int
+ {
+ if ($limit < 1) {
+ return 1;
+ }
+
+ return min(self::HARD_ITEM_CEILING, $limit);
+ }//end clampLimit()
+
+ /**
+ * Cache-first feed fetch. Returns `null` on any failure so the
+ * caller can record the URL as failed without short-circuiting the
+ * other URLs in the batch.
+ *
+ * @param string $url Feed URL to fetch.
+ *
+ * @return string|null Raw feed payload, or null on failure.
+ */
+ private function fetchFeedPayload(string $url): ?string
+ {
+ $cache = $this->getCache();
+ $cacheKey = 'feed_'.sha1(string: $url);
+
+ if ($cache !== null) {
+ $cached = $cache->get(key: $cacheKey);
+ if (is_string(value: $cached) === true && $cached !== '') {
+ return $cached;
+ }
+ }
+
+ try {
+ $client = $this->clientService->newClient();
+ $response = $client->get(
+ uri: $url,
+ options: [
+ 'timeout' => self::FETCH_TIMEOUT_SECONDS,
+ 'connect_timeout' => self::FETCH_TIMEOUT_SECONDS,
+ 'verify' => true,
+ ]
+ );
+
+ $statusCode = $response->getStatusCode();
+ if ($statusCode < 200 || $statusCode >= 300) {
+ $this->logger->warning(
+ message: 'NewsWidget: feed fetch returned HTTP '.$statusCode.' for '.$url
+ );
+ return null;
+ }
+
+ $body = (string) $response->getBody();
+ if ($body === '') {
+ return null;
+ }
+
+ if ($cache !== null) {
+ $ttl = $this->appConfig->getValueInt(
+ app: Application::APP_ID,
+ key: 'news_widget_feed_cache_ttl_seconds',
+ default: self::DEFAULT_CACHE_TTL
+ );
+ $cache->set(key: $cacheKey, value: $body, ttl: $ttl);
+ }
+
+ return $body;
+ } catch (Throwable $e) {
+ $this->logger->warning(
+ message: 'NewsWidget: feed fetch failed for '.$url,
+ context: ['exception' => $e]
+ );
+ return null;
+ }//end try
+ }//end fetchFeedPayload()
+
+ /**
+ * Lazily resolve the distributed cache. Returns `null` when the
+ * Nextcloud cache subsystem is unavailable (e.g. unit tests with a
+ * stub factory that returns `null`).
+ *
+ * @return ICache|null
+ */
+ private function getCache(): ?ICache
+ {
+ if ($this->cache !== null) {
+ return $this->cache;
+ }
+
+ try {
+ $this->cache = $this->cacheFactory->createDistributed(prefix: 'mydash_news_');
+ } catch (Throwable $e) {
+ $this->logger->info(
+ message: 'NewsWidget: cache subsystem unavailable, falling back to direct fetch',
+ context: ['exception' => $e]
+ );
+ $this->cache = null;
+ }
+
+ return $this->cache;
+ }//end getCache()
+
+ /**
+ * Convert a single Atom `` into the canonical item shape.
+ *
+ * @param SimpleXMLElement $entry The Atom entry node.
+ * @param string $sourceUrl The feed URL.
+ * @param string $sourceTitle The display name for the feed.
+ *
+ * @return array Canonical item.
+ */
+ private function normaliseAtomEntry(SimpleXMLElement $entry, string $sourceUrl, string $sourceTitle): array
+ {
+ $title = '';
+ if (isset($entry->title) === true) {
+ $title = (string) $entry->title;
+ }
+
+ $summary = '';
+ if (isset($entry->summary) === true) {
+ $summary = (string) $entry->summary;
+ } else if (isset($entry->content) === true) {
+ $summary = (string) $entry->content;
+ }
+
+ $link = '';
+ if (isset($entry->link) === true) {
+ // Atom links are .
+ $href = $entry->link['href'] ?? null;
+ if ($href !== null) {
+ $link = (string) $href;
+ }
+ }
+
+ $pubDate = '';
+ if (isset($entry->updated) === true) {
+ $pubDate = (string) $entry->updated;
+ } else if (isset($entry->published) === true) {
+ $pubDate = (string) $entry->published;
+ }
+
+ $guid = '';
+ if (isset($entry->id) === true) {
+ $guid = (string) $entry->id;
+ }
+
+ if ($guid === '') {
+ $guid = sha1(string: $title.'|'.$pubDate.'|'.$sourceUrl);
+ }
+
+ return [
+ 'guid' => $guid,
+ 'title' => $title,
+ 'summary' => $this->sanitiseSummaryHtml(html: $summary),
+ 'link' => $link,
+ 'pubDate' => $pubDate,
+ 'sourceUrl' => $sourceUrl,
+ 'sourceTitle' => $sourceTitle,
+ 'thumbnailUrl' => null,
+ ];
+ }//end normaliseAtomEntry()
+
+ /**
+ * Convert a single RSS `- ` into the canonical item shape.
+ *
+ * @param SimpleXMLElement $item The RSS item node.
+ * @param string $sourceUrl The feed URL.
+ * @param string $sourceTitle The display name for the feed.
+ *
+ * @return array
Canonical item.
+ */
+ private function normaliseRssItem(SimpleXMLElement $item, string $sourceUrl, string $sourceTitle): array
+ {
+ $title = '';
+ if (isset($item->title) === true) {
+ $title = (string) $item->title;
+ }
+
+ $summary = '';
+ if (isset($item->description) === true) {
+ $summary = (string) $item->description;
+ }
+
+ $link = '';
+ if (isset($item->link) === true) {
+ $link = (string) $item->link;
+ }
+
+ $pubDate = '';
+ if (isset($item->pubDate) === true) {
+ $pubDate = (string) $item->pubDate;
+ }
+
+ $guid = '';
+ if (isset($item->guid) === true) {
+ $guid = (string) $item->guid;
+ }
+
+ if ($guid === '') {
+ $guid = sha1(string: $title.'|'.$pubDate.'|'.$sourceUrl);
+ }
+
+ $thumbnail = null;
+ if (isset($item->enclosure) === true) {
+ $url = $item->enclosure['url'] ?? null;
+ $type = $item->enclosure['type'] ?? null;
+ if ($url !== null
+ && ($type === null || str_starts_with(haystack: (string) $type, needle: 'image/') === true)
+ ) {
+ $thumbnail = (string) $url;
+ }
+ }
+
+ return [
+ 'guid' => $guid,
+ 'title' => $title,
+ 'summary' => $this->sanitiseSummaryHtml(html: $summary),
+ 'link' => $link,
+ 'pubDate' => $pubDate,
+ 'sourceUrl' => $sourceUrl,
+ 'sourceTitle' => $sourceTitle,
+ 'thumbnailUrl' => $thumbnail,
+ ];
+ }//end normaliseRssItem()
+
+ /**
+ * Canonical empty response so callers don't repeat the literal.
+ *
+ * @return array{
+ * items: array>,
+ * feedsFailed: int,
+ * failedUrls: array
+ * }
+ */
+ private function emptyResponse(): array
+ {
+ return [
+ 'items' => [],
+ 'feedsFailed' => 0,
+ 'failedUrls' => [],
+ ];
+ }//end emptyResponse()
+}//end class
diff --git a/lib/Service/OrgNavigationService.php b/lib/Service/OrgNavigationService.php
new file mode 100644
index 00000000..bff86da4
--- /dev/null
+++ b/lib/Service/OrgNavigationService.php
@@ -0,0 +1,643 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service;
+
+use InvalidArgumentException;
+use OCP\Files\IAppData;
+use OCP\Files\NotFoundException;
+use OCP\Files\SimpleFS\ISimpleFile;
+use OCP\Files\SimpleFS\ISimpleFolder;
+use Throwable;
+
+/**
+ * Org-wide navigation tree service.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) IAppData + group
+ * resolver are both
+ * unavoidable here.
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Validation +
+ * filtering + persistence
+ * share one bounded class
+ * by design.
+ */
+class OrgNavigationService
+{
+ /**
+ * Name of the IAppData subfolder where org-nav JSON files live.
+ *
+ * @var string
+ */
+ public const FOLDER = 'org-navigation';
+
+ /**
+ * Maximum file size accepted on read or write (5 MB; REQ-ONAV-001).
+ *
+ * @var int
+ */
+ public const MAX_FILE_BYTES = (5 * 1024 * 1024);
+
+ /**
+ * Maximum tree depth (root counts as level 1; REQ-ONAV-001/003).
+ *
+ * @var int
+ */
+ public const MAX_DEPTH = 3;
+
+ /**
+ * Default language code used when the caller does not specify
+ * `?lang=` (REQ-ONAV-001).
+ *
+ * @var string
+ */
+ public const DEFAULT_LANGUAGE = 'nl';
+
+ /**
+ * Languages supported in v1 of the capability (REQ-ONAV-001).
+ *
+ * @var array
+ */
+ public const SUPPORTED_LANGUAGES = ['nl', 'en'];
+
+ /**
+ * URL schemes rejected outright by the validator
+ * (REQ-ONAV-003, REQ-ONAV-011).
+ *
+ * @var array
+ */
+ private const FORBIDDEN_URL_SCHEMES = ['javascript:', 'data:', 'vbscript:'];
+
+ /**
+ * Constructor.
+ *
+ * @param IAppData $appData Nextcloud app-data
+ * accessor for the
+ * MyDash app.
+ * @param AdminTemplateService $templateService Routing resolver — the
+ * only allowed wrapper
+ * around
+ * `IGroupManager::getUserGroupIds`
+ * (REQ-TMPL-013).
+ */
+ public function __construct(
+ private readonly IAppData $appData,
+ private readonly AdminTemplateService $templateService,
+ ) {
+ }//end __construct()
+
+ /**
+ * Read the persisted tree for the given language.
+ *
+ * Returns `[]` when the file does not yet exist; a corrupted JSON
+ * payload also resolves to `[]` so a hand-edited file never bricks
+ * the rail (REQ-ONAV-008 empty-state handling).
+ *
+ * @param string $language Language code (validated against the
+ * supported set).
+ *
+ * @return array> The persisted tree.
+ */
+ public function getTree(string $language=self::DEFAULT_LANGUAGE): array
+ {
+ $lang = $this->normaliseLanguage(language: $language);
+
+ try {
+ $folder = $this->appData->getFolder(name: self::FOLDER);
+ } catch (NotFoundException) {
+ return [];
+ }
+
+ try {
+ $file = $folder->getFile(name: $this->fileNameFor(language: $lang));
+ } catch (NotFoundException) {
+ return [];
+ }
+
+ return $this->decodeFile(file: $file);
+ }//end getTree()
+
+ /**
+ * Persist a validated tree for the given language.
+ *
+ * Wholesale replacement (REQ-ONAV-003). Validates BEFORE touching
+ * storage so a malformed payload never half-writes.
+ *
+ * @param array $tree The tree to persist.
+ * @param string $language Language code.
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException When validation fails (the
+ * controller maps to HTTP 400).
+ */
+ public function setTree(array $tree, string $language=self::DEFAULT_LANGUAGE): void
+ {
+ $lang = $this->normaliseLanguage(language: $language);
+
+ $this->validateTree(tree: $tree);
+
+ $folder = $this->getOrCreateFolder();
+ $name = $this->fileNameFor(language: $lang);
+
+ $payload = json_encode(value: $tree, flags: (JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
+ if ($payload === false) {
+ throw new InvalidArgumentException(message: 'Failed to encode tree to JSON');
+ }
+
+ if (strlen(string: $payload) > self::MAX_FILE_BYTES) {
+ throw new InvalidArgumentException(
+ message: 'Tree exceeds maximum file size of 5 MB'
+ );
+ }
+
+ try {
+ $file = $folder->getFile(name: $name);
+ $file->putContent(data: $payload);
+ } catch (NotFoundException) {
+ $folder->newFile(name: $name, content: $payload);
+ }
+ }//end setTree()
+
+ /**
+ * Filter a tree down to nodes the given user is permitted to see.
+ *
+ * Visibility rule per REQ-ONAV-002:
+ * - `groupVisibility === null` → visible to everyone
+ * - `groupVisibility === [g1, g2, ...]` → visible iff user is in
+ * at least one listed group
+ * - hidden parent cascades to children → children dropped wholesale
+ *
+ * @param array> $tree The tree to filter.
+ * @param string $userId The viewing user.
+ *
+ * @return array> The filtered tree.
+ */
+ public function filterTreeByUserGroups(array $tree, string $userId): array
+ {
+ if ($tree === []) {
+ return [];
+ }
+
+ $userGroups = $this->templateService->getUserGroupIdsFor(userId: $userId);
+
+ return $this->filterRecursive(tree: $tree, userGroups: $userGroups);
+ }//end filterTreeByUserGroups()
+
+ /**
+ * Validate a tree wholesale (REQ-ONAV-003).
+ *
+ * @param array $tree The tree to validate.
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException With a stable message on the
+ * first violation discovered.
+ */
+ public function validateTree(array $tree): void
+ {
+ $seenIds = [];
+ $this->validateRecursive(nodes: $tree, level: 1, seenIds: $seenIds);
+ }//end validateTree()
+
+ /**
+ * Reject URLs that use a disallowed scheme.
+ *
+ * Returns the original URL when accepted; otherwise throws so the
+ * caller can surface a single curated error message
+ * (REQ-ONAV-003, REQ-ONAV-011).
+ *
+ * @param string $url The URL to test.
+ *
+ * @return string The accepted URL (unchanged).
+ *
+ * @throws InvalidArgumentException When the URL uses
+ * `javascript:`, `data:`, or
+ * `vbscript:` (any case).
+ */
+ public function sanitiseUrl(string $url): string
+ {
+ $lower = strtolower(string: ltrim(string: $url));
+ foreach (self::FORBIDDEN_URL_SCHEMES as $scheme) {
+ if (str_starts_with(haystack: $lower, needle: $scheme) === true) {
+ throw new InvalidArgumentException(
+ message: 'URL scheme is not allowed'
+ );
+ }
+ }
+
+ return $url;
+ }//end sanitiseUrl()
+
+ /**
+ * Normalise a caller-supplied language code.
+ *
+ * Falls back to {@see self::DEFAULT_LANGUAGE} when the input is
+ * empty or unsupported. Defensive — the controller already
+ * validates, but keeping the fallback in the service means a
+ * direct service consumer (e.g. CLI tooling) cannot crash here.
+ *
+ * @param string $language Caller language code.
+ *
+ * @return string Normalised language code (always one of
+ * {@see self::SUPPORTED_LANGUAGES}).
+ */
+ private function normaliseLanguage(string $language): string
+ {
+ $lower = strtolower(string: trim(string: $language));
+ if (in_array(needle: $lower, haystack: self::SUPPORTED_LANGUAGES, strict: true) === false) {
+ return self::DEFAULT_LANGUAGE;
+ }
+
+ return $lower;
+ }//end normaliseLanguage()
+
+ /**
+ * Build the file name for a given language code.
+ *
+ * @param string $language Language code (already normalised).
+ *
+ * @return string The file name (no path).
+ */
+ private function fileNameFor(string $language): string
+ {
+ return ($language.'.json');
+ }//end fileNameFor()
+
+ /**
+ * Read and decode the JSON file body, returning `[]` on any failure.
+ *
+ * @param ISimpleFile $file The file to read.
+ *
+ * @return array> The decoded tree, or
+ * `[]` when the file is
+ * empty or corrupt.
+ */
+ private function decodeFile(ISimpleFile $file): array
+ {
+ try {
+ $size = $file->getSize();
+ } catch (Throwable) {
+ return [];
+ }
+
+ if ($size > self::MAX_FILE_BYTES) {
+ return [];
+ }
+
+ try {
+ $contents = $file->getContent();
+ } catch (Throwable) {
+ return [];
+ }
+
+ if ($contents === '') {
+ return [];
+ }
+
+ $decoded = json_decode(json: $contents, associative: true);
+ if (is_array($decoded) === false) {
+ return [];
+ }
+
+ return $decoded;
+ }//end decodeFile()
+
+ /**
+ * Get the org-navigation folder, creating it if it does not exist.
+ *
+ * @return ISimpleFolder The folder.
+ */
+ private function getOrCreateFolder(): ISimpleFolder
+ {
+ try {
+ return $this->appData->getFolder(name: self::FOLDER);
+ } catch (NotFoundException) {
+ return $this->appData->newFolder(name: self::FOLDER);
+ }
+ }//end getOrCreateFolder()
+
+ /**
+ * Recursive validator (REQ-ONAV-003).
+ *
+ * @param array $nodes The current level's nodes.
+ * @param int $level The depth of `$nodes` (root = 1).
+ * @param array $seenIds Map of UUIDs already used (by
+ * reference so siblings,
+ * children and grandchildren
+ * share the same uniqueness
+ * namespace).
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException On the first violation.
+ */
+ private function validateRecursive(array $nodes, int $level, array &$seenIds): void
+ {
+ if ($level > self::MAX_DEPTH) {
+ throw new InvalidArgumentException(
+ message: 'Tree depth cannot exceed 3 levels'
+ );
+ }
+
+ foreach ($nodes as $node) {
+ if (is_array($node) === false) {
+ throw new InvalidArgumentException(
+ message: 'Each node must be an object'
+ );
+ }
+
+ $this->validateNodeShape(node: $node, seenIds: $seenIds);
+
+ $children = ($node['children'] ?? []);
+ if (is_array($children) === false) {
+ throw new InvalidArgumentException(
+ message: 'Node children must be an array'
+ );
+ }
+
+ if ($children !== []) {
+ $this->validateRecursive(
+ nodes: $children,
+ level: ($level + 1),
+ seenIds: $seenIds
+ );
+ }
+ }//end foreach
+ }//end validateRecursive()
+
+ /**
+ * Validate a single node's shape and per-field rules.
+ *
+ * @param array $node The node.
+ * @param array $seenIds Map of UUIDs already used
+ * (by reference; updated on
+ * success).
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException On the first violation.
+ */
+ private function validateNodeShape(array $node, array &$seenIds): void
+ {
+ $this->assertId(node: $node, seenIds: $seenIds);
+ $this->assertLabel(node: $node);
+ $this->assertUrl(node: $node);
+ $this->assertGroupVisibility(node: $node);
+ $this->assertOptionalScalars(node: $node);
+ }//end validateNodeShape()
+
+ /**
+ * Validate the node id and stamp it into the seen-ids map.
+ *
+ * @param array $node The node.
+ * @param array $seenIds Map of UUIDs already used
+ * (by reference; updated on success).
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException When the id is missing,
+ * not a UUID, or duplicated.
+ */
+ private function assertId(array $node, array &$seenIds): void
+ {
+ $id = ($node['id'] ?? null);
+ if (is_string($id) === false || $this->isUuid(value: $id) === false) {
+ throw new InvalidArgumentException(
+ message: 'Node id must be a valid UUID'
+ );
+ }
+
+ if (isset($seenIds[$id]) === true) {
+ throw new InvalidArgumentException(
+ message: 'duplicate node id: '.$id
+ );
+ }
+
+ $seenIds[$id] = true;
+
+ }//end assertId()
+
+ /**
+ * Validate the node label.
+ *
+ * @param array $node The node.
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException When the label is missing or empty.
+ */
+ private function assertLabel(array $node): void
+ {
+ $label = ($node['label'] ?? null);
+ if (is_string($label) === false || trim(string: $label) === '') {
+ throw new InvalidArgumentException(
+ message: 'label is required'
+ );
+ }
+
+ }//end assertLabel()
+
+ /**
+ * Validate the optional URL field.
+ *
+ * @param array $node The node.
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException When the URL has an invalid type
+ * or scheme.
+ */
+ private function assertUrl(array $node): void
+ {
+ $url = ($node['url'] ?? null);
+ if ($url === null) {
+ return;
+ }
+
+ if (is_string($url) === false) {
+ throw new InvalidArgumentException(
+ message: 'url must be a string when set'
+ );
+ }
+
+ // Rejects javascript:, data:, vbscript:.
+ $this->sanitiseUrl(url: $url);
+
+ }//end assertUrl()
+
+ /**
+ * Validate the optional `groupVisibility` array.
+ *
+ * @param array $node The node.
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException On any shape violation.
+ */
+ private function assertGroupVisibility(array $node): void
+ {
+ $visibility = ($node['groupVisibility'] ?? null);
+ if ($visibility === null) {
+ return;
+ }
+
+ if (is_array($visibility) === false || $visibility === []) {
+ throw new InvalidArgumentException(
+ message: 'groupVisibility must be null or a non-empty array of strings'
+ );
+ }
+
+ foreach ($visibility as $groupId) {
+ if (is_string($groupId) === false || $groupId === '') {
+ throw new InvalidArgumentException(
+ message: 'groupVisibility entries must be non-empty strings'
+ );
+ }
+ }
+
+ }//end assertGroupVisibility()
+
+ /**
+ * Validate the remaining optional scalar fields.
+ *
+ * @param array $node The node.
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException When a field carries the
+ * wrong type.
+ */
+ private function assertOptionalScalars(array $node): void
+ {
+ $newTab = ($node['openInNewTab'] ?? null);
+ if ($newTab !== null && is_bool($newTab) === false) {
+ throw new InvalidArgumentException(
+ message: 'openInNewTab must be a boolean when set'
+ );
+ }
+
+ $icon = ($node['icon'] ?? null);
+ if ($icon !== null && is_string($icon) === false) {
+ throw new InvalidArgumentException(
+ message: 'icon must be a string when set'
+ );
+ }
+
+ }//end assertOptionalScalars()
+
+ /**
+ * Recursive group-visibility filter (REQ-ONAV-002).
+ *
+ * @param array> $tree The current
+ * subtree.
+ * @param string[] $userGroups The viewing
+ * user's groups.
+ *
+ * @return array> The filtered subtree.
+ */
+ private function filterRecursive(array $tree, array $userGroups): array
+ {
+ $userGroupIndex = array_flip(array: $userGroups);
+ $result = [];
+
+ foreach ($tree as $node) {
+ if ($this->isVisible(node: $node, userGroupIndex: $userGroupIndex) === false) {
+ // Hidden parent cascades — children dropped wholesale.
+ continue;
+ }
+
+ $children = ($node['children'] ?? []);
+ if (is_array($children) === true && $children !== []) {
+ $node['children'] = $this->filterRecursive(
+ tree: $children,
+ userGroups: $userGroups
+ );
+ }
+
+ $result[] = $node;
+ }
+
+ return $result;
+ }//end filterRecursive()
+
+ /**
+ * True when the user is permitted to see the given node.
+ *
+ * @param array $node The node to test.
+ * @param array $userGroupIndex Flipped user groups
+ * (group id → array
+ * position) for O(1)
+ * membership lookup.
+ *
+ * @return bool True when visible.
+ */
+ private function isVisible(array $node, array $userGroupIndex): bool
+ {
+ $visibility = ($node['groupVisibility'] ?? null);
+ if ($visibility === null) {
+ return true;
+ }
+
+ if (is_array($visibility) === false || $visibility === []) {
+ // Treat malformed visibility as "visible to all" so a
+ // legacy/migrated record never disappears silently.
+ return true;
+ }
+
+ foreach ($visibility as $groupId) {
+ if (is_string($groupId) === false) {
+ continue;
+ }
+
+ if (isset($userGroupIndex[$groupId]) === true) {
+ return true;
+ }
+ }
+
+ return false;
+ }//end isVisible()
+
+ /**
+ * UUID v1..v5 detector — matches the canonical 8-4-4-4-12 hex form.
+ *
+ * @param string $value The candidate value.
+ *
+ * @return bool True when the input is a valid UUID.
+ */
+ private function isUuid(string $value): bool
+ {
+ return preg_match(
+ pattern: '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
+ subject: $value
+ ) === 1;
+ }//end isUuid()
+}//end class
diff --git a/lib/Service/OrphanedDataCleanupService.php b/lib/Service/OrphanedDataCleanupService.php
new file mode 100644
index 00000000..88f6070f
--- /dev/null
+++ b/lib/Service/OrphanedDataCleanupService.php
@@ -0,0 +1,442 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 MyDash Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace OCA\MyDash\Service;
+
+use OCA\MyDash\AppInfo\Application;
+use OCA\MyDash\Db\CleanupResult;
+use OCA\MyDash\Service\Cleanup\CategoryRegistryService;
+use OCP\Activity\Exceptions\IncompleteActivityException;
+use OCP\Activity\IManager as IActivityManager;
+use OCP\ICache;
+use OCP\ICacheFactory;
+use OCP\IDBConnection;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Orchestrates scan + purge across all registered cleanup categories.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The orchestrator
+ * legitimately
+ * composes the
+ * registry, cache,
+ * DB transaction,
+ * activity and
+ * logger
+ * collaborators in
+ * one place.
+ */
+class OrphanedDataCleanupService
+{
+ /**
+ * Cache TTL for scan results, in seconds (REQ-CLN-010).
+ *
+ * Five minutes — short enough that an admin who navigates to the
+ * cleanup page after a manual purge sees fresh numbers, long
+ * enough that repeated UI refreshes don't hammer the DB.
+ *
+ * @var int
+ */
+ public const CACHE_TTL_SECONDS = 300;
+
+ /**
+ * Cache key for the most recent scan result.
+ *
+ * @var string
+ */
+ public const CACHE_KEY_SCAN = 'mydash.cleanup.scan';
+
+ /**
+ * Activity event type emitted on every real (non-dry-run) purge.
+ *
+ * @var string
+ */
+ public const ACTIVITY_TYPE = 'mydash_cleanup_purge';
+
+ /**
+ * Cache instance, lazily resolved via {@see ICacheFactory}.
+ *
+ * @var ICache|null
+ */
+ private ?ICache $cache = null;
+
+ /**
+ * Constructor.
+ *
+ * @param CategoryRegistryService $registry Category registry.
+ * @param ICacheFactory $cacheFactory Distributed-cache
+ * factory.
+ * @param IDBConnection $db DB connection
+ * (for dry-run
+ * transactions).
+ * @param IActivityManager $activityManager Activity event
+ * publisher.
+ * @param LoggerInterface $logger PSR-3 logger
+ * used for purge
+ * audit lines.
+ */
+ public function __construct(
+ private readonly CategoryRegistryService $registry,
+ private readonly ICacheFactory $cacheFactory,
+ private readonly IDBConnection $db,
+ private readonly IActivityManager $activityManager,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Scan one or more categories for orphaned rows.
+ *
+ * When `$categoryNames` is empty the full registered set is
+ * scanned. Categories whose `isAvailable()` returns `false` are
+ * recorded under `skipped` instead of contributing a count.
+ *
+ * The result is cached under {@see self::CACHE_KEY_SCAN} for
+ * {@see self::CACHE_TTL_SECONDS} seconds whenever the caller
+ * asks for the full set; partial scans bypass the cache to avoid
+ * polluting the full-set entry with category-filtered numbers.
+ *
+ * @param array $categoryNames Names to scan, or [].
+ *
+ * @return CleanupResult The result.
+ */
+ public function scan(array $categoryNames=[]): CleanupResult
+ {
+ if (count(value: $categoryNames) === 0) {
+ $cached = $this->getCachedScanResult();
+ if ($cached !== null) {
+ return $cached;
+ }
+ }
+
+ $names = $this->resolveCategoryNames(requested: $categoryNames);
+
+ $start = (int) round(num: (microtime(as_float: true) * 1000));
+ $byCategory = [];
+ $skipped = [];
+
+ foreach ($names as $name) {
+ $category = $this->registry->getCategoryByName(name: $name);
+ if ($category === null) {
+ $skipped[] = $name;
+ continue;
+ }
+
+ if ($category->isAvailable() === false) {
+ $skipped[] = $name;
+ continue;
+ }
+
+ $byCategory[$name] = (int) $category->scan();
+ }
+
+ $duration = ((int) round(num: (microtime(as_float: true) * 1000)) - $start);
+ $result = CleanupResult::fromCounts(
+ byCategory: $byCategory,
+ durationMs: $duration,
+ dryRun: false,
+ skipped: $skipped,
+ );
+
+ if (count(value: $categoryNames) === 0) {
+ $this->setCachedScanResult(result: $result);
+ }
+
+ return $result;
+ }//end scan()
+
+ /**
+ * Purge orphaned rows in one or more categories.
+ *
+ * When `$categoryNames` is empty the full registered set is
+ * purged. Categories whose `isAvailable()` returns `false` are
+ * recorded under `skipped`.
+ *
+ * Dry-run mode wraps the entire walk in a single transaction and
+ * rolls it back at the end so individual category implementations
+ * can use one delete path for both modes (REQ-CLN-003).
+ *
+ * On a successful real purge the scan cache is invalidated
+ * (REQ-CLN-010) and exactly one Activity event is emitted
+ * (REQ-CLN-009).
+ *
+ * @param array $categoryNames Names to purge, or [].
+ * @param bool $dryRun True for simulation.
+ * @param string|null $userId The actor for audit
+ * (null = system).
+ * @param string $source Origin label for the
+ * activity event ('cli',
+ * 'api', 'job').
+ *
+ * @return CleanupResult The result.
+ */
+ public function purge(
+ array $categoryNames=[],
+ bool $dryRun=false,
+ ?string $userId=null,
+ string $source='api'
+ ): CleanupResult {
+ $names = $this->resolveCategoryNames(requested: $categoryNames);
+ $start = (int) round(num: (microtime(as_float: true) * 1000));
+ $byCategory = [];
+ $skipped = [];
+
+ if ($dryRun === true) {
+ $this->db->beginTransaction();
+ }
+
+ try {
+ foreach ($names as $name) {
+ $category = $this->registry->getCategoryByName(name: $name);
+ if ($category === null) {
+ $skipped[] = $name;
+ continue;
+ }
+
+ if ($category->isAvailable() === false) {
+ $skipped[] = $name;
+ continue;
+ }
+
+ $byCategory[$name] = (int) $category->purge(dryRun: $dryRun);
+ }
+
+ if ($dryRun === true) {
+ // REQ-CLN-003: rollback so the simulation has zero
+ // persistent side effect.
+ $this->db->rollBack();
+ }
+ } catch (Throwable $t) {
+ if ($dryRun === true && $this->db->inTransaction() === true) {
+ $this->db->rollBack();
+ }
+
+ throw $t;
+ }//end try
+
+ $duration = ((int) round(num: (microtime(as_float: true) * 1000)) - $start);
+ $result = CleanupResult::fromCounts(
+ byCategory: $byCategory,
+ durationMs: $duration,
+ dryRun: $dryRun,
+ skipped: $skipped,
+ );
+
+ if ($dryRun === false) {
+ $this->invalidateCache();
+
+ if ($result->getTotalRows() > 0) {
+ $this->emitActivityEvent(
+ result: $result,
+ userId: $userId,
+ source: $source,
+ );
+ }
+
+ $this->logger->info(
+ message: sprintf(
+ 'mydash.cleanup.purge source=%s user=%s rows=%d duration_ms=%d categories=%s',
+ $source,
+ ($userId ?? 'system'),
+ $result->getTotalRows(),
+ $result->getDurationMs(),
+ implode(separator: ',', array: array_keys(array: $byCategory)),
+ )
+ );
+ }//end if
+
+ return $result;
+ }//end purge()
+
+ /**
+ * Read the most recent cached scan result.
+ *
+ * Returns `null` when the cache backend has no value (expired,
+ * never written, or unavailable). The cache is constructed lazily
+ * via {@see ICacheFactory::createDistributed()} so installs
+ * without a memory cache silently fall through to fresh scans.
+ *
+ * @return CleanupResult|null The cached result or null.
+ */
+ public function getCachedScanResult(): ?CleanupResult
+ {
+ $payload = $this->cache()->get(key: self::CACHE_KEY_SCAN);
+ if (is_array(value: $payload) === false) {
+ return null;
+ }
+
+ return new CleanupResult(
+ byCategory: (array) ($payload['byCategory'] ?? []),
+ totalRows: (int) ($payload['totalRows'] ?? 0),
+ durationMs: (int) ($payload['durationMs'] ?? 0),
+ dryRun: (bool) ($payload['dryRun'] ?? false),
+ scannedAt: (string) ($payload['scannedAt'] ?? ''),
+ skipped: (array) ($payload['skipped'] ?? []),
+ );
+ }//end getCachedScanResult()
+
+ /**
+ * Persist a scan result into the distributed cache for
+ * {@see self::CACHE_TTL_SECONDS} seconds.
+ *
+ * @param CleanupResult $result The result to cache.
+ *
+ * @return void
+ */
+ public function setCachedScanResult(CleanupResult $result): void
+ {
+ $this->cache()->set(
+ key: self::CACHE_KEY_SCAN,
+ value: $result->jsonSerialize(),
+ ttl: self::CACHE_TTL_SECONDS,
+ );
+ }//end setCachedScanResult()
+
+ /**
+ * Invalidate the cached scan result.
+ *
+ * Called automatically after every successful real purge so the
+ * next scan reflects the fresh state (REQ-CLN-010).
+ *
+ * @return void
+ */
+ public function invalidateCache(): void
+ {
+ $this->cache()->remove(key: self::CACHE_KEY_SCAN);
+ }//end invalidateCache()
+
+ /**
+ * Resolve the requested category-name list, normalising the
+ * "empty means all" convention.
+ *
+ * @param array