This file provides guidance to all AI agents (Claude, Codex, Gemini, etc.) working with code in this repository.
Nextcloud Tables is a Nextcloud app (PHP backend + Vue.js frontend) that lets users create and manage custom data tables with typed columns, views, sharing, and import/export. It ships a full OCS REST API and integrates with the Nextcloud event, activity, search, and reference systems.
All contributions generated or assisted by this agent must fully comply with:
- AI Contribution Policy - the primary reference for AI-specific rules, covering disclosure, author accountability, communication, security, licensing, code quality, and autonomous agent behavior.
- Contribution Guidelines - covering testing requirements, the Developer Certificate of Origin (DCO), license headers, conventional commits, and translations. These apply in full to all contributions regardless of how they were produced.
- Add an
Assisted-by: AGENT_NAME:MODEL_VERSIONgit trailer to every commit containing AI-assisted content. - Ensure every pull request includes a disclosure of AI tool use in the PR description.
- Produce focused, scoped pull requests that address exactly one concern. Do not touch unrelated files or introduce incidental refactors.
- Verify all dependencies against actual package registries before suggesting them. Do not use hallucinated or unverified package names.
- Explicitly inform the contributor when any action they are about to take, or have taken, would violate the AI Contribution Policy or the Contribution Guidelines. Do not silently proceed. State which rule is at risk and what the contributor should do instead.
- Warn the contributor if a pull request is growing too large. A PR approaching several thousand lines of changed code is a signal that it should be split into smaller, focused PRs. Suggest a logical split before the PR is opened, not after.
- Recommend opening a ticket for discussion before starting implementation whenever a feature or change is sufficiently complex - for example when it touches multiple subsystems, requires architectural decisions, or the right approach is not yet clear. A ticket allows maintainers and the contributor to align on direction before code is written, avoiding wasted effort on a PR that may be rejected or require fundamental rework.
- Open issues, submit pull requests, post review comments, or send security reports autonomously. Every contribution must be reviewed and submitted by a human.
- Add
Signed-off-bytags to commits. Only the human contributor can certify the Developer Certificate of Origin. - Generate or submit security reports without independent human verification. Report verified vulnerabilities via HackerOne, not as GitHub issues.
- Write PR descriptions, review comments, or issue reports on behalf of the contributor. These must be in the contributor's own words.
- Fully automate the resolution of issues labeled
good first issueor similar beginner-friendly labels. - Submit code that has not been reviewed and cleaned up by the contributor. Dead code, redundant logic, excessive comments, and unrelated changes must be removed before submission.
composer install # PHP dependencies
npm install # JS dependencies
npm run watch # Frontend dev build with watchnpm run build # Production JS build
make build # Full production build (JS + PHP assembly)make lint # All linting (PHP, JS, CSS, XML)
make lint-fix # Auto-fix all
composer cs:check # PHP coding standards check
composer cs:fix # PHP coding standards fix
composer psalm # PHP static analysis (Psalm)
npm run lint # ESLint (JS/Vue/TS)
npm run lint:fix # ESLint auto-fix
npm run stylelint # CSS/SCSS linting
npm run stylelint:fix # CSS/SCSS auto-fixmake test # All tests (unit + Behat + Cypress)
composer test # PHP unit tests
composer test:unit:local # PHP unit tests without Nextcloud bootstrap
npm run tests:component # Cypress component tests
npm run test:e2e # Playwright E2E tests
npm run test:e2e:ui # Playwright with interactive UITo run a single PHP test file:
vendor/bin/phpunit --bootstrap tests/unit/bootstrap.php tests/unit/path/to/TestFile.phpcomposer run openapi # Regenerate openapi.json and TS types from PHP annotations
npm run typescript:generate # Regenerate TS types from openapi.json onlyThe app follows the standard Nextcloud layered pattern:
lib/Controller/β OCS REST API controllers.Api1Controller.phpis the monolithic v1 handler; newer controllers (e.g.ApiTablesController,ContextController) are split by resource type. Public (unauthenticated share-token) variants exist alongside standard controllers.lib/Service/β All business logic lives here. Each resource has its own service (TableService, RowService, ColumnService, ViewService, ShareService, ContextService, ImportService, etc.).PermissionsServiceis the central access-control authority.lib/Db/β Doctrine DBAL mappers and entity classes. Row data is stored in typed cell tables (tables_row_cells_text,_number,_datetime, etc.) rather than a single JSON column.Row2Mapperhandles the join/assembly logic.lib/Migration/β Versioned schema migrations (e.g.Version000900Date20250710000000.php).lib/Middleware/βPermissionMiddlewareandShareControlMiddlewareenforce access control before controllers run.lib/Event/+lib/Listener/β Domain events (TableDeletedEvent, RowDeletedEvent, etc.) dispatched through Nextcloud's event system; listeners handle audit logging, user-deletion cleanup, and analytics integration.lib/Model/β Value objects and DTOs (FilterGroup, SortRuleSet, ViewUpdateInput, RowDataInput, TableScheme, ColumnSettings, Permissions).
Routes are defined in appinfo/routes.php (~100 routes covering the full REST API plus Nextcloud UI hooks).
Single-page app built with Vue 2.7, Vue Router 3, and Pinia (state management). Compiled with Vite.
src/store/store.jsβ Primary Pinia store (tables, columns, views, shares, contexts).src/store/data.jsβ Row/cell data state.src/modules/β UI regions:navigation/(left sidebar with contexts/tables),main/(table grid view),sidebar/(right panel),modals/(all dialogs).src/types/β TypeScript interfaces auto-generated fromopenapi.json; do not edit manually.src/shared/β Reusable components and utilities shared across modules.
Supports PostgreSQL, MySQL, and SQLite. The unusual design detail is that row cell values are stored in per-type tables (tables_row_cells_text, tables_row_cells_number, tables_row_cells_datetime, tables_row_cells_selection, tables_row_cells_usergroup) rather than in one polymorphic column, which affects any queries or migrations touching row data.
openapi.json (auto-generated) is the source of truth for the REST API contract. The TypeScript types in src/types/ are derived from it β always regenerate both together with composer run openapi when changing API shapes. Never edit openapi.json or src/types/openapi/openapi.ts by hand; they are generated artifacts.
-
All commits must be signed off (
git commit -s) per the Developer Certificate of Origin (DCO). All PRs targetmaster. Backports use/backport to stable-X.Yin a PR comment. -
Commit messages must follow the Conventional Commits v1.0.0 specification β e.g.
feat(import): support remapping selection options,fix(rows): handle empty cell values on export. -
Every commit made with AI assistance must include the
Assisted-bytrailer mandated by the AI Contribution Policy (see "What this agent must always do" above):Assisted-by: ClaudeCode:claude-sonnet-4-6 Assisted-by: Copilot:gpt-4oGeneral pattern:
Assisted-by: AGENT_NAME:MODEL_VERSIONIf multiple agents or models contributed to a commit, add one trailer per agent/model combination:
Assisted-by: OpenCode:claude-opus-4-5 Assisted-by: OpenCode:claude-sonnet-4-5
- Include a short summary of what changed. Example:
fix: prevent crash on empty todo title. - Pull Request: When the agent creates a PR, it should include a description summarizing the changes and why they were made. If a GitHub issue exists, reference it (e.g., βCloses #123β).
- Do not use decorative section-divider comments of any kind (e.g.
// ββ Title βββ,// ------,// ======). - Every new file must end with exactly one empty trailing line (no more, no less).
- After writing or modifying any PHP code, run the following checks before considering the task complete:
composer run cs:fixβ auto-correct coding-standard violations, then verify withcomposer cs:checkthat no issues remain.composer run psalmβ static analysis; fix every reported type error or logical issue (no suppressions).composer run lintβ PHP syntax check across all source files.
- After writing or modifying any frontend code (Vue, JS, TS, CSS/SCSS), run
npm run devto verify the frontend compiles without errors before considering the task complete.
Apply standard clean-code practices to every file you touch:
- Single responsibility β each class and method does one thing. Split large methods if they handle multiple concerns.
- Meaningful names β variables, parameters, and methods must describe their purpose. Avoid abbreviations and generic names like
$data,$arr, or$tmp. - No dead code β do not leave commented-out code, unused variables, or unreachable branches in the codebase.
- Early returns β prefer guard clauses over deeply nested
if/elsetrees. - Boolean casts β use explicit
(bool)only when a value truly represents a boolean; do not silently coerce unrelated types. - Avoid double negatives β name booleans positively (
isEnabled,hasShares) rather than negatively (isNotDisabled).
Never add @psalm-suppress annotations to work around a type error. A suppression is a red flag that signals the code or its type annotation is wrong. Fix the root cause instead:
- If the return type annotation does not match what the method actually returns, fix the annotation or the implementation.
- Use explicit, closed Psalm array shapes β
array{columnId: int, order: int}β never leave a trailing...in a shape literal. - Do not use
@psalm-suppress MismatchingDocblockReturnType(or any other suppression) just because a Psalm rule is inconvenient to satisfy.
Always use the outline variant of a vue-material-design-icons icon. Import from e.g. ArchiveArrowDownOutline.vue, never the filled variant (ArchiveArrowDown.vue). This keeps the icon style consistent across the app.
Do not implement an explicit isXxx(): bool method on a class that extends Entity (or EntitySuper). The base class handles isXxx calls via __call magic for any protected bool $xxx property. Instead, declare the method in the class-level @method docblock so that static analysis and IDE completion still work:
* @method isArchived(): boolNever build a IQueryBuilder query inside a loop. Construct the query once before the loop using $qb->createParameter('name') as a placeholder for the value that changes per iteration. Inside the loop call $qb->setParameter('name', $value, IQueryBuilder::PARAM_*) to bind the new value. This avoids re-parsing and re-compiling the query on every iteration.
IN clauses must be chunked to at most 1 000 items for Oracle compatibility. Use a named constant (DB_CHUNK_SIZE = 1_000) rather than a magic number. When collecting results across chunks, accumulate into an array and spread with array_merge(...$results) after the loop β never call array_merge inside a loop, as that rebuilds the array on every iteration.
private const DB_CHUNK_SIZE = 1_000;
// ...
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->table)
->where($qb->expr()->in('node_id', $qb->createParameter('chunk')));
$results = [];
foreach (array_chunk($ids, self::DB_CHUNK_SIZE) as $chunk) {
$qb->setParameter('chunk', $chunk, IQueryBuilder::PARAM_INT_ARRAY);
$results[] = $this->findEntities($qb);
}
return array_merge(...$results);When a service constructor gains a new dependency, add a corresponding $this->createMock(NewDependency::class) in every setUp() method that instantiates that service, and pass the mock as the matching constructor argument. Failing to do so causes ArgumentCountError at test runtime.
Every new OCS endpoint must carry:
#[NoAdminRequired]#[RequirePermission(...)]on every method that accesses a resource by ID β not just mutation endpoints. Without it, access is only implicitly enforced by the mapper's SQL filter, which is correct but non-obvious and inconsistent. UsePERMISSION_READfor read-only or soft-state operations (e.g. archive/unarchive); usePERMISSION_MANAGEfor mutations.#[UserRateLimit(limit: 20, period: 60)]for mutation endpoints (seeImportControllerfor the pattern)
When adding or auditing a #[RequirePermission] attribute, also verify the method body and docblock are consistent:
- a
PermissionErrorcatch block returning$this->handlePermissionError($e) Http::STATUS_FORBIDDENin the@returndocblock union type- a
403: No permissionsOpenAPI annotation line
Every controller method must return ->jsonSerialize() directly. Do not add a separate GET round-trip after a create/update/delete β the response body is the authoritative post-mutation state.
After any of the following changes, run composer run openapi to regenerate openapi.json and the TypeScript types in src/types/openapi/openapi.ts. CI fails if either file is stale.
Triggers that require regeneration:
- Adding, removing, or renaming a controller route
- Changing a controller method signature (parameters, return type, or PHPDoc
@param/@returnannotations) - Changing a response shape (adding/removing fields in
ResponseDefinitions.phpor ajsonSerialize()method) - Adding or removing an HTTP status code from a controller method's return type annotation
- Changing a Psalm array-shape type that appears in a public API response
Selection column values are encoded as magic strings: @selection-id-{id} where {id} is the selection option's DB primary key. This format is used by the filter component, the sort evaluator, and any condition-based feature (e.g. conditional formatting). Always use this format β never store or compare bare option labels.
On import/export, selection option IDs change. ColumnService::importColumn() must return a selectionOptionIdMap alongside the new column ID so callers can remap stored option references. Unmapped IDs should be flagged as broken: true rather than silently dropped.
When a stored rule, filter, or condition references a column or option that no longer exists (due to deletion, type change, or import remapping), mark it broken: true instead of deleting it. Surface the broken state in the UI. Provide an auto-clear path that removes the flag when the rule is next saved in a valid state.
- Never use
v-html,innerHTML,eval, ornew Functionwith user-supplied values. - Apply dynamic styles via Vue's
:stylebinding only. - Validate any user-supplied CSS color values on the backend with
/^#[0-9a-fA-F]{3,6}$/before storing. Reject other formats.
Validate structured array input at the controller boundary, before the service layer.
When a controller parameter accepts a structured array (e.g. $columnSettings, $sort), parse and validate it into a typed value object β such as ColumnSettings::createFromInputArray() or SortRuleSet::createFromInputArray() β immediately in the controller, before calling any service method. If the input is invalid, return Http::STATUS_BAD_REQUEST with a descriptive message. Never pass a raw unvalidated array into a service method.
// Good β validate at controller boundary, pass value objects downstream
try {
$columnSettingsObj = $columnSettings !== null
? ColumnSettings::createFromInputArray($columnSettings)
: null;
$sortObj = $sort !== null ? SortRuleSet::createFromInputArray($sort) : null;
} catch (\InvalidArgumentException $e) {
return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
return new DataResponse($this->service->update(..., $columnSettingsObj, $sortObj)->jsonSerialize());Service methods must declare typed value-object parameters, not raw arrays.
TableService::update() and equivalent methods must accept ?ColumnSettings and ?SortRuleSet, not ?array. This makes the contract explicit and prevents the service from receiving unvalidated data.
Value objects must throw, not silently coerce.
fromArray() / __construct() methods on value objects must throw \InvalidArgumentException when required fields are missing or have an incompatible type. Do not add silent casts like (int)$data['columnId'] that accept garbage input without error β that hides bugs and lets invalid data propagate to the database. The correct pattern is to call static::assertRequiredFields($data) (which throws) before casting.