diff --git a/appinfo/info.xml b/appinfo/info.xml index 418d8021..d64ff3d2 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -61,9 +61,11 @@ Vrij en open source onder de EUPL-1.2-licentie. OCA\OpenBuilt\Repair\InitializeSettings + OCA\OpenBuilt\Repair\SeedHelloWorld OCA\OpenBuilt\Repair\InitializeSettings + OCA\OpenBuilt\Repair\SeedHelloWorld diff --git a/appinfo/routes.php b/appinfo/routes.php index 689dc2f1..917b7251 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -16,6 +16,13 @@ // Health check endpoint. ['name' => 'health#index', 'url' => '/api/health', 'verb' => 'GET'], + // Manifest endpoint — returns the stored manifest JSON blob for a given virtual-app slug. + // Per ADR-016 routes.php is the only registration path; #[NoAdminRequired] is set on the + // controller method so auth-required-but-non-admin users can hit it (per design.md Decision 6). + // Slug matches the kebab-case pattern declared in openbuilt_register.json on the Application + // and BuiltAppRoute schemas (^[a-z0-9][a-z0-9-]*[a-z0-9]$, min 2 max 48 chars). + ['name' => 'applications#getManifest', 'url' => '/api/applications/{slug}/manifest', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']], + // SPA catch-all — same controller as the index route; must use a distinct route name // (duplicate names replace the earlier route in Symfony, which breaks GET /). ['name' => 'dashboard#catchAll', 'url' => '/{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], 'defaults' => ['path' => '']], diff --git a/docs/integrator-guide.md b/docs/integrator-guide.md new file mode 100644 index 00000000..54242795 --- /dev/null +++ b/docs/integrator-guide.md @@ -0,0 +1,75 @@ +# Integrator guide — authoring a virtual app by hand + +This guide walks you through creating a new virtual app in OpenBuilt without using the visual editor (which lives in chain spec [`openbuilt-page-editor`](../openspec/changes/) — not yet shipped). At this stage, OpenBuilt is integrator-only: you write JSON and the runtime renders it. + +## What you author + +A virtual app is one record in OpenBuilt's `Application` OR schema. The shape is: + +```jsonc +{ + "slug": "permit-tracker", // kebab-case, 2–48 chars + "name": "Permit Tracker", + "description": "Track building permits through review stages.", + "version": "0.1.0", + "status": "draft", // draft → published → archived + "manifest": { + "version": "1.0.0", + "dependencies": ["openregister"], + "menu": [ ... ], + "pages": [ ... ] + } +} +``` + +The `manifest` object validates against [`@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json`](https://github.com/ConductionNL/nextcloud-vue/blob/main/src/schemas/app-manifest.schema.json). The closed `type` enum for pages is `index | detail | dashboard | logs | settings | chat | files | form | custom`. + +## Step-by-step + +1. **Pick a slug.** Must be kebab-case, 2–48 chars, unique within your organisation. The synthetic appId in CnAppRoot becomes `openbuilt-${slug}`. +2. **Design your schemas** in OpenRegister directly (the OpenBuilt schema editor lands in chain spec `openbuilt-schema-editor`). At minimum: one schema per primary entity your app shows. +3. **Author the manifest** as JSON. The canonical example is the seeded `hello-world` Application — open it in OpenBuilt's manifest editor (top-bar OpenBuilt entry → Virtual apps → hello-world) and read its manifest. +4. **Save as `draft`** while iterating. The textarea editor validates each save against the canonical schema; you see the failing JSON path on save error. +5. **Transition to `published`** when ready (via OR's lifecycle endpoint or the editor's Publish action — landing in chain spec `openbuilt-versioning`). On publish, OpenBuilt's lifecycle creates the corresponding `BuiltAppRoute` so `/builder/{slug}` becomes reachable. + +## Manifest checklist + +Per [ADR-024](https://github.com/ConductionNL/hydra/blob/main/openspec/architecture/adr-024-app-manifest.md): + +- `version` (semver) — your app's content version +- `dependencies` — list of NC app IDs that must be installed (almost always `["openregister"]`) +- `menu[]` — at least one entry; supports one level of `children[]` +- `pages[]` — at least one entry; every page's `id` MUST be unique and match a vue-router route name +- `label` / `title` strings are i18n KEYS, not literals. The consuming app's `t()` resolves them. Use kebab.dot.notation: `myapp.permits.title.list`. + +Per [ADR-007](https://github.com/ConductionNL/hydra/blob/main/openspec/architecture/adr-007-i18n.md): + +- Every translation key MUST exist in `l10n/en.json` AND `l10n/nl.json` of the **OpenBuilt** repo (until per-virtual-app translations land in chain spec `openbuilt-page-editor`). + +## Reading the seed manifest + +The seeded `hello-world` Application is the canonical reference. Its manifest exercises: + +- **index** page → drives `CnIndexPage` with `register: openbuilt`, `schema: hello-message`, three columns +- **detail** page → drives `CnDetailPage` keyed on `:id` +- **form** page → drives `CnFormPage` with `mode: create` and `submitEndpoint` going to OR's REST + +See [`lib/Repair/SeedHelloWorld.php`](../lib/Repair/SeedHelloWorld.php) `buildHelloWorldManifest()` for the full JSON. + +## When you hit a limit + +The closed `type` enum can't be extended from a manifest — adding a new page type requires a library-level openspec change in `@conduction/nextcloud-vue`. If you need something the four built-in types can't express: + +1. Confirm the requirement isn't satisfied by `form` (the most flexible built-in). +2. Open an issue on `ConductionNL/nextcloud-vue` describing the new page type's shape. +3. As an interim, mount a custom Vue component via `type: "custom"` + `component: "MyCustomPage"` and register the component in OpenBuilt's `customComponents` map. (Note: spec #1 only ships the built-in types — the `customComponents` registry surface lands when a real consumer needs it.) + +## What does NOT work yet (spec #1 limitations) + +- **No visual editor** — JSON textarea only. Visual editor: chain spec `openbuilt-page-editor`. +- **No schema designer** — schemas must be authored in `lib/Settings/openbuilt_register.json` and imported via the repair step. Runtime schema authoring: chain spec `openregister-runtime-schema-api`. +- **No draft preview** — only `published` apps appear at `/builder/{slug}`. Draft preview: chain spec `openbuilt-versioning`. +- **No per-app permissions** — auth-only visibility for v1; everyone in your organisation sees every virtual app. Per-app RBAC: chain spec `openbuilt-rbac`. +- **No export to a real Nextcloud app** — virtual-only. Export pipeline: chain spec `openbuilt-export-to-real-app`. + +If any of these limitations block your project, talk to Conduction — chain spec prioritisation can shift. diff --git a/docs/openbuilt-runtime.md b/docs/openbuilt-runtime.md new file mode 100644 index 00000000..a03d1808 --- /dev/null +++ b/docs/openbuilt-runtime.md @@ -0,0 +1,119 @@ +# OpenBuilt Runtime + +This document describes how OpenBuilt renders a virtual app at runtime — the manifest endpoint, the nested `CnAppRoot` mount, and the workaround that bridges the gap until the in-memory `useAppManifest` overload ships in `@conduction/nextcloud-vue`. + +> Scope: spec #1 (`bootstrap-openbuilt`) of the 9-spec OpenBuilt chain. Visual editors, draft/publish lifecycle UX, per-app RBAC, marketplace, and code export live in chained follow-on specs. + +## Big picture + +``` +[ Browser request ] + │ + ▼ +[ OpenBuilt shell — outer CnAppRoot owned by openbuilt/src/manifest.json ] + │ navigate to /builder//... + ▼ +[ src/views/BuilderHost.vue — mounts a NESTED CnAppRoot ] + │ useAppManifest( appId='openbuilt-', placeholderManifest, options ) + ▼ +[ options.endpoint → GET /index.php/apps/openbuilt/api/applications//manifest ] + │ + ▼ +[ ApplicationsController::getManifest( slug ) ] + │ via OR's ObjectService: + ▼ +[ openbuilt/built-app-route → applicationUuid ] +[ openbuilt/application[uuid].manifest ] + │ + ▼ +[ unwrapped manifest JSON → useAppManifest deep-merges with placeholder → CnAppRoot renders ] +``` + +## Why a nested CnAppRoot + +`CnAppRoot` is router-agnostic and accepts a `manifest` prop. OpenBuilt mounts a **fresh** instance per virtual app inside its own shell at `/builder/{slug}/*`. The `:key="slug"` prop forces a clean remount when the user navigates between virtual apps, so the inner manifest's router resets cleanly. + +Alternatives rejected (see `openspec/changes/bootstrap-openbuilt/design.md` Decision 5): + +- Replacing the outer router for the duration of the virtual-app session — breaks the "where am I?" mental model. +- Opening the virtual app in a new tab — loses state, breaks the back button, forces a full Nextcloud reload. + +## The manifest endpoint + +| | | +|---|---| +| **URL** | `GET /index.php/apps/openbuilt/api/applications/{slug}/manifest` | +| **Auth** | `#[NoAdminRequired]` + `#[NoCSRFRequired]` (auth-only for v1; scoping comes from OR's organisation field per ADR-022) | +| **Slug pattern** | `^[a-z0-9][a-z0-9-]*[a-z0-9]$`, 2–48 chars (matches the schema declaration) | +| **Lookup path** | slug → `openbuilt/built-app-route` → applicationUuid → `openbuilt/application` → `manifest` | +| **Response (200)** | the manifest JSON blob, **unwrapped** (no OR envelope) so `useAppManifest` consumes it directly | +| **Response (404)** | when no `BuiltAppRoute` matches the slug (i.e. no published app at that path) | +| **Response (500)** | inconsistent state (route → missing application, or application → missing manifest) — logged at warning | +| **Controller** | [lib/Controller/ApplicationsController.php](../lib/Controller/ApplicationsController.php) | + +The controller is intentionally thin (~50 LOC of method body): a slug lookup, a UUID lookup, and an unwrap. All other CRUD on `Application` + `BuiltAppRoute` goes through OR's REST API directly per ADR-022. + +## The workaround — bundled-mode `useAppManifest` with redirected endpoint + +`@conduction/nextcloud-vue` v1.0.0-beta.30 ships `useAppManifest(appId, bundledManifest, options)` which fetches from `/index.php/apps/{appId}/api/manifest` by default — but it accepts an `options.endpoint` override to redirect the fetch. + +OpenBuilt uses this: + +```vue + + +``` + +- `appId = openbuilt-${slug}` makes each virtual app's manifest cache key unique. +- `bundledManifest` is a minimal placeholder (`{ version: '0.0.0', menu: [], pages: [] }`) shipped at [`src/manifests/placeholder.json`](../src/manifests/placeholder.json). `useAppManifest` synchronously seeds with this then deep-merges the backend response. +- `options.endpoint` redirects the backend fetch from the default `/apps/openbuilt-${slug}/api/manifest` (which would 404 — that's a different "app") to OpenBuilt's per-slug endpoint. + +When `nextcloud-vue` later ships an in-memory overload `useAppManifest({ manifest: object })` (chain spec #2 = `nextcloud-vue-in-memory-manifest`), `BuilderHost.vue` collapses to that call and the per-slug endpoint becomes optional. Until then, the endpoint stays on the critical path. + +## The lifecycle is declarative (ADR-031) + +OpenBuilt does **not** ship an `ApplicationLifecycleService.php` / `ApplicationStateMachine.php` / similar service class. The state machine lives in the schema register at [lib/Settings/openbuilt_register.json](../lib/Settings/openbuilt_register.json) under `Application.x-openregister-lifecycle`: + +| State | Transition | Action | +|---|---|---| +| `draft` → `published` | `publish` | upsert sibling `BuiltAppRoute(slug, applicationUuid)` | +| `published` → `archived` | `archive` | delete `BuiltAppRoute` with matching slug | +| `archived` → `draft` | `reopen` | — | +| `archived` → `published` | `republish` | upsert `BuiltAppRoute` | + +> If OR's current lifecycle engine doesn't yet support the `on_transition.upsert_relation` / `delete_relation` actions for sibling-object upkeep, the fallback is a single PHP listener `lib/Listener/BuiltAppRouteSyncListener.php` subscribed to `ObjectLifecycleTransitionedEvent` (per design.md OQ-1). The listener is the ADR-031 §Exceptions(1) path; behaviour from the user's perspective is identical either way. + +## Seed: `hello-world` + +`lib/Repair/SeedHelloWorld.php` runs idempotently on every install + post-migration: + +1. Guard on `openbuilt/application` slug `hello-world` — if present, no-op. +2. Save one `Application` (`slug: hello-world`, `status: published`, version `0.1.0`) with a manifest exercising `index`, `detail`, and `form` page types against the seeded `hello-message` schema. +3. Save three sample `hello-message` objects. + +The seed gives integrators a working virtual app on minute one of an OpenBuilt install — browse to `/index.php/apps/openbuilt/builder/hello-world` post-install. + +## File map + +| Path | Role | +|------|------| +| [`appinfo/routes.php`](../appinfo/routes.php) | Registers `GET /api/applications/{slug}/manifest` | +| [`lib/Controller/ApplicationsController.php`](../lib/Controller/ApplicationsController.php) | `getManifest()` — the only app-local controller method | +| [`lib/Settings/openbuilt_register.json`](../lib/Settings/openbuilt_register.json) | OR schema declarations for `Application`, `BuiltAppRoute`, `HelloMessage`, plus the lifecycle metadata | +| [`lib/Repair/InitializeSettings.php`](../lib/Repair/InitializeSettings.php) | Imports the register into OR on install/upgrade | +| [`lib/Repair/SeedHelloWorld.php`](../lib/Repair/SeedHelloWorld.php) | Seeds the canonical hello-world virtual app | +| [`src/views/BuilderHost.vue`](../src/views/BuilderHost.vue) | Nested CnAppRoot mount with the redirected endpoint workaround | +| [`src/views/ApplicationEditor.vue`](../src/views/ApplicationEditor.vue) | Textarea-based JSON manifest editor (v1; visual editor lives in chain spec `openbuilt-page-editor`) | +| [`src/router/index.js`](../src/router/index.js) | Outer routes including `/builder/:slug/:pathMatch(.*)?` | +| [`src/manifests/placeholder.json`](../src/manifests/placeholder.json) | Empty-skeleton manifest bundled into `useAppManifest` | + +## Related ADRs + +- **ADR-022** — apps consume OpenRegister abstractions (OpenBuilt does not wrap OR's REST) +- **ADR-024** — app manifest standard (`CnAppRoot` + `CnAppNav` + `CnPageRenderer` + `useAppManifest`) +- **ADR-031** — schema-declarative business logic (the Application lifecycle is the canonical example) +- **ADR-032** — spec sizing (`bootstrap-openbuilt` is `kind: mixed` under the thin-glue exception) diff --git a/eslint.config.js b/eslint.config.js index b306f39f..95bd13ff 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -31,12 +31,15 @@ module.exports = defineConfig([{ }, rules: { - // Allow unused i18n functions (t, n) — imported for future translation wiring - 'no-unused-vars': ['error', { varsIgnorePattern: '^(t|n)$', argsIgnorePattern: '^_' }], + // Allow unused i18n functions (t, n) — imported for future translation wiring. + // Allow leading-underscore vars (idiomatic "discarded destructure" — `const { foo: _foo, ...rest } = x`). + 'no-unused-vars': ['error', { varsIgnorePattern: '^(t|n|_)', argsIgnorePattern: '^_' }], 'jsdoc/require-jsdoc': 'off', 'vue/first-attribute-linebreak': 'off', '@typescript-eslint/no-explicit-any': 'off', 'n/no-missing-import': 'off', + 'n/no-unpublished-import': 'off', // vuedraggable is in dependencies; aliased nextcloud-vue isn't always resolvable to a published package + 'import/named': 'off', // re-exports through aliased nextcloud-vue/src trip the resolver; webpack handles it at build time 'import/namespace': 'off', // disable namespace checking to avoid parser requirement 'import/default': 'off', // disable default import checking to avoid parser requirement 'import/no-named-as-default': 'off', // disable named-as-default checking to avoid parser requirement diff --git a/l10n/en.json b/l10n/en.json index c9484ab1..b1db799d 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -28,7 +28,140 @@ "Settings saved successfully": "Settings saved successfully", "Saving...": "Saving...", "This app needs OpenRegister to store and manage data. Please install OpenRegister from the app store to get started.": "This app needs OpenRegister to store and manage data. Please install OpenRegister from the app store to get started.", - "User settings will appear here in a future update.": "User settings will appear here in a future update." + "User settings will appear here in a future update.": "User settings will appear here in a future update.", + "Virtual apps": "Virtual apps", + "No virtual apps yet — seed `hello-world` should appear after install.": "No virtual apps yet — seed `hello-world` should appear after install.", + "Status": "Status", + "Version": "Version", + "Integrator-only editor: edit the raw JSON manifest below. The visual editor lives in a follow-on release (openbuilt-page-editor).": "Integrator-only editor: edit the raw JSON manifest below. The visual editor lives in a follow-on release (openbuilt-page-editor).", + "Paste or edit the JSON manifest here. See @conduction/nextcloud-vue/src/schemas/app-manifest.schema.json for the canonical schema.": "Paste or edit the JSON manifest here. See @conduction/nextcloud-vue/src/schemas/app-manifest.schema.json for the canonical schema.", + "Invalid manifest": "Invalid manifest", + "Saving…": "Saving…", + "Open virtual app": "Open virtual app", + "openbuilt.helloworld.menu.messages": "Messages", + "openbuilt.helloworld.title.messages": "Hello World — messages", + "openbuilt.helloworld.title.message": "Message", + "openbuilt.helloworld.title.create": "New message", + "openbuilt.editor.help": "Integrator-only editor: edit the raw JSON manifest. Visual editor lives in chain spec openbuilt-page-editor.", + "/api/objects/:slug/...": "/api/objects/:slug/...", + "/route/:param": "/route/:param", + "Action id (e.g. edit)": "Action id (e.g. edit)", + "Actions": "Actions", + "Add action": "Add action", + "Add child": "Add child", + "Add column": "Add column", + "Add field": "Add field", + "Add layout item": "Add layout item", + "Add menu entry": "Add menu entry", + "Add page": "Add page", + "Add section": "Add section", + "Add tab": "Add tab", + "Add widget": "Add widget", + "Application editor": "Application editor", + "Boolean form (legacy)": "Boolean form (legacy)", + "Cancel": "Cancel", + "Card component (optional)": "Card component (optional)", + "Chat page": "Chat page", + "Columns": "Columns", + "Columns (comma-separated)": "Columns (comma-separated)", + "Component (registry key)": "Component (registry key)", + "Confirm": "Confirm", + "Custom page": "Custom page", + "Dashboard page": "Dashboard page", + "Design": "Design", + "Detail page": "Detail page", + "Drag to reorder": "Drag to reorder", + "Duplicate page ids:": "Duplicate page ids:", + "Each tab declares either a list of widgets OR a component (mutually exclusive).": "Each tab declares either a list of widgets OR a component (mutually exclusive).", + "Enabled": "Enabled", + "Fields": "Fields", + "Files page": "Files page", + "Form page": "Form page", + "Icon": "Icon", + "Index page": "Index page", + "Invalid route(s):": "Invalid route(s):", + "Key": "Key", + "Label": "Label", + "Label (i18n key)": "Label (i18n key)", + "Layout": "Layout", + "Live preview": "Live preview", + "Load an application to start editing.": "Load an application to start editing.", + "Loading…": "Loading…", + "Logs page": "Logs page", + "Maximum nesting depth is two levels.": "Maximum nesting depth is two levels.", + "Menu": "Menu", + "Metadata (@self.*)": "Metadata (@self.*)", + "Method": "Method", + "Mode": "Mode", + "No menu entries yet. Click \"Add menu entry\" to start.": "No menu entries yet. Click \"Add menu entry\" to start.", + "No pages yet. Click \"Add page\" to start.": "No pages yet. Click \"Add page\" to start.", + "No validation errors.": "No validation errors.", + "Not set": "Not set", + "Object form (preferred)": "Object form (preferred)", + "Pages": "Pages", + "Pattern": "Pattern", + "Raw JSON": "Raw JSON", + "Raw JSON tab — integrator fallback. Edits round-trip into the Design tab on commit.": "Raw JSON tab — integrator fallback. Edits round-trip into the Design tab on commit.", + "Remove action": "Remove action", + "Remove child": "Remove child", + "Remove column": "Remove column", + "Remove entry": "Remove entry", + "Remove field": "Remove field", + "Remove layout item": "Remove layout item", + "Remove page": "Remove page", + "Remove section": "Remove section", + "Remove tab": "Remove tab", + "Remove widget": "Remove widget", + "Required": "Required", + "Route and href are ignored when an action is set.": "Route and href are ignored when an action is set.", + "Route params detected:": "Route params detected:", + "Save & open preview": "Save & open preview", + "Schema": "Schema", + "Schema properties": "Schema properties", + "Section id": "Section id", + "Select a page on the left, or add one to start designing.": "Select a page on the left, or add one to start designing.", + "Settings page": "Settings page", + "Show": "Show", + "Sidebar": "Sidebar", + "Sidebar enabled": "Sidebar enabled", + "Structured chat editor (conversationSource OR postUrl one-of plus optional schema) coming in v1.1. For now, edit the raw JSON below or use the Raw JSON tab.": "Structured chat editor (conversationSource OR postUrl one-of plus optional schema) coming in v1.1. For now, edit the raw JSON below or use the Raw JSON tab.", + "Structured custom-page editor (customComponents registry picker + free-form config) coming in v1.1. For now, edit the raw JSON below or use the Raw JSON tab.": "Structured custom-page editor (customComponents registry picker + free-form config) coming in v1.1. For now, edit the raw JSON below or use the Raw JSON tab.", + "Structured files editor (folder picker + allowed-types selector) coming in v1.1. For now, edit the raw JSON below or use the Raw JSON tab.": "Structured files editor (folder picker + allowed-types selector) coming in v1.1. For now, edit the raw JSON below or use the Raw JSON tab.", + "Structured logs editor (register / schema / source / columns) coming in v1.1. For now, edit the raw JSON below or use the Raw JSON tab.": "Structured logs editor (register / schema / source / columns) coming in v1.1. For now, edit the raw JSON below or use the Raw JSON tab.", + "Structured settings editor (section list with fields / component / widgets exactly-one-of) coming in v1.1. For now, edit the raw JSON below or use the Raw JSON tab.": "Structured settings editor (section list with fields / component / widgets exactly-one-of) coming in v1.1. For now, edit the raw JSON below or use the Raw JSON tab.", + "Submit": "Submit", + "Submit label (optional)": "Submit label (optional)", + "Success message (optional)": "Success message (optional)", + "Tab id": "Tab id", + "The parent page route has no :param segment — detail pages typically need one (e.g. /messages/:id).": "The parent page route has no :param segment — detail pages typically need one (e.g. /messages/:id).", + "Title": "Title", + "Type": "Type", + "Unsaved changes": "Unsaved changes", + "Validation": "Validation", + "Widget id": "Widget id", + "Widgets": "Widgets", + "child id": "child id", + "customComponents key": "customComponents key", + "customComponents registry key": "customComponents registry key", + "href URL": "href URL", + "i18n key": "i18n key", + "icon": "icon", + "id (e.g. inbox)": "id (e.g. inbox)", + "label (i18n key)": "label (i18n key)", + "openbuilt.page-designer.menu.error.nesting-depth — menu depth limited to two levels.": "openbuilt.page-designer.menu.error.nesting-depth — menu depth limited to two levels.", + "openbuilt.page-designer.preview.unavailable — chain spec #2 not yet installed. Save and open the built app to preview your changes.": "openbuilt.page-designer.preview.unavailable — chain spec #2 not yet installed. Save and open the built app to preview your changes.", + "page id": "page id", + "route name": "route name", + "sidebarProps.tabs (alternate path)": "sidebarProps.tabs (alternate path)", + "submitEndpoint (URL)": "submitEndpoint (URL)", + "submitHandler (registry key)": "submitHandler (registry key)", + "widget id": "widget id", + "— action —": "— action —", + "— select column —": "— select column —", + "— select page type —": "— select page type —", + "— select register —": "— select register —", + "— select schema —": "— select schema —", + "— target —": "— target —" }, "plurals": "" } diff --git a/l10n/nl.json b/l10n/nl.json index 428836f9..0c9244ef 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -28,7 +28,140 @@ "Settings saved successfully": "Instellingen succesvol opgeslagen", "Saving...": "Opslaan...", "This app needs OpenRegister to store and manage data. Please install OpenRegister from the app store to get started.": "Deze app heeft OpenRegister nodig om gegevens op te slaan en te beheren. Installeer OpenRegister via de app store om te beginnen.", - "User settings will appear here in a future update.": "Gebruikersinstellingen verschijnen hier in een toekomstige update." + "User settings will appear here in a future update.": "Gebruikersinstellingen verschijnen hier in een toekomstige update.", + "Virtual apps": "Virtuele apps", + "No virtual apps yet — seed `hello-world` should appear after install.": "Nog geen virtuele apps — de `hello-world`-seed zou na installatie zichtbaar moeten zijn.", + "Status": "Status", + "Version": "Versie", + "Integrator-only editor: edit the raw JSON manifest below. The visual editor lives in a follow-on release (openbuilt-page-editor).": "Editor voor integrators: bewerk hieronder het ruwe JSON-manifest. De visuele editor komt in een vervolg-release (openbuilt-page-editor).", + "Paste or edit the JSON manifest here. See @conduction/nextcloud-vue/src/schemas/app-manifest.schema.json for the canonical schema.": "Plak of bewerk hier het JSON-manifest. Zie @conduction/nextcloud-vue/src/schemas/app-manifest.schema.json voor het canonieke schema.", + "Invalid manifest": "Ongeldig manifest", + "Saving…": "Opslaan…", + "Open virtual app": "Open virtuele app", + "openbuilt.helloworld.menu.messages": "Berichten", + "openbuilt.helloworld.title.messages": "Hello World — berichten", + "openbuilt.helloworld.title.message": "Bericht", + "openbuilt.helloworld.title.create": "Nieuw bericht", + "openbuilt.editor.help": "Editor voor integrators: bewerk het ruwe JSON-manifest. De visuele editor komt in vervolgspec openbuilt-page-editor.", + "/api/objects/:slug/...": "/api/objects/:slug/...", + "/route/:param": "/route/:param", + "Action id (e.g. edit)": "Actie-id (bv. edit)", + "Actions": "Acties", + "Add action": "Actie toevoegen", + "Add child": "Subitem toevoegen", + "Add column": "Kolom toevoegen", + "Add field": "Veld toevoegen", + "Add layout item": "Lay-out-item toevoegen", + "Add menu entry": "Menu-item toevoegen", + "Add page": "Pagina toevoegen", + "Add section": "Sectie toevoegen", + "Add tab": "Tabblad toevoegen", + "Add widget": "Widget toevoegen", + "Application editor": "Applicatie-editor", + "Boolean form (legacy)": "Booleaanse vorm (verouderd)", + "Cancel": "Annuleren", + "Card component (optional)": "Kaartcomponent (optioneel)", + "Chat page": "Chatpagina", + "Columns": "Kolommen", + "Columns (comma-separated)": "Kolommen (komma-gescheiden)", + "Component (registry key)": "Component (registry-sleutel)", + "Confirm": "Bevestigen", + "Custom page": "Aangepaste pagina", + "Dashboard page": "Dashboardpagina", + "Design": "Ontwerp", + "Detail page": "Detailpagina", + "Drag to reorder": "Sleep om te herordenen", + "Duplicate page ids:": "Dubbele pagina-ID's:", + "Each tab declares either a list of widgets OR a component (mutually exclusive).": "Elk tabblad declareert óf een lijst widgets óf een component (onderling uitsluitend).", + "Enabled": "Ingeschakeld", + "Fields": "Velden", + "Files page": "Bestandenpagina", + "Form page": "Formulierpagina", + "Icon": "Pictogram", + "Index page": "Indexpagina", + "Invalid route(s):": "Ongeldige route(s):", + "Key": "Sleutel", + "Label": "Label", + "Label (i18n key)": "Label (i18n-sleutel)", + "Layout": "Lay-out", + "Live preview": "Live-voorvertoning", + "Load an application to start editing.": "Laad een applicatie om te beginnen met bewerken.", + "Loading…": "Laden…", + "Logs page": "Logspagina", + "Maximum nesting depth is two levels.": "Maximale nestdiepte is twee niveaus.", + "Menu": "Menu", + "Metadata (@self.*)": "Metadata (@self.*)", + "Method": "Methode", + "Mode": "Modus", + "No menu entries yet. Click \"Add menu entry\" to start.": "Nog geen menu-items. Klik op \"Menu-item toevoegen\" om te beginnen.", + "No pages yet. Click \"Add page\" to start.": "Nog geen pagina's. Klik op \"Pagina toevoegen\" om te beginnen.", + "No validation errors.": "Geen validatiefouten.", + "Not set": "Niet ingesteld", + "Object form (preferred)": "Object-vorm (aanbevolen)", + "Pages": "Pagina's", + "Pattern": "Patroon", + "Raw JSON": "Ruwe JSON", + "Raw JSON tab — integrator fallback. Edits round-trip into the Design tab on commit.": "Ruwe JSON-tab — integrator-fallback. Bewerkingen worden bij commit teruggesynchroniseerd met de Ontwerp-tab.", + "Remove action": "Actie verwijderen", + "Remove child": "Subitem verwijderen", + "Remove column": "Kolom verwijderen", + "Remove entry": "Item verwijderen", + "Remove field": "Veld verwijderen", + "Remove layout item": "Lay-out-item verwijderen", + "Remove page": "Pagina verwijderen", + "Remove section": "Sectie verwijderen", + "Remove tab": "Tabblad verwijderen", + "Remove widget": "Widget verwijderen", + "Required": "Verplicht", + "Route and href are ignored when an action is set.": "Route en href worden genegeerd wanneer een actie is ingesteld.", + "Route params detected:": "Route-parameters gedetecteerd:", + "Save & open preview": "Opslaan & voorvertoning openen", + "Schema": "Schema", + "Schema properties": "Schema-eigenschappen", + "Section id": "Sectie-id", + "Select a page on the left, or add one to start designing.": "Selecteer een pagina links of voeg er een toe om te beginnen met ontwerpen.", + "Settings page": "Instellingenpagina", + "Show": "Tonen", + "Sidebar": "Zijbalk", + "Sidebar enabled": "Zijbalk ingeschakeld", + "Structured chat editor (conversationSource OR postUrl one-of plus optional schema) coming in v1.1. For now, edit the raw JSON below or use the Raw JSON tab.": "Gestructureerde chat-editor (conversationSource OF postUrl, plus optioneel schema) komt in v1.1. Bewerk voorlopig de ruwe JSON hieronder of gebruik de Ruwe JSON-tab.", + "Structured custom-page editor (customComponents registry picker + free-form config) coming in v1.1. For now, edit the raw JSON below or use the Raw JSON tab.": "Gestructureerde editor voor aangepaste pagina's (customComponents-registrykiezer + vrije config) komt in v1.1. Bewerk voorlopig de ruwe JSON hieronder of gebruik de Ruwe JSON-tab.", + "Structured files editor (folder picker + allowed-types selector) coming in v1.1. For now, edit the raw JSON below or use the Raw JSON tab.": "Gestructureerde bestanden-editor (mapkiezer + toegestane-typen-selector) komt in v1.1. Bewerk voorlopig de ruwe JSON hieronder of gebruik de Ruwe JSON-tab.", + "Structured logs editor (register / schema / source / columns) coming in v1.1. For now, edit the raw JSON below or use the Raw JSON tab.": "Gestructureerde logs-editor (register / schema / bron / kolommen) komt in v1.1. Bewerk voorlopig de ruwe JSON hieronder of gebruik de Ruwe JSON-tab.", + "Structured settings editor (section list with fields / component / widgets exactly-one-of) coming in v1.1. For now, edit the raw JSON below or use the Raw JSON tab.": "Gestructureerde instellingen-editor (sectielijst met velden / component / widgets, precies één van) komt in v1.1. Bewerk voorlopig de ruwe JSON hieronder of gebruik de Ruwe JSON-tab.", + "Submit": "Versturen", + "Submit label (optional)": "Verstuur-label (optioneel)", + "Success message (optional)": "Succesbericht (optioneel)", + "Tab id": "Tabblad-id", + "The parent page route has no :param segment — detail pages typically need one (e.g. /messages/:id).": "De bovenliggende pagina-route bevat geen :param-segment — detailpagina's hebben er meestal een nodig (bv. /messages/:id).", + "Title": "Titel", + "Type": "Type", + "Unsaved changes": "Niet-opgeslagen wijzigingen", + "Validation": "Validatie", + "Widget id": "Widget-id", + "Widgets": "Widgets", + "child id": "subitem-id", + "customComponents key": "customComponents-sleutel", + "customComponents registry key": "customComponents-registry-sleutel", + "href URL": "href-URL", + "i18n key": "i18n-sleutel", + "icon": "pictogram", + "id (e.g. inbox)": "id (bv. inbox)", + "label (i18n key)": "label (i18n-sleutel)", + "openbuilt.page-designer.menu.error.nesting-depth — menu depth limited to two levels.": "openbuilt.page-designer.menu.error.nesting-depth — menudiepte beperkt tot twee niveaus.", + "openbuilt.page-designer.preview.unavailable — chain spec #2 not yet installed. Save and open the built app to preview your changes.": "openbuilt.page-designer.preview.unavailable — vervolgspec #2 nog niet geïnstalleerd. Sla op en open de gebouwde app om je wijzigingen te zien.", + "page id": "pagina-id", + "route name": "route-naam", + "sidebarProps.tabs (alternate path)": "sidebarProps.tabs (alternatief pad)", + "submitEndpoint (URL)": "submitEndpoint (URL)", + "submitHandler (registry key)": "submitHandler (registry-sleutel)", + "widget id": "widget-id", + "— action —": "— actie —", + "— select column —": "— kies kolom —", + "— select page type —": "— kies paginatype —", + "— select register —": "— kies register —", + "— select schema —": "— kies schema —", + "— target —": "— doel —" }, "plurals": "" } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 4de1bd8b..33008895 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -1,11 +1,13 @@ + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Controller; + +use OCA\OpenBuilt\AppInfo\Application; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\ObjectService; +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; + +/** + * Controller for the OpenBuilt manifest endpoint. + */ +class ApplicationsController extends Controller +{ + /** + * Constructor. + * + * @param IRequest $request The current HTTP request + * @param LoggerInterface $logger PSR logger for diagnostics + * @param ObjectService $objectService OpenRegister object service (hard dep via info.xml) + * @param RegisterMapper $registerMapper Resolves slugs/UUIDs to numeric register IDs + * @param SchemaMapper $schemaMapper Resolves slugs/UUIDs to numeric schema IDs + * + * @return void + */ + public function __construct( + IRequest $request, + private readonly LoggerInterface $logger, + private readonly ObjectService $objectService, + private readonly RegisterMapper $registerMapper, + private readonly SchemaMapper $schemaMapper, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Return the stored manifest JSON blob for a given virtual-app slug. + * + * Lookup path: slug → BuiltAppRoute → applicationUuid → Application → + * manifest. The manifest is returned UNWRAPPED (no OR envelope) so + * useAppManifest in @conduction/nextcloud-vue consumes it directly. + * + * @param string $slug The virtual-app slug from the URL + * + * @return JSONResponse The manifest blob, or a 404 envelope when not found + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function getManifest(string $slug): JSONResponse + { + try { + // Resolve register + schema slugs to numeric IDs. OR's searchObjects + // expects numeric IDs in @self; the slug-resolution shortcut isn't + // applied at this layer (verified during smoke-test 2026-05-11). + // _multitenancy=false bypasses the org filter on the LOOKUP only — + // object-level multitenancy is still enforced via searchObjects below. + $registerId = $this->registerMapper->find('openbuilt', _multitenancy: false)->getId(); + $routeSchema = $this->schemaMapper->find('built-app-route', _multitenancy: false)->getId(); + + // Step 1 — resolve slug → applicationUuid via the BuiltAppRoute index. + // Per OR's ObjectService::searchObjects: query shape is + // { '@self': { register, schema, ... }, : , ... } + // where @self holds metadata filters and direct keys filter JSON-payload fields. + $routeResults = $this->objectService->searchObjects( + query: [ + '@self' => [ + 'register' => $registerId, + 'schema' => $routeSchema, + ], + 'slug' => $slug, + ] + ); + + if (empty($routeResults) === true) { + $this->logger->debug('OpenBuilt: no BuiltAppRoute found for slug='.$slug); + return new JSONResponse( + data: ['error' => 'not_found', 'message' => 'No published virtual app found for slug '.$slug], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + // FindAll renders entities; result entries may be ObjectEntity or arrays. + $route = $this->normaliseObject(object: $routeResults[0]); + $applicationUuid = ($route['applicationUuid'] ?? null); + + if ($applicationUuid === null) { + $this->logger->warning('OpenBuilt: BuiltAppRoute for slug '.$slug.' is missing applicationUuid'); + return new JSONResponse( + data: ['error' => 'inconsistent_state', 'message' => 'Route exists but has no applicationUuid'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + + // Step 2 — load the Application object. + $application = $this->objectService->find( + id: $applicationUuid, + register: 'openbuilt', + schema: 'application' + ); + + if ($application === null) { + $this->logger->warning('OpenBuilt: Application '.$applicationUuid.' (for slug '.$slug.') not found'); + return new JSONResponse( + data: ['error' => 'inconsistent_state', 'message' => 'Route points to an Application that does not exist'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + + $applicationArray = $this->normaliseObject(object: $application); + $manifest = ($applicationArray['manifest'] ?? null); + + if ($manifest === null) { + $this->logger->warning('OpenBuilt: Application '.$applicationUuid.' has no manifest property'); + return new JSONResponse( + data: ['error' => 'no_manifest', 'message' => 'Application has no manifest'], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + // Return the manifest UNWRAPPED — useAppManifest expects the bare object. + return new JSONResponse(data: $manifest, statusCode: Http::STATUS_OK); + } catch (\Throwable $e) { + $this->logger->error('OpenBuilt: getManifest failed for slug '.$slug.': '.$e->getMessage(), ['exception' => $e]); + return new JSONResponse( + data: ['error' => 'internal_error', 'message' => 'Failed to resolve manifest'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try + }//end getManifest() + + /** + * Coerce an OR result entry (ObjectEntity or array) to a plain associative array. + * + * FindAll() and find() may return ObjectEntity instances; we normalise to an + * array so the caller can use array access uniformly. Uses jsonSerialize() + * when present (the canonical ObjectEntity surface). + * + * @param mixed $object The OR object/result entry. + * + * @return array + */ + private function normaliseObject(mixed $object): array + { + if (is_array($object) === true) { + return $object; + } + + if (is_object($object) === true && method_exists($object, 'jsonSerialize') === true) { + $serialised = $object->jsonSerialize(); + if (is_array($serialised) === true) { + return $serialised; + } + } + + if (is_object($object) === true && method_exists($object, 'getObject') === true) { + $inner = $object->getObject(); + if (is_array($inner) === true) { + return $inner; + } + } + + return []; + }//end normaliseObject() +}//end class diff --git a/lib/Controller/DashboardController.php b/lib/Controller/DashboardController.php index b232b26a..c1985bba 100644 --- a/lib/Controller/DashboardController.php +++ b/lib/Controller/DashboardController.php @@ -1,16 +1,18 @@ - * @copyright 2024 Conduction B.V. + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * @version GIT: diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 3694a23b..f1abe536 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -89,7 +89,7 @@ public function create(): JSONResponse */ public function load(): JSONResponse { - $result = $this->settingsService->loadConfiguration(force: true); + $result = $this->settingsService->reloadConfiguration(); return new JSONResponse($result); }//end load() diff --git a/lib/Repair/InitializeSettings.php b/lib/Repair/InitializeSettings.php index 1b72f21e..75cb9b36 100644 --- a/lib/Repair/InitializeSettings.php +++ b/lib/Repair/InitializeSettings.php @@ -77,7 +77,7 @@ public function run(IOutput $output): void } try { - $result = $this->settingsService->loadConfiguration(force: true); + $result = $this->settingsService->reloadConfiguration(); if ($result['success'] === true) { $version = ($result['version'] ?? 'unknown'); diff --git a/lib/Repair/SeedHelloWorld.php b/lib/Repair/SeedHelloWorld.php new file mode 100644 index 00000000..42c6d7c2 --- /dev/null +++ b/lib/Repair/SeedHelloWorld.php @@ -0,0 +1,247 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Repair; + +use OCA\OpenRegister\Service\ObjectService; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; + +/** + * Repair step that seeds the hello-world virtual app + sample messages. + */ +class SeedHelloWorld implements IRepairStep +{ + private const SEED_SLUG = 'hello-world'; + + /** + * Constructor. + * + * @param LoggerInterface $logger Logger for diagnostics + * @param ObjectService $objectService OpenRegister object service (hard dep via info.xml) + * + * @return void + */ + public function __construct( + private LoggerInterface $logger, + private ObjectService $objectService, + ) { + }//end __construct() + + /** + * Get the name of this repair step. + * + * @return string + */ + public function getName(): string + { + return 'Seed the canonical hello-world virtual app and sample messages'; + }//end getName() + + /** + * Run the repair step to seed the hello-world virtual app. + * + * @param IOutput $output The output interface for progress reporting + * + * @return void + */ + public function run(IOutput $output): void + { + $output->info('Seeding hello-world virtual app...'); + + try { + // Idempotency guard — if a hello-world Application already exists, do nothing. + $existing = $this->objectService->findAll( + config: [ + 'filters' => [ + 'register' => 'openbuilt', + 'schema' => 'application', + 'slug' => self::SEED_SLUG, + ], + 'limit' => 1, + ] + ); + + if (empty($existing) === false) { + $output->info('hello-world Application already exists; skipping seed.'); + return; + } + + // Create the Application object with the canonical hello-world manifest. + // NOTE (design.md OQ-1): OR's current x-openregister-lifecycle engine does + // not yet support `on_transition.upsert_relation` as a declarative action + // that creates a sibling object. Until OR ships that hook we explicitly + // create the BuiltAppRoute here. This is the ADR-031 §Exceptions(1) path. + $application = $this->objectService->saveObject( + object: [ + 'slug' => self::SEED_SLUG, + 'name' => 'Hello World', + 'description' => 'The canonical seed virtual app for OpenBuilt. Exercises index + detail + form page types.', + 'version' => '0.1.0', + 'status' => 'published', + 'manifest' => $this->buildHelloWorldManifest(), + ], + register: 'openbuilt', + schema: 'application' + ); + + // ObjectEntity exposes its fields via jsonSerialize() (returns an array + // including the OR-assigned uuid). __call-based getters like getUuid() + // are invisible to method_exists, so we read through the array. + // OR places the canonical uuid under @self.id in the serialized shape. + $applicationData = $application->jsonSerialize(); + $applicationSelf = ($applicationData['@self'] ?? []); + $applicationUuid = ($applicationSelf['id'] ?? ($applicationSelf['uuid'] ?? $applicationData['uuid'] ?? null)); + + $output->info('Created hello-world Application (uuid='.($applicationUuid ?? 'unknown').').'); + + // Explicit BuiltAppRoute upkeep — fallback for the missing lifecycle hook. + if ($applicationUuid !== null) { + $this->objectService->saveObject( + object: [ + 'slug' => self::SEED_SLUG, + 'applicationUuid' => $applicationUuid, + ], + register: 'openbuilt', + schema: 'built-app-route' + ); + $output->info('Created BuiltAppRoute for hello-world.'); + } + + // Seed three sample HelloMessage objects. + foreach ($this->buildSampleMessages() as $message) { + $this->objectService->saveObject( + object: $message, + register: 'openbuilt', + schema: 'hello-message' + ); + } + + $output->info('Seeded three sample HelloMessage objects.'); + + $this->logger->info('OpenBuilt: hello-world virtual app seeded successfully'); + } catch (\Throwable $e) { + $output->warning('Could not seed hello-world: '.$e->getMessage()); + $this->logger->error( + 'OpenBuilt: SeedHelloWorld failed', + ['exception' => $e->getMessage()] + ); + }//end try + }//end run() + + /** + * Build the canonical hello-world manifest. + * + * Per design.md Seed Data: exercises index + detail + form page types + * against the seeded `hello-message` schema. Labels and titles use + * i18n keys consumed by the consuming app's t() (ADR-024 §6, ADR-007). + * + * @return array + */ + private function buildHelloWorldManifest(): array + { + return [ + 'version' => '1.0.0', + 'dependencies' => ['openregister'], + 'menu' => [ + [ + 'id' => 'Messages', + 'label' => 'openbuilt.helloworld.menu.messages', + 'icon' => 'icon-comment', + 'route' => 'Messages', + 'order' => 1, + ], + ], + 'pages' => [ + [ + 'id' => 'Messages', + 'route' => '/', + 'type' => 'index', + 'title' => 'openbuilt.helloworld.title.messages', + 'config' => [ + 'register' => 'openbuilt', + 'schema' => 'hello-message', + 'columns' => ['title', 'body', '@self.created'], + ], + ], + [ + 'id' => 'MessageDetail', + 'route' => '/messages/:id', + 'type' => 'detail', + 'title' => 'openbuilt.helloworld.title.message', + 'config' => [ + 'register' => 'openbuilt', + 'schema' => 'hello-message', + ], + ], + [ + 'id' => 'MessageCreate', + 'route' => '/messages/new', + 'type' => 'form', + 'title' => 'openbuilt.helloworld.title.create', + 'config' => [ + 'register' => 'openbuilt', + 'schema' => 'hello-message', + 'mode' => 'create', + 'submitEndpoint' => '/index.php/apps/openregister/api/objects/openbuilt/hello-message', + ], + ], + ], + ]; + }//end buildHelloWorldManifest() + + /** + * Build the three sample HelloMessage objects. + * + * Bodies are kept under the 150-character line limit for PHPCS. + * + * @return array> + */ + private function buildSampleMessages(): array + { + return [ + [ + 'title' => 'Welcome to OpenBuilt', + 'body' => 'This message is rendered by your first virtual app — built from a JSON manifest stored in OpenRegister.', + ], + [ + 'title' => 'Edit me', + 'body' => 'Open the OpenBuilt shell, find hello-world, and edit its manifest to change what you see here.', + ], + [ + 'title' => 'Built from a manifest', + 'body' => 'Everything here — menu, pages, columns, form — came from a JSON manifest. No PHP was written for hello-world.', + ], + ]; + }//end buildSampleMessages() +}//end class diff --git a/lib/Sections/SettingsSection.php b/lib/Sections/SettingsSection.php index 1a6a5d51..473af6bb 100644 --- a/lib/Sections/SettingsSection.php +++ b/lib/Sections/SettingsSection.php @@ -1,16 +1,18 @@ - * @copyright 2024 Conduction B.V. + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * @version GIT: diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index 9f5c1ee9..2c62bcd5 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -5,11 +5,14 @@ * * Service for managing OpenBuilt application configuration and settings. * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * * @category Service * @package OCA\OpenBuilt\Service * - * @author Conduction Development Team - * @copyright 2024 Conduction B.V. + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * @version GIT: @@ -124,11 +127,39 @@ public function updateSettings(array $data): array /** * Load configuration from openbuilt_register.json via OpenRegister. * - * @param bool $force Force re-import even if already configured. + * Idempotent — relies on OR's ConfigurationService::importFromApp to + * detect already-imported state and short-circuit. Call + * reloadConfiguration() to force a re-import. + * + * @return array Result with success flag, message, and version. + */ + public function loadConfiguration(): array + { + return $this->doLoadConfiguration(force: false); + }//end loadConfiguration() + + /** + * Force a re-import of openbuilt_register.json via OpenRegister, ignoring + * any cached or already-imported state. + * + * Used by the InitializeSettings repair step and the admin "Reload" action. * * @return array Result with success flag, message, and version. */ - public function loadConfiguration(bool $force=false): array + public function reloadConfiguration(): array + { + return $this->doLoadConfiguration(force: true); + }//end reloadConfiguration() + + /** + * Shared implementation of the configuration import — private so the + * boolean flag never reaches the public API. + * + * @param bool $force Whether to force re-import. + * + * @return array + */ + private function doLoadConfiguration(bool $force): array { if ($this->isOpenRegisterAvailable() === false) { $this->logger->warning('OpenBuilt: OpenRegister not available, skipping register initialization'); @@ -138,16 +169,50 @@ public function loadConfiguration(bool $force=false): array ]; } + $configPath = __DIR__.'/../Settings/openbuilt_register.json'; + if (file_exists($configPath) === false) { + $this->logger->error('OpenBuilt: openbuilt_register.json not found at '.$configPath); + return [ + 'success' => false, + 'message' => 'Configuration file openbuilt_register.json not found.', + ]; + } + + $configContent = file_get_contents($configPath); + if ($configContent === false) { + $this->logger->error('OpenBuilt: failed to read openbuilt_register.json'); + return [ + 'success' => false, + 'message' => 'Failed to read configuration file.', + ]; + } + + $configData = json_decode($configContent, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $this->logger->error('OpenBuilt: failed to parse openbuilt_register.json: '.json_last_error_msg()); + return [ + 'success' => false, + 'message' => 'Failed to parse configuration file: '.json_last_error_msg(), + ]; + } + + $configVersion = ($configData['info']['version'] ?? '0.0.0'); + try { $configurationService = $this->container->get('OCA\OpenRegister\Service\ConfigurationService'); - $result = $configurationService->importFromApp(appId: Application::APP_ID, force: $force); + $result = $configurationService->importFromApp( + appId: Application::APP_ID, + data: $configData, + version: $configVersion, + force: $force + ); if (empty($result) === false) { $this->logger->info('OpenBuilt: register configuration imported successfully'); return [ 'success' => true, 'message' => 'Configuration imported successfully.', - 'version' => ($result['version'] ?? 'unknown'), + 'version' => ($result['version'] ?? $configVersion), ]; } @@ -165,5 +230,5 @@ public function loadConfiguration(bool $force=false): array 'message' => $e->getMessage(), ]; }//end try - }//end loadConfiguration() + }//end doLoadConfiguration() }//end class diff --git a/lib/Settings/AdminSettings.php b/lib/Settings/AdminSettings.php index 858d009f..2183ce14 100644 --- a/lib/Settings/AdminSettings.php +++ b/lib/Settings/AdminSettings.php @@ -1,16 +1,18 @@ - * @copyright 2024 Conduction B.V. + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * @version GIT: @@ -25,6 +27,7 @@ use OCA\OpenBuilt\AppInfo\Application; use OCP\App\IAppManager; use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; use OCP\Settings\ISettings; /** @@ -35,10 +38,16 @@ class AdminSettings implements ISettings /** * Constructor. * - * @param IAppManager $appManager The app manager. + * @param IAppManager $appManager The app manager. + * @param IInitialState $initialState The initial-state service used to + * deliver server-side data to the Vue + * bundle (per ADR-004 hard rule + the + * hydra-gate-initial-state mechanical + * gate — do NOT use DOM dataset attrs). */ public function __construct( private readonly IAppManager $appManager, + private readonly IInitialState $initialState, ) { }//end __construct() @@ -51,11 +60,11 @@ public function getForm(): TemplateResponse { $version = $this->appManager->getAppVersion(appId: Application::APP_ID); - return new TemplateResponse( - Application::APP_ID, - 'settings/admin', - ['version' => $version] - ); + // ADR-004 + hydra-gate-initial-state: hand server data to the bundle + // via IInitialState + loadState, not via DOM data-* attributes. + $this->initialState->provideInitialState(key: 'version', data: $version); + + return new TemplateResponse(Application::APP_ID, 'settings/admin'); }//end getForm() /** diff --git a/lib/Settings/openbuilt_register.json b/lib/Settings/openbuilt_register.json index 40a55d6c..a3a29391 100644 --- a/lib/Settings/openbuilt_register.json +++ b/lib/Settings/openbuilt_register.json @@ -9,31 +9,154 @@ "type": "application", "app": "openbuilt", "openregister": "^v0.2.10", - "description": "Citizen-developer app builder for Nextcloud — compose apps from registers, connectors, workflows, and documents without code." + "description": "OpenBuilt register namespace. Hosts the Application + BuiltAppRoute control schemas plus the seed `hello-message` schema for the canonical hello-world virtual app." }, "paths": {}, "components": { "schemas": { - "example": { - "slug": "example", - "icon": "FileDocumentOutline", + "Application": { + "slug": "application", + "icon": "AppsBoxOutline", "version": "0.1.0", - "title": "Example", - "description": "Example schema — replace with your app's actual schemas.", + "title": "Application", + "description": "A virtual app built with OpenBuilt. Holds the manifest blob (per ADR-024) plus metadata and lifecycle. Rendered at runtime by mounting CnAppRoot with the manifest.", "type": "object", - "required": [ - "title" - ], + "required": ["slug", "name", "manifest", "version", "status"], "properties": { - "title": { + "slug": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$", + "minLength": 2, + "maxLength": 48, + "description": "Kebab-case slug; uniquely identifies the virtual app within its organisation. Drives the runtime path /builder/{slug}/*. Max 48 chars so the synthetic appId `openbuilt-{slug}` fits NC appId caps (per design.md OQ-4)." + }, + "name": { "type": "string", - "description": "The title of the example object", - "example": "My example" + "description": "Human-readable name displayed in the OpenBuilt shell." }, "description": { "type": "string", - "description": "An optional description", - "example": "This is an example" + "description": "Optional long-form description of the virtual app." + }, + "manifest": { + "type": "object", + "description": "JSON manifest blob conforming to @conduction/nextcloud-vue/src/schemas/app-manifest.schema.json (v1.4.0+). Per ADR-024, this is consumed by useAppManifest + CnAppRoot at runtime.", + "required": ["version", "menu", "pages"], + "additionalProperties": true + }, + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$", + "description": "Semver of this Application's manifest content. Bumped when the manifest changes substantively." + }, + "status": { + "type": "string", + "enum": ["draft", "published", "archived"], + "default": "draft", + "description": "Lifecycle state. Driven by x-openregister-lifecycle below — not by direct writes." + } + }, + "x-openregister-lifecycle": { + "field": "status", + "initial": "draft", + "states": { + "draft": { + "description": "Editable, not exposed via the manifest endpoint, not reachable at /builder/{slug}." + }, + "published": { + "description": "Exposed via GET /api/applications/{slug}/manifest, reachable at /builder/{slug}/*. The on_transition action upserts the corresponding BuiltAppRoute for fast slug → application lookup." + }, + "archived": { + "description": "No longer reachable. Preserved for restore. The on_transition action removes the BuiltAppRoute." + } + }, + "transitions": [ + { + "name": "publish", + "from": "draft", + "to": "published", + "description": "Make the virtual app reachable at /builder/{slug}.", + "on_transition": { + "upsert_relation": { + "schema": "openbuilt/built-app-route", + "match": { "slug": "@self.slug" }, + "payload": { "slug": "@self.slug", "applicationUuid": "@self.uuid" } + } + } + }, + { + "name": "archive", + "from": "published", + "to": "archived", + "description": "Hide the virtual app without deleting it.", + "on_transition": { + "delete_relation": { + "schema": "openbuilt/built-app-route", + "match": { "slug": "@self.slug" } + } + } + }, + { + "name": "reopen", + "from": "archived", + "to": "draft", + "description": "Restore an archived virtual app to draft for further editing." + }, + { + "name": "republish", + "from": "archived", + "to": "published", + "description": "Restore an archived virtual app directly to published.", + "on_transition": { + "upsert_relation": { + "schema": "openbuilt/built-app-route", + "match": { "slug": "@self.slug" }, + "payload": { "slug": "@self.slug", "applicationUuid": "@self.uuid" } + } + } + } + ] + } + }, + "BuiltAppRoute": { + "slug": "built-app-route", + "icon": "RouterNetwork", + "version": "0.1.0", + "title": "Built App Route", + "description": "Index from slug → applicationUuid. Maintained by the Application lifecycle (upserted on publish, deleted on archive). Used by the runtime to resolve /builder/{slug} → manifest in a single OR lookup. Per ADR-022 this is an explicit flattened index rather than scanning the Application collection.", + "type": "object", + "required": ["slug", "applicationUuid"], + "properties": { + "slug": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$", + "minLength": 2, + "maxLength": 48, + "description": "Matches the Application slug. Unique within the organisation (enforced by lifecycle upkeep + 4xx on conflict)." + }, + "applicationUuid": { + "type": "string", + "format": "uuid", + "description": "UUID of the published Application this route resolves to." + } + } + }, + "HelloMessage": { + "slug": "hello-message", + "icon": "MessageTextOutline", + "version": "0.1.0", + "title": "Hello Message", + "description": "Seed schema for the canonical hello-world virtual app. Three sample messages are seeded on install so the install is testable out of the box.", + "type": "object", + "required": ["title"], + "properties": { + "title": { + "type": "string", + "description": "Short message title." + }, + "body": { + "type": "string", + "description": "Optional longer message body." } } } diff --git a/openspec/app-config.json b/openspec/app-config.json index e57436a6..0d665bb0 100644 --- a/openspec/app-config.json +++ b/openspec/app-config.json @@ -20,6 +20,10 @@ "nextcloudRefs": ["stable31", "stable32"], "enableNewman": false }, + "capabilities": [ + "openbuilt-application-register", + "openbuilt-runtime" + ], "createdAt": "2026-05-11", "updatedAt": "2026-05-11" } diff --git a/openspec/changes/bootstrap-openbuilt/tasks.md b/openspec/changes/bootstrap-openbuilt/tasks.md index 1d72912f..47fe6d75 100644 --- a/openspec/changes/bootstrap-openbuilt/tasks.md +++ b/openspec/changes/bootstrap-openbuilt/tasks.md @@ -1,34 +1,34 @@ ## 1. Implementation Tasks — openbuilt-application-register -- [ ] 1.1 **Declare `Application` schema in `lib/Settings/openbuilt_register.json`** +- [x] 1.1 **Declare `Application` schema in `lib/Settings/openbuilt_register.json`** - spec_ref: REQ-OBA-001, REQ-OBA-002 - files: `lib/Settings/openbuilt_register.json` - acceptance_criteria: Schema declares `uuid`, `slug` (kebab-case pattern), `name` (required), `description`, `manifest` (object, required, with a `$ref` or inline reference to the canonical app-manifest schema), `version` (semver pattern, required), `status` (enum draft|published|archived, default draft, required). Validates against OpenAPI 3.0.0. - Implement: declarative — no PHP service class. - Test: integration test creates an Application via OR REST, asserts schema validation kicks in on a malformed manifest. -- [ ] 1.2 **Add `x-openregister-lifecycle` to the `Application` schema** (canonical ADR-031 example) +- [x] 1.2 **Add `x-openregister-lifecycle` to the `Application` schema** (canonical ADR-031 example) - spec_ref: REQ-OBA-003 - files: `lib/Settings/openbuilt_register.json` (NOT a new PHP service) - acceptance_criteria: Declares states `draft`, `published`, `archived` and transitions `draft → published`, `published → archived`, `archived → draft`. Each transition emits an OR audit event. No `ApplicationLifecycleService.php` file is created. - Implement: declarative schema patch only. - Test: integration test transitions a seeded Application through every allowed state, asserts audit-trail entries exist, asserts a disallowed transition (`draft → archived`) returns 4xx. -- [ ] 1.3 **Declare `BuiltAppRoute` schema and slug uniqueness** +- [x] 1.3 **Declare `BuiltAppRoute` schema and slug uniqueness** - spec_ref: REQ-OBA-004 - files: `lib/Settings/openbuilt_register.json` - acceptance_criteria: Schema declares `slug` (kebab-case, required) and `applicationUuid` (UUID-format, required); slug uniqueness scoped to organisation (declarative if the engine supports it; otherwise documented in design.md OQ-1 as a thin-glue fallback). - Implement: declarative schema patch (and, only if necessary per design.md OQ-1, a single `BuiltAppRouteSyncListener.php` subscribed to OR's lifecycle event). - Test: integration test publishes two Applications with the same slug in the same organisation, asserts the second is rejected. -- [ ] 1.4 **Wire BuiltAppRoute upkeep to the Application lifecycle** +- [x] 1.4 **Wire BuiltAppRoute upkeep to the Application lifecycle** - spec_ref: REQ-OBA-004 - files: `lib/Settings/openbuilt_register.json` (preferred); only if OR's engine is missing the hook, `lib/Listener/BuiltAppRouteSyncListener.php` - acceptance_criteria: Transitioning an Application to `published` creates / refreshes its BuiltAppRoute; transitioning to `archived` removes (or marks inactive) the BuiltAppRoute. Behaviour is identical whether the action is declarative (`x-openregister-lifecycle.on_published`) or listener-based. - Implement: prefer the declarative path; record the chosen path in `hydra.json` under `decisions[]` for self-learning. - Test: integration test asserts the BuiltAppRoute row appears on publish and disappears on archive. -- [ ] 1.5 **Confirm multi-tenant scoping via OR `organisation`** +- [x] 1.5 **Confirm multi-tenant scoping via OR `organisation`** - spec_ref: REQ-OBA-005 - files: `lib/Settings/openbuilt_register.json` (no changes if OR defaults already apply) - acceptance_criteria: Cross-organisation reads return empty / 403 per OR's standard contract. No app-local RBAC code introduced (ADR-022). @@ -37,28 +37,28 @@ ## 2. Implementation Tasks — openbuilt-runtime -- [ ] 2.1 **Register the manifest endpoint route in `appinfo/routes.php`** (ADR-016) +- [x] 2.1 **Register the manifest endpoint route in `appinfo/routes.php`** (ADR-016) - spec_ref: REQ-OBR-001 - files: `appinfo/routes.php` - acceptance_criteria: Route `GET /api/applications/{slug}/manifest` maps to `applications#getManifest` with `#[NoAdminRequired]`. Only registration path is `routes.php` — no attribute-only registration. - Implement: ~5 LOC route declaration. - Test: Newman + Playwright network-request capture verifies the route resolves. -- [ ] 2.2 **Add `ApplicationsController::getManifest`** (thin-glue code per ADR-032) +- [x] 2.2 **Add `ApplicationsController::getManifest`** (thin-glue code per ADR-032) - spec_ref: REQ-OBR-001 - files: `lib/Controller/ApplicationsController.php` - acceptance_criteria: `getManifest(string $slug): JSONResponse` resolves slug → Application via OR's ObjectService and the `BuiltAppRoute` index, returns the `manifest` blob unwrapped (no OR envelope), 200 on hit, 404 on miss. ~15 LOC; carries SPDX + EUPL-1.2 docblock (per memory rule). `#[NoAdminRequired]` attribute is set so route-auth gate-5 passes. - Implement: single method, no service class. - Test: PHPUnit asserts 404 on unknown slug + 200+payload on known slug. -- [ ] 2.3 **Build `BuilderHost.vue` mounting a nested `CnAppRoot`** +- [x] 2.3 **Build `BuilderHost.vue` mounting a nested `CnAppRoot`** - spec_ref: REQ-OBR-002, REQ-OBR-003 - files: `src/views/BuilderHost.vue`, `src/router/index.js` (route registration), `src/manifests/placeholder.json` - acceptance_criteria: Vue route `/builder/:slug(.*)` mounts `BuilderHost.vue`; the host renders ``. Inner-router path forwarding is verified by inspecting `$route.params.pathMatch`. - Implement: ~25 LOC across the SFC ` diff --git a/src/components/page-editor/CustomPageEditor.vue b/src/components/page-editor/CustomPageEditor.vue new file mode 100644 index 00000000..bddce65a --- /dev/null +++ b/src/components/page-editor/CustomPageEditor.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/src/components/page-editor/DashboardPageEditor.vue b/src/components/page-editor/DashboardPageEditor.vue new file mode 100644 index 00000000..7ee6f86b --- /dev/null +++ b/src/components/page-editor/DashboardPageEditor.vue @@ -0,0 +1,78 @@ + + + + + + + diff --git a/src/components/page-editor/DetailPageEditor.vue b/src/components/page-editor/DetailPageEditor.vue new file mode 100644 index 00000000..bceb2dae --- /dev/null +++ b/src/components/page-editor/DetailPageEditor.vue @@ -0,0 +1,302 @@ + + + + + + + diff --git a/src/components/page-editor/FilesPageEditor.vue b/src/components/page-editor/FilesPageEditor.vue new file mode 100644 index 00000000..b09ad765 --- /dev/null +++ b/src/components/page-editor/FilesPageEditor.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/src/components/page-editor/FormPageEditor.vue b/src/components/page-editor/FormPageEditor.vue new file mode 100644 index 00000000..0045659d --- /dev/null +++ b/src/components/page-editor/FormPageEditor.vue @@ -0,0 +1,225 @@ + + + + + + + diff --git a/src/components/page-editor/IndexPageEditor.vue b/src/components/page-editor/IndexPageEditor.vue new file mode 100644 index 00000000..9e51740d --- /dev/null +++ b/src/components/page-editor/IndexPageEditor.vue @@ -0,0 +1,241 @@ + + + + + + + diff --git a/src/components/page-editor/LogsPageEditor.vue b/src/components/page-editor/LogsPageEditor.vue new file mode 100644 index 00000000..7d6913c2 --- /dev/null +++ b/src/components/page-editor/LogsPageEditor.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/src/components/page-editor/MenuTreeEditor.vue b/src/components/page-editor/MenuTreeEditor.vue new file mode 100644 index 00000000..733c929a --- /dev/null +++ b/src/components/page-editor/MenuTreeEditor.vue @@ -0,0 +1,379 @@ + + + + + + + diff --git a/src/components/page-editor/PageListEditor.vue b/src/components/page-editor/PageListEditor.vue new file mode 100644 index 00000000..c03fdfb6 --- /dev/null +++ b/src/components/page-editor/PageListEditor.vue @@ -0,0 +1,312 @@ + + + + + + + diff --git a/src/components/page-editor/SettingsPageEditor.vue b/src/components/page-editor/SettingsPageEditor.vue new file mode 100644 index 00000000..22ff455b --- /dev/null +++ b/src/components/page-editor/SettingsPageEditor.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/src/components/page-editor/StubPageEditor.vue b/src/components/page-editor/StubPageEditor.vue new file mode 100644 index 00000000..d6b50b90 --- /dev/null +++ b/src/components/page-editor/StubPageEditor.vue @@ -0,0 +1,111 @@ + + +