Build a Nextcloud app on the Conduction stack — Part 4: Knowledge + ship
Spin up xWiki locally, author zone-specific knowledge articles, surface them per-desk via an OpenRegister knowledge_article schema, package the app, publish to the Conduction app store.
The final part of the four-part DeskDesk tutorial. Part 3 made bookings appear in NC Calendar with one schema annotation. Part 4 brings external knowledge — zone-specific etiquette, equipment notes, troubleshooting — from xWiki into the desk detail sidebar via a knowledge_article OpenRegister schema. Then we package the app and ship it.
External knowledge integration is the canonical case for OpenConnector: pull data from a system you don't own (xWiki here, but the same applies to Confluence, Notion, SharePoint, Decos, Mendix), surface it inside Nextcloud-native UI without making the user context-switch.
Step 0: Spin up xWiki
The Conduction dev stack already declares an xWiki service under the xwiki profile of apps-extra/openregister/docker-compose.yml:
xwiki:
profiles: [xwiki, integrations]
image: xwiki:lts-postgres-tomcat
container_name: openregister-xwiki
ports: ["8086:8080"]
volumes: ["xwiki-data:/usr/local/xwiki"]
environment:
DB_USER: nextcloud
DB_PASSWORD: "!ChangeMe!"
DB_HOST: db
DB_DATABASE: xwiki
depends_on: [db]
xWiki shares the Postgres container with Nextcloud + OpenRegister, but uses a separate database called xwiki. Create it once:
docker exec openregister-postgres psql -U nextcloud -d postgres -c "CREATE DATABASE xwiki;"
Now start xWiki with the profile:
cd apps-extra/openregister
docker compose --profile xwiki up -d xwiki
The first boot takes about a minute — xWiki initialises its schema in the empty Postgres database and unpacks bundled webapps. Wait for the health check to flip to healthy:
docker ps --filter name=openregister-xwiki --format '{{.Status}}'
# Up 22 seconds (health: starting) <- still booting
# Up 1 minute (healthy) <- ready
Open http://localhost:8086. xWiki redirects to its Distribution Wizard because the empty database has no admin user and no flavor.
Step 0a: Walk the Distribution Wizard
- Continue past the welcome screen.
- Step 1 - Admin user. Fill in:
Click Register and login. You're now signed in asFirst Name: Desk Last Name: Admin Username: admin Password: admin1234 (any 8+ chars works) Confirm: admin1234 Email: admin@deskdesk.localadmin. - Step 2 - Flavor. Click Let the wiki be empty (the bundled Standard Flavor is a ~150 MB download we don't need for this tutorial). Confirm.
- Step 5 - Report lists the pages created (just
HomeandXWiki). Click Continue to land on the Main page.
The wiki is now bootstrapped. Sanity-check the REST endpoint:
curl -u admin:admin1234 -H 'Accept: application/json' \
http://localhost:8086/rest/wikis/xwiki/spaces | head -c 200
# {"links":[],"spaces":[{"links":[...,"id":"xwiki:XWiki", ...}]}
Two things to note about the URL: the REST API lives at /rest, not /xwiki/rest (the latter is the human view path), and basic auth is the default.
Step 1: Author the knowledge articles
For tutorial purposes we use a dedicated parent space DeskDeskKnowledge with one subspace per zone. The folder layout matches the desk's zone enum (east, central, west, north, south) so each desk maps cleanly to its zone's articles.
You can author the articles via xWiki's UI (Create Page → set parent space → write content). Faster path: drive xWiki's REST API directly. The same payload shape works from cURL, OpenConnector, or any HTTP client.
put() {
curl -s -u admin:admin1234 -X PUT \
-H "Content-Type: application/xml" \
-d "<page xmlns=\"http://www.xwiki.org\"><title>$3</title><syntax>xwiki/2.1</syntax><content>$4</content></page>" \
"http://localhost:8086/rest/wikis/xwiki/spaces/DeskDeskKnowledge/spaces/$1/pages/$2" \
-o /dev/null -w "%{http_code} $1/$2\n"
}
put East Etiquette "East zone etiquette" \
"The east windows get direct sun 14:00 to 17:00 in summer. Bring a curtain clip from the supplies cabinet on floor 3. Phone calls are fine in this zone, phonebooths are a 30-second walk away."
put Central MeetingDesks "Central zone meeting desks" \
"Central desks 11 to 14 seat four. The cabling channel under each desk has two HDMI ports and USB-C 100W power. Camera and mic in the ceiling are wired into the in-room Jitsi instance. See the QR code on the desk."
put West QuietRules "West zone quiet rules" \
"The west zone is the designated quiet zone. No calls, no meetings. Keep notifications muted. The kitchen is on the opposite side of the floor for a reason."
Each call returns 201. Browse to http://localhost:8086/xwiki/bin/view/DeskDeskKnowledge/East/Etiquette to see the East article in xWiki's standard view. The same content is also fetchable via REST:
curl -u admin:admin1234 \
http://localhost:8086/rest/wikis/xwiki/spaces/DeskDeskKnowledge/spaces/East/pages
Three short articles. Realistic enough that you'd actually write them in production. The key piece is the subspace name: that's what we'll pivot on from DeskDesk's side.
Why the tutorial walks you through authoring instead of seeding programmatically: in real teams, the wiki articles are organic — written by whoever notices the etiquette gap, edited by the next person, archived when the floorplan changes. The integration we build makes them findable from where the user already is, not the other way around.
Step 2: Add a knowledge_article schema to DeskDesk
Before we sync from xWiki, give OpenRegister a place to put the articles. Add a fourth schema to lib/Settings/deskdesk_register.json:
"knowledge_article": {
"slug": "knowledge_article",
"icon": "BookOpenVariantOutline",
"version": "0.1.0",
"title": "Knowledge article",
"description": "An external knowledge article surfaced in DeskDesk via OpenConnector. Read-only — canonical source lives in xWiki.",
"type": "object",
"required": ["name", "zone"],
"properties": {
"name": { "type": "string" },
"zone": { "type": "string", "enum": ["north", "south", "east", "west", "central"] },
"body": { "type": "string" },
"url": { "type": "string", "format": "uri" },
"externalId": { "type": "string" }
}
}
Important: the JSON key and the slug field must match (knowledge_article on both sides). OpenRegister builds the REST endpoint from the slug, so a mismatch yields Schema not found: 'knowledge_article' at fetch time.
While you're in there, bump the register's info.version to 0.3.0 so the import handler picks the new schema up on next run:
"info": {
"title": "DeskDesk Register",
"description": "Floors, desks, bookings, and knowledge articles.",
"version": "0.3.0"
}
Also seed three articles matching the xWiki content. The seeds make the app demo-able before you wire the sync, and serve as a reference shape for the OpenConnector mapping you'll build in Step 3.
"knowledge-east-etiquette": {
"@self": { "register": "deskdesk", "schema": "knowledge_article", "slug": "knowledge-east-etiquette" },
"name": "East zone etiquette",
"zone": "east",
"body": "The east windows get direct sun 14:00 to 17:00 in summer...",
"url": "http://localhost:8086/xwiki/bin/view/DeskDeskKnowledge/East/Etiquette",
"externalId": "xwiki:DeskDeskKnowledge.East.Etiquette"
}
Add the knowledge_article slug to the small list SettingsService::SCHEMA_SLUGS in lib/Service/SettingsService.php so /api/settings exposes its numeric id alongside the other schemas. Same in src/store/store.js:
const SCHEMAS = ['floor', 'desk', 'booking', 'knowledge_article']
Reload the configuration so OpenRegister picks up the new schema + seeds:
docker exec nextcloud apache2ctl graceful
# then from your Nextcloud session, POST /apps/deskdesk/api/settings/load
# (the deskdesk admin page has a Reload button that does this for you)
Step 3: Create an OpenConnector source for xWiki
The seeds get the UI working immediately. For live data — when an article changes in xWiki you want to see the change in DeskDesk on the next sync — you wire OpenConnector.
Go to /apps/openconnector/sources and create a new source.
Name: xWiki Knowledge
Type: API
Location: http://openregister-xwiki:8080/rest
(container-to-container hostname; from the host you use http://localhost:8086/rest)
Auth: Basic
Username: admin
Password: admin1234
Headers: Accept: application/json
Test: GET /wikis/xwiki/spaces/DeskDeskKnowledge/spaces/East/pages
The Test should return the East article. OpenConnector saves the source with a numeric id; we reference it from the synchronisation in the next step.
Step 4: Define the synchronisation
Sources surface raw API responses; synchronisations map them into OpenRegister objects. Create a synchronisation:
Name: xWiki articles → OpenRegister
Source: xWiki Knowledge (the one you just made)
Source endpoint: /wikis/xwiki/spaces/DeskDeskKnowledge/spaces/{zone}/pages
with zone iterated over ['East', 'Central', 'West']
Target register: deskdesk
Target schema: knowledge_article
Mapping:
title → name
content → body
spaces.last → zone (lowercased)
id → externalId
xwikiAbsoluteUrl → url
Schedule: every 30 minutes
Save and run once. You'll see three knowledge_article objects in OpenRegister whose externalId field starts with xwiki:. Delete the seed objects you added in Step 2 — they're superseded by the synced ones.
Step 5: Surface articles in CnObjectSidebar
The desk-detail page already mounts CnObjectSidebar. To add a Knowledge tab on it, declare a custom tab in the manifest:
{
"id": "desks-detail",
"route": "/desks/:id",
"type": "detail",
"title": "deskdesk.desks.detailTitle",
"config": {
"register": "deskdesk",
"schema": "desk",
"sidebar": {
"tabs": [
{ "id": "data", "label": "deskdesk.sidebar.data", "widgets": [{ "type": "data" }] },
{ "id": "knowledge", "label": "deskdesk.sidebar.knowledge", "component": "knowledge-tab" },
{ "id": "metadata", "label": "deskdesk.sidebar.metadata", "widgets": [{ "type": "metadata" }] }
]
}
}
}
The data and metadata tabs use built-in widget types. The knowledge tab points at a custom component knowledge-tab, which the App registers via the customComponents map.
Create the tab component itself at src/views/KnowledgeTab.vue:
<template>
<div class="knowledge-tab">
<NcLoadingIcon v-if="loading" :size="32" />
<NcEmptyContent
v-else-if="!articles.length"
:name="t('deskdesk', 'No articles for this zone yet')" />
<article v-for="article in articles" :key="article.id" class="knowledge-tab__article">
<header class="knowledge-tab__head">
<h3>{{ article.name }}</h3>
<a v-if="article.url" :href="article.url" target="_blank" rel="noopener noreferrer">
{{ t('deskdesk', 'Open in wiki') }} ↗
</a>
</header>
<p class="knowledge-tab__body">{{ article.body }}</p>
</article>
</div>
</template>
<script>
import { NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
import { useObjectStore } from '../store/store.js'
export default {
name: 'KnowledgeTab',
components: { NcEmptyContent, NcLoadingIcon },
props: {
objectId: { type: String, required: true },
objectType: { type: String, default: 'desk' },
},
setup() { return { objectStore: useObjectStore() } },
data() { return { articles: [], loading: true } },
watch: {
objectId: {
immediate: true,
async handler() {
this.loading = true
try {
const desk = await this.objectStore.fetchObject(this.objectType, this.objectId)
if (!desk?.zone) { this.articles = []; return }
await this.objectStore.fetchCollection('knowledge_article', { zone: desk.zone, _limit: 25 })
this.articles = this.objectStore.collections.knowledge_article || []
} finally { this.loading = false }
},
},
},
}
</script>
Register the component in src/App.vue and pass it to CnAppRoot via the :custom-components prop:
<script>
import KnowledgeTab from './views/KnowledgeTab.vue'
const customComponents = {
'knowledge-tab': KnowledgeTab,
}
export default {
/* ... */
data() {
return { customComponents, /* ... */ }
},
}
</script>
<template>
<CnAppRoot
:app-id="appId"
:manifest="manifest"
:page-types="pageTypes"
:custom-components="customComponents">
<template #sidebar>
<CnObjectSidebar v-if="objectSidebarState.active" ... />
</template>
</CnAppRoot>
</template>
(See the reference repo for the complete file — App.vue also provides objectSidebarState so CnDetailPage's external-sidebar channel works.)
Build, reload, navigate to a 3-East desk's detail URL. The right sidebar shows three tabs — Data, Knowledge, Metadata. Click Knowledge. The "East zone etiquette" article appears, with the body rendered and a link back to the wiki for editing.
Navigate to a 2-West desk: same tab, different article ("West zone quiet rules"). The zone filter does the matching for free; you didn't write per-desk wiring.
Step 6: Package the app
The release process is mechanical:
- Bump the version in
appinfo/info.xml(<version>0.1.0</version>→<version>0.4.0</version>). - Run the production build:
composer install --no-dev --optimize-autoloader npm install --legacy-peer-deps npm run build - Strip unwanted files — vendor dev dependencies, node_modules, tests, openspec changes, .git, .github (your CI is already at the source level). The template ships a
Makefiletarget for this; if not, the production tarball pattern is:tar --exclude=node_modules --exclude=vendor/bin \ --exclude=tests --exclude=openspec/changes \ --exclude=.git --exclude=.github \ -czf deskdesk-0.4.0.tar.gz deskdesk/ - Sign the tarball with the EC key Nextcloud's app store expects (see the Nextcloud docs).
- Push to GitHub with a tag matching the version (
git tag v0.4.0 && git push origin v0.4.0). The template ships a.github/workflows/release.ymlthat picks up the tag, builds the tarball, signs it, and publishes the release.
For the Conduction app store (apps.conduction.nl), the same release pipeline pushes to that endpoint when the secret is configured. See the .github/workflows/release.yml reference.
Step 7: Submit to apps.nextcloud.com (optional)
If you want DeskDesk in the public Nextcloud app store, register at apps.nextcloud.com, upload your signed tarball, fill out the form (description, screenshots, license — EUPL-1.2 is fine — categories organization), and wait for review. The cycle is usually 1–3 days for first-time apps.
What you've built
You started Part 1 with an empty template. Forty-eight steps later, you have:
- A real Nextcloud app with the canonical chassis
- Four OpenRegister schemas with relations and seed data
- A manifest-driven shell with two small wrappers (Index, Detail) that go away once the library wires auto-fetch into the default page types
- Bookings that appear in NC Calendar via a schema annotation
- Knowledge articles from xWiki that appear in the desk-detail sidebar via OpenConnector
- A versioned, packaged, publishable release
Total Vue you wrote: three small files (IndexPageWrapper, DetailPageWrapper, KnowledgeTab) — about 100 lines together. Total PHP: the rename ritual + a path-resolution fix in SettingsService + the schema-slug list. Everything else is JSON.
That ratio — JSON describing what you want, framework rendering it — is the entire point of the Conduction stack. Schemas drive both backend and frontend. Manifests drive the shell. Schema annotations drive cross-app integrations. You declare; the framework renders.
