A Nextcloud integration app that provides rich link previews for Basecamp card URLs in Text and Talk documents. When a user pastes a Basecamp card URL and selects "Show link preview", it renders a compact inline widget showing the Basecamp logo, card title, status, column, project, assignees, due date, and comment count.
Use the dev container image from ghcr.io/juliusknorr/nextcloud-dev-php83:latest. The project root is mounted into the container as the app directory.
docker run --rm --name nc-dev -p 8090:80 \
-e SERVER_BRANCH=stable31 \
-v $(pwd):/var/www/html/apps-extra/integration_basecamp \
ghcr.io/juliusknorr/nextcloud-dev-php83:latestDefault login: admin / admin
Use ./nc-dev.sh as a wrapper for docker exec commands:
./nc-dev.sh occ app:enable integration_basecamp
./nc-dev.sh occ app:disable integration_basecamp
./nc-dev.sh log # last 20 log lines
./nc-dev.sh log-basecamp # basecamp-specific log lines
./nc-dev.sh log-errors # errors only
./nc-dev.sh php -r "..." # run PHP code
./nc-dev.sh curl <url> # curl as adminThe Text app is not shipped with the dev container and must be installed for testing link previews:
./nc-dev.sh bash -c "cd /var/www/html/apps && git clone --depth 1 --branch stable31 https://github.com/nextcloud/text.git text && cd text && composer install --no-dev"
./nc-dev.sh occ app:enable textRe-enable the app to pick up manifest changes:
./nc-dev.sh occ app:disable integration_basecamp && ./nc-dev.sh occ app:enable integration_basecampThe app uses Nextcloud's Reference Provider pattern (same as the GitHub and Deck integrations):
BasecampCardReferenceProvidermatches Basecamp card URLs via regexBasecampAPIServicefetches card data from Basecamp API (Bearer token auth with auto-refresh)- A Vue 3 widget (
BasecampCardReferenceWidget.vue) renders the compact inline preview BasecampReferenceListenerinjects the reference JS whenRenderReferenceEventfires
The Text editor does NOT automatically show link previews for pasted URLs. When a standalone link is detected in a paragraph:
- A ⋮ menu appears to the left of the link
- The user can toggle between "Text only" and "Show link preview"
- When "Show link preview" is selected, the Text editor creates a Preview node that uses
NcReferenceListto render our widget
This toggle behavior is controlled by the Text editor (apps/text/src/nodes/Preview.js), not by our app.
- Admin configures Client ID + Client Secret (stored encrypted via
ICrypto) - Users connect via "Connect to Basecamp" in Personal Settings, which initiates the OAuth redirect flow
- Access tokens expire after 14 days; the app automatically refreshes them using the refresh token
- Fallback: if no per-user token exists, the app falls back to an app-level admin token (if set)
- Token exchange and refresh endpoints are at
launchpad.37signals.com/authorization/token
https://3.basecamp.com/{account_id}/buckets/{project_id}/card_tables/cards/{card_id}
Fragments like #__recording_XXXXX are ignored (still matches).
appinfo/info.xml— App manifest (namespace:IntegrationBasecamp)appinfo/routes.php— API routes (config, OAuth redirect, disconnect, Smart Picker API)lib/AppInfo/Application.php— Bootstrap, registers reference providers + event listenerlib/Reference/BasecampCardReferenceProvider.php— URL matching + API resolution → rich objectlib/Reference/BasecampCreateCardReferenceProvider.php— Smart Picker provider for card creationlib/Service/BasecampAPIService.php— Basecamp API client, OAuth token management, auto-refreshlib/Settings/Admin.php— Admin settings (Client ID/Secret, link preview toggle)lib/Settings/Personal.php— Personal settings (Connect/Disconnect Basecamp)lib/Settings/AdminSection.php— Settings section registrationlib/Controller/ConfigController.php— Config endpoints + OAuth callback handlerlib/Controller/BasecampAPIController.php— API endpoints for Smart Picker (projects, columns, card creation)lib/Listener/BasecampReferenceListener.php— Injects reference JS on RenderReferenceEventsrc/reference.js— Registers Vue widget + Smart Picker custom elementsrc/views/BasecampCardReferenceWidget.vue— Compact card preview widgetsrc/views/CreateBasecampCardPicker.vue— Smart Picker card creation dialogsrc/components/AdminSettings.vue— Admin settings form (Client ID/Secret)src/components/PersonalSettings.vue— Personal settings (Connect/Disconnect)
composer install # PHP autoloader
npm install # Frontend dependencies
npm run build # Production build
npm run dev # Development build
npm run watch # Development build with watchAfter changing Vue/JS files, run npm run build. PHP changes are reflected immediately in the container.
- Base URL:
https://3.basecampapi.com/{account_id}/ - Auth:
Authorization: Bearer {token} - Card endpoint:
buckets/{project_id}/card_tables/cards/{card_id}.json - User-Agent header is required by Basecamp API policy
- OAuth tokens expire after 14 days; refresh tokens last ~10 years
- User info endpoint:
https://launchpad.37signals.com/authorization.json - API docs: https://github.com/basecamp/bc3-api
getBody()returns a Stream, not a string. Nextcloud'sIClient(Guzzle wrapper) returnsGuzzleHttp\Psr7\StreamfromgetBody(). Always cast with(string)$response->getBody()before passing tojson_decode().- No
?status=activefilter on projects. The projects endpoint (/projects.json) does NOT accept astatusquery parameter — it will return400 Bad Request. - Card tables are in the project dock. There is no
/card_tables.jsonlisting endpoint. To find card tables, GET the project (/projects/{id}.json) and look fordockentries with"name": "kanban_board". - Columns are embedded in the card table. GET
/buckets/{projectId}/card_tables/{id}.jsonreturns alistsarray containing the columns. - Card creation does not support assignees. POST to
/card_tables/lists/{columnId}/cards.jsononly acceptstitle,content,due_on,notify. To set assignees, follow up with a PUT to/card_tables/cards/{cardId}.jsonwithassignee_ids.
Authorization: https://launchpad.37signals.com/authorization/new?type=web_server&client_id=...&redirect_uri=...
Token exchange: POST https://launchpad.37signals.com/authorization/token?type=web_server&client_id=...&client_secret=...&code=...&redirect_uri=...
Token refresh: POST https://launchpad.37signals.com/authorization/token?type=refresh&refresh_token=...&client_id=...&client_secret=...
The "/" Smart Picker uses registerCustomPickerElement() from @nextcloud/vue/components/NcRichText. Communication between the Vue picker component and the Nextcloud framework happens via DOM CustomEvents:
- Submit:
el.dispatchEvent(new CustomEvent('submit', { bubbles: true, detail: url }))— thedetailvalue (a URL string) gets inserted into the document - Cancel:
el.dispatchEvent(new CustomEvent('cancel', { bubbles: true })) - The
NcCustomPickerRenderResultreturned from the registration callback wraps the element and the Vue app instance (for cleanup viaunmount()) - Picker-only providers must return
false/nullfrommatchReference()andresolveReference(). IfmatchReference()returnstrue, Nextcloud classifies the provider as a URL resolver and excludes it from the "/" picker menu.
- The rich object type
integration_basecamp_cardmust match exactly between PHP (BasecampCardReferenceProvider::RICH_OBJECT_TYPE) and JS (registerWidget('integration_basecamp_card', ...)) - Admin settings JS must be loaded explicitly via
Util::addScript()in theAdmin::getForm()method — Nextcloud does not auto-load it - The
#[PasswordConfirmationRequired]attribute onsetSensitiveAdminConfigensures Client ID/Secret changes require password re-entry - The reference cache prefix uses
$this->userIdso that disconnecting/reconnecting invalidates a user's cached references - The Basecamp API requires a descriptive
User-Agentheader; requests without one will be rejected
- Nextcloud copies app files to
/var/www/html/integration_basecamp/(outsidecustom_apps/). This internal copy is used for serving JS/CSS and is NOT updated when you replace files incustom_apps/. You mustrm -rf /var/www/html/integration_basecamp/and thenocc app:disable+occ app:enableto force Nextcloud to re-create it. occ upgradedoes not reliably detect app-level version changes. Always useapp:disable+app:enableinstead.- The
?v=cache-buster hash in JS URLs is based on the Nextcloud version, not the app version. It does not change when the app is updated. - Version bump must happen before tagging. The version in
info.xmlmust match the git tag — the GitHub Actions workflow builds from the tagged source, and Nextcloud usesinfo.xmlto detect the installed version.
nextcloud/integration_github— The primary reference for the Reference Provider pattern, OAuth flow, and admin/personal settings structure- Nextcloud Deck (
apps-writable/deck/) — Reference for compact inline widget styling; note that Deck uses Vue 2 while this project uses Vue 3