diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index 0313c4d..9f7c7ea 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -22,5 +22,8 @@ jobs:
enable-eslint: true
enable-phpunit: true
enable-newman: false
+ enable-playwright: true
+ playwright-test-path: tests/e2e
+ playwright-seed-command: "php occ app:disable app-template && php occ app:enable app-template"
additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"main"}]'
enable-sbom: true
diff --git a/.github/workflows/template-setup.yml b/.github/workflows/template-setup.yml
new file mode 100644
index 0000000..0c26b14
--- /dev/null
+++ b/.github/workflows/template-setup.yml
@@ -0,0 +1,135 @@
+# One-time setup workflow — runs when a new repository is created from this template.
+# Replaces all placeholders with the repo name and description, creates branches, then deletes itself.
+
+name: Template Setup
+
+on:
+ create:
+
+jobs:
+ setup:
+ # Only run on the default branch and only if this is a new repo (not a branch creation)
+ if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.ref_type == 'repository'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Derive naming variants
+ id: names
+ run: |
+ # Get repo name (kebab-case)
+ REPO_NAME="${{ github.event.repository.name }}"
+ DESCRIPTION="${{ github.event.repository.description }}"
+
+ # Default description if empty
+ if [ -z "$DESCRIPTION" ]; then
+ DESCRIPTION="A Nextcloud app"
+ fi
+
+ # Derive variants
+ KEBAB="$REPO_NAME"
+ SNAKE=$(echo "$KEBAB" | tr '-' '_')
+ PASCAL=$(echo "$KEBAB" | sed -E 's/(^|-)([a-z])/\U\2/g')
+ DISPLAY=$(echo "$KEBAB" | sed -E 's/(^|-)([a-z])/\U\2/g; s/([A-Z])/ \1/g; s/^ //')
+ UPPER_SNAKE=$(echo "$SNAKE" | tr '[:lower:]' '[:upper:]')
+
+ echo "kebab=$KEBAB" >> "$GITHUB_OUTPUT"
+ echo "snake=$SNAKE" >> "$GITHUB_OUTPUT"
+ echo "pascal=$PASCAL" >> "$GITHUB_OUTPUT"
+ echo "display=$DISPLAY" >> "$GITHUB_OUTPUT"
+ echo "upper_snake=$UPPER_SNAKE" >> "$GITHUB_OUTPUT"
+ echo "description=$DESCRIPTION" >> "$GITHUB_OUTPUT"
+
+ echo "App name: $KEBAB ($PASCAL)"
+ echo "Description: $DESCRIPTION"
+
+ - name: Replace placeholders in all files
+ run: |
+ KEBAB="${{ steps.names.outputs.kebab }}"
+ SNAKE="${{ steps.names.outputs.snake }}"
+ PASCAL="${{ steps.names.outputs.pascal }}"
+ DISPLAY="${{ steps.names.outputs.display }}"
+ UPPER_SNAKE="${{ steps.names.outputs.upper_snake }}"
+ DESCRIPTION="${{ steps.names.outputs.description }}"
+ ORG="${{ github.repository_owner }}"
+
+ # Find all text files to process
+ FILES=$(find . \
+ -not -path './.git/*' \
+ -not -path './node_modules/*' \
+ -not -path './vendor/*' \
+ -not -path './js/*' \
+ -not -path './setup.sh' \
+ -not -name 'template-setup.yml' \
+ -type f \
+ \( -name '*.php' -o -name '*.js' -o -name '*.vue' -o -name '*.json' \
+ -o -name '*.xml' -o -name '*.yml' -o -name '*.yaml' -o -name '*.md' \
+ -o -name '*.css' -o -name '*.neon' -o -name '*.config.js' \))
+
+ for file in $FILES; do
+ # Replace template placeholders
+ sed -i "s|app-template|${KEBAB}|g" "$file"
+ sed -i "s|app_template|${SNAKE}|g" "$file"
+ sed -i "s|AppTemplate|${PASCAL}|g" "$file"
+ sed -i "s|App Template|${DISPLAY}|g" "$file"
+ sed -i "s|APP_TEMPLATE|${UPPER_SNAKE}|g" "$file"
+ sed -i "s|nextcloud-app-template|${KEBAB}|g" "$file"
+ # Update GitHub org references
+ sed -i "s|ConductionNL/nextcloud-app-template|${ORG}/${KEBAB}|g" "$file"
+ sed -i "s|ConductionNL/app-template|${ORG}/${KEBAB}|g" "$file"
+ done
+
+ # Update info.xml description
+ if [ -f "appinfo/info.xml" ]; then
+ sed -i "s|[^<]*|${DESCRIPTION}|" "appinfo/info.xml"
+ fi
+
+ # Rename register JSON
+ if [ -f "lib/Settings/app_template_register.json" ]; then
+ mv "lib/Settings/app_template_register.json" "lib/Settings/${SNAKE}_register.json"
+ fi
+
+ # Update settings mount point
+ if [ -f "templates/settings/admin.php" ]; then
+ sed -i "s|app-template-settings|${KEBAB}-settings|g" "templates/settings/admin.php"
+ fi
+
+ echo "Placeholders replaced successfully"
+
+ - name: Remove setup files
+ run: |
+ rm -f setup.sh
+ rm -f .github/workflows/template-setup.yml
+ echo "Setup files removed"
+
+ - name: Commit changes
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add -A
+ git commit -m "chore: Initialize ${{ steps.names.outputs.display }} from template
+
+ Auto-configured from nextcloud-app-template:
+ - Replaced all placeholders with ${{ steps.names.outputs.kebab }}
+ - Renamed register JSON to ${{ steps.names.outputs.snake }}_register.json
+ - Removed setup script and this workflow"
+
+ - name: Push to main and create branches
+ run: |
+ # Push the initialized main branch
+ git push origin main
+
+ # Create beta and development branches
+ git checkout -b beta
+ git push origin beta
+
+ git checkout -b development
+ git push origin development
+
+ echo "Created branches: main, beta, development"
diff --git a/README.md b/README.md
index b360af8..6db62b8 100644
--- a/README.md
+++ b/README.md
@@ -1,124 +1,232 @@
# Nextcloud App Template
-A starting point for building Nextcloud apps following [ConductionNL](https://github.com/ConductionNL) conventions.
+A starting point for building Nextcloud apps following [ConductionNL](https://github.com/ConductionNL) conventions. Includes a full-stack scaffold with PHP backend, Vue 2 frontend, OpenRegister integration, quality tooling, CI/CD, and automated setup.
-## What's included
+## Getting Started
-- **PHP scaffold** — `Application`, `DashboardController`, `AdminSettings`, `SettingsSection`
-- **Vue 2 + Pinia frontend** — `App.vue`, router, settings store, object store
-- **Admin settings page** — version card + configurable settings form
-- **OpenRegister integration** — pre-wired object store for the OpenRegister data layer
-- **Full quality pipeline** — PHPCS, PHPMD, Psalm, PHPStan, ESLint, Stylelint
-- **CI/CD workflows** — code quality, beta/stable releases, branch protection, OpenSpec sync, issue triage
-- **Custom PHPCS sniff** — enforces named parameters on all internal code calls
+There are two ways to set up a new app from this template:
-## Quick start
+### Option A: Automatic (GitHub Actions)
-### 1. Use this template
+1. Click **Use this template** on GitHub
+2. Name your repository (kebab-case, e.g. `my-cool-app`) and add a description
+3. The **Template Setup** workflow runs automatically and:
+ - Replaces all placeholders with your repo name
+ - Renames the register JSON file
+ - Creates `main`, `beta`, and `development` branches
+ - Deletes the setup script and workflow
-Click **Use this template** on GitHub, or use the `/app-create` skill in the workspace.
+### Option B: Manual (setup script)
-### 2. Replace placeholders
+1. Clone or create from template
+2. Run the interactive setup script:
-| Placeholder | Replace with |
-|---|---|
-| `app-template` | Your app ID (kebab-case, e.g. `my-new-app`) |
-| `app_template` | Snake-case variant (e.g. `my_new_app`) |
-| `AppTemplate` | PascalCase namespace (e.g. `MyNewApp`) |
-| `APP_TEMPLATE` | SCREAMING_SNAKE constant (e.g. `MY_NEW_APP`) |
-| `Nextcloud App Template` | Human-readable name |
-| `A template for creating new Nextcloud apps` | Your app description |
+```bash
+bash setup.sh
+```
+
+The script asks for your app name, description, and author, then replaces all placeholders and renames files. Delete `setup.sh` when done.
-### 3. Install dependencies
+### After Setup
```bash
composer install
npm install
+npm run build
+docker exec nextcloud php occ app:enable your-app-name
```
-### 4. Build frontend
+## What to Customize
-```bash
-npm run dev # development build with watch
-npm run build # production build
+After setup, these are the files you'll typically modify:
+
+| File | What to change |
+|------|---------------|
+| `lib/Settings/*_register.json` | Define your schemas (object types) in OpenAPI 3.0.0 format |
+| `lib/Service/SettingsService.php` | Add config keys for each schema in `CONFIG_KEYS` and `SLUG_TO_CONFIG_KEY` |
+| `src/store/store.js` | Register object types from settings config |
+| `src/router/index.js` | Add routes for your views (list + detail per object type) |
+| `src/navigation/MainMenu.vue` | Add navigation items for your object types |
+| `src/views/` | Add list and detail views for your object types |
+| `lib/Listener/DeepLinkRegistrationListener.php` | Register deep links for your object types |
+| `l10n/en.json` + `l10n/nl.json` | Add translation keys |
+| `appinfo/info.xml` | Update descriptions, categories, screenshots |
+| `openspec/config.yaml` | Update project context for spec-driven development |
+| `project.md` | Document your app's architecture and features |
+
+## Repository Structure
+
+```
+app-template/
+|
+|-- appinfo/ # Nextcloud app metadata
+| |-- info.xml # App manifest (name, version, deps, navigation, settings, repair steps)
+| +-- routes.php # API routes + SPA catch-all
+|
+|-- lib/ # PHP backend
+| |-- AppInfo/
+| | +-- Application.php # Main app class (IBootstrap), registers listeners
+| |-- Controller/
+| | |-- DashboardController.php # Renders the main SPA template
+| | +-- SettingsController.php # Settings API (GET/POST + load/reimport)
+| |-- Service/
+| | +-- SettingsService.php # Config management, OpenRegister import, auto-configure
+| |-- Listener/
+| | +-- DeepLinkRegistrationListener.php # Unified search deep links
+| |-- Repair/
+| | +-- InitializeSettings.php # Post-migration: auto-imports register JSON
+| |-- Settings/
+| | |-- AdminSettings.php # Admin settings page
+| | +-- *_register.json # OpenRegister schema definitions (OpenAPI 3.0.0)
+| +-- Sections/
+| +-- SettingsSection.php # Admin settings section
+|
+|-- src/ # Vue 2 frontend
+| |-- main.js # App entry point (Pinia, router, translations)
+| |-- settings.js # Admin settings entry point
+| |-- pinia.js # Pinia store instance
+| |-- App.vue # Root component (OpenRegister check, sidebar, loading)
+| |-- router/
+| | +-- index.js # Hash-mode router with catch-all
+| |-- store/
+| | |-- store.js # Store initializer (fetches settings, registers object types)
+| | +-- modules/
+| | |-- settings.js # Settings Pinia store (fetch/save config)
+| | +-- object.js # Generic OpenRegister object store (CRUD by type)
+| |-- navigation/
+| | +-- MainMenu.vue # Left sidebar navigation
+| |-- views/
+| | |-- Dashboard.vue # Main dashboard view
+| | +-- settings/
+| | |-- AdminRoot.vue # Admin settings with version card
+| | |-- Settings.vue # Admin config form
+| | +-- UserSettings.vue # Personal preferences dialog
+| +-- assets/
+| +-- app.css # Global styles (CSS variables only)
+|
+|-- templates/ # PHP templates for SPA mounting
+| |-- index.php # Main app: loads JS, renders
+| +-- settings/
+| +-- admin.php # Admin: loads settings JS, renders mount point
+|
+|-- img/ # Icons and screenshots
+| |-- app.svg # Light mode icon
+| |-- app-dark.svg # Dark mode icon
+| +-- app-store.svg # App store listing icon
+|
+|-- l10n/ # Translations
+| |-- en.json # English
+| +-- nl.json # Dutch
+|
+|-- tests/ # PHPUnit tests
+| |-- bootstrap.php # Test bootstrap (OCP autoloader)
+| +-- unit/
+| +-- Controller/
+| +-- SettingsControllerTest.php
+|
+|-- openspec/ # Spec-driven development
+| +-- config.yaml # OpenSpec project configuration
+|
+|-- .github/workflows/ # CI/CD pipelines
+| |-- code-quality.yml # PHPCS, PHPMD, Psalm, PHPStan, ESLint
+| |-- release-stable.yml # Stable release (on main push)
+| |-- release-beta.yml # Beta release (on beta push)
+| |-- branch-protection.yml # PR check enforcement
+| |-- sync-to-beta.yml # Auto-sync development -> beta
+| |-- openspec-sync.yml # Sync specs to project management
+| |-- issue-triage.yml # Auto-triage issues to project board
+| |-- documentation.yml # Build docs site
+| +-- template-setup.yml # One-time setup (deletes itself after run)
+|
+|-- phpcs-custom-sniffs/ # Custom PHPCS rules
+| +-- CustomSniffs/Sniffs/Functions/NamedParametersSniff.php
+|
+|-- setup.sh # Interactive setup script (delete after use)
+|-- project.md # Project architecture documentation
+|-- composer.json # PHP dependencies + quality scripts
+|-- package.json # npm dependencies + build scripts
+|-- webpack.config.js # Webpack 5 config (main + settings entries)
+|-- phpcs.xml # PHPCS standard
+|-- phpmd.xml # PHPMD ruleset
+|-- phpstan.neon # PHPStan config (level 5)
+|-- psalm.xml # Psalm config (error level 4)
+|-- phpunit.xml # PHPUnit config
++-- LICENSE # EUPL-1.2
```
-### 5. Enable in Nextcloud
+## Architecture
+
+### Data Flow
-```bash
-docker exec nextcloud php occ app:enable app-template
```
+User --> Vue Frontend (Pinia stores)
+ |
+ |--> OpenRegister API (object CRUD)
+ |--> Settings API (/api/settings)
+
+Admin --> Settings Page
+ |
+ |--> SettingsController --> SettingsService --> IAppConfig
+ |--> Load/reimport --> ConfigurationService --> *_register.json
+```
+
+### Key Patterns
+
+- **Thin client** — The app owns no database tables. All data is stored via OpenRegister.
+- **Register JSON** — Schema definitions live in `lib/Settings/*_register.json` (OpenAPI 3.0.0 format). They are auto-imported on app install via the `InitializeSettings` repair step.
+- **Deep links** — The `DeepLinkRegistrationListener` registers URL patterns so Nextcloud's unified search links directly to your detail views.
+- **Sidebar state** — `App.vue` provides a reactive `sidebarState` object via Vue's `provide/inject` for the `CnIndexSidebar` component.
+- **Hash-mode router** — All apps use hash-mode routing (`/#/path`) with a catch-all backend route.
+
+### Placeholder Naming Convention
+
+The template uses four naming variants that get replaced during setup:
+
+| Variant | Template Value | Example |
+|---------|---------------|---------|
+| kebab-case | `app-template` | `my-cool-app` |
+| snake_case | `app_template` | `my_cool_app` |
+| PascalCase | `AppTemplate` | `MyCoolApp` |
+| Display | `App Template` | `My Cool App` |
-## Code quality
+## Code Quality
```bash
-composer check:strict # lint + phpcs + phpmd + psalm + phpstan + tests
-composer cs:fix # auto-fix PHPCS issues
-npm run lint # ESLint
-npm run stylelint # Stylelint
-```
+# Full PHP quality suite (PHPCS + PHPMD + Psalm + PHPStan + tests)
+composer check:strict
-## Project structure
+# Quick check (lint + PHPCS + Psalm + tests)
+composer check
-```
-app-template/
-├── appinfo/
-│ ├── info.xml # App metadata
-│ └── routes.php # API + SPA routes
-├── lib/
-│ ├── AppInfo/Application.php
-│ ├── Controller/DashboardController.php
-│ ├── Settings/AdminSettings.php
-│ └── Sections/SettingsSection.php
-├── templates/
-│ ├── index.php # Main SPA shell
-│ └── settings/admin.php # Admin settings shell
-├── src/
-│ ├── main.js # Main app entry
-│ ├── settings.js # Admin settings entry
-│ ├── App.vue # Root component
-│ ├── pinia.js # Pinia instance
-│ ├── router/index.js # Vue Router
-│ ├── store/
-│ │ ├── store.js # Store initializer
-│ │ └── modules/
-│ │ ├── settings.js # Settings Pinia store
-│ │ └── object.js # Generic OpenRegister object store
-│ ├── views/
-│ │ ├── Dashboard.vue
-│ │ └── settings/
-│ │ ├── AdminRoot.vue
-│ │ └── Settings.vue
-│ └── assets/app.css
-├── .github/workflows/ # CI/CD pipelines
-├── phpcs-custom-sniffs/ # Named parameters enforcement
-├── composer.json
-├── package.json
-├── webpack.config.js
-├── psalm.xml
-├── phpstan.neon
-├── phpcs.xml
-└── phpmd.xml
+# Auto-fix PHP code style
+composer cs:fix
+
+# Frontend linting
+npm run lint # ESLint
+npm run lint-fix # ESLint auto-fix
+npm run stylelint # CSS/SCSS linting
```
## Branches
-| Branch | Purpose |
-|---|---|
-| `main` | Stable releases — triggers release workflow |
-| `development` | Active development — auto-syncs to `beta` |
-| `beta` | Beta releases |
-| `documentation` | Docs site (GitHub Pages) |
+| Branch | Purpose | Trigger |
+|--------|---------|---------|
+| `main` | Stable releases | Push triggers release-stable workflow |
+| `beta` | Beta releases | Push triggers release-beta workflow |
+| `development` | Active development | Push auto-syncs to beta |
+| `documentation` | Docs site | Push triggers GitHub Pages build |
## Requirements
- PHP 8.1+
-- Nextcloud 28–33
+- Nextcloud 28-33
- Node.js 20+, npm 10+
- [OpenRegister](https://github.com/ConductionNL/openregister) (runtime dependency)
## License
-EUPL-1.2 — see [LICENSE](LICENSE)
+EUPL-1.2 — see [LICENSE](LICENSE).
+
+Registered on the Nextcloud App Store as AGPL (the store does not recognize EUPL).
**Support:** support@conduction.nl | [conduction.nl](https://conduction.nl)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 6fb1353..183778a 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -2,8 +2,8 @@
app-template
- Nextcloud App Template
- Nextcloud App Template
+ App Template
+ App Template
A template for creating new Nextcloud apps
Een sjabloon voor het maken van nieuwe Nextcloud-apps
Conduction
AppTemplate
- https://github.com/ConductionNL/nextcloud-app-template
- https://github.com/ConductionNL/nextcloud-app-template
- https://github.com/ConductionNL/nextcloud-app-template
+ https://github.com/ConductionNL/app-template
+ https://github.com/ConductionNL/app-template
+ https://github.com/ConductionNL/app-template
tools
- https://github.com/ConductionNL/nextcloud-app-template
- https://github.com/ConductionNL/nextcloud-app-template/discussions
- https://github.com/ConductionNL/nextcloud-app-template/issues
- https://github.com/ConductionNL/nextcloud-app-template
+ https://github.com/ConductionNL/app-template
+ https://github.com/ConductionNL/app-template/discussions
+ https://github.com/ConductionNL/app-template/issues
+ https://github.com/ConductionNL/app-template
- https://raw.githubusercontent.com/ConductionNL/nextcloud-app-template/main/img/app-store.svg
+ https://raw.githubusercontent.com/ConductionNL/app-template/main/img/app-store.svg
@@ -69,4 +71,10 @@ Vrij en open source onder de EUPL-1.2-licentie.
OCA\AppTemplate\Settings\AdminSettings
OCA\AppTemplate\Sections\SettingsSection
+
+
+
+ OCA\AppTemplate\Repair\InitializeSettings
+
+
diff --git a/appinfo/routes.php b/appinfo/routes.php
index 30da406..f0995b5 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -4,18 +4,15 @@
return [
'routes' => [
- // Dashboard + Settings.
+ // Dashboard page.
['name' => 'dashboard#page', 'url' => '/', 'verb' => 'GET'],
+
+ // Settings API.
['name' => 'settings#index', 'url' => '/api/settings', 'verb' => 'GET'],
['name' => 'settings#create', 'url' => '/api/settings', 'verb' => 'POST'],
- ['name' => 'settings#load', 'url' => '/api/settings/load', 'verb' => 'POST'],
-
- // Prometheus metrics endpoint.
- ['name' => 'metrics#index', 'url' => '/api/metrics', 'verb' => 'GET'],
- // Health check endpoint.
- ['name' => 'health#index', 'url' => '/api/health', 'verb' => 'GET'],
+ ['name' => 'settings#load', 'url' => '/api/settings/load', 'verb' => 'POST'],
- // SPA catch-all — serves the Vue app for any frontend route (history mode)
+ // SPA catch-all — serves the Vue app for any frontend route (hash mode fallback).
['name' => 'dashboard#page', 'url' => '/{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], 'defaults' => ['path' => '']],
],
];
diff --git a/l10n/en.json b/l10n/en.json
new file mode 100644
index 0000000..9563c4a
--- /dev/null
+++ b/l10n/en.json
@@ -0,0 +1,34 @@
+{
+ "translations": {
+ "App Template": "App Template",
+ "Dashboard": "Dashboard",
+ "Configuration": "Configuration",
+ "Settings": "Settings",
+ "Save": "Save",
+ "Saving...": "Saving...",
+ "Cancel": "Cancel",
+ "Delete": "Delete",
+ "Edit": "Edit",
+ "Search": "Search",
+ "Loading...": "Loading...",
+ "Register": "Register",
+ "OpenRegister register ID": "OpenRegister register ID",
+ "Settings saved successfully": "Settings saved successfully",
+ "Configuration saved": "Configuration saved",
+ "OpenRegister is required": "OpenRegister is required",
+ "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.",
+ "Install OpenRegister": "Install OpenRegister",
+ "Welcome to App Template": "Welcome to App Template",
+ "Start building your Nextcloud app here.": "Start building your Nextcloud app here.",
+ "Version Information": "Version Information",
+ "Information about the current App Template installation": "Information about the current App Template installation",
+ "Support": "Support",
+ "For support, contact us at": "For support, contact us at",
+ "App Template settings": "App Template settings",
+ "Preferences": "Preferences",
+ "Configure your personal preferences.": "Configure your personal preferences.",
+ "Notifications for changes": "Notifications for changes",
+ "Get notified when items are changed.": "Get notified when items are changed.",
+ "Configure the app settings": "Configure the app settings"
+ }
+}
diff --git a/l10n/nl.json b/l10n/nl.json
new file mode 100644
index 0000000..7c68c5e
--- /dev/null
+++ b/l10n/nl.json
@@ -0,0 +1,34 @@
+{
+ "translations": {
+ "App Template": "App Template",
+ "Dashboard": "Dashboard",
+ "Configuration": "Configuratie",
+ "Settings": "Instellingen",
+ "Save": "Opslaan",
+ "Saving...": "Opslaan...",
+ "Cancel": "Annuleren",
+ "Delete": "Verwijderen",
+ "Edit": "Bewerken",
+ "Search": "Zoeken",
+ "Loading...": "Laden...",
+ "Register": "Register",
+ "OpenRegister register ID": "OpenRegister register-ID",
+ "Settings saved successfully": "Instellingen succesvol opgeslagen",
+ "Configuration saved": "Configuratie opgeslagen",
+ "OpenRegister is required": "OpenRegister is vereist",
+ "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.",
+ "Install OpenRegister": "OpenRegister installeren",
+ "Welcome to App Template": "Welkom bij App Template",
+ "Start building your Nextcloud app here.": "Begin hier met het bouwen van je Nextcloud-app.",
+ "Version Information": "Versie-informatie",
+ "Information about the current App Template installation": "Informatie over de huidige App Template-installatie",
+ "Support": "Ondersteuning",
+ "For support, contact us at": "Voor ondersteuning, neem contact op via",
+ "App Template settings": "App Template-instellingen",
+ "Preferences": "Voorkeuren",
+ "Configure your personal preferences.": "Configureer je persoonlijke voorkeuren.",
+ "Notifications for changes": "Meldingen bij wijzigingen",
+ "Get notified when items are changed.": "Ontvang een melding wanneer items worden gewijzigd.",
+ "Configure the app settings": "Configureer de app-instellingen"
+ }
+}
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index 3aa1da1..e9e0a7c 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -21,6 +21,8 @@
namespace OCA\AppTemplate\AppInfo;
+use OCA\OpenRegister\Event\DeepLinkRegistrationEvent;
+use OCA\AppTemplate\Listener\DeepLinkRegistrationListener;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
@@ -52,12 +54,11 @@ public function __construct()
*/
public function register(IRegistrationContext $context): void
{
- // Register your event listeners and services here.
- // Example:
- // $context->registerEventListener(
- // event: SomeEvent::class,
- // listener: SomeListener::class
- // );
+ // Register deep link listener for Nextcloud unified search integration.
+ $context->registerEventListener(
+ event: DeepLinkRegistrationEvent::class,
+ listener: DeepLinkRegistrationListener::class
+ );
}//end register()
/**
diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php
new file mode 100644
index 0000000..0c197a4
--- /dev/null
+++ b/lib/Controller/SettingsController.php
@@ -0,0 +1,99 @@
+
+ * @copyright 2024 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\AppTemplate\Controller;
+
+use OCA\AppTemplate\AppInfo\Application;
+use OCA\AppTemplate\Service\SettingsService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+
+/**
+ * Controller for managing AppTemplate application settings.
+ */
+class SettingsController extends Controller
+{
+ /**
+ * Constructor for the SettingsController.
+ *
+ * @param IRequest $request The request object
+ * @param SettingsService $settingsService The settings service
+ *
+ * @return void
+ */
+ public function __construct(
+ IRequest $request,
+ private SettingsService $settingsService,
+ ) {
+ parent::__construct(appName: Application::APP_ID, request: $request);
+ }//end __construct()
+
+ /**
+ * Retrieve all current settings.
+ *
+ * @NoAdminRequired
+ *
+ * @return JSONResponse
+ */
+ public function index(): JSONResponse
+ {
+ return new JSONResponse(
+ [
+ 'success' => true,
+ 'config' => $this->settingsService->getSettings(),
+ ]
+ );
+ }//end index()
+
+ /**
+ * Update settings with provided data.
+ *
+ * @return JSONResponse
+ */
+ public function create(): JSONResponse
+ {
+ $data = $this->request->getParams();
+ $config = $this->settingsService->updateSettings($data);
+
+ return new JSONResponse(
+ [
+ 'success' => true,
+ 'config' => $config,
+ ]
+ );
+ }//end create()
+
+ /**
+ * Re-import the configuration from app_template_register.json.
+ *
+ * Forces a fresh import regardless of version, auto-configuring
+ * all schema and register IDs from the import result.
+ *
+ * @return JSONResponse
+ */
+ public function load(): JSONResponse
+ {
+ $result = $this->settingsService->loadConfiguration(force: true);
+
+ return new JSONResponse($result);
+ }//end load()
+}//end class
diff --git a/lib/Listener/DeepLinkRegistrationListener.php b/lib/Listener/DeepLinkRegistrationListener.php
new file mode 100644
index 0000000..5e9ed94
--- /dev/null
+++ b/lib/Listener/DeepLinkRegistrationListener.php
@@ -0,0 +1,58 @@
+
+ * @copyright 2024 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\AppTemplate\Listener;
+
+use OCA\OpenRegister\Event\DeepLinkRegistrationEvent;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+
+/**
+ * Registers AppTemplate's deep link URL patterns with OpenRegister's search provider.
+ *
+ * When a user searches in Nextcloud's unified search, results for AppTemplate schemas
+ * will link directly to AppTemplate's detail views.
+ */
+class DeepLinkRegistrationListener implements IEventListener
+{
+ /**
+ * Handle the deep link registration event.
+ *
+ * @param Event $event The event to handle
+ *
+ * @return void
+ */
+ public function handle(Event $event): void
+ {
+ if ($event instanceof DeepLinkRegistrationEvent === false) {
+ return;
+ }
+
+ // Register deep links for each object type.
+ // Update the register slug, schema slug, and URL template to match your app.
+ $event->register(
+ appId: 'app-template',
+ registerSlug: 'app-template',
+ schemaSlug: 'example',
+ urlTemplate: '/apps/app-template/#/examples/{uuid}'
+ );
+ }//end handle()
+}//end class
diff --git a/lib/Repair/InitializeSettings.php b/lib/Repair/InitializeSettings.php
new file mode 100644
index 0000000..2e91764
--- /dev/null
+++ b/lib/Repair/InitializeSettings.php
@@ -0,0 +1,101 @@
+
+ * @copyright 2024 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\AppTemplate\Repair;
+
+use OCA\AppTemplate\Service\SettingsService;
+use OCP\Migration\IOutput;
+use OCP\Migration\IRepairStep;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Repair step that initializes AppTemplate configuration via ConfigurationService.
+ */
+class InitializeSettings implements IRepairStep
+{
+ /**
+ * Constructor for InitializeSettings.
+ *
+ * @param SettingsService $settingsService The settings service
+ * @param LoggerInterface $logger The logger interface
+ *
+ * @return void
+ */
+ public function __construct(
+ private SettingsService $settingsService,
+ private LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Get the name of this repair step.
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'Initialize App Template register and schemas via ConfigurationService';
+ }//end getName()
+
+ /**
+ * Run the repair step to initialize AppTemplate configuration.
+ *
+ * @param IOutput $output The output interface for progress reporting
+ *
+ * @return void
+ */
+ public function run(IOutput $output): void
+ {
+ $output->info('Initializing App Template configuration...');
+
+ if ($this->settingsService->isOpenRegisterAvailable() === false) {
+ $output->warning(
+ 'OpenRegister is not installed or enabled. Skipping auto-configuration.'
+ );
+ $this->logger->warning(
+ 'AppTemplate: OpenRegister not available, skipping register initialization'
+ );
+ return;
+ }
+
+ try {
+ $result = $this->settingsService->loadConfiguration(force: true);
+
+ if ($result['success'] === true) {
+ $version = ($result['version'] ?? 'unknown');
+ $output->info(
+ 'App Template configuration imported successfully (version: '.$version.')'
+ );
+ } else {
+ $message = ($result['message'] ?? 'unknown error');
+ $output->warning(
+ 'App Template configuration import issue: '.$message
+ );
+ }
+ } catch (\Throwable $e) {
+ $output->warning('Could not auto-configure App Template: '.$e->getMessage());
+ $this->logger->error(
+ 'AppTemplate initialization failed',
+ ['exception' => $e->getMessage()]
+ );
+ }//end try
+ }//end run()
+}//end class
diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php
new file mode 100644
index 0000000..654a2d3
--- /dev/null
+++ b/lib/Service/SettingsService.php
@@ -0,0 +1,307 @@
+
+ * @copyright 2024 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\AppTemplate\Service;
+
+use OCA\AppTemplate\AppInfo\Application;
+use OCP\IAppConfig;
+use OCP\App\IAppManager;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Service for managing AppTemplate application configuration and settings.
+ */
+class SettingsService
+{
+ /**
+ * Configuration keys managed by this app.
+ *
+ * Add your own config keys here — one per schema/object type plus the register.
+ */
+ private const CONFIG_KEYS = [
+ 'register',
+ 'example_schema',
+ ];
+
+ /**
+ * Mapping of schema slugs (from app_template_register.json) to app config keys.
+ *
+ * When OpenRegister imports the register JSON, it creates schema objects with slugs.
+ * This map connects those slugs to the IAppConfig keys above.
+ */
+ private const SLUG_TO_CONFIG_KEY = [
+ 'example' => 'example_schema',
+ ];
+
+ private const OPENREGISTER_APP_ID = 'openregister';
+
+ /**
+ * Constructor for the SettingsService.
+ *
+ * @param IAppConfig $appConfig The app configuration service
+ * @param IAppManager $appManager The app manager service
+ * @param ContainerInterface $container The DI container
+ * @param LoggerInterface $logger The logger interface
+ *
+ * @return void
+ */
+ public function __construct(
+ private IAppConfig $appConfig,
+ private IAppManager $appManager,
+ private ContainerInterface $container,
+ private LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Check if OpenRegister is installed and enabled.
+ *
+ * @return bool
+ */
+ public function isOpenRegisterAvailable(): bool
+ {
+ return $this->appManager->isEnabledForUser(self::OPENREGISTER_APP_ID);
+ }//end isOpenRegisterAvailable()
+
+ /**
+ * Load the register configuration from app_template_register.json via ConfigurationService.
+ *
+ * @param bool $force Whether to force re-import regardless of version
+ *
+ * @return array Import result
+ */
+ public function loadConfiguration(bool $force=false): array
+ {
+ if ($this->isOpenRegisterAvailable() === false) {
+ return [
+ 'success' => false,
+ 'message' => 'OpenRegister is not installed or enabled',
+ ];
+ }
+
+ try {
+ $configurationService = $this->container->get(
+ 'OCA\OpenRegister\Service\ConfigurationService'
+ );
+ } catch (\Exception $e) {
+ $this->logger->error(
+ 'AppTemplate: Could not access ConfigurationService',
+ ['exception' => $e->getMessage()]
+ );
+ return [
+ 'success' => false,
+ 'message' => 'Could not access ConfigurationService: '.$e->getMessage(),
+ ];
+ }
+
+ $configPath = __DIR__.'/../Settings/app_template_register.json';
+ if (file_exists($configPath) === false) {
+ $this->logger->error(
+ 'AppTemplate: Configuration file not found at '.$configPath
+ );
+ return [
+ 'success' => false,
+ 'message' => 'Configuration file not found',
+ ];
+ }
+
+ $configContent = file_get_contents($configPath);
+ $configData = json_decode($configContent, true);
+
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ $this->logger->error('AppTemplate: Invalid JSON in configuration file');
+ return [
+ 'success' => false,
+ 'message' => 'Invalid JSON in configuration file',
+ ];
+ }
+
+ $configVersion = ($configData['info']['version'] ?? '0.0.0');
+
+ try {
+ $importResult = $configurationService->importFromApp(
+ appId: Application::APP_ID,
+ data: $configData,
+ version: $configVersion,
+ force: $force,
+ );
+
+ $this->logger->info(
+ 'AppTemplate: Configuration imported successfully',
+ ['version' => $configVersion]
+ );
+
+ // Auto-configure schema IDs from import result.
+ $configuredCount = $this->autoConfigureAfterImport(importResult: $importResult);
+
+ return [
+ 'success' => true,
+ 'message' => 'Configuration imported and auto-configured ('.$configuredCount.' schemas mapped)',
+ 'version' => $configVersion,
+ 'configured' => $configuredCount,
+ 'result' => $importResult,
+ ];
+ } catch (\Exception $e) {
+ $this->logger->error(
+ 'AppTemplate: Configuration import failed',
+ ['exception' => $e->getMessage()]
+ );
+ return [
+ 'success' => false,
+ 'message' => 'Import failed: '.$e->getMessage(),
+ ];
+ }//end try
+ }//end loadConfiguration()
+
+ /**
+ * Get all current settings as an associative array.
+ *
+ * @return array
+ */
+ public function getSettings(): array
+ {
+ $config = [];
+ foreach (self::CONFIG_KEYS as $key) {
+ $config[$key] = $this->appConfig->getValueString(Application::APP_ID, $key, '');
+ }
+
+ return $config;
+ }//end getSettings()
+
+ /**
+ * Update settings with the provided data.
+ *
+ * @param array $data The settings data to update
+ *
+ * @return array
+ */
+ public function updateSettings(array $data): array
+ {
+ foreach (self::CONFIG_KEYS as $key) {
+ if (isset($data[$key]) === true) {
+ $this->appConfig->setValueString(Application::APP_ID, $key, (string) $data[$key]);
+ }
+ }
+
+ $this->logger->info('AppTemplate settings updated', ['keys' => array_keys($data)]);
+
+ return $this->getSettings();
+ }//end updateSettings()
+
+ /**
+ * Get a single configuration value by key.
+ *
+ * @param string $key The configuration key
+ * @param string $default The default value if key not found
+ *
+ * @return string
+ */
+ public function getConfigValue(string $key, string $default=''): string
+ {
+ return $this->appConfig->getValueString(Application::APP_ID, $key, $default);
+ }//end getConfigValue()
+
+ /**
+ * Set a single configuration value.
+ *
+ * @param string $key The configuration key
+ * @param string $value The value to set
+ *
+ * @return void
+ */
+ public function setConfigValue(string $key, string $value): void
+ {
+ $this->appConfig->setValueString(Application::APP_ID, $key, $value);
+ }//end setConfigValue()
+
+ /**
+ * Auto-configure schema and register IDs from the import result.
+ *
+ * @param array $importResult The result from ConfigurationService::importFromApp()
+ *
+ * @return int The number of schemas successfully configured
+ */
+ private function autoConfigureAfterImport(array $importResult): int
+ {
+ $configuredCount = 0;
+
+ // Configure register ID from imported registers.
+ $registers = ($importResult['registers'] ?? []);
+ foreach ($registers as $register) {
+ if (is_object($register) === false) {
+ continue;
+ }
+
+ $registerId = (string) $register->getId();
+ $this->appConfig->setValueString(
+ Application::APP_ID,
+ 'register',
+ $registerId
+ );
+ $this->logger->info(
+ 'AppTemplate: Auto-configured register ID',
+ ['registerId' => $registerId]
+ );
+ break;
+ }
+
+ // Configure schema IDs from imported schemas.
+ $schemas = ($importResult['schemas'] ?? []);
+ foreach ($schemas as $schema) {
+ if (is_object($schema) === false) {
+ continue;
+ }
+
+ $slug = $schema->getSlug();
+ if (isset(self::SLUG_TO_CONFIG_KEY[$slug]) === false) {
+ continue;
+ }
+
+ $configKey = self::SLUG_TO_CONFIG_KEY[$slug];
+ $schemaId = (string) $schema->getId();
+
+ $this->appConfig->setValueString(
+ Application::APP_ID,
+ $configKey,
+ $schemaId
+ );
+
+ $this->logger->debug(
+ 'AppTemplate: Auto-configured schema',
+ [
+ 'slug' => $slug,
+ 'configKey' => $configKey,
+ 'schemaId' => $schemaId,
+ ]
+ );
+
+ $configuredCount++;
+ }//end foreach
+
+ $this->logger->info(
+ 'AppTemplate: Auto-configuration complete',
+ ['configuredSchemas' => $configuredCount]
+ );
+
+ return $configuredCount;
+ }//end autoConfigureAfterImport()
+}//end class
diff --git a/lib/Settings/app_template_register.json b/lib/Settings/app_template_register.json
new file mode 100644
index 0000000..9502d85
--- /dev/null
+++ b/lib/Settings/app_template_register.json
@@ -0,0 +1,48 @@
+{
+ "openapi": "3.0.0",
+ "info": {
+ "title": "App Template Register",
+ "description": "Register containing all schemas for the App Template application. Replace this with your own schemas.",
+ "version": "0.1.0"
+ },
+ "x-openregister": {
+ "type": "application",
+ "app": "app-template",
+ "openregister": "^v0.2.10",
+ "description": "A template for creating new Nextcloud apps"
+ },
+ "paths": {},
+ "components": {
+ "schemas": {
+ "example": {
+ "slug": "example",
+ "icon": "FileDocumentOutline",
+ "version": "1.0.0",
+ "x-schema-org-type": "schema:Thing",
+ "title": "Example",
+ "description": "An example object type. Replace this with your own schema definition.",
+ "type": "object",
+ "required": [
+ "title"
+ ],
+ "properties": {
+ "title": {
+ "type": "string",
+ "maxLength": 255,
+ "description": "Name of this item"
+ },
+ "description": {
+ "type": "string",
+ "description": "Detailed description"
+ },
+ "status": {
+ "type": "string",
+ "enum": ["draft", "active", "archived"],
+ "default": "draft",
+ "description": "Current status"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..0e5a2cf
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ ./tests/unit
+
+
+
+
+ lib/
+
+
+
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..250a958
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,26 @@
+import { defineConfig } from '@playwright/test'
+
+const baseURL = process.env.NEXTCLOUD_URL || 'http://localhost:8080'
+
+export default defineConfig({
+ testDir: './tests/e2e',
+ timeout: 30_000,
+ retries: 1,
+ use: {
+ baseURL,
+ storageState: './tests/e2e/.auth/user.json',
+ screenshot: 'only-on-failure',
+ trace: 'on-first-retry',
+ },
+ globalSetup: './tests/e2e/global-setup.ts',
+ projects: [
+ {
+ name: 'chromium',
+ use: { browserName: 'chromium' },
+ },
+ ],
+ reporter: [
+ ['html', { open: 'never' }],
+ ['list'],
+ ],
+})
diff --git a/project.md b/project.md
new file mode 100644
index 0000000..5f07aed
--- /dev/null
+++ b/project.md
@@ -0,0 +1,71 @@
+# App Template
+
+## Overview
+
+A template for creating new Nextcloud apps following ConductionNL conventions. Replace this description with your app's purpose.
+
+## Tech Stack
+
+- **Backend:** PHP 8.1+, Nextcloud 28-33
+- **Frontend:** Vue 2.7, Pinia 2.1, Vue Router 3.6, Webpack 5
+- **UI Components:** @nextcloud/vue 8.x, @conduction/nextcloud-vue
+- **Data Layer:** OpenRegister (JSON object storage with schema validation)
+- **Quality:** PHPCS, PHPMD, Psalm, PHPStan, ESLint, Stylelint
+- **CI/CD:** GitHub Actions (quality, release, branch protection)
+
+## Architecture
+
+**Pattern:** Thin client on OpenRegister — this app owns no database tables.
+
+- Frontend Vue stores query the OpenRegister API directly
+- Backend is minimal: settings controller + configuration import
+- Register/schema definitions in `lib/Settings/app_template_register.json`
+- Auto-imported via repair step on app install/enable
+
+## Key Directories
+
+```
+appinfo/ — App metadata, routes
+lib/ — PHP backend (controllers, services, listeners, repair)
+src/ — Vue frontend (views, store, router, navigation)
+templates/ — PHP template shells for SPA mounting
+img/ — App icons and screenshots
+l10n/ — Translations (en, nl)
+tests/ — PHPUnit tests
+openspec/ — OpenSpec configuration and specs
+```
+
+## Development
+
+```bash
+# Install dependencies
+composer install
+npm install
+
+# Build frontend
+npm run build # production
+npm run dev # development
+npm run watch # watch mode
+
+# Quality checks
+composer check:strict # Full PHP quality suite
+npm run lint # ESLint
+npm run stylelint # CSS linting
+
+# Tests (inside Nextcloud container)
+docker exec -w /var/www/html/custom_apps/app-template nextcloud php vendor/bin/phpunit -c phpunit.xml
+```
+
+## Features
+
+- [ ] Dashboard
+- [ ] Admin settings with OpenRegister configuration
+- [ ] User settings
+- [ ] Deep link search integration
+- [ ] Example object type CRUD
+
+## Standards
+
+- NL Design System (CSS variables, WCAG AA)
+- Schema.org type annotations
+- OpenRegister data layer (no custom DB tables)
diff --git a/setup.sh b/setup.sh
new file mode 100755
index 0000000..b82dcc2
--- /dev/null
+++ b/setup.sh
@@ -0,0 +1,208 @@
+#!/bin/bash
+#
+# Interactive setup script for ConductionNL Nextcloud App Template
+#
+# Replaces all template placeholders with your app's details.
+# Run once after creating a new repository from this template.
+#
+# Usage: bash setup.sh
+#
+
+set -euo pipefail
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+BLUE='\033[0;34m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+echo -e "${BLUE}================================${NC}"
+echo -e "${BLUE} ConductionNL App Setup${NC}"
+echo -e "${BLUE}================================${NC}"
+echo ""
+
+# --- Gather information ---
+
+# Try to detect from git remote
+DETECTED_NAME=""
+REMOTE_URL=$(git remote get-url origin 2>/dev/null || echo "")
+if [[ -n "$REMOTE_URL" ]]; then
+ DETECTED_NAME=$(echo "$REMOTE_URL" | sed -E 's|.*/([^/]+)(\.git)?$|\1|')
+fi
+
+# App name (kebab-case)
+echo -e "${YELLOW}App name (kebab-case, e.g. 'my-cool-app'):${NC}"
+if [[ -n "$DETECTED_NAME" && "$DETECTED_NAME" != "nextcloud-app-template" ]]; then
+ echo -e " Detected from git remote: ${GREEN}${DETECTED_NAME}${NC}"
+ read -rp " App name [$DETECTED_NAME]: " APP_NAME
+ APP_NAME="${APP_NAME:-$DETECTED_NAME}"
+else
+ read -rp " App name: " APP_NAME
+fi
+
+if [[ -z "$APP_NAME" ]]; then
+ echo -e "${RED}Error: App name is required.${NC}"
+ exit 1
+fi
+
+# Validate kebab-case
+if [[ ! "$APP_NAME" =~ ^[a-z][a-z0-9-]*$ ]]; then
+ echo -e "${RED}Error: App name must be kebab-case (lowercase letters, numbers, hyphens).${NC}"
+ exit 1
+fi
+
+# Description
+echo ""
+echo -e "${YELLOW}Short description (one line, for info.xml summary):${NC}"
+read -rp " Description: " APP_DESCRIPTION
+
+if [[ -z "$APP_DESCRIPTION" ]]; then
+ APP_DESCRIPTION="A Nextcloud app"
+fi
+
+# Dutch description
+echo ""
+echo -e "${YELLOW}Dutch description (leave empty to skip):${NC}"
+read -rp " Beschrijving: " APP_DESCRIPTION_NL
+
+if [[ -z "$APP_DESCRIPTION_NL" ]]; then
+ APP_DESCRIPTION_NL="Een Nextcloud-app"
+fi
+
+# Author
+echo ""
+echo -e "${YELLOW}Author name [Conduction]:${NC}"
+read -rp " Author: " APP_AUTHOR
+APP_AUTHOR="${APP_AUTHOR:-Conduction}"
+
+# Author email
+echo ""
+echo -e "${YELLOW}Author email [info@conduction.nl]:${NC}"
+read -rp " Email: " APP_EMAIL
+APP_EMAIL="${APP_EMAIL:-info@conduction.nl}"
+
+# --- Derive naming variants ---
+
+# kebab-case: my-cool-app (already have this)
+APP_KEBAB="$APP_NAME"
+
+# snake_case: my_cool_app
+APP_SNAKE=$(echo "$APP_KEBAB" | tr '-' '_')
+
+# PascalCase: MyCoolApp
+APP_PASCAL=$(echo "$APP_KEBAB" | sed -E 's/(^|-)([a-z])/\U\2/g')
+
+# Display Name (from kebab, capitalize words)
+APP_DISPLAY=$(echo "$APP_KEBAB" | sed -E 's/(^|-)([a-z])/\U\2/g; s/([A-Z])/ \1/g; s/^ //')
+
+echo ""
+echo -e "${BLUE}--- Naming variants ---${NC}"
+echo -e " kebab-case: ${GREEN}${APP_KEBAB}${NC}"
+echo -e " snake_case: ${GREEN}${APP_SNAKE}${NC}"
+echo -e " PascalCase: ${GREEN}${APP_PASCAL}${NC}"
+echo -e " Display: ${GREEN}${APP_DISPLAY}${NC}"
+echo -e " Description: ${GREEN}${APP_DESCRIPTION}${NC}"
+echo -e " Author: ${GREEN}${APP_AUTHOR} <${APP_EMAIL}>${NC}"
+echo ""
+
+read -rp "Proceed with these values? [Y/n]: " CONFIRM
+CONFIRM="${CONFIRM:-Y}"
+if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
+ echo "Aborted."
+ exit 0
+fi
+
+echo ""
+echo -e "${BLUE}Replacing placeholders...${NC}"
+
+# --- Replacement function ---
+replace_in_file() {
+ local file="$1"
+ if [[ -f "$file" ]]; then
+ # Template placeholders
+ sed -i "s|app-template|${APP_KEBAB}|g" "$file"
+ sed -i "s|app_template|${APP_SNAKE}|g" "$file"
+ sed -i "s|AppTemplate|${APP_PASCAL}|g" "$file"
+ sed -i "s|App Template|${APP_DISPLAY}|g" "$file"
+ sed -i "s|APP_TEMPLATE|$(echo "$APP_SNAKE" | tr '[:lower:]' '[:upper:]')|g" "$file"
+ # Also handle the nextcloud-app-template repo name
+ sed -i "s|nextcloud-app-template|${APP_KEBAB}|g" "$file"
+ fi
+}
+
+# --- Process all files ---
+
+# Find all text files (skip .git, node_modules, vendor, build artifacts)
+FILES=$(find . \
+ -not -path './.git/*' \
+ -not -path './node_modules/*' \
+ -not -path './vendor/*' \
+ -not -path './js/*' \
+ -not -path './build/*' \
+ -not -path './setup.sh' \
+ -type f \
+ \( -name '*.php' -o -name '*.js' -o -name '*.vue' -o -name '*.json' \
+ -o -name '*.xml' -o -name '*.yml' -o -name '*.yaml' -o -name '*.md' \
+ -o -name '*.css' -o -name '*.neon' -o -name '*.config.js' \))
+
+for file in $FILES; do
+ replace_in_file "$file"
+ echo -e " ${GREEN}✓${NC} $file"
+done
+
+# --- Rename the register JSON file ---
+if [[ -f "lib/Settings/app_template_register.json" ]]; then
+ mv "lib/Settings/app_template_register.json" "lib/Settings/${APP_SNAKE}_register.json"
+ echo -e " ${GREEN}✓${NC} Renamed register JSON to ${APP_SNAKE}_register.json"
+ # Update the reference in SettingsService.php
+ sed -i "s|${APP_SNAKE}_register.json|${APP_SNAKE}_register.json|g" "lib/Service/SettingsService.php"
+fi
+
+# --- Update descriptions in info.xml ---
+if [[ -f "appinfo/info.xml" ]]; then
+ sed -i "s|.*|${APP_DESCRIPTION}|" "appinfo/info.xml"
+ sed -i "s|.*|${APP_DESCRIPTION_NL}|" "appinfo/info.xml"
+ sed -i "s|[^<]*|${APP_AUTHOR}|" "appinfo/info.xml"
+ echo -e " ${GREEN}✓${NC} Updated info.xml descriptions and author"
+fi
+
+# --- Update composer.json ---
+if [[ -f "composer.json" ]]; then
+ # The name replacement is already handled by the general replacements
+ echo -e " ${GREEN}✓${NC} Updated composer.json"
+fi
+
+# --- Update GitHub URLs ---
+ORG="ConductionNL"
+GITHUB_URLS_FILES=$(find . -type f \( -name '*.xml' -o -name '*.md' -o -name '*.yaml' -o -name '*.yml' \) \
+ -not -path './.git/*' -not -path './node_modules/*' -not -path './vendor/*')
+for file in $GITHUB_URLS_FILES; do
+ if [[ -f "$file" ]]; then
+ sed -i "s|ConductionNL/nextcloud-app-template|${ORG}/${APP_KEBAB}|g" "$file"
+ sed -i "s|ConductionNL/app-template|${ORG}/${APP_KEBAB}|g" "$file"
+ fi
+done
+echo -e " ${GREEN}✓${NC} Updated GitHub URLs"
+
+# --- Update settings mount point in templates ---
+if [[ -f "templates/settings/admin.php" ]]; then
+ sed -i "s|app-template-settings|${APP_KEBAB}-settings|g" "templates/settings/admin.php"
+fi
+
+echo ""
+echo -e "${GREEN}================================${NC}"
+echo -e "${GREEN} Setup complete!${NC}"
+echo -e "${GREEN}================================${NC}"
+echo ""
+echo -e "Next steps:"
+echo -e " 1. Review the changes: ${BLUE}git diff${NC}"
+echo -e " 2. Install dependencies: ${BLUE}composer install && npm install${NC}"
+echo -e " 3. Build the frontend: ${BLUE}npm run build${NC}"
+echo -e " 4. Update the register JSON with your schemas: ${BLUE}lib/Settings/${APP_SNAKE}_register.json${NC}"
+echo -e " 5. Add your object types in: ${BLUE}src/store/store.js${NC}"
+echo -e " 6. Add navigation items in: ${BLUE}src/navigation/MainMenu.vue${NC}"
+echo -e " 7. Add routes in: ${BLUE}src/router/index.js${NC}"
+echo -e " 8. Update translations in: ${BLUE}l10n/en.json${NC} and ${BLUE}l10n/nl.json${NC}"
+echo -e " 9. Delete this setup script: ${BLUE}rm setup.sh${NC}"
+echo ""
diff --git a/src/App.vue b/src/App.vue
index 44e762e..1d9ee35 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -23,20 +23,22 @@
-
-
-
-
-
-
-
-
-
+
+
@@ -47,9 +49,11 @@
diff --git a/src/navigation/MainMenu.vue b/src/navigation/MainMenu.vue
new file mode 100644
index 0000000..71e8cdd
--- /dev/null
+++ b/src/navigation/MainMenu.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/router/index.js b/src/router/index.js
index 4fdb249..1ae31f9 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -1,15 +1,18 @@
import Vue from 'vue'
import Router from 'vue-router'
-import { generateUrl } from '@nextcloud/router'
import Dashboard from '../views/Dashboard.vue'
+import AdminRoot from '../views/settings/AdminRoot.vue'
Vue.use(Router)
export default new Router({
- mode: 'history',
- base: generateUrl('/apps/app-template'),
+ mode: 'hash',
routes: [
{ path: '/', name: 'Dashboard', component: Dashboard },
+ { path: '/settings', name: 'Settings', component: AdminRoot },
+ // Add your routes here, for example:
+ // { path: '/examples', name: 'Examples', component: ExampleList },
+ // { path: '/examples/:id', name: 'ExampleDetail', component: ExampleDetail, props: route => ({ exampleId: route.params.id }) },
{ path: '*', redirect: '/' },
],
})
diff --git a/src/store/store.js b/src/store/store.js
index 6e29bed..598bd12 100644
--- a/src/store/store.js
+++ b/src/store/store.js
@@ -1,4 +1,3 @@
-import { generateUrl } from '@nextcloud/router'
import { useObjectStore } from './modules/object.js'
import { useSettingsStore } from './modules/settings.js'
@@ -6,12 +5,19 @@ export async function initializeStores() {
const settingsStore = useSettingsStore()
const objectStore = useObjectStore()
- objectStore.configure({
- baseUrl: generateUrl('/apps/openregister/api/objects'),
- schemaBaseUrl: generateUrl('/apps/openregister/api/schemas'),
- })
+ const config = await settingsStore.fetchSettings()
- await settingsStore.fetchSettings()
+ if (config) {
+ // Register object types from settings.
+ // Each object type maps a config key to an OpenRegister schema + register.
+ if (config.register && config.example_schema) {
+ objectStore.registerObjectType('example', config.example_schema, config.register)
+ }
+ // Add more object types here as you add schemas to your register JSON:
+ // if (config.register && config.another_schema) {
+ // objectStore.registerObjectType('another', config.another_schema, config.register)
+ // }
+ }
return { settingsStore, objectStore }
}
diff --git a/src/views/settings/UserSettings.vue b/src/views/settings/UserSettings.vue
new file mode 100644
index 0000000..41ec7f1
--- /dev/null
+++ b/src/views/settings/UserSettings.vue
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+ {{ t('app-template', 'Configure your personal preferences.') }}
+
+
+
+ updateSetting('notify_changes', v)">
+ {{ t('app-template', 'Notifications for changes') }}
+
+
+ {{ t('app-template', 'Get notified when items are changed.') }}
+
+
+
+
+
+
+
+
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..0e114d8
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,48 @@
+
+ * @copyright 2024 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);
+
+// Define that we're running PHPUnit.
+define('PHPUNIT_RUN', 1);
+
+// Include Composer's autoloader.
+require_once __DIR__ . '/../vendor/autoload.php';
+
+// Register OCP/NCU classes from nextcloud/ocp package.
+// nextcloud/ocp has no autoload section in its composer.json, so we register it manually.
+spl_autoload_register(function (string $class): void {
+ $prefixMap = [
+ 'OCP\\' => __DIR__ . '/../vendor/nextcloud/ocp/OCP/',
+ 'NCU\\' => __DIR__ . '/../vendor/nextcloud/ocp/NCU/',
+ ];
+
+ foreach ($prefixMap as $prefix => $dir) {
+ if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
+ continue;
+ }
+
+ $relative = str_replace(search: '\\', replace: '/', subject: substr($class, strlen($prefix)));
+ $file = $dir . $relative . '.php';
+ if (file_exists($file) === true) {
+ require_once $file;
+ }
+
+ break;
+ }//end foreach
+
+});
diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore
new file mode 100644
index 0000000..976ce5b
--- /dev/null
+++ b/tests/e2e/.gitignore
@@ -0,0 +1,3 @@
+.auth/
+test-results/
+playwright-report/
diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts
new file mode 100644
index 0000000..b3199ed
--- /dev/null
+++ b/tests/e2e/global-setup.ts
@@ -0,0 +1,26 @@
+import { chromium, type FullConfig } from '@playwright/test'
+import * as fs from 'fs'
+import * as path from 'path'
+
+async function globalSetup(config: FullConfig) {
+ const baseURL = config.projects[0].use.baseURL || 'http://localhost:8080'
+ const authDir = path.join(__dirname, '.auth')
+
+ if (!fs.existsSync(authDir)) {
+ fs.mkdirSync(authDir, { recursive: true })
+ }
+
+ const browser = await chromium.launch()
+ const page = await browser.newPage()
+
+ await page.goto(`${baseURL}/login`)
+ await page.getByRole('textbox', { name: 'Account name or email' }).fill('admin')
+ await page.getByRole('textbox', { name: 'Password' }).fill('admin')
+ await page.getByRole('button', { name: 'Log in', exact: true }).click()
+ await page.waitForURL('**/apps/**')
+
+ await page.context().storageState({ path: path.join(authDir, 'user.json') })
+ await browser.close()
+}
+
+export default globalSetup
diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts
new file mode 100644
index 0000000..e94ddba
--- /dev/null
+++ b/tests/e2e/smoke.spec.ts
@@ -0,0 +1,17 @@
+import { test, expect } from '@playwright/test'
+
+// Replace 'app-template' with your app ID from appinfo/info.xml
+const APP_ID = 'app-template'
+
+test.describe('Smoke tests', () => {
+ test('app loads without server errors', async ({ page }) => {
+ const response = await page.goto(`/apps/${APP_ID}/`)
+ expect(response?.status()).toBeLessThan(500)
+ })
+
+ test('sidebar navigation is visible', async ({ page }) => {
+ await page.goto(`/apps/${APP_ID}/`)
+ const nav = page.locator('#app-navigation, [role="navigation"]').first()
+ await expect(nav).toBeVisible()
+ })
+})
diff --git a/tests/flows/README.md b/tests/flows/README.md
new file mode 100644
index 0000000..4fc5f4c
--- /dev/null
+++ b/tests/flows/README.md
@@ -0,0 +1,22 @@
+# Test Flows
+
+This directory contains markdown test flow files for LLM-based testing.
+
+Test flows describe user journeys in structured markdown that can be executed by:
+- **LLM test agents** (via `/test-app` or persona testers)
+- **Human testers** following the steps manually
+
+## Naming Convention
+
+Files are numbered and named by user journey:
+- `01-dashboard.md` — Dashboard page verification
+- `02-sidebar-navigation.md` — Navigation structure
+- `03-{feature}.md` — Feature-specific flows
+
+## Structure
+
+Each flow file contains:
+- **Persona** — Who is performing the test
+- **Preconditions** — Required state before testing
+- **Steps** — Numbered actions with expected results
+- **Verification** — What to check after the flow completes
diff --git a/tests/unit/Controller/SettingsControllerTest.php b/tests/unit/Controller/SettingsControllerTest.php
new file mode 100644
index 0000000..a7ce55e
--- /dev/null
+++ b/tests/unit/Controller/SettingsControllerTest.php
@@ -0,0 +1,120 @@
+
+ * @copyright 2024 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\AppTemplate\Tests\Unit\Controller;
+
+use OCA\AppTemplate\Controller\SettingsController;
+use OCA\AppTemplate\Service\SettingsService;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Tests for SettingsController.
+ */
+class SettingsControllerTest extends TestCase
+{
+
+ /**
+ * The controller under test.
+ *
+ * @var SettingsController
+ */
+ private SettingsController $controller;
+
+ /**
+ * Mock IRequest.
+ *
+ * @var IRequest&MockObject
+ */
+ private IRequest&MockObject $request;
+
+ /**
+ * Mock SettingsService.
+ *
+ * @var SettingsService&MockObject
+ */
+ private SettingsService&MockObject $settingsService;
+
+ /**
+ * Set up test fixtures.
+ *
+ * @return void
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->request = $this->createMock(IRequest::class);
+ $this->settingsService = $this->createMock(SettingsService::class);
+
+ $this->controller = new SettingsController(
+ request: $this->request,
+ settingsService: $this->settingsService,
+ );
+
+ }//end setUp()
+
+ /**
+ * Test that index() returns a JSONResponse with success and config keys.
+ *
+ * @return void
+ */
+ public function testIndexReturnsJsonResponseWithExpectedKeys(): void
+ {
+ $this->settingsService->expects($this->once())
+ ->method('getSettings')
+ ->willReturn(['register' => 'test-register']);
+
+ $result = $this->controller->index();
+
+ self::assertInstanceOf(JSONResponse::class, $result);
+ self::assertTrue($result->getData()['success']);
+ self::assertArrayHasKey('config', $result->getData());
+
+ }//end testIndexReturnsJsonResponseWithExpectedKeys()
+
+ /**
+ * Test that create() calls updateSettings with request params and returns success.
+ *
+ * @return void
+ */
+ public function testCreateCallsUpdateSettingsAndReturnsSuccess(): void
+ {
+ $params = ['register' => 'new-register'];
+
+ $this->request->expects($this->once())
+ ->method('getParams')
+ ->willReturn($params);
+
+ $this->settingsService->expects($this->once())
+ ->method('updateSettings')
+ ->with($params)
+ ->willReturn($params);
+
+ $result = $this->controller->create();
+
+ self::assertInstanceOf(JSONResponse::class, $result);
+ self::assertTrue($result->getData()['success']);
+ self::assertArrayHasKey('config', $result->getData());
+
+ }//end testCreateCallsUpdateSettingsAndReturnsSuccess()
+
+}//end class