Everything needed to work on the plugin itself: the repo layout, the local test instance, the architectural constraints that make the code look the way it does, and the release process.
main.go the entire plugin — models, permissions, CRUD, HTTP handlers,
migrations, event listener, UI routes
ui/ the management UI: index.html + app.js, vanilla JS, no build
scripts/ test-instance helpers (run-test-env.sh)
compose.test.yml test instance: stock Vikunja image, plugin mounted live
config.test.yml test config: yaegi loader enabled, whitelist, testing token
db/ test instance SQLite database (bind mount, git-ignored)
docs/
├── PRD.md product requirements, motivation, design principles
├── installation.md admin-facing setup guide
├── api-reference.md the REST API reference
├── stories/ the S1–S9 story breakdown, dependency graph, progress
└── superpowers/ per-story design specs and implementation plans
.github/workflows/ release.yml — packages the plugin zip on v* tags
There is no build step: the plugin is interpreted at runtime by Vikunja's yaegi loader from the mounted source. A Go toolchain is not needed to develop it — only to work on the fork itself.
A Docker-based Vikunja instance with the plugin mounted live. From the repo root:
./scripts/run-test-env.sh # start, wait healthy, seed a user, print a JWT
docker compose -f compose.test.yml restart # apply edits to main.go / ui/
docker compose -f compose.test.yml down # stop (DB persists in ./db, wiped on next start)- The container runs the fork's image with the repo bind-mounted at
/app/vikunja/plugins/custom-fields— no recompile, restart to reload. - Confirm loads (or errors) with
docker compose -f compose.test.yml logs | grep -i "loaded plugin". - The SQLite database is directly readable on the host:
sqlite3 db/vikunja.db. config.test.ymlenables theservice.testingtokenseed endpoint for fixture data (PUT /api/v2/test/{table}with the raw token from that file — authorization is the raw token string, not a JWT).- Port (4176), image tag, and secrets are defined in
compose.test.ymlandconfig.test.yml— those files are the authority.
compose.test.override.yml builds the fork image locally instead of pulling,
for frontend work that runs alongside plugin work.
The plugin is a single package main file interpreted by yaegi. The moving
parts:
- Data model — five xorm tables created by one migration
(
20260829160000-create-custom-field-tables):custom_field_definitions,custom_field_values,custom_field_options,custom_field_value_options,custom_field_projects. Vikunja runs plugin migrations automatically on startup, after core migrations and beforeInit(). The migration is modified in place while the feature is unreleased (theproject_viewsprecedent); once in production, schema changes become append-only migrations. - Permissions —
CanCreate/CanRead/CanUpdate/CanDeleteon definitions (whitelist-gated viaIsManager) and on values (delegating to the host'smodels.Task.CanRead/CanUpdate). Deny-by-default: an empty whitelist denies everyone. - Validation — pure functions (
validateDefinition,validateValue,validateAssignment) with no DB access; option IDs are resolved separately byresolveOptionIDs. - CRUD — model methods take an
*xorm.Sessionand never open or commit it; handlers own the session (db.NewSession(),defer s.Close(), explicitCommit()), which is what makes the bulk value write atomic. - Handlers — thin: parse → permission check → model call → commit → JSON. Every write re-reads the resource afterwards for a canonical response.
- Events — a
task.deletedlistener cascades a task's custom field values (the host dispatches asynchronously after its own commit, so the listener opens its own session). - Impact previews —
computeImpactimplements the diff semantics (type change → all values; removed options → values using them; tightened numeric range → out-of-range values) used by both the management UI and the API.
The plugin runs inside Vikunja's interpreter, and several host conveniences are unavailable to it. These are deliberate seams for a future upstream conversion, not bugs:
- The
webpackage is not in the symbol table (web.Auth,web.HTTPError). Consequences:- Permission methods take
*user.Userinstead ofweb.Auth— yaegi accepts it where the host expects aweb.Authat runtime. - Errors use plugin-local 9000s codes translated to
echo.NewHTTPError(code, message). toHTTPErrordiscriminates error kinds by message prefix — yaegi wraps interpreted errors soswitch err.(type)never matches, and a multi-expressioncase a, b, c:clause evaluates only its first expression, so each prefix gets its own case clause.
- Permission methods take
- Interpreted structs serialize as
{}throughc.JSON— responses are built asmap[string]interface{}field by field (definitionToMap,fieldConfigMap), never by echoing a struct. xorm DB reads/writes of interpreted structs work fine. - Table names must be passed explicitly —
TableName()methods are invisible to xorm on interpreted structs, so migrations sync withtx.Table("name").Sync2(&T{})(upstream PR #3549). - Sliceargs in
IN (?)— under yaegi a slice bind parameter passes through as one argument and the driver rejects it, so placeholder lists are expanded manually (countValuesUsingRemovedOptions). - Typed factory functions — yaegi wraps return values per declared type,
so the loader requires
NewAuthenticatedRouterPlugin,NewUnauthenticatedRouterPlugin, andNewMigrationPluginas separate entry points alongsideNewPlugin.
The host symbol table lives at vikunja/pkg/yaegi_symbols/ in the fork repo
— check it before assuming any Vikunja symbol is unavailable to the plugin.
If the feature is proposed upstream:
- Restore
web.Authin permission signatures. - Replace
echo.NewHTTPErrortranslation with the host'sHTTPError()/ErrCodeconvention (pkg/models/error.go; https://vikunja.io/docs/custom-errors/), which re-enablesswitch err.(type). - Revert to xorm's native slice expansion for
IN (?). - Fold the frontend patches into the frontend repo.
The plugin follows git flow with develop as trunk; releases and hotfixes
are finished with git flow release finish / git flow hotfix finish, which
cut v-prefixed, GPG-signed tags on main (per this repo's gitflow config).
- Finish the release — the tag push triggers
.github/workflows/release.yml. - The workflow packages
README.md,LICENSE,main.go, andui/viagit archiveunder a fixed top-levelcustom-fields/folder intocustom-fields-<tag>.zip, and attaches it to a draft GitHub release with generated notes. - Edit the draft before publishing: state the tested fork version pairing (the fork and plugin are versioned independently; only the current pairing is supported) and anything an upgrading admin must know. Then publish — the zip installs with a single unzip into the plugins directory.