Merge branch 'main' into auto-redirect-oidc-login

This commit is contained in:
surfingbytes 2026-06-08 08:01:13 +02:00 committed by GitHub
commit 7ae40e893c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
335 changed files with 72884 additions and 4226 deletions

View File

@ -0,0 +1,186 @@
---
name: api-v2-routes
description: Use when adding or changing a resource on the Huma-backed /api/v2 API (new endpoints, porting a v1 resource, editing pkg/routes/api/v2/). Covers per-operation Huma handlers, the shared envelopes, error/auth bridging, REST verb conventions, and what's automatic.
user-invocable: true
---
# Adding /api/v2 routes for a CRUDable resource
`/api/v2` is served by [Huma v2](https://github.com/danielgtaylor/huma) mounted on an Echo group via the vendored `pkg/modules/humaecho5` adapter. Unlike v1's generic `WebHandler`, each operation is a typed Huma handler registered explicitly. The handlers are thin: they pull auth off the context, call the same `pkg/web/handler.Do*` functions v1 uses, and translate domain errors into RFC 9457 responses.
**Reference implementation:** `pkg/routes/api/v2/labels.go` is the canonical example — copy its shape. Shared envelopes live in `pkg/routes/api/v2/types.go`; the auth/error bridge in `pkg/routes/api/v2/errors.go`; config in `pkg/routes/api/v2/huma.go`.
## Prerequisite: the model must be CRUDable
v2 handlers call `handler.DoReadAll/DoReadOne/DoCreate/DoUpdate/DoDelete`, which invoke the model's `Can*` methods. If the model isn't already a working v1 resource, do the model work first — invoke the **`crudable`** skill. Permissions are enforced at the model level; **never** re-check them in a v2 handler.
**Every exposed model field needs a `doc:` tag.** v2's schema is reflected from struct tags at runtime; Huma cannot read the Go doc comments swaggo uses for v1. A field without `doc:"..."` ships with no description in the spec. Add the tag alongside the existing comment (keep both — swaggo still reads the comment for v1, and they should stay in sync):
```go
// The title of the label. You'll see this one on tasks associated with it.
Title string `json:"title" minLength:"1" maxLength:"250" doc:"The title of the label. You'll see this one on tasks associated with it."`
```
These model edits are safe for v1 — swaggo, XORM, and govalidator all ignore the `doc` tag. (Huma *does* read validation tags like `minLength`/`maxLength`/`enum`/`format`, so those carry over without a `doc` tag.) As with operations, a `doc` tag earns its place when it says something the field name and type don't: a format hint ("hex, 6 chars"), a read-only note ("set by the server; ignored on write"), units, or allowed values. "The label description." on a `Description` field is filler. See `pkg/models/label.go` for the reference.
**Mark server-controlled fields `readOnly:"true"`.** Because the same model struct is the request body *and* the response, fields the client can never set — `id`, `created`, `updated`, `created_by`, and similar server-derived relations/IDs — should carry `readOnly:"true"`. Huma reflects this into the OpenAPI schema (`readOnly: true`), so docs and client generators present the field as response-only and drop it from request examples:
```go
ID int64 `json:"id" readOnly:"true" doc:"The unique, numeric id of this label."`
CreatedBy *user.User `xorm:"-" json:"created_by" readOnly:"true" doc:"The user who created this label."`
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this label was created. You cannot change this value."`
```
The tag is **documentation only** — Huma does *not* reject these fields if a client sends them on create/update. Actual immutability still comes from the model layer (XORM-managed `created`/`updated`, `created_by` being `xorm:"-"` and set server-side). It's also harmless on v1 (swaggo/XORM/govalidator ignore it). Don't bother tagging fields that are already `json:"-"` (absent from the schema entirely), and skip it on response-only structs like the error model — there it's cosmetic since they never appear as a request body. See `pkg/models/label.go` and `pkg/user/user.go`.
## Steps
### 1. Create `pkg/routes/api/v2/<resource>.go`
Define the list-response body, a `Register<Resource>Routes(api huma.API)` function, and one handler per operation. Mirror `labels.go` exactly:
```go
// Element type matches what models.<Model>.ReadAll returns; extra fields
// tagged json:"-" keep the wire shape identical to the plain model.
type fooListBody struct {
Body Paginated[*models.Foo]
}
func RegisterFooRoutes(api huma.API) {
tags := []string{"foos"}
Register(api, huma.Operation{
OperationID: "foos-list",
Summary: "List foos",
Description: "Returns the foos the authenticated user has access to, paginated.",
Method: http.MethodGet, Path: "/foos", Tags: tags,
}, foosList)
Register(api, huma.Operation{OperationID: "foos-read", Summary: "Get a foo", Description: "...", Method: http.MethodGet, Path: "/foos/{id}", Tags: tags}, foosRead)
Register(api, huma.Operation{OperationID: "foos-create", Summary: "Create a foo", Description: "...", Method: http.MethodPost, Path: "/foos", Tags: tags}, foosCreate)
Register(api, huma.Operation{OperationID: "foos-update", Summary: "Update a foo", Description: "...", Method: http.MethodPut, Path: "/foos/{id}", Tags: tags}, foosUpdate)
Register(api, huma.Operation{OperationID: "foos-delete", Summary: "Delete a foo", Description: "...", Method: http.MethodDelete, Path: "/foos/{id}", Tags: tags}, foosDelete)
}
```
Use the package's `Register` wrapper, **not** `huma.Register` directly — it sets `DefaultStatus` from the verb (POST → 201, DELETE → 204). Don't spell out `DefaultStatus` unless you need a non-default code. Don't set `Security:` per operation — it's applied globally in `NewAPI`.
**Every operation needs a `Summary` and `Description`.** v2's OpenAPI spec is generated from these `Operation` fields at runtime — unlike v1's swaggo, Huma cannot read Go doc comments, so anything you don't put in the `Operation` (or in a `doc:` tag, see below) is simply absent from the spec and the docs UI. An operation without them ships undocumented.
**Make the description document the non-obvious — don't restate the verb+noun.** "Deletes a label" adds nothing over `DELETE /labels/{id}`. Spend the description on what a consumer *can't* infer from the method/path/schema: permission scope ("only the owner may delete it"; "returns only labels you can see, not a global list"), full-replace vs partial (PUT replaces, PATCH merges), read-only/conditional behavior (ETag → `If-None-Match` → 304), side effects (create sets ownership), non-obvious status codes. If the honest description is just the verb+noun, a short summary alone is fine — don't pad. See `labels.go` for the calibration.
### 2. Write the handlers
Every handler: pull auth with `authFromCtx(ctx)`, call the matching `handler.Do*`, wrap returned errors in `translateDomainError`. Use the shared envelopes from `types.go` (`singleBody`, `singleReadBody`, `emptyBody`, `ListParams`, `Paginated`/`NewPaginated`).
- **List** takes `*ListParams` (gives you `page`/`per_page`/`q` for free, already `doc:`-tagged in `types.go` — no need to re-document them) and returns `*fooListBody`. **You must type-assert the `DoReadAll` result to the concrete slice**`result` is `any`, and a blind cast or a generic wrapper silently serialises `[]` (the "generic-any silent-empty trap"). Return a hard error on mismatch:
```go
items, ok := result.([]*models.Foo)
if !ok {
return nil, fmt.Errorf("foos.ReadAll returned unexpected type %T", result)
}
return &fooListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
```
- **Extra query params go *directly* on the handler's input struct — not in a shared/embedded helper.** Beyond `ListParams`, if an operation needs its own query params (`expand`, `order_by`, `include_public`, …), declare each as a direct field with its own `query:"…"` tag on that operation's input struct, then bind it onto the model. A shared or embedded struct of query fields silently **fails to bind** under Huma when combined with other query params/embeds — the field arrives empty (hit while implementing Project's `expand`). Flatten them into the input struct.
- **Read** embeds `conditional.Params` in its input. To surface the caller's permission, define a small per-resource response struct that **embeds the model by value** and adds the permission: `type fooReadBody struct { models.Foo; MaxPermission models.Permission \`json:"max_permission" readOnly:"true" doc:"..."\` }`. Go and Huma both promote the embedded model's fields, so the wire shape is flat (model fields + `max_permission`) with no custom marshaler and nothing added to the shared model struct. Capture `DoReadOne`'s returned max permission (it is `0`/`1`/`2` on success — **never discard it as `_`**), build the body, and `return conditionalReadResponse(&in.Params, body, foo.Updated, maxPermission)`. The shared helper (in `types.go`) folds the permission into the ETag (so a share/role change invalidates the cache), applies the conditional precondition (304/412), and returns `*singleReadBody[fooReadBody]`. See `labels.go`/`project_views.go`. (A generic `struct{ T; ... }` is impossible — Go forbids embedding a type parameter — so the per-resource struct is the price of a flat shape without a marshaler.)
- **Create / Update** return `*singleBody[Model]` and set the model's `ID` from the path (URL wins over body). **Update's request body must be the same `fooReadBody` the read returns, not the bare model** — AutoPatch's GET→PUT round trip echoes the read body (max_permission included) into the PUT, and because `max_permission` is a declared `readOnly` property of `fooReadBody`'s schema, Huma accepts and ignores it on write rather than rejecting it. Take `&in.Body.Foo` (the embedded model — value-embedded, so never nil) and ignore the embedded `MaxPermission`. Create stays a bare `Body Model` (AutoPatch only round-trips into PUT).
- **Delete** returns `*emptyBody`.
### 3. Self-register the resource
Resources self-register — **you do not edit `pkg/routes/routes.go`**. In your resource file, add an `init()` that hands your registrar to `AddRouteRegistrar`:
```go
func init() { AddRouteRegistrar(RegisterFooRoutes) }
func RegisterFooRoutes(api huma.API) { ... }
```
`registerAPIRoutesV2` in `routes.go` calls `apiv2.RegisterAll(api)`, which runs every registered registrar (in init/filename order — route order is irrelevant) and then `EnableAutoPatch`. New resources touch zero shared lines, so they never conflict on `routes.go`.
Notes:
- **Give each registrar a DISTINCT name.** They share package `apiv2`, so two resources both exporting `RegisterAvatarRoutes` collide and won't compile — that actually happened and the upload one had to be renamed (`RegisterAvatarRoutes` for the binary endpoint vs `RegisterAvatarUploadRoutes` for the upload). Name yours after the specific resource.
- **Config-gated resources check the flag inside the registrar.** `RegisterAll` runs at request-router-setup time, after config is loaded, so a `RegisterFooRoutes` may early-return (or skip individual `Register` calls) based on `config.FooEnabled.GetBool()`. Don't try to gate at `init()` time — config isn't loaded yet.
- **AutoPatch is automatic.** `RegisterAll` calls `EnableAutoPatch` after all registrars — don't call it yourself, and don't register a manual PATCH (see "What's automatic").
## REST verb conventions (v2 inverts v1)
| Operation | v1 | v2 |
|---|---|---|
| create | PUT | **POST** |
| update | POST | **PUT** (and PATCH) |
| read / read-all / delete | GET / GET / DELETE | same |
## Non-CRUDable / custom routes
Not everything is plain CRUD — bulk operations, custom actions (`POST /tasks/{id}/duplicate`), sub-resource toggles, RPC-ish endpoints. These still go through Huma and reuse most of the machinery, but two responsibilities move **into your handler** because there's no `handler.Do*` doing them for you:
1. **Permission enforcement is now yours.** This is the one place the "never check permissions in the handler" rule inverts. With no generic `Do*` to call the model's `Can*`, the handler must do it explicitly — load the relevant entity and call its permission method, then refuse on denial. Mirror the v1 custom-handler shape (`pkg/routes/api/v1/task_attachment.go`):
```go
func tasksDuplicate(ctx context.Context, in *struct{ ID int64 `path:"id"` }) (*singleBody[models.Task], error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
s := db.NewSession()
defer s.Close()
t := &models.Task{ID: in.ID}
can, err := t.CanUpdate(s, a) // or whichever Can* gates this action
if err != nil {
_ = s.Rollback()
return nil, translateDomainError(err)
}
if !can {
return nil, huma.Error403Forbidden("forbidden")
}
// ... do the work against s ...
if err := s.Commit(); err != nil {
return nil, translateDomainError(err)
}
return &singleBody[models.Task]{Body: t}, nil
}
```
2. **Session / transaction management is now yours.** The `Do*` helpers open and commit their own `xorm.Session`; custom handlers open one with `db.NewSession()`, `defer s.Close()`, and `Commit`/`Rollback` explicitly for anything that writes.
Otherwise the same rules apply: register with the `Register` wrapper, pull auth via `authFromCtx`, route every error through `translateDomainError`, and reuse the `types.go` envelopes — or define a small body struct when none fits (don't bend a custom response into `singleBody` if it's awkward).
**Verb choice:** pick by semantics, not the CRUD table. Non-idempotent actions are `POST`. AutoPatch only synthesises PATCH for GET+PUT *pairs*, so standalone custom routes are never touched.
**Token permissions still automatic, but mind the derived name:** `collectRoutesForAPITokens` keys a route off its prefix-stripped path, so `POST /api/v2/tasks/{id}/duplicate` lands under the `tasks` group as a `duplicate` permission. Single-segment custom paths fall into the `other` group. Name the path so the derived `(group, permission)` reads sensibly — that string is what users grant tokens against.
## What's automatic — do NOT hand-roll
- **PATCH**`EnableAutoPatch` synthesises a JSON-Merge-Patch PATCH for every GET+PUT pair. `RegisterAll` invokes it after all registrars, so it's automatic — don't call `EnableAutoPatch` and don't register PATCH yourself.
- **API token permissions**`collectRoutesForAPITokens` walks the Echo router after registration, so your new routes land in the v2 token table automatically under the same `(group, permission)` keys as their v1 names. PATCH is intentionally not stored; `CanDoAPIRoute` accepts it as an alias for the stored PUT (see `pkg/models/api_routes.go`).
- **Security schemes**`JWTKeyAuth` + `APITokenAuth` are declared globally in `NewAPI`. For a public endpoint, set `Security: []map[string][]string{}` on that operation and add its path to `unauthenticatedAPIPaths` in `routes.go`.
- **Error shape**`translateDomainError` maps any `web.HTTPErrorProcessor` (e.g. `ErrFooDoesNotExist`) onto Huma's status error, producing RFC 9457 `application/problem+json`. Errors without HTTP semantics become 500.
- **OpenAPI spec / Scalar docs / `$schema` URLs** — handled in `huma.go`. Leave `Servers` alone (the relative entry must stay at index 0).
## Anti-patterns (these get flagged)
- Re-checking permissions in the handler instead of trusting `handler.Do*` → the model's `Can*`.
- Blind `result.([]*models.Foo)` without the `ok` check, or returning the `any` straight into the envelope — silent empty lists.
- `huma.Register` instead of the package `Register` wrapper (loses the verb-based status).
- Per-operation `Security:` lines (now global) or registering a manual PATCH (AutoPatch does it).
- Returning a raw model error instead of routing it through `translateDomainError` → leaks a 500 instead of the right code.
- Unquoted ETag in the response header.
- Operations without `Summary`/`Description`, or model fields without `doc:` tags — they ship undocumented because Huma can't read Go comments.
- Server-controlled fields (`id`, `created`, `updated`, `created_by`) on a shared input/output model left without `readOnly:"true"` — the docs then present them as writable request fields.
## Tests (mandatory)
Mirror the v1 webtest shape so v2 parity is readable side-by-side. Use the `webHandlerTestV2` harness in `pkg/webtests/integrations.go` — it takes the same `urlParams` map as v1's `webHandlerTest`. See `pkg/webtests/huma_label_test.go`:
- One `Test<Resource>` covering list/read/create/update/delete, positive + negative (forbidden, nonexistent), mirroring the v1 model test.
- v2-only behaviour (ETag/304, PATCH merge-patch) goes in separate top-level `Test<Resource>_*` funcs using the `humaRequest`/`humaTokenFor` helpers in `pkg/webtests/huma_helpers_test.go`.
- The RFC 9457 error-body shape is asserted **once** globally in `TestHuma_ErrorShapeIsRFC9457` — don't re-assert the full problem+json shape per resource, just the status code.
Run with `mage test:filter Test<Resource>` while iterating. **Caveat:** `mage test:filter` injects `-short`, which makes `pkg/webtests` skip entirely (the suite short-circuits in short mode), so it silently reports success without running your webtest. To actually exercise a single webtest, run it directly: `go test -run '<Name>' ./pkg/webtests/`. Save output to a file per the project test-output rule.
## Related
- `crudable` skill — the model-layer prerequisite
- `pkg/routes/api/v2/labels.go` — reference resource
- `pkg/routes/api/v2/{types,errors,huma}.go` — shared envelopes, bridge, config
- `pkg/web/handler/core.go` — the `Do*` functions handlers call

6
.github/actionlint.yaml vendored Normal file
View File

@ -0,0 +1,6 @@
self-hosted-runner:
# Custom labels from third-party runner providers used in our workflows.
# Listed here so actionlint doesn't flag them as unknown.
labels:
- namespace-profile-default
- blacksmith-8vcpu-ubuntu-2204

View File

@ -0,0 +1,189 @@
name: Release binaries
description: |
Build, sign, and publish release binaries for a Vikunja sub-project.
Derives every per-project path, cache key, artifact name, and S3 target
from the `project` input. Callers only need to provide the project name,
the raw `git describe` value, and pass through the GPG/S3 secrets as
inputs (composite actions can't read the `secrets` context directly).
inputs:
project:
description: 'Which project to build: "vikunja" or "veans".'
required: true
release-version:
description: |
Raw git describe value (e.g. v1.2.3 or v2.3.0-408-ge053d317). Always
passed through to the build so the binary embeds the precise commit.
Filenames and the S3 directory use "unstable" instead whenever
github.ref_type isn't "tag".
required: true
# Secrets — composite actions can't read the `secrets` context directly, so
# the caller threads them through as inputs.
gpg-passphrase:
required: true
gpg-sign-key:
required: true
s3-access-key-id:
required: true
s3-secret-access-key:
required: true
s3-endpoint:
required: true
s3-bucket:
required: true
s3-region:
required: true
runs:
using: composite
steps:
- name: Set project paths
shell: bash
env:
PROJECT: ${{ inputs.project }}
RELEASE_VERSION_INPUT: ${{ inputs.release-version }}
VERSION_OR_UNSTABLE: ${{ github.ref_type == 'tag' && inputs.release-version || 'unstable' }}
run: |
set -euo pipefail
case "$PROJECT" in
vikunja|veans) ;;
*)
echo "::error::Unknown project '$PROJECT'. Expected 'vikunja' or 'veans'." >&2
exit 1
;;
esac
case "$PROJECT" in
vikunja)
output_dir="."
dist_prefix="dist"
;;
veans)
output_dir="veans"
dist_prefix="veans/dist"
;;
esac
{
echo "PROJECT=$PROJECT"
echo "RELEASE_VERSION=$RELEASE_VERSION_INPUT"
echo "VERSION_OR_UNSTABLE=$VERSION_OR_UNSTABLE"
echo "XGO_OUT_NAME=${PROJECT}-${VERSION_OR_UNSTABLE}"
echo "OUTPUT_DIR=$output_dir"
echo "DIST_PREFIX=$dist_prefix"
echo "S3_TARGET_PATH=/${PROJECT}/${VERSION_OR_UNSTABLE}"
echo "ARTIFACT_BINARIES_NAME=${PROJECT}_bins"
echo "ARTIFACT_ZIPS_NAME=${PROJECT}_bin_packages"
} >> "$GITHUB_ENV"
- name: Download Mage binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
- name: Make mage-static executable
shell: bash
run: chmod +x ./mage-static
- name: Download frontend dist (vikunja only)
if: inputs.project == 'vikunja'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: frontend_dist
path: frontend/dist
- name: Generate config.yml.sample (vikunja only)
if: inputs.project == 'vikunja'
shell: bash
run: ./mage-static generate:config-yaml 1
- name: Install upx
shell: bash
run: |
set -euo pipefail
wget -q https://github.com/upx/upx/releases/download/v5.0.0/upx-5.0.0-amd64_linux.tar.xz
echo 'b32abf118d721358a50f1aa60eacdbf3298df379c431c3a86f139173ab8289a1 upx-5.0.0-amd64_linux.tar.xz' > upx-5.0.0-amd64_linux.tar.xz.sha256
sha256sum -c upx-5.0.0-amd64_linux.tar.xz.sha256
tar xf upx-5.0.0-amd64_linux.tar.xz
sudo mv upx-5.0.0-amd64_linux/upx /usr/local/bin
- name: Setup xgo cache
uses: useblacksmith/cache@71c7c918062ba3861252d84b07fe5ab2a6b467a6 # v5
with:
path: /home/runner/.xgo-cache
key: xgo-${{ inputs.project }}-${{ hashFiles('**/go.sum') }}
restore-keys: |
xgo-${{ inputs.project }}-
- name: Install mage for the build module
shell: bash
run: go install github.com/magefile/mage@v1.17.2
- name: Build release artifacts
shell: bash
env:
RELEASE_VERSION: ${{ env.RELEASE_VERSION }}
XGO_OUT_NAME: ${{ env.XGO_OUT_NAME }}
PROJECT: ${{ env.PROJECT }}
run: |
set -euo pipefail
export PATH="$PATH:$(go env GOPATH)/bin"
cd build && mage release:build "$PROJECT"
- name: GPG setup
uses: kolaente/action-gpg@main
with:
gpg-passphrase: ${{ inputs.gpg-passphrase }}
gpg-sign-key: ${{ inputs.gpg-sign-key }}
- name: Sign zips
shell: bash
env:
DIST_PREFIX: ${{ env.DIST_PREFIX }}
RELEASE_GPG_PASSPHRASE: ${{ inputs.gpg-passphrase }}
run: |
set -euo pipefail
zip_dir="${DIST_PREFIX}/zip"
echo "=== GPG agent status ==="
gpg-connect-agent 'keyinfo --list' /bye || true
echo "=== GPG secret keys ==="
gpg -K --with-keygrip
echo "=== GPG public keys ==="
gpg --list-keys
echo "=== Signing files in $zip_dir ==="
ls -hal "$zip_dir"/*
for file in "$zip_dir"/*; do
gpg -v \
--default-key 7D061A4AA61436B40713D42EFF054DACD908493A \
-b --batch --yes \
--passphrase "$RELEASE_GPG_PASSPHRASE" \
--pinentry-mode loopback \
--sign "$file"
done
- name: Upload zips to S3
uses: kolaente/s3-action@main
with:
s3-access-key-id: ${{ inputs.s3-access-key-id }}
s3-secret-access-key: ${{ inputs.s3-secret-access-key }}
s3-endpoint: ${{ inputs.s3-endpoint }}
s3-bucket: ${{ inputs.s3-bucket }}
s3-region: ${{ inputs.s3-region }}
target-path: ${{ env.S3_TARGET_PATH }}
files: ${{ env.DIST_PREFIX }}/zip/*
strip-path-prefix: ${{ env.DIST_PREFIX }}/zip/
- name: Store binaries
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ env.ARTIFACT_BINARIES_NAME }}
path: ./${{ env.DIST_PREFIX }}/binaries/*
- name: Store binary packages
if: github.ref_type == 'tag'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ env.ARTIFACT_ZIPS_NAME }}
path: ./${{ env.DIST_PREFIX }}/zip/*

View File

@ -0,0 +1,204 @@
name: Release OS package
description: >
Build a single deb/rpm/apk/archlinux package for the given project + arch
via nfpm, optionally GPG-sign it (archlinux is signed inline; rpm is signed
by nfpm itself), upload it to S3, and store it as a workflow artifact.
Most paths and names are derived from `project`; the matrix only needs to
supply the per-arch and per-format inputs.
inputs:
project:
description: 'Project name (vikunja | veans). Drives all derived paths.'
required: true
release-version:
description: |
RELEASE_VERSION env value — the same version that ended up in the
binaries artifact. Always embedded in the package metadata via
nfpm; filenames and the S3 directory use "unstable" instead
whenever github.ref_type isn't "tag".
required: true
packager:
description: 'nfpm packager: rpm | deb | apk | archlinux.'
required: true
nfpm-arch:
description: 'nfpm arch field (amd64 | arm64 | arm7).'
required: true
pkg-arch:
description: 'Package-format arch used in the output filename (x86_64 | aarch64 | armv7).'
required: true
go-name:
description: 'Go-style arch token used in the binary filename (linux-amd64 | linux-arm64 | linux-arm-7).'
required: true
# Secrets — composite actions can't read `${{ secrets.* }}` directly, so the
# caller threads them through as inputs.
gpg-passphrase:
required: true
gpg-sign-key:
required: true
s3-access-key-id:
required: true
s3-secret-access-key:
required: true
s3-endpoint:
required: true
s3-bucket:
required: true
s3-region:
required: true
runs:
using: composite
steps:
- name: Set project paths
shell: bash
env:
PROJECT: ${{ inputs.project }}
RELEASE_VERSION: ${{ inputs.release-version }}
VERSION_OR_UNSTABLE: ${{ github.ref_type == 'tag' && inputs.release-version || 'unstable' }}
PACKAGER: ${{ inputs.packager }}
PKG_ARCH: ${{ inputs.pkg-arch }}
GO_NAME: ${{ inputs.go-name }}
run: |
case "$PROJECT" in
vikunja)
echo "BINARIES_DOWNLOAD_PATH=." >> "$GITHUB_ENV"
echo "STAGED_BINARY_PATH=./vikunja" >> "$GITHUB_ENV"
echo "NFPM_BIN_PATH=" >> "$GITHUB_ENV"
echo "NFPM_CONFIG_PATH=./nfpm.yaml" >> "$GITHUB_ENV"
# No leading "./" — the s3-action's strip-path-prefix must
# match the glob output exactly, and the glob doesn't emit it.
echo "PACKAGE_OUTPUT_DIR=dist/os-packages" >> "$GITHUB_ENV"
;;
veans)
echo "BINARIES_DOWNLOAD_PATH=./veans-binaries" >> "$GITHUB_ENV"
echo "STAGED_BINARY_PATH=./veans/veans-bin" >> "$GITHUB_ENV"
echo "NFPM_BIN_PATH=./veans/veans-bin" >> "$GITHUB_ENV"
echo "NFPM_CONFIG_PATH=./veans/nfpm.yaml" >> "$GITHUB_ENV"
echo "PACKAGE_OUTPUT_DIR=veans/dist/os-packages" >> "$GITHUB_ENV"
;;
*)
echo "::error::unknown project '$PROJECT' (expected vikunja|veans)"
exit 1
;;
esac
echo "VERSION_OR_UNSTABLE=$VERSION_OR_UNSTABLE" >> "$GITHUB_ENV"
echo "BINARIES_ARTIFACT_NAME=${PROJECT}_bins" >> "$GITHUB_ENV"
echo "BINARY_GLOB=${PROJECT}-*-${GO_NAME}" >> "$GITHUB_ENV"
echo "PACKAGE_FILENAME=${PROJECT}-${VERSION_OR_UNSTABLE}-${PKG_ARCH}.${PACKAGER}" >> "$GITHUB_ENV"
echo "ARTIFACT_NAME=${PROJECT}_os_package_${PACKAGER}_${PKG_ARCH}" >> "$GITHUB_ENV"
echo "S3_TARGET_PATH=/${PROJECT}/${VERSION_OR_UNSTABLE}" >> "$GITHUB_ENV"
- name: Download project binaries
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: ${{ env.BINARIES_ARTIFACT_NAME }}
path: ${{ env.BINARIES_DOWNLOAD_PATH }}
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: Install mage
shell: bash
run: go install github.com/magefile/mage@v1.17.2
- name: Generate config.yml.sample (vikunja only)
# vikunja's nfpm.yaml ships ./config.yml.sample as /etc/vikunja/config.yml.
# release-binaries generates it for the zip bundles, but this job runs on a
# fresh runner, so we regenerate it here before nfpm packs it.
if: inputs.project == 'vikunja'
shell: bash
run: |
export PATH=$PATH:$GOPATH/bin
mage generate:config-yaml 1
- name: Write GPG key for nfpm
if: inputs.packager == 'rpm'
shell: bash
env:
RELEASE_GPG_SIGN_KEY: ${{ inputs.gpg-sign-key }}
run: printf '%s' "$RELEASE_GPG_SIGN_KEY" > /tmp/nfpm-signing-key.gpg
- name: GPG setup for archlinux signing
if: inputs.packager == 'archlinux'
uses: kolaente/action-gpg@main
with:
gpg-passphrase: ${{ inputs.gpg-passphrase }}
gpg-sign-key: ${{ inputs.gpg-sign-key }}
- name: Prepare nfpm config
shell: bash
working-directory: build
env:
RELEASE_VERSION: ${{ inputs.release-version }}
NFPM_ARCH: ${{ inputs.nfpm-arch }}
NFPM_BIN_PATH: ${{ env.NFPM_BIN_PATH }}
PROJECT: ${{ inputs.project }}
run: |
export PATH=$PATH:$GOPATH/bin
mage release:prepare-nfpm-config "$PROJECT" "$NFPM_ARCH"
- name: Stage binary
shell: bash
run: |
# Resolve the single matching binary and mv it into place.
matched=()
for f in $BINARIES_DOWNLOAD_PATH/$BINARY_GLOB; do
[ -e "$f" ] || continue
matched+=("$f")
done
if [ ${#matched[@]} -ne 1 ]; then
echo "::error::expected exactly 1 binary matching '$BINARIES_DOWNLOAD_PATH/$BINARY_GLOB', found ${#matched[@]}"
ls -la "$BINARIES_DOWNLOAD_PATH" || true
exit 1
fi
mkdir -p "$(dirname "$STAGED_BINARY_PATH")"
mv "${matched[0]}" "$STAGED_BINARY_PATH"
chmod +x "$STAGED_BINARY_PATH"
- name: Ensure package output dir exists
shell: bash
run: mkdir -p "$PACKAGE_OUTPUT_DIR"
- name: Create package
uses: kolaente/action-gh-nfpm@master
with:
packager: ${{ inputs.packager }}
target: ${{ env.PACKAGE_OUTPUT_DIR }}/${{ env.PACKAGE_FILENAME }}
config: ${{ env.NFPM_CONFIG_PATH }}
env:
NFPM_GPG_KEY_FILE: ${{ inputs.packager == 'rpm' && '/tmp/nfpm-signing-key.gpg' || '' }}
NFPM_PASSPHRASE: ${{ inputs.packager == 'rpm' && inputs.gpg-passphrase || '' }}
- name: Sign archlinux package
if: inputs.packager == 'archlinux'
shell: bash
env:
GPG_PASSPHRASE: ${{ inputs.gpg-passphrase }}
run: |
gpg --default-key 7D061A4AA61436B40713D42EFF054DACD908493A \
--batch --yes \
--passphrase "$GPG_PASSPHRASE" \
--pinentry-mode loopback \
--detach-sign \
"$PACKAGE_OUTPUT_DIR/$PACKAGE_FILENAME"
- name: Upload to S3
uses: kolaente/s3-action@main
with:
s3-access-key-id: ${{ inputs.s3-access-key-id }}
s3-secret-access-key: ${{ inputs.s3-secret-access-key }}
s3-endpoint: ${{ inputs.s3-endpoint }}
s3-bucket: ${{ inputs.s3-bucket }}
s3-region: ${{ inputs.s3-region }}
target-path: ${{ env.S3_TARGET_PATH }}
files: ${{ env.PACKAGE_OUTPUT_DIR }}/*
strip-path-prefix: ${{ env.PACKAGE_OUTPUT_DIR }}/
- name: Store OS package
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ env.ARTIFACT_NAME }}
path: ${{ env.PACKAGE_OUTPUT_DIR }}/*

View File

@ -41,7 +41,7 @@ jobs:
- name: Check for changes
id: check_changes
run: |
if git diff --quiet; then
if [ -z "$(git status --porcelain pkg/i18n/lang frontend/src/i18n/lang)" ]; then
echo "changes_exist=0" >> "$GITHUB_OUTPUT"
else
echo "changes_exist=1" >> "$GITHUB_OUTPUT"
@ -51,7 +51,8 @@ jobs:
run: |
git config --local user.email "bot@vikunja.io"
git config --local user.name "Frederick [Bot]"
git commit -am "chore(i18n): update translations via Crowdin"
git add pkg/i18n/lang frontend/src/i18n/lang
git commit -m "chore(i18n): update translations via Crowdin"
- name: Push changes
if: steps.check_changes.outputs.changes_exist != '0'
uses: ad-m/github-push-action@master

View File

@ -4,6 +4,40 @@ on:
workflow_call:
jobs:
build-mage:
runs-on: ubuntu-latest
name: prepare-build-mage
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: Cache build mage
id: cache-build-mage
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
with:
key: ${{ runner.os }}-build-mage-build-${{ hashFiles('build/magefile.go') }}
path: |
./build/build-mage-static
# Statically compile build/magefile.go so publish-repos can run repo
# metadata targets inside ubuntu/fedora/archlinux containers without
# needing a Go toolchain available there.
- name: Install mage
if: ${{ steps.cache-build-mage.outputs.cache-hit != 'true' }}
run: go install github.com/magefile/mage@v1.17.2
- name: Compile build mage
if: ${{ steps.cache-build-mage.outputs.cache-hit != 'true' }}
working-directory: build
run: |
export PATH=$PATH:$GOPATH/bin
mage -compile ./build-mage-static
- name: Store build mage binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: build_mage_bin
path: ./build/build-mage-static
docker:
runs-on: namespace-profile-default
steps:
@ -63,83 +97,36 @@ jobs:
- name: Git describe
id: ghd
uses: proudust/gh-describe@v2
- uses: useblacksmith/setup-go@647ac649bd5b480f2a262e3e3e5f4d150ed452ad # v6
with:
go-version: stable
- name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
- name: get frontend
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: frontend_dist
path: frontend/dist
- run: chmod +x ./mage-static
- name: install upx
run: |
wget https://github.com/upx/upx/releases/download/v5.0.0/upx-5.0.0-amd64_linux.tar.xz
echo 'b32abf118d721358a50f1aa60eacdbf3298df379c431c3a86f139173ab8289a1 upx-5.0.0-amd64_linux.tar.xz' > upx-5.0.0-amd64_linux.tar.xz.sha256
sha256sum -c upx-5.0.0-amd64_linux.tar.xz.sha256
tar xf upx-5.0.0-amd64_linux.tar.xz
mv upx-5.0.0-amd64_linux/upx /usr/local/bin
- name: setup xgo cache
uses: useblacksmith/cache@71c7c918062ba3861252d84b07fe5ab2a6b467a6 # v5
with:
path: /home/runner/.xgo-cache
key: ${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: build and release
env:
RELEASE_VERSION: ${{ steps.ghd.outputs.describe }}
XGO_OUT_NAME: vikunja-${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}
run: |
export PATH=$PATH:$GOPATH/bin
./mage-static release
- name: GPG setup
uses: kolaente/action-gpg@main
with:
gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}"
gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}"
- name: sign
run: |
echo "=== GPG agent status ==="
gpg-connect-agent 'keyinfo --list' /bye || true
echo "=== GPG secret keys ==="
gpg -K --with-keygrip
echo "=== GPG public keys ==="
gpg --list-keys
echo "=== GNUPG directory contents ==="
ls -la ~/.gnupg/
ls -la ~/.gnupg/private-keys-v1.d/ || true
echo "=== Signing files ==="
ls -hal dist/zip/*
for file in dist/zip/*; do
gpg -v --default-key 7D061A4AA61436B40713D42EFF054DACD908493A -b --batch --yes --passphrase "${{ secrets.RELEASE_GPG_PASSPHRASE }}" --pinentry-mode loopback --sign "$file"
done
- name: Upload
uses: kolaente/s3-action@main
- uses: ./.github/actions/release-binaries
with:
project: vikunja
release-version: ${{ steps.ghd.outputs.describe }}
gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }}
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
s3-bucket: ${{ secrets.S3_BUCKET }}
s3-region: ${{ secrets.S3_REGION }}
target-path: /vikunja/${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}
files: "dist/zip/*"
strip-path-prefix: dist/zip/
- name: Store Binaries
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
veans-binaries:
runs-on: blacksmith-8vcpu-ubuntu-2204
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Git describe
id: ghd
uses: proudust/gh-describe@v2
- uses: ./.github/actions/release-binaries
with:
name: vikunja_bins
path: ./dist/binaries/*
- name: Store Binary Packages
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
if: ${{ github.ref_type == 'tag' }}
with:
name: vikunja_bin_packages
path: ./dist/zip/*
project: veans
release-version: ${{ steps.ghd.outputs.describe }}
gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }}
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
s3-bucket: ${{ secrets.S3_BUCKET }}
s3-region: ${{ secrets.S3_REGION }}
os-package:
runs-on: ubuntu-latest
@ -147,11 +134,7 @@ jobs:
- binaries
strategy:
matrix:
package:
- rpm
- deb
- apk
- archlinux
package: [rpm, deb, apk, archlinux]
arch:
- go_name: linux-amd64
nfpm: amd64
@ -165,76 +148,70 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Vikunja Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_bins
- name: Git describe
id: ghd
uses: proudust/gh-describe@v2
- name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
- name: Write GPG key for nfpm
if: matrix.package == 'rpm'
run: echo -n "${{ secrets.RELEASE_GPG_SIGN_KEY }}" > /tmp/nfpm-signing-key.gpg
- name: GPG setup for package signing
if: matrix.package == 'archlinux'
uses: kolaente/action-gpg@main
with:
gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}"
gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}"
- name: Prepare
env:
RELEASE_VERSION: ${{ steps.ghd.outputs.describe }}
NFPM_ARCH: ${{ matrix.arch.nfpm }}
run: |
chmod +x ./mage-static
./mage-static release:prepare-nfpm-config
mkdir -p ./dist/os-packages
mv ./vikunja-*-${{ matrix.arch.go_name }} ./vikunja
chmod +x ./vikunja
- name: Create package
id: nfpm
uses: kolaente/action-gh-nfpm@master
- uses: ./.github/actions/release-os-package
with:
project: vikunja
release-version: ${{ steps.ghd.outputs.describe }}
packager: ${{ matrix.package }}
target: ./dist/os-packages/vikunja-${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}-${{ matrix.arch.pkg }}.${{ matrix.package }}
config: ./nfpm.yaml
env:
NFPM_GPG_KEY_FILE: ${{ (matrix.package == 'rpm') && '/tmp/nfpm-signing-key.gpg' || '' }}
NFPM_PASSPHRASE: ${{ (matrix.package == 'rpm') && secrets.RELEASE_GPG_PASSPHRASE || '' }}
- name: Sign package
if: matrix.package == 'archlinux'
run: |
gpg --default-key 7D061A4AA61436B40713D42EFF054DACD908493A \
--batch --yes \
--passphrase "${{ secrets.RELEASE_GPG_PASSPHRASE }}" \
--pinentry-mode loopback \
--detach-sign \
./dist/os-packages/vikunja-${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}-${{ matrix.arch.pkg }}.${{ matrix.package }}
- name: Upload
uses: kolaente/s3-action@main
with:
nfpm-arch: ${{ matrix.arch.nfpm }}
pkg-arch: ${{ matrix.arch.pkg }}
go-name: ${{ matrix.arch.go_name }}
gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }}
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
s3-bucket: ${{ secrets.S3_BUCKET }}
s3-region: ${{ secrets.S3_REGION }}
target-path: /vikunja/${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}
files: "dist/os-packages/*"
strip-path-prefix: dist/os-packages/
- name: Store OS Packages
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
veans-os-package:
runs-on: ubuntu-latest
needs:
- veans-binaries
strategy:
matrix:
package: [rpm, deb, apk, archlinux]
arch:
- go_name: linux-amd64
nfpm: amd64
pkg: x86_64
- go_name: linux-arm64
nfpm: arm64
pkg: aarch64
- go_name: linux-arm-7
nfpm: arm7
pkg: armv7
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Git describe
id: ghd
uses: proudust/gh-describe@v2
- uses: ./.github/actions/release-os-package
with:
name: vikunja_os_package_${{ matrix.package }}_${{ matrix.arch.pkg }}
path: ./dist/os-packages/*
project: veans
release-version: ${{ steps.ghd.outputs.describe }}
packager: ${{ matrix.package }}
nfpm-arch: ${{ matrix.arch.nfpm }}
pkg-arch: ${{ matrix.arch.pkg }}
go-name: ${{ matrix.arch.go_name }}
gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }}
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
s3-bucket: ${{ secrets.S3_BUCKET }}
s3-region: ${{ secrets.S3_REGION }}
publish-repos:
runs-on: ubuntu-latest
needs:
- build-mage
- os-package
- veans-os-package
- desktop
strategy:
fail-fast: false
@ -260,10 +237,14 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary
- name: Download build mage binary
# Statically compiled in test.yml's build-mage job so it runs inside
# ubuntu/fedora/archlinux containers without a Go toolchain.
if: matrix.format != 'apk'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
name: build_mage_bin
path: build
- name: Download all server OS packages
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
@ -272,6 +253,16 @@ jobs:
merge-multiple: true
path: dist/repo-work/incoming
- name: Download all veans OS packages
# Merged into the same incoming dir so reprepro / createrepo_c /
# repo-add / the apk loop pick them up alongside vikunja's packages
# — same suite, same arch fan-out, no extra source entry for users.
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: veans_os_package_*
merge-multiple: true
path: dist/repo-work/incoming
- name: Download desktop packages (Linux)
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
@ -338,12 +329,13 @@ jobs:
- name: Generate repo metadata
if: matrix.format != 'apk'
working-directory: build
env:
RELEASE_GPG_KEY: 7D061A4AA61436B40713D42EFF054DACD908493A
RELEASE_GPG_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
run: |
chmod +x ./mage-static
./mage-static ${{ matrix.mage_target }}
chmod +x ./build-mage-static
./build-mage-static ${{ matrix.mage_target }}
- name: Generate APK repo metadata
if: matrix.format == 'apk'
@ -538,6 +530,8 @@ jobs:
needs:
- binaries
- os-package
- veans-binaries
- veans-os-package
- desktop
- publish-repos
if: ${{ github.ref_type == 'tag' }}
@ -555,6 +549,17 @@ jobs:
pattern: vikunja_os_package_*
merge-multiple: true
- name: Download Veans Binaries
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: veans_bin_packages
- name: Download Veans OS Packages
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: veans_os_package_*
merge-multiple: true
- name: Download Desktop Package Linux
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
@ -581,4 +586,9 @@ jobs:
vikunja*.deb
vikunja*.apk
vikunja*.archlinux
veans*.zip
veans*.rpm
veans*.deb
veans*.apk
veans*.archlinux
Vikunja Desktop*

View File

@ -78,6 +78,39 @@ jobs:
with:
version: v2.10.1
veans-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: golangci-lint
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
with:
version: v2.10.1
working-directory: veans
veans-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: Install mage
# The cached mage-static artifact has the parent magefile compiled
# in — we need a generic mage binary to pick up veans/magefile.go.
run: go install github.com/magefile/mage@v1.17.2
- name: Run unit tests
# `mage test` is the Aliases entry for Test.All which passes
# `-short` — the e2e package's TestMain skips under -short,
# mirroring the parent monorepo's pkg/webtests convention. The
# heavier test-veans-e2e job runs the full suite against the
# api-build artifact.
working-directory: veans
run: mage test
check-translations:
runs-on: ubuntu-latest
needs: mage
@ -404,6 +437,76 @@ jobs:
name: frontend_dist
path: ./frontend/dist
test-veans-e2e:
runs-on: ubuntu-latest
needs:
- api-build
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Vikunja Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_bin
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: Install mage
# The cached mage-static artifact has the parent magefile compiled
# in — we need a generic mage binary to pick up veans/magefile.go.
run: go install github.com/magefile/mage@v1.17.2
- run: chmod +x ./vikunja
- name: Run veans e2e against ephemeral Vikunja
env:
VIKUNJA_SERVICE_INTERFACE: ":3456"
VIKUNJA_SERVICE_PUBLICURL: "http://127.0.0.1:3456/"
VIKUNJA_SERVICE_JWTSECRET: "veans-e2e-jwt-secret-do-not-use-in-production"
# Enables PATCH /api/v1/test/{table} — the e2e suite seeds its
# own admin via this endpoint (see veans/e2e/helpers.go), same
# mechanism the playwright suite uses.
VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB
VIKUNJA_DATABASE_TYPE: sqlite
VIKUNJA_DATABASE_PATH: memory
VIKUNJA_LOG_LEVEL: WARNING
VIKUNJA_MAILER_ENABLED: "false"
VIKUNJA_REDIS_ENABLED: "false"
VIKUNJA_RATELIMIT_NOAUTHLIMIT: "1000"
VEANS_E2E_API_URL: http://127.0.0.1:3456
# Same value as VIKUNJA_SERVICE_TESTINGTOKEN above — pass-through
# so the test harness can authenticate against /api/v1/test/.
VEANS_E2E_TESTING_TOKEN: averyLongSecretToSe33dtheDB
run: |
set -e
# Boot the prebuilt API and tests in one shell — backgrounded
# processes don't survive step boundaries on GH runners.
nohup ./vikunja web > /tmp/vikunja.log 2>&1 &
API_PID=$!
trap "kill $API_PID 2>/dev/null || true" EXIT
for i in $(seq 1 60); do
if curl -sf http://127.0.0.1:3456/api/v1/info >/dev/null 2>&1; then
echo "API ready after ${i}s"
break
fi
sleep 1
done
if ! curl -sf http://127.0.0.1:3456/api/v1/info >/dev/null; then
echo "::error::API failed to start; log:"
cat /tmp/vikunja.log
exit 1
fi
# `mage test:e2e` builds the binary once and exports VEANS_BINARY
# so each subtest reuses it (plain `mage test` would rebuild per
# test via buildOrLocate()). The suite seeds its own admin
# internally — no curl seeding here.
(cd veans && mage test:e2e)
- name: Upload API log on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: veans-e2e-vikunja-log
path: /tmp/vikunja.log
retention-days: 7
test-frontend-e2e-playwright:
runs-on: ubuntu-latest
needs:

View File

@ -11,12 +11,24 @@ The project consists of:
- `desktop/` Electron wrapper application
- `docs/` Documentation website
## API Version Policy — new work goes to /api/v2
**`/api/v1` is effectively deprecated and frozen.** It still runs and is fully supported for existing clients, but it should not grow.
- **Every new route goes on `/api/v2`** (the Huma-backed API in `pkg/routes/api/v2/`). This includes new CRUDable entities, new custom/non-CRUD endpoints, and new actions on existing resources.
- **Before adding any v2 route, invoke the `api-v2-routes` skill** — it covers both CRUD and non-CRUD shapes.
- **Touch `/api/v1` only to:** fix a bug, or port an existing resource to v2. Do not add net-new functionality there.
- Models in `pkg/models/` are shared by both APIs — a new entity still gets its model + `Can*` methods (invoke `crudable`); only the HTTP surface differs (v2, not v1).
If a task says "add an endpoint for X" without naming a version, it means v2.
## Skills
Before writing code in these areas, invoke the matching skill with the `Skill` tool. They are short checklists derived from recurring review feedback — loading them up front avoids rework.
- Adding or modifying a model in `pkg/models/` (new CRUD, new or changed `Can*` methods, anything touching permissions): invoke `crudable`.
- Creating or editing any file under `pkg/migration/`: invoke `migration`.
- Adding **any** new API route (new entity, custom action, or porting from v1) — all new routes go on the Huma-backed `/api/v2`, editing `pkg/routes/api/v2/`: invoke `api-v2-routes`. See the API Version Policy above.
## Plans and Worktrees
@ -172,11 +184,10 @@ Modern Vue 3 composition API application with TypeScript:
### Adding New Features
**Backend Changes:**
1. Create/modify models in `pkg/models/` with proper CRUD and Permissions interfaces as required
2. Add database migration if needed: `mage dev:make-migration <StructName>`
1. Create/modify models in `pkg/models/` with proper CRUD and Permissions interfaces as required (invoke the `crudable` skill)
2. Add database migration if needed: `mage dev:make-migration <StructName>` (invoke the `migration` skill)
3. Create/update services in `pkg/services/` for complex business logic
4. Add API routes in `pkg/routes/api/v1/` following existing patterns
5. Update Swagger annotations
4. Add API routes on **`/api/v2`** in `pkg/routes/api/v2/` — invoke the `api-v2-routes` skill. Do **not** add new routes to `/api/v1`; it is frozen (see API Version Policy above)
**Frontend Changes:**
1. Create TypeScript interfaces in `src/modelTypes/` matching backend models
@ -192,10 +203,11 @@ Modern Vue 3 composition API application with TypeScript:
4. Update TypeScript interfaces in frontend `src/modelTypes/`
### API Development
- All API endpoints follow RESTful conventions under `/api/v1/`
- Use generic web handlers in `pkg/web/handler/` for standard CRUD operations
- Implement proper permissions checking using the Permissions interface
- Add Swagger annotations for automatic documentation generation
- **New endpoints go on `/api/v2`** (Huma-backed, `pkg/routes/api/v2/`). `/api/v1` is frozen — see the API Version Policy near the top. Invoke the `api-v2-routes` skill before writing v2 routes.
- v2 verb conventions differ from v1: POST creates, PUT/PATCH update (v1 used PUT to create, POST to update).
- Both versions reuse the generic `pkg/web/handler/` `Do*` functions for standard CRUD, which enforce permissions via the model's `Can*` methods.
- Implement permission checks at the model level via the Permissions interface — never in the route handler (the exception: non-CRUD v2 actions must call `Can*` explicitly; the skill covers this).
- v2 generates its OpenAPI spec from Go types automatically — no Swagger annotations. v1's swaggo annotations stay as-is but no new ones are needed.
### Testing
- Backend: Feature tests alongside source files, web tests in `pkg/webtests/`
@ -250,6 +262,8 @@ In the frontend, all translation strings live in `frontend/src/i18n/lang`. For t
You only need to adjust the `en.json` file with the source string. The actual translation happens elsewhere.
After adjusting the source string, you need to call the respective translation library with the key. Both are similar, check the existing code to figure it out.
**Do not add a new language from scratch or translate strings into other languages yourself.** Translations are managed through a dedicated workflow. If you are asked to add a new language, translate existing strings, or update translations for non-English locales, point the user to the translation guide instead: https://vikunja.io/docs/translations/
## Key Files and Conventions
**Configuration:**
@ -261,12 +275,13 @@ After adjusting the source string, you need to call the respective translation l
- Go: golangci-lint per `.golangci.yml`; use goimports; wrap errors with `fmt.Errorf("...: %w", err)`; enforce permissions checks in models; never log secrets; do not edit generated `pkg/swagger/*`
- Vue: ESLint + TS; single quotes, trailing commas, no semicolons, tab indent; script setup + lang ts; keep services/models in sync with backend
- Follow existing patterns for consistency
- **Comments: document the *why*, not the *what* — default to no comment.** Don't write comments that restate the code, a function/struct/field name, or a signature; they're noise the reader skips past (a comment that takes longer to read than the code it describes should be deleted). Only comment a genuinely non-obvious *why* — a gotcha, an invariant, a rejected alternative, a cross-file constraint — in one tight line. Be aggressive about cutting on the first pass, not just when asked.
- Before creating a new file, function, or helper, search the codebase (`grep` / `rg`) for existing code that does the same thing. Prefer extending an existing helper over duplicating it. If logic overlaps an existing function significantly, reuse it.
**Naming Conventions:**
- Go: Standard Go conventions (PascalCase for exports, camelCase for private)
- Vue: PascalCase for components, camelCase for composables
- API endpoints: kebab-case in URLs, camelCase in JSON
- API endpoints: kebab-case in URLs, snake_case in JSON
**Permissions and Permissions:**
- Always implement Permissions interface for new models

View File

@ -28,7 +28,7 @@ ENV RELEASE_VERSION=$RELEASE_VERSION
RUN export PATH=$PATH:$GOPATH/bin && \
mage build:clean && \
mage release:xgo "${TARGETOS}/${TARGETARCH}/${TARGETVARIANT}"
(cd build && mage release:xgo vikunja "${TARGETOS}/${TARGETARCH}/${TARGETVARIANT}")
RUN mkdir -p /tmp && chmod 1777 /tmp
@ -50,7 +50,7 @@ WORKDIR /app/vikunja
ENTRYPOINT [ "/app/vikunja/vikunja" ]
EXPOSE 3456
COPY --from=apibuilder --chown=1000:1000 /tmp /tmp
COPY --from=apibuilder --chown=1000:1000 --chmod=1777 /tmp /tmp
USER 1000

View File

@ -2,7 +2,7 @@
rc-update add vikunja default
# Fix the config to contain proper values
NEW_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)
NEW_SECRET=$(head -c 512 /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32)
sed -i "s/<jwt-secret>/$NEW_SECRET/g" /etc/vikunja/config.yml
sed -i "s/<rootpath>/\/opt\/vikunja\//g" /etc/vikunja/config.yml
sed -i "s/path: \"\.\/vikunja.db\"/path: \"\\/opt\/vikunja\/vikunja.db\"/g" /etc/vikunja/config.yml

View File

@ -3,7 +3,7 @@
systemctl enable vikunja.service
# Fix the config to contain proper values
NEW_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)
NEW_SECRET=$(head -c 512 /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32)
sed -i "s/<jwt-secret>/$NEW_SECRET/g" /etc/vikunja/config.yml
sed -i "s/<rootpath>/\/opt\/vikunja\//g" /etc/vikunja/config.yml
sed -i "s/path: \"\.\/vikunja.db\"/path: \"\\/opt\/vikunja\/vikunja.db\"/g" /etc/vikunja/config.yml

5
build/go.mod Normal file
View File

@ -0,0 +1,5 @@
module code.vikunja.io/build
go 1.25.0
require github.com/magefile/mage v1.17.2

2
build/go.sum Normal file
View File

@ -0,0 +1,2 @@
github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=

757
build/magefile.go Normal file
View File

@ -0,0 +1,757 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//go:build mage
// Centralized release pipeline for every Go binary in this monorepo.
//
// Both vikunja and veans cross-compile through the same code: xgo for the full
// OS/arch matrix, upx where the binary supports it, sha256 alongside each
// artifact, per-target zip bundle, and nfpm.yaml templating for deb/rpm/apk/
// archlinux packaging. Repository-metadata targets (apt/rpm/pacman) consume
// the merged ../dist/repo-work/incoming/ tree the CI populates from both
// projects' packages.
//
// The module is intentionally separate from the project magefiles so the
// release tooling can evolve without touching them. The small filesystem
// helpers (copyFile, moveFile, sha256File) are duplicated rather than
// imported — this magefile depends on nothing but stdlib + mage.
package main
import (
"context"
"crypto/sha256"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"github.com/magefile/mage/mg"
"github.com/magefile/mage/sh"
)
// -----------------------------------------------------------------------------
// project definitions
// project describes one releasable Go binary in this monorepo. Adding a new
// project means adding an entry to projectByName plus a constructor below.
type project struct {
// Name is the short identifier used on the CLI: `mage release:build <name>`.
Name string
// Root is the project root, relative to this build/ directory.
Root string
// BuildPath is the Go package to build, relative to Root (e.g. "." or "./cmd/foo").
BuildPath string
// Executable is the output binary name (sans -<os>-<arch> suffix).
Executable string
// BuildTags are the base build tags applied to every cross-compile.
BuildTags string
// Ldflags returns the full -X flag string for the given version.
Ldflags func(version string) string
// NfpmConfigPath is the nfpm.yaml location, relative to Root.
NfpmConfigPath string
// NfpmBinPathDefault is the default <binlocation> substitution. Empty
// means use the Executable name as-is.
NfpmBinPathDefault string
// OsPackageExtras hook copies any extra files (LICENSE, sample config…)
// into each per-target bundle folder. Called once per binary.
OsPackageExtras func(folder string, p *project) error
}
func projectByName(name string) (*project, error) {
switch name {
case "vikunja":
return vikunjaProject(), nil
case "veans":
return veansProject(), nil
default:
return nil, fmt.Errorf("unknown project %q (known: vikunja, veans)", name)
}
}
func vikunjaProject() *project {
return &project{
Name: "vikunja",
Root: "../",
BuildPath: ".",
Executable: "vikunja",
BuildTags: "osusergo netgo",
Ldflags: func(v string) string {
// Matches the parent magefile's pre-refactor ldflags. The
// main.Tags value is the literal build-tag string baked in
// for `vikunja info` to report.
return fmt.Sprintf(`-X "code.vikunja.io/api/pkg/version.Version=%s" -X "main.Tags=osusergo netgo"`, v)
},
NfpmConfigPath: "nfpm.yaml",
NfpmBinPathDefault: "vikunja",
OsPackageExtras: func(folder string, p *project) error {
// config.yml.sample must be generated by the CI (or local dev)
// before this runs — we don't want to vendor the
// config-raw.json→YAML logic. The workflow does
// `mage generate:config-yaml 1` in the project root before
// invoking release:build.
if err := copyFile(filepath.Join(p.Root, "config.yml.sample"), filepath.Join(folder, "config.yml.sample")); err != nil {
return fmt.Errorf("copy config.yml.sample (run `mage generate:config-yaml 1` first): %w", err)
}
return copyFile(filepath.Join(p.Root, "LICENSE"), filepath.Join(folder, "LICENSE"))
},
}
}
func veansProject() *project {
return &project{
Name: "veans",
Root: "../veans/",
BuildPath: "./cmd/veans",
Executable: "veans",
BuildTags: "osusergo netgo",
Ldflags: func(v string) string {
return fmt.Sprintf(`-X main.version=%s`, v)
},
NfpmConfigPath: "nfpm.yaml",
NfpmBinPathDefault: "./veans",
OsPackageExtras: func(folder string, _ *project) error {
// veans intentionally doesn't carry its own LICENSE — the
// AGPLv3 at the repo root applies to both.
return copyFile("../LICENSE", filepath.Join(folder, "LICENSE"))
},
}
}
// -----------------------------------------------------------------------------
// version resolution
func releaseVersion(ctx context.Context) (string, error) {
if v := os.Getenv("RELEASE_VERSION"); v != "" {
return v, nil
}
out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--always", "--abbrev=10").Output()
if err != nil {
return "", fmt.Errorf("git describe: %w", err)
}
return strings.Replace(strings.TrimSpace(string(out)), "-g", "-", 1), nil
}
func versionTagOrUnstable(v string) string {
switch v {
case "", "main":
return "unstable"
default:
return v
}
}
// -----------------------------------------------------------------------------
// Release namespace
type Release mg.Namespace
// Build runs the full release pipeline for the named project: dirs → xgo
// (windows/linux/darwin in parallel) → upx → copy → sha256 → per-target
// bundle dir → zip.
func (Release) Build(ctx context.Context, name string) error {
p, err := projectByName(name)
if err != nil {
return err
}
version, err := releaseVersion(ctx)
if err != nil {
return err
}
if err := releaseDirs(p); err != nil {
return err
}
if err := prepareXgo(ctx); err != nil {
return err
}
if err := xgoAllOS(ctx, p, version); err != nil {
return err
}
if err := compressBinaries(p); err != nil {
return err
}
if err := copyBinaries(p); err != nil {
return err
}
if err := writeChecksums(p); err != nil {
return err
}
if err := bundleOsPackages(p); err != nil {
return err
}
return zipBundles(ctx, p)
}
// Xgo cross-compiles a single os/arch[/variant] target for the named project.
// Variant follows the parent magefile convention: `linux/arm/7` → arm-7.
//
// Unlike Release.Build, this skips prepareXgo on purpose: the only caller
// that hits this path in CI is the Dockerfile, which runs inside the xgo
// image (xgo binary already present, docker daemon not available). Local
// users invoking `mage release:xgo` need to install xgo themselves.
func (Release) Xgo(ctx context.Context, name, target string) error {
p, err := projectByName(name)
if err != nil {
return err
}
version, err := releaseVersion(ctx)
if err != nil {
return err
}
parts := strings.Split(target, "/")
if len(parts) < 2 {
return fmt.Errorf("invalid target %q (expected os/arch[/variant])", target)
}
variant := ""
if len(parts) > 2 && parts[2] != "" {
variant = "-" + strings.ReplaceAll(parts[2], "v", "")
}
return runXgo(ctx, p, version, parts[0]+"/"+parts[1]+variant)
}
// PrepareNFPMConfig templates the named project's nfpm.yaml in place for the
// given nfpm arch (amd64|arm64|arm7|386). Destructive — CI checks out a fresh
// copy per matrix shard so the trampling is fine.
func (Release) PrepareNFPMConfig(ctx context.Context, name, arch string) error {
p, err := projectByName(name)
if err != nil {
return err
}
version, err := releaseVersion(ctx)
if err != nil {
return err
}
cfgPath := filepath.Join(p.Root, p.NfpmConfigPath)
raw, err := os.ReadFile(cfgPath)
if err != nil {
return err
}
binLocation := os.Getenv("NFPM_BIN_PATH")
if binLocation == "" {
binLocation = p.NfpmBinPathDefault
if binLocation == "" {
binLocation = p.Executable
}
}
out := strings.ReplaceAll(string(raw), "<version>", version)
out = strings.ReplaceAll(out, "<arch>", arch)
out = strings.ReplaceAll(out, "<binlocation>", binLocation)
return os.WriteFile(cfgPath, []byte(out), 0o600)
}
// -----------------------------------------------------------------------------
// Repo-metadata targets — project-agnostic; operate on the merged tree at
// ../dist/repo-work/incoming and ../dist/repo-output.
// RepoApt generates an APT repository (reprepro) for every .deb in the
// incoming tree. REPO_SUITE (stable|unstable) selects the target suite;
// RELEASE_GPG_KEY + RELEASE_GPG_PASSPHRASE drive the Release file signing.
func (Release) RepoApt(ctx context.Context) error {
suite := repoSuite()
incomingDir := filepath.Join(repoRootDist, "repo-work", "incoming")
outputBase := filepath.Join(repoRootDist, "repo-output", "apt")
confDir := filepath.Join(outputBase, "conf")
if err := os.MkdirAll(confDir, 0o755); err != nil {
return fmt.Errorf("creating reprepro conf dir: %w", err)
}
distConf, err := os.ReadFile("reprepro-dist-conf")
if err != nil {
return fmt.Errorf("reading reprepro-dist-conf: %w", err)
}
if err := os.WriteFile(filepath.Join(confDir, "distributions"), distConf, 0o600); err != nil {
return fmt.Errorf("writing distributions config: %w", err)
}
debs, err := filepath.Glob(filepath.Join(incomingDir, "*.deb"))
if err != nil {
return err
}
for _, deb := range debs {
abs, _ := filepath.Abs(deb)
if err := sh.RunV("reprepro", "-b", outputBase, "includedeb", suite, abs); err != nil {
return fmt.Errorf("reprepro includedeb %s: %w", filepath.Base(deb), err)
}
}
gpgKey := os.Getenv("RELEASE_GPG_KEY")
gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE")
releaseFile := filepath.Join(outputBase, "dists", suite, "Release")
if _, err := os.Stat(releaseFile); err == nil {
if err := sh.RunV("gpg",
"--default-key", gpgKey,
"--batch", "--yes",
"--passphrase", gpgPassphrase,
"--pinentry-mode", "loopback",
"--detach-sign", "--armor",
"-o", releaseFile+".gpg",
releaseFile,
); err != nil {
return fmt.Errorf("signing Release (detached): %w", err)
}
if err := sh.RunV("gpg",
"--default-key", gpgKey,
"--batch", "--yes",
"--passphrase", gpgPassphrase,
"--pinentry-mode", "loopback",
"--clearsign",
"-o", filepath.Join(filepath.Dir(releaseFile), "InRelease"),
releaseFile,
); err != nil {
return fmt.Errorf("signing Release (clearsign): %w", err)
}
}
fmt.Println("APT repo metadata generated in", outputBase)
return nil
}
// RepoRpm generates an RPM repository (createrepo_c) per arch in
// ../dist/repo-work/incoming/.
func (Release) RepoRpm(ctx context.Context) error {
suite := repoSuite()
incomingDir := filepath.Join(repoRootDist, "repo-work", "incoming")
outputBase := filepath.Join(repoRootDist, "repo-output", "rpm", suite)
gpgKey := os.Getenv("RELEASE_GPG_KEY")
gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE")
for _, arch := range []string{"x86_64", "aarch64", "armv7"} {
repoDir := filepath.Join(outputBase, arch)
if err := os.MkdirAll(repoDir, 0o755); err != nil {
return err
}
rpms, _ := filepath.Glob(filepath.Join(incomingDir, "*-"+arch+".rpm"))
if len(rpms) == 0 {
continue
}
for _, rpm := range rpms {
abs, _ := filepath.Abs(rpm)
dst := filepath.Join(repoDir, filepath.Base(rpm))
_ = os.Remove(dst)
if err := os.Symlink(abs, dst); err != nil {
return err
}
}
args := []string{repoDir}
if _, err := os.Stat(filepath.Join(repoDir, "repodata")); err == nil {
args = []string{"--update", repoDir}
}
if err := sh.RunV("createrepo_c", args...); err != nil {
return fmt.Errorf("createrepo_c for %s: %w", arch, err)
}
if err := sh.RunV("gpg",
"--default-key", gpgKey,
"--batch", "--yes",
"--passphrase", gpgPassphrase,
"--pinentry-mode", "loopback",
"--detach-sign", "--armor",
"-o", filepath.Join(repoDir, "repodata", "repomd.xml.asc"),
filepath.Join(repoDir, "repodata", "repomd.xml"),
); err != nil {
return fmt.Errorf("signing repomd.xml for %s: %w", arch, err)
}
}
fmt.Println("RPM repo metadata generated in", outputBase)
return nil
}
// RepoPacman generates a Pacman repository (repo-add) per arch.
func (Release) RepoPacman(ctx context.Context) error {
suite := repoSuite()
incomingDir := filepath.Join(repoRootDist, "repo-work", "incoming")
outputBase := filepath.Join(repoRootDist, "repo-output", "pacman", suite)
gpgKey := os.Getenv("RELEASE_GPG_KEY")
gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE")
for _, arch := range []string{"x86_64", "aarch64", "armv7"} {
repoDir := filepath.Join(outputBase, arch)
if err := os.MkdirAll(repoDir, 0o755); err != nil {
return err
}
pkgs, _ := filepath.Glob(filepath.Join(incomingDir, "*-"+arch+".archlinux"))
if len(pkgs) == 0 {
continue
}
for _, pkg := range pkgs {
abs, _ := filepath.Abs(pkg)
dst := filepath.Join(repoDir, filepath.Base(pkg))
_ = os.Remove(dst)
if err := os.Symlink(abs, dst); err != nil {
return err
}
}
dbPath := filepath.Join(repoDir, "vikunja.db.tar.gz")
repoPkgs, _ := filepath.Glob(filepath.Join(repoDir, "*.archlinux"))
repoAddArgs := append([]string{dbPath}, repoPkgs...)
if err := sh.RunV("repo-add", repoAddArgs...); err != nil {
return fmt.Errorf("repo-add for %s: %w", arch, err)
}
for _, name := range []string{"vikunja.db", "vikunja.files"} {
link := filepath.Join(repoDir, name)
_ = os.Remove(link)
if err := os.Symlink(name+".tar.gz", link); err != nil {
return fmt.Errorf("creating symlink %s: %w", name, err)
}
}
if err := sh.RunV("gpg",
"--default-key", gpgKey,
"--batch", "--yes",
"--passphrase", gpgPassphrase,
"--pinentry-mode", "loopback",
"--detach-sign",
"-o", filepath.Join(repoDir, "vikunja.db.sig"),
dbPath,
); err != nil {
return fmt.Errorf("signing db for %s: %w", arch, err)
}
}
fmt.Println("Pacman repo metadata generated in", outputBase)
return nil
}
// -----------------------------------------------------------------------------
// pipeline internals
const (
distSubdir = "dist"
subBin = "binaries"
subRelease = "release"
subZip = "zip"
// repoRootDist is where the repo-publish targets read and write — it's
// the dist/ directory at the repo root, not under build/. The CI
// populates dist/repo-work/incoming with packages from every project.
repoRootDist = "../dist"
)
func projectDist(p *project, sub string) string {
return filepath.Join(p.Root, distSubdir, sub)
}
func releaseDirs(p *project) error {
for _, d := range []string{subBin, subRelease, subZip} {
if err := os.MkdirAll(projectDist(p, d), 0o755); err != nil {
return err
}
}
return nil
}
func prepareXgo(_ context.Context) error {
if _, err := exec.LookPath("xgo"); err != nil {
fmt.Println("xgo not found, installing src.techknowlogick.com/xgo...")
if err := sh.RunV("go", "install", "src.techknowlogick.com/xgo@latest"); err != nil {
return fmt.Errorf("installing xgo: %w", err)
}
}
fmt.Println("Pulling latest xgo docker image...")
return sh.RunV("docker", "pull", "ghcr.io/techknowlogick/xgo:latest")
}
func xgoOutName(p *project, version string) string {
if v := os.Getenv("XGO_OUT_NAME"); v != "" {
return v
}
return p.Executable + "-" + versionTagOrUnstable(version)
}
func runXgo(ctx context.Context, p *project, version, targets string) error {
extraLdflags := `-linkmode external -extldflags "-static" `
// xgo's darwin builds can't use the static external linker.
if strings.HasPrefix(targets, "darwin") {
extraLdflags = ""
}
// xgo resolves its last arg as a Go package path. Running it from build/
// with `../` confuses the module resolution (it tries to find a package
// inside this build module). Invoke xgo from the project root so we can
// pass p.BuildPath ("." or "./cmd/veans") just like the original
// per-project magefiles did.
absRoot, err := filepath.Abs(p.Root)
if err != nil {
return fmt.Errorf("resolve project root: %w", err)
}
absDest, err := filepath.Abs(projectDist(p, subBin))
if err != nil {
return fmt.Errorf("resolve dest dir: %w", err)
}
//nolint:gosec // mage helper; args are derived from the static project table above.
cmd := exec.CommandContext(ctx, "xgo",
"-dest", absDest,
"-tags", p.BuildTags,
"-ldflags", extraLdflags+p.Ldflags(version),
"-targets", targets,
"-out", xgoOutName(p, version),
p.BuildPath,
)
cmd.Dir = absRoot
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func xgoAllOS(ctx context.Context, p *project, version string) error {
groups := []string{
"windows/*",
strings.Join([]string{
"linux/amd64",
"linux/arm-5",
"linux/arm-6",
"linux/arm-7",
"linux/arm64",
"linux/mips",
"linux/mipsle",
"linux/mips64",
"linux/mips64le",
"linux/riscv64",
}, ","),
"darwin-10.15/*",
}
var (
wg sync.WaitGroup
mu sync.Mutex
firstErr error
)
record := func(err error) {
if err == nil {
return
}
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
}
for _, targets := range groups {
wg.Add(1)
go func(t string) {
defer wg.Done()
record(runXgo(ctx, p, version, t))
}(targets)
}
wg.Wait()
return firstErr
}
// compressBinaries runs upx -9 over each binary that upx can handle. The skip
// list matches the parent magefile's behavior.
func compressBinaries(p *project) error {
var (
wg sync.WaitGroup
mu sync.Mutex
firstErr error
)
record := func(err error) {
if err == nil {
return
}
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
}
walkErr := filepath.Walk(projectDist(p, subBin), func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
name := info.Name()
if !strings.Contains(name, p.Executable) {
return nil
}
if strings.Contains(name, "mips") ||
strings.Contains(name, "s390x") ||
strings.Contains(name, "riscv64") ||
strings.Contains(name, "darwin") ||
(strings.Contains(name, "windows") && strings.Contains(name, "arm64")) {
return nil
}
wg.Add(1)
go func(pp string) {
defer wg.Done()
if err := sh.RunV("chmod", "+x", pp); err != nil {
record(err)
return
}
record(sh.RunV("upx", "-9", pp))
}(path)
return nil
})
if walkErr != nil {
return walkErr
}
wg.Wait()
return firstErr
}
func copyBinaries(p *project) error {
return filepath.Walk(projectDist(p, subBin), func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
if !strings.Contains(info.Name(), p.Executable) {
return nil
}
return copyFile(path, filepath.Join(projectDist(p, subRelease), info.Name()))
})
}
func writeChecksums(p *project) error {
release := projectDist(p, subRelease)
return filepath.Walk(release, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
if strings.HasSuffix(info.Name(), ".sha256") {
return nil
}
sum, err := sha256File(path)
if err != nil {
return err
}
return os.WriteFile(path+".sha256", []byte(sum+" "+info.Name()+"\n"), 0o644)
})
}
func bundleOsPackages(p *project) error {
release := projectDist(p, subRelease)
bins := map[string]os.FileInfo{}
if err := filepath.Walk(release, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
if strings.HasSuffix(info.Name(), ".sha256") {
return nil
}
bins[path] = info
return nil
}); err != nil {
return err
}
for binPath, info := range bins {
folder := filepath.Join(release, info.Name()+"-full")
if err := os.MkdirAll(folder, 0o755); err != nil {
return err
}
if err := moveFile(binPath+".sha256", filepath.Join(folder, info.Name()+".sha256")); err != nil {
return err
}
if err := moveFile(binPath, filepath.Join(folder, info.Name())); err != nil {
return err
}
if p.OsPackageExtras != nil {
if err := p.OsPackageExtras(folder, p); err != nil {
return err
}
}
}
return nil
}
func zipBundles(ctx context.Context, p *project) error {
zipDirAbs, err := filepath.Abs(projectDist(p, subZip))
if err != nil {
return err
}
release := projectDist(p, subRelease)
return filepath.Walk(release, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() || filepath.Base(path) == subRelease {
return nil
}
fmt.Printf("Zipping %s...\n", info.Name())
zipFile := filepath.Join(zipDirAbs, info.Name()+".zip")
//nolint:gosec // mage helper; args derive from the local filesystem walk above.
c := exec.CommandContext(ctx, "zip", "-r", zipFile, ".", "-i", "*")
c.Dir = path
c.Stdout, c.Stderr = os.Stdout, os.Stderr
return c.Run()
})
}
// repoSuite validates the REPO_SUITE env var; defaults to "stable". Limiting
// the values prevents path traversal via the suite name flowing into a
// filesystem path.
func repoSuite() string {
switch os.Getenv("REPO_SUITE") {
case "stable", "unstable":
return os.Getenv("REPO_SUITE")
default:
return "stable"
}
}
// -----------------------------------------------------------------------------
// helpers — duplicated from the project magefiles so this module depends on
// nothing but stdlib + mage. Don't import these from elsewhere; rewrite them
// here if they need to change.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
si, err := os.Stat(src)
if err != nil {
return err
}
if err := os.Chmod(dst, si.Mode()); err != nil {
return err
}
return out.Close()
}
func moveFile(src, dst string) error {
if err := copyFile(src, dst); err != nil {
return err
}
return os.Remove(src)
}
func sha256File(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
// Aliases for kebab-case spelling at the CLI.
var Aliases = map[string]any{
"release": Release.Build,
"release:build": Release.Build,
"release:xgo": Release.Xgo,
"release:prepare-nfpm-config": Release.PrepareNFPMConfig,
"release:repo-apt": Release.RepoApt,
"release:repo-rpm": Release.RepoRpm,
"release:repo-pacman": Release.RepoPacman,
}

BIN
desktop/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@ -397,7 +397,11 @@ function toggleQuickEntry() {
// ─── System tray ─────────────────────────────────────────────────────
function setupTray() {
if (!tray) {
const iconPath = path.join(__dirname, 'build', 'icon.png')
// NOTE: load the icon from the app root, not build/. The build/ directory is
// electron-builder's buildResources dir and is NOT packaged into the app, so
// referencing build/icon.png here works in dev but yields an empty tray icon
// in packaged releases (see issue #2668).
const iconPath = path.join(__dirname, 'icon.png')
const icon = nativeImage.createFromPath(iconPath).resize({width: 16, height: 16})
tray = new Tray(icon)
tray.setToolTip('Vikunja')

View File

@ -61,8 +61,8 @@
}
},
"devDependencies": {
"electron": "40.9.2",
"electron-builder": "26.8.1",
"electron": "40.10.2",
"electron-builder": "26.15.0",
"unzipper": "0.12.3"
},
"dependencies": {
@ -76,7 +76,9 @@
"minimatch": "^10.2.3",
"tar": "^7.5.11",
"@tootallnate/once": "^3.0.1",
"picomatch": ">=4.0.4"
"picomatch": ">=4.0.4",
"tmp": ">=0.2.6",
"ip-address": ">=10.1.1"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -23,7 +23,6 @@
// It has to be the full url, including the last /api/v1 part and port.
// You can change this if your api is not reachable on the same port as the frontend.
window.API_URL = '/api/v1'
window.ALLOW_ICON_CHANGES = true
</script>
</body>
</html>

View File

@ -60,6 +60,7 @@
"@kyvg/vue3-notification": "3.4.2",
"@sentry/vue": "10.36.0",
"@tiptap/core": "3.17.0",
"@tiptap/extension-blockquote": "3.17.0",
"@tiptap/extension-code-block-lowlight": "3.17.0",
"@tiptap/extension-hard-break": "3.17.0",
"@tiptap/extension-image": "3.17.0",
@ -76,7 +77,7 @@
"@tiptap/vue-3": "3.17.0",
"@vueuse/core": "14.1.0",
"@vueuse/router": "14.1.0",
"axios": "1.15.0",
"axios": "1.16.0",
"blurhash": "2.0.5",
"bulma-css-variables": "0.9.33",
"change-case": "5.4.4",
@ -100,7 +101,7 @@
"vue-i18n": "11.2.8",
"vue-router": "4.6.4",
"vuemoji-picker": "0.3.2",
"workbox-precaching": "7.4.0",
"workbox-precaching": "7.4.1",
"zhyswan-vuedraggable": "4.1.3"
},
"devDependencies": {
@ -109,55 +110,55 @@
"@histoire/plugin-vue": "1.0.0-beta.1",
"@playwright/test": "1.58.2",
"@sentry/vite-plugin": "3.6.1",
"@tailwindcss/vite": "4.2.4",
"@tailwindcss/vite": "4.3.0",
"@tsconfig/node24": "24.0.4",
"@types/codemirror": "5.60.17",
"@types/is-touch-device": "1.0.3",
"@types/node": "24.12.2",
"@types/node": "24.13.1",
"@types/sortablejs": "1.15.9",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.59.0",
"@typescript-eslint/parser": "8.59.0",
"@vitejs/plugin-vue": "6.0.6",
"@vue/eslint-config-typescript": "14.7.0",
"@vue/test-utils": "2.4.7",
"@typescript-eslint/eslint-plugin": "8.60.1",
"@typescript-eslint/parser": "8.60.1",
"@vitejs/plugin-vue": "6.0.7",
"@vue/eslint-config-typescript": "14.8.0",
"@vue/test-utils": "2.4.11",
"@vue/tsconfig": "0.9.1",
"@vueuse/shared": "14.2.1",
"@vueuse/shared": "14.3.0",
"autoprefixer": "10.5.0",
"browserslist": "4.28.2",
"caniuse-lite": "1.0.30001790",
"caniuse-lite": "1.0.30001797",
"csstype": "3.2.3",
"esbuild": "0.28.0",
"eslint": "9.39.4",
"eslint-plugin-depend": "1.5.0",
"eslint-plugin-vue": "10.9.0",
"happy-dom": "20.9.0",
"eslint-plugin-vue": "10.9.2",
"happy-dom": "20.10.2",
"histoire": "1.0.0-beta.1",
"otplib": "12.0.1",
"postcss": "8.5.10",
"postcss": "8.5.15",
"postcss-easing-gradients": "3.0.1",
"postcss-html": "1.8.1",
"postcss-preset-env": "11.2.1",
"rollup": "4.60.2",
"postcss-preset-env": "11.3.0",
"rollup": "4.61.1",
"rollup-plugin-visualizer": "6.0.11",
"sass-embedded": "1.99.0",
"stylelint": "17.9.0",
"sass-embedded": "1.100.0",
"stylelint": "17.13.0",
"stylelint-config-property-sort-order-smacss": "10.0.0",
"stylelint-config-recommended-vue": "1.6.1",
"stylelint-config-standard-scss": "17.0.0",
"stylelint-use-logical": "2.1.3",
"tailwindcss": "4.2.4",
"tailwindcss": "4.3.0",
"typescript": "5.9.3",
"unplugin-inject-preload": "3.0.0",
"vite": "7.3.2",
"vite-plugin-pwa": "1.2.0",
"vite-plugin-vue-devtools": "8.1.1",
"vite": "7.3.5",
"vite-plugin-pwa": "1.3.0",
"vite-plugin-vue-devtools": "8.1.2",
"vite-svg-loader": "5.1.1",
"vitest": "4.1.5",
"vue-tsc": "3.2.7",
"wait-on": "9.0.5",
"workbox-cli": "7.4.0",
"ws": "8.20.0"
"vitest": "4.1.8",
"vue-tsc": "3.3.3",
"wait-on": "9.0.10",
"workbox-cli": "7.4.1",
"ws": "8.21.0"
},
"pnpm": {
"onlyBuiltDependencies": [
@ -172,7 +173,10 @@
"rollup": "$rollup",
"basic-ftp": ">=5.2.2",
"serialize-javascript": "^7.0.5",
"flatted": "^3.4.1"
"flatted": "^3.4.1",
"ip-address": ">=10.1.1",
"postcss": ">=8.5.10",
"tmp": ">=0.2.6"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@ export const DATE_RANGES = {
// Key is the title, as a translation string, the first entry of the value array
// is the "from" date, the second one is the "to" date.
'today': ['now/d', 'now/d+1d'],
'tomorrow': ['now/d+1d', 'now/d+2d'],
'lastWeek': ['now/w-1w', 'now/w'],
'thisWeek': ['now/w', 'now/w+1w'],

View File

@ -2,6 +2,7 @@
import { computed } from 'vue'
import { useNow } from '@vueuse/core'
import { useAuthStore } from '@/stores/auth'
import { useConfigStore } from '@/stores/config'
import { useColorScheme } from '@/composables/useColorScheme'
import LogoFull from '@/assets/logo-full.svg?component'
@ -13,9 +14,10 @@ const now = useNow({
})
const authStore = useAuthStore()
const configStore = useConfigStore()
const { isDark } = useColorScheme()
const Logo = computed(() => window.ALLOW_ICON_CHANGES
const Logo = computed(() => configStore.allowIconChanges
&& authStore.settings.frontendSettings.allowIconChanges
&& now.value.getMonth() === 5
? LogoFullPride

View File

@ -84,6 +84,8 @@ import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
import {createDateFromString} from '@/helpers/time/createDateFromString'
import {useI18n} from 'vue-i18n'
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
import {useTimeFormat} from '@/composables/useTimeFormat'
import {TIME_FORMAT} from '@/constants/timeFormat'
const props = defineProps<{
modelValue: Date | null | string
@ -94,6 +96,7 @@ const emit = defineEmits<{
}>()
const {t} = useI18n({useScope: 'global'})
const {store: timeFormat} = useTimeFormat()
const date = ref<Date | null>(null)
const changed = ref(false)
@ -111,7 +114,7 @@ const flatPickerConfig = computed(() => ({
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
time_24hr: timeFormat.value === TIME_FORMAT.HOURS_24,
inline: true,
locale: useFlatpickrLanguage().value,
}))

View File

@ -0,0 +1,161 @@
<template>
<NodeViewWrapper
as="blockquote"
class="comment-quote"
:class="{'comment-quote--has-parent': hasParent}"
:data-comment-id="commentId === null ? null : String(commentId)"
>
<div
v-if="commentId !== null && ctx"
contenteditable="false"
class="comment-quote__header"
>
<template v-if="parent">
<img
v-if="avatarUrl"
:src="avatarUrl"
alt=""
class="comment-quote__avatar"
width="20"
height="20"
>
<span class="comment-quote__author">{{ authorName }}</span>
<BaseButton
v-tooltip="t('task.comment.jumpToOriginal')"
class="comment-quote__jump"
:aria-label="t('task.comment.jumpToOriginal')"
@click="onJump"
>
<Icon icon="angle-right" />
</BaseButton>
</template>
<span
v-else
class="comment-quote__author comment-quote__author--missing"
>
{{ t('task.comment.deletedComment') }}
</span>
</div>
<NodeViewContent class="comment-quote__body" />
</NodeViewWrapper>
</template>
<script lang="ts" setup>
import {computed, inject, ref, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import {nodeViewProps, NodeViewWrapper, NodeViewContent} from '@tiptap/vue-3'
import BaseButton from '@/components/base/BaseButton.vue'
import {fetchAvatarBlobUrl, getDisplayName} from '@/models/user'
import {commentReplyContextKey} from '@/components/tasks/partials/commentReplyContext'
const props = defineProps(nodeViewProps)
const {t} = useI18n({useScope: 'global'})
const ctx = inject(commentReplyContextKey, null)
const commentId = computed<number | null>(() => {
const raw = props.node.attrs.commentId
if (raw === null || raw === undefined) {
return null
}
const id = Number(raw)
return Number.isInteger(id) && id > 0 ? id : null
})
const parent = computed(() => {
if (commentId.value === null || !ctx) {
return undefined
}
return ctx.findComment(commentId.value)
})
const hasParent = computed(() => parent.value !== undefined)
const authorName = computed(() => {
const p = parent.value
return p ? getDisplayName(p.author) : ''
})
const avatarUrl = ref('')
// Bumped on every parent change so stale avatar fetches (older parent)
// don't overwrite a newer one if the user navigates between comments
// while fetches are still in flight.
let avatarFetchToken = 0
watch(parent, (p) => {
avatarUrl.value = ''
const token = ++avatarFetchToken
if (!p?.author) {
return
}
fetchAvatarBlobUrl(p.author, 20)
.then((url) => {
if (token === avatarFetchToken) {
avatarUrl.value = (url as string) ?? ''
}
})
.catch(() => {
// Swallow a missing avatar isn't worth a user-visible error;
// the header still renders with the author name.
})
}, {immediate: true})
function onJump() {
if (commentId.value !== null && ctx) {
ctx.scrollToComment(commentId.value)
}
}
</script>
<style lang="scss">
.tiptap blockquote.comment-quote {
margin-block: .5rem;
.comment-quote__header {
display: flex;
align-items: center;
gap: .5rem;
padding-block-end: .25rem;
font-size: .85rem;
color: var(--grey-600);
user-select: none;
}
.comment-quote__avatar {
border-radius: 50%;
flex: 0 0 auto;
}
.comment-quote__author {
font-weight: 600;
color: var(--grey-700);
&--missing {
font-style: italic;
color: var(--grey-500);
}
}
.comment-quote__jump {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--grey-500);
padding: .15rem .25rem;
border-radius: 9999px;
transition: background-color $transition, color $transition;
&:hover {
color: var(--grey-800);
background: var(--grey-200);
}
}
.comment-quote__body > :first-child {
margin-block-start: 0;
}
}
</style>

View File

@ -166,6 +166,7 @@ import Mention from '@tiptap/extension-mention'
import {TaskList} from '@tiptap/extension-list'
import {TaskItemWithId} from './taskItemWithId'
import {BlockquoteWithCommentId} from './blockquoteWithCommentId'
import HardBreak from '@tiptap/extension-hard-break'
import Commands from './commands'
@ -417,7 +418,9 @@ const extensions : Extensions = [
StarterKit.configure({
codeBlock: false,
hardBreak: false,
blockquote: false,
}),
BlockquoteWithCommentId,
CodeBlockLowlight.configure({
lowlight: createLowlight(common),
@ -775,6 +778,24 @@ function setModeAndValue(value: string) {
})
}
// Replace the editor content with a reply draft (prefilled blockquote + empty
// paragraph) and enter edit mode immediately so the user can start typing.
// Returns synchronously after the next tick to let DOM updates settle.
async function setReplyContent(value: string) {
if (!editor.value) return
editor.value.commands.setContent(value, {
...defaultSetContentOptions,
emitUpdate: false,
})
internalMode.value = 'edit'
modelValue.value = editor.value.getHTML()
contentHasChanged.value = true
await nextTick()
editor.value.commands.focus('end')
}
defineExpose({setReplyContent})
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
function setFocusToEditor(event: KeyboardEvent) {

View File

@ -0,0 +1,65 @@
import {describe, it, expect} from 'vitest'
import {Editor} from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import {BlockquoteWithCommentId} from './blockquoteWithCommentId'
describe('BlockquoteWithCommentId extension', () => {
const createEditor = (content: string = '') => {
return new Editor({
extensions: [
StarterKit.configure({blockquote: false}),
BlockquoteWithCommentId,
],
content,
})
}
it('preserves data-comment-id through setContent → getHTML round-trip', () => {
const editor = createEditor('<blockquote data-comment-id="42"><p>hi</p></blockquote>')
const html = editor.getHTML()
expect(html).toContain('data-comment-id="42"')
editor.destroy()
})
it('renders a plain blockquote (no attribute) unchanged', () => {
const editor = createEditor('<blockquote><p>just a quote</p></blockquote>')
const html = editor.getHTML()
expect(html).toContain('<blockquote>')
expect(html).not.toContain('data-comment-id')
editor.destroy()
})
it('preserves nested rich content inside the blockquote', () => {
const editor = createEditor(
'<blockquote data-comment-id="7"><p>this is <strong>bold</strong> text</p></blockquote>',
)
const html = editor.getHTML()
expect(html).toContain('data-comment-id="7"')
expect(html).toContain('<strong>bold</strong>')
editor.destroy()
})
it('drops a malformed data-comment-id (non-integer)', () => {
const editor = createEditor('<blockquote data-comment-id="abc"><p>x</p></blockquote>')
const html = editor.getHTML()
expect(html).not.toContain('data-comment-id')
editor.destroy()
})
it('drops a non-positive data-comment-id', () => {
const editor = createEditor('<blockquote data-comment-id="0"><p>x</p></blockquote>')
const html = editor.getHTML()
expect(html).not.toContain('data-comment-id')
editor.destroy()
})
})

View File

@ -0,0 +1,50 @@
import Blockquote from '@tiptap/extension-blockquote'
import {VueNodeViewRenderer} from '@tiptap/vue-3'
import BlockquoteCommentView from './BlockquoteCommentView.vue'
/**
* Blockquote extension that preserves `data-comment-id` across parse/serialize.
* Used as the canonical reply marker: a comment that quotes another comment
* stores the referenced comment's id on the wrapping blockquote, so both the
* backend (for implicit-mention notifications) and the frontend (for the
* jump-to-original chevron) can find it without a separate schema field.
*
* A Vue NodeView renders the in-app header + chevron when the surrounding
* component (Comments.vue) provides a `commentReplyContext`. Outside that
* context (task descriptions, etc.) the NodeView falls back to a plain
* blockquote.
*/
export const BlockquoteWithCommentId = Blockquote.extend({
addAttributes() {
return {
...this.parent?.(),
commentId: {
default: null,
parseHTML: (element: HTMLElement) => {
const raw = element.getAttribute('data-comment-id')
if (raw === null) {
return null
}
const id = Number(raw)
if (!Number.isInteger(id) || id <= 0) {
return null
}
return id
},
renderHTML: (attributes) => {
if (attributes.commentId === null || attributes.commentId === undefined) {
return {}
}
return {
'data-comment-id': String(attributes.commentId),
}
},
},
}
},
addNodeView() {
return VueNodeViewRenderer(BlockquoteCommentView)
},
})

View File

@ -135,6 +135,7 @@ defineExpose({
inline-size: 100%;
text-align: start;
background: transparent;
color: inherit;
border-radius: $radius;
border: 0;
padding: 0.375rem 0.5rem;

View File

@ -58,6 +58,7 @@ import {
faPlay,
faPlus,
faPowerOff,
faRss,
faSearch,
faShareAlt,
faSignOutAlt,
@ -168,6 +169,7 @@ library.add(faPercent)
library.add(faPlay)
library.add(faPlus)
library.add(faPowerOff)
library.add(faRss)
library.add(faSave)
library.add(faSearch)
library.add(faShareAlt)

View File

@ -17,7 +17,7 @@
>
<BaseButton
:aria-label="$t('misc.closeDialog')"
class="close"
class="close d-print-none"
@click="$emit('close')"
>
<Icon icon="times" />
@ -62,7 +62,7 @@
<script lang="ts" setup>
import BaseButton from '@/components/base/BaseButton.vue'
import {ref, useAttrs, watch, onBeforeUnmount} from 'vue'
import {ref, useAttrs, watch, onBeforeUnmount, onMounted} from 'vue'
const props = withDefaults(defineProps<{
enabled?: boolean,
@ -158,6 +158,37 @@ watch(dialogRef, (dialog) => {
dialog.showModal()
})
// A <dialog> opened with showModal() lives in the browser's top layer, which
// renders only the first page during print (top-layer elements are
// viewport-anchored and don't paginate). Temporarily swap to a non-modal
// dialog for the duration of the print so the content flows in normal
// document order and can break across pages.
let wasModalBeforePrint = false
function handleBeforePrint() {
const dialog = dialogRef.value
if (dialog && dialog.matches(':modal')) {
wasModalBeforePrint = true
dialog.close()
dialog.show()
}
}
function handleAfterPrint() {
if (!wasModalBeforePrint) return
wasModalBeforePrint = false
const dialog = dialogRef.value
if (dialog && dialog.open) {
dialog.close()
dialog.showModal()
}
}
onMounted(() => {
window.addEventListener('beforeprint', handleBeforePrint)
window.addEventListener('afterprint', handleAfterPrint)
})
onBeforeUnmount(() => {
if (closeTimer) {
clearTimeout(closeTimer)
@ -167,6 +198,8 @@ onBeforeUnmount(() => {
if (previouslyFocused.value instanceof HTMLElement) {
previouslyFocused.value.focus()
}
window.removeEventListener('beforeprint', handleBeforePrint)
window.removeEventListener('afterprint', handleAfterPrint)
})
</script>
@ -256,6 +289,20 @@ $modal-width: 1024px;
}
}
// Default width for centered modals. Scoped with :not(.is-wide) so the
// `wide` prop can still expand the modal (the .is-wide rule below would
// otherwise be outranked by .default .modal-content's specificity).
.default .modal-content:not(.is-wide),
.hint-modal .modal-content:not(.is-wide) {
inline-size: calc(100% - 2rem);
max-inline-size: 640px;
@media screen and (max-width: $tablet) {
inline-size: 100%;
max-inline-size: none;
}
}
// scrolling-content
// used e.g. for <TaskDetailViewModal>
.scrolling .modal-content {
@ -347,6 +394,31 @@ $modal-width: 1024px;
}
}
// Unconstrain the native <dialog> so the full modal content flows onto the
// printed page instead of being clipped to the viewport-sized top layer.
@media print {
.modal-dialog {
position: static;
inline-size: auto;
block-size: auto;
max-inline-size: none;
max-block-size: none;
&::backdrop {
display: none;
}
}
.modal-container {
overflow: visible;
min-block-size: 0;
}
:deep(.card) {
min-block-size: 0 !important;
}
}
.modal-content:has(.modal-header) {
display: flex;
flex-direction: column;

View File

@ -1,66 +1,96 @@
<template>
<Notifications
position="bottom left"
:max="2"
:ignore-duplicates="true"
class="global-notification"
role="status"
aria-live="polite"
>
<template #body="{ item, close }">
<!-- FIXME: overlay whole notification with button and add event listener on that button instead -->
<div
class="vue-notification-template vue-notification"
:class="[
item.type,
]"
@click="close()"
>
<Teleport :to="teleportTarget">
<Notifications
position="bottom left"
:max="2"
:ignore-duplicates="true"
class="global-notification"
role="status"
aria-live="polite"
>
<template #body="{ item, close }">
<!-- FIXME: overlay whole notification with button and add event listener on that button instead -->
<div
v-if="item.title"
class="notification-title"
class="vue-notification-template vue-notification"
:class="[
item.type,
]"
@click="close()"
>
{{ item.title }}
</div>
<div class="notification-content">
<template v-if="Array.isArray(item.text)">
<template
v-for="(t, k) in item.text"
:key="k"
>
{{ t }}<br>
<div
v-if="item.title"
class="notification-title"
>
{{ item.title }}
</div>
<div class="notification-content">
<template v-if="Array.isArray(item.text)">
<template
v-for="(t, k) in item.text"
:key="k"
>
{{ t }}<br>
</template>
</template>
</template>
<template v-else>
{{ item.text }}
</template>
<span
v-if="item.duplicates > 0"
class="tw:text-xs tw:font-bold tw:ml-1"
<template v-else>
{{ item.text }}
</template>
<span
v-if="item.duplicates > 0"
class="tw:text-xs tw:font-bold tw:ml-1"
>
×{{ item.duplicates + 1 }}
</span>
</div>
<div
v-if="item.data?.actions?.length > 0"
class="mbs-2 tw:flex tw:justify-end tw:gap-2"
>
×{{ item.duplicates + 1 }}
</span>
<XButton
v-for="(action, i) in item.data.actions"
:key="'action_' + i"
:shadow="false"
class="is-small"
variant="secondary"
@click="action.callback"
>
{{ action.title }}
</XButton>
</div>
</div>
<div
v-if="item.data?.actions?.length > 0"
class="mbs-2 tw:flex tw:justify-end tw:gap-2"
>
<XButton
v-for="(action, i) in item.data.actions"
:key="'action_' + i"
:shadow="false"
class="is-small"
variant="secondary"
@click="action.callback"
>
{{ action.title }}
</XButton>
</div>
</div>
</template>
</Notifications>
</template>
</Notifications>
</Teleport>
</template>
<script lang="ts" setup>
import {onBeforeUnmount, onMounted, ref} from 'vue'
const teleportTarget = ref<string | HTMLElement>('body')
let observer: MutationObserver | null = null
function syncTeleportTarget() {
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.modal-dialog[open]')
teleportTarget.value = dialogs.item(dialogs.length - 1) ?? 'body'
}
onMounted(() => {
syncTeleportTarget()
observer = new MutationObserver(syncTeleportTarget)
observer.observe(document.body, {
attributes: true,
attributeFilter: ['open'],
childList: true,
subtree: true,
})
})
onBeforeUnmount(() => {
observer?.disconnect()
observer = null
})
</script>
<style scoped>
.vue-notification {
z-index: 9999;

View File

@ -2,15 +2,24 @@
<div
class="user"
:class="{'is-inline': isInline}"
:style="{'--avatar-size': `${avatarSize}px`}"
>
<img
v-tooltip="displayName"
:height="avatarSize"
:src="avatarSrc"
:width="avatarSize"
:alt="'Avatar of ' + displayName"
class="avatar"
>
<span class="avatar-wrapper">
<img
v-tooltip="displayName"
:height="avatarSize"
:src="avatarSrc"
:width="avatarSize"
:alt="'Avatar of ' + displayName"
class="avatar"
>
<span
v-if="isBot"
v-tooltip="t('user.settings.bots.badge')"
class="bot-badge"
aria-label="Bot"
>B</span>
</span>
<span
v-if="showUsername"
class="username"
@ -20,6 +29,7 @@
<script lang="ts" setup>
import {computed, ref, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import {fetchAvatarBlobUrl, getDisplayName} from '@/models/user'
import type {IUser} from '@/modelTypes/IUser'
@ -35,7 +45,10 @@ const props = withDefaults(defineProps<{
isInline: false,
})
const {t} = useI18n({useScope: 'global'})
const displayName = computed(() => getDisplayName(props.user))
const isBot = computed(() => ((props.user as IUser & {botOwnerId?: number}).botOwnerId ?? 0) > 0)
const avatarSrc = ref('')
async function loadAvatar() {
@ -55,9 +68,40 @@ watch(() => [props.user, props.avatarSize], loadAvatar, { immediate: true })
}
}
.avatar {
border-radius: 100%;
vertical-align: middle;
.avatar-wrapper {
position: relative;
display: inline-flex;
margin-inline-end: .5rem;
}
.avatar {
inline-size: var(--avatar-size);
block-size: var(--avatar-size);
border-radius: 100%;
vertical-align: middle;
}
.bot-badge {
position: absolute;
inset-block-end: 0;
inset-inline-start: 0;
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 40%;
block-size: 40%;
min-inline-size: 14px;
min-block-size: 14px;
max-inline-size: 22px;
max-block-size: 22px;
font-size: .65rem;
font-weight: 700;
line-height: 1;
color: var(--white);
background: var(--primary);
border: 2px solid var(--white);
border-radius: 100%;
text-transform: uppercase;
pointer-events: auto;
}
</style>

View File

@ -24,7 +24,18 @@
ref="popup"
class="notifications-list"
>
<span class="head">{{ $t('notification.title') }}</span>
<div class="head">
<span>{{ $t('notification.title') }}</span>
<BaseButton
v-tooltip="$t('notification.subscribeFeed')"
class="feed-link"
:to="{name: 'user.settings.feeds'}"
@click="showNotifications = false"
>
<span class="is-sr-only">{{ $t('notification.subscribeFeed') }}</span>
<Icon icon="rss" />
</BaseButton>
</div>
<div
v-for="(n, index) in notifications"
:key="n.id"
@ -284,6 +295,19 @@ async function markAllRead() {
font-family: $vikunja-font;
font-size: 1rem;
padding: .5rem;
display: flex;
align-items: center;
justify-content: space-between;
.feed-link {
color: var(--grey-500);
transition: color $transition;
&:hover,
&:focus {
color: var(--primary);
}
}
}
.single-notification {

View File

@ -1094,6 +1094,7 @@ $filter-container-height: '1rem - #{$switch-view-height}';
.bucket-footer {
position: sticky;
inset-block-end: 0;
z-index: 2;
block-size: min-content;
padding: .5rem;
background-color: var(--grey-100);

View File

@ -190,12 +190,17 @@ watchEffect(() => {
let focusRafId: number | null = null
watchEffect(() => {
if (active.value && isQuickAddMode) {
selectedCmd.value = commands.value.newTask
if (active.value) {
if (isQuickAddMode) {
selectedCmd.value = commands.value.newTask
}
// The input may not be focusable yet due to:
// 1. Modal transition (v-if + <Transition appear>) delaying DOM readiness
// 2. Electron window not yet visible (shown after did-finish-load)
// 1. Modal mounts the <dialog> via v-if and then calls showModal() in a
// follow-up flush, so v-focus fires while the dialog is still closed
// and the focus() call is dropped.
// 2. In quick-add mode the Electron window isn't visible until
// did-finish-load.
// Retry with rAF until focus actually lands on the input.
const tryFocus = () => {
if (!active.value) {

View File

@ -11,7 +11,7 @@
{{ currentBucketTitle }}
<Icon
icon="pencil-alt"
class="change-indicator"
class="change-indicator d-print-none"
/>
</BaseButton>
</template>

View File

@ -128,7 +128,7 @@
/>
<Reactions
v-model="c.reactions"
class="mbs-2"
class="mbs-2 d-print-none"
entity-kind="comments"
:entity-id="c.id"
:disabled="!canWrite"
@ -173,6 +173,7 @@
<div class="field">
<Editor
v-if="editorActive"
ref="newCommentEditor"
v-model="newCommentText"
:class="{
'is-loading':
@ -222,7 +223,7 @@
</template>
<script setup lang="ts">
import {ref, reactive, computed, shallowReactive, watch} from 'vue'
import {ref, reactive, computed, nextTick, provide, shallowReactive, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import BaseButton from '@/components/base/BaseButton.vue'
@ -246,6 +247,7 @@ import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
import Reactions from '@/components/input/Reactions.vue'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {commentReplyContextKey, scrollAndHighlightComment} from '@/components/tasks/partials/commentReplyContext'
const props = withDefaults(defineProps<{
taskId: number,
@ -304,15 +306,19 @@ const actions = computed(() => {
if (!props.canWrite) {
return {}
}
return Object.fromEntries(comments.value.map((comment) => ([
comment.id,
comment.author.id === currentUserId.value
? [{
return Object.fromEntries(comments.value.map((comment) => {
const list: {action: () => void, title: string}[] = [{
action: () => startReplyTo(comment),
title: t('task.comment.reply'),
}]
if (comment.author.id === currentUserId.value) {
list.push({
action: () => toggleDelete(comment.id),
title: t('misc.delete'),
}]
: [],
])))
})
}
return [comment.id, list]
}))
})
const frontendUrl = computed(() => configStore.frontendUrl)
@ -321,6 +327,55 @@ const commentStorageKey = computed(() => `task-comment-${props.taskId}`)
const currentPage = ref(1)
const commentsRef = ref<HTMLElement | null>(null)
const newCommentEditor = ref<{setReplyContent: (html: string) => Promise<void>} | null>(null)
provide(commentReplyContextKey, {
findComment: (id: number) => comments.value.find(c => c.id === id),
scrollToComment: scrollAndHighlightComment,
})
// Strip <mention-user> elements from a reply quote so reposting the parent
// body doesn't trigger fresh notifications for users mentioned in the
// original. The inner text is kept so the quote still reads correctly.
function stripMentionsForQuote(html: string): string {
if (!html) {
return ''
}
const doc = new DOMParser().parseFromString(`<div>${html}</div>`, 'text/html')
doc.querySelectorAll('mention-user').forEach((el) => {
const label = (el.getAttribute('data-label') ?? el.textContent ?? '').trim()
el.replaceWith(label ? `@${label.replace(/^@+/, '')}` : '')
})
return doc.body.firstElementChild?.innerHTML ?? ''
}
async function startReplyTo(parent: ITaskComment) {
const body = stripMentionsForQuote(parent.comment ?? '')
const draft = `<blockquote data-comment-id="${parent.id}">${body}</blockquote><p></p>`
if (!editorActive.value) {
editorActive.value = true
}
// Editor mounts asynchronously through defineAsyncComponent; wait until
// the ref is populated before pushing content in. Bail with a warning
// rather than fall back to `newCommentText = draft` the modelValue
// watcher in TipTap.vue would land the editor in preview mode, leaving
// the user unable to type without clicking the editor first.
const editor = await waitForEditorRef()
if (!editor) {
console.warn('Reply editor did not mount in time; aborting reply prefill.')
return
}
await editor.setReplyContent(draft)
}
async function waitForEditorRef() {
const start = performance.now()
while (!newCommentEditor.value && performance.now() - start < 2000) {
await nextTick()
}
return newCommentEditor.value
}
async function attachmentUpload(files: File[] | FileList): (Promise<string[]>) {
@ -517,11 +572,10 @@ function getCommentUrl(commentId: string) {
align-items: flex-start;
display: flex;
text-align: inherit;
padding-block-start: .5rem;
& + .media {
border-block-start: 1px solid rgba(var(--border-rgb), 0.5);
margin-block-start: 1rem;
padding-block-start: 1rem;
margin-block-start: .5rem;
}
}
@ -529,7 +583,7 @@ function getCommentUrl(commentId: string) {
flex-basis: auto;
flex-grow: 0;
flex-shrink: 0;
margin: 0 1rem !important;
margin: 0 .5rem !important;
}
.comment-info {
@ -605,4 +659,15 @@ function getCommentUrl(commentId: string) {
.comments-container {
scroll-margin-block-start: 4rem;
}
.media.comment {
scroll-margin-block-start: 4rem;
transition: background-color .3s ease-out;
border-radius: $radius;
}
.media.comment.comment-highlight {
background-color: hsla(var(--primary-hsl), 0.18);
transition: background-color .15s ease-in;
}
</style>

View File

@ -49,6 +49,8 @@ import flatPickr from 'vue-flatpickr-component'
import TaskService from '@/services/task'
import type {ITask} from '@/modelTypes/ITask'
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
import {useTimeFormat} from '@/composables/useTimeFormat'
import {TIME_FORMAT} from '@/constants/timeFormat'
const props = defineProps<{
modelValue: ITask,
@ -59,6 +61,7 @@ const emit = defineEmits<{
}>()
const {t} = useI18n({useScope: 'global'})
const {store: timeFormat} = useTimeFormat()
const taskService = shallowReactive(new TaskService())
const task = ref<ITask>()
@ -103,7 +106,7 @@ const flatPickerConfig = computed(() => ({
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
time_24hr: timeFormat.value === TIME_FORMAT.HOURS_24,
inline: true,
locale: useFlatpickrLanguage().value,
}))

View File

@ -1,5 +1,7 @@
<template>
<div>
<div
:class="{'d-print-none': isEmpty}"
>
<h3>
<span class="icon is-grey">
<Icon icon="align-left" />
@ -48,6 +50,7 @@ import CustomTransition from '@/components/misc/CustomTransition.vue'
import Editor from '@/components/input/AsyncEditor'
import { clearEditorDraft } from '@/helpers/editorDraftStorage'
import { isEditorContentEmpty } from '@/helpers/editorContentEmpty'
import type { ITask } from '@/modelTypes/ITask'
import { useTaskStore } from '@/stores/tasks'
@ -82,6 +85,8 @@ const changeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const descriptionStorageKey = computed(() => `task-description-${props.modelValue.id}`)
const isEmpty = computed(() => isEditorContentEmpty(description.value))
async function saveWithDelay() {
if (description.value === props.modelValue.description) {
hasChanges.value = false

View File

@ -18,7 +18,7 @@
<BaseButton
v-if="hasClose"
:aria-label="$t('task.detail.closeTaskDetail')"
class="close"
class="close d-print-none"
@click="$emit('close')"
>
<Icon icon="times" />
@ -39,7 +39,7 @@
<BaseButton
v-if="hasClose"
:aria-label="$t('task.detail.closeTaskDetail')"
class="close"
class="close d-print-none"
@click="$emit('close')"
>
<Icon icon="times" />

View File

@ -36,7 +36,7 @@
</label>
<div
key="field-search"
class="field"
class="field task-relation-search-field"
>
<Multiselect
v-model="newTaskRelation.task"
@ -77,6 +77,7 @@
</span>
</template>
</Multiselect>
<QuickAddMagic />
</div>
<div
key="field-kind"
@ -200,6 +201,7 @@ import CustomTransition from '@/components/misc/CustomTransition.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import Multiselect from '@/components/input/Multiselect.vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import QuickAddMagic from '@/components/tasks/partials/QuickAddMagic.vue'
import {error, success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
@ -362,7 +364,7 @@ async function removeTaskRelation() {
}
async function createAndRelateTask(title: string) {
const newTask = await taskService.create(new TaskModel({title, projectId: props.projectId}))
const newTask = await taskStore.createNewTask({title, projectId: props.projectId})
newTaskRelation.task = newTask
await addTaskRelation()
}
@ -459,6 +461,17 @@ async function toggleTaskDone(task: ITask) {
padding: 0.5rem;
}
.task-relation-search-field {
position: relative;
:deep(.quick-add-magic-trigger-btn) {
position: absolute;
inset-block-start: .75rem;
inset-inline-end: .75rem;
z-index: 4;
}
}
// FIXME: The height of the actual checkbox in the <FancyCheckbox/> component is too much resulting in a
// weired positioning of the checkbox. Setting the height here is a workaround until we fix the styling
// of the component.

View File

@ -383,7 +383,7 @@ function hasTextSelected() {
function openTaskDetail(event: MouseEvent | KeyboardEvent) {
if (event.target instanceof HTMLElement) {
const isInteractiveElement = event.target.closest('a, button, .favorite, [role="button"]')
const isInteractiveElement = event.target.closest('a, button, label, input[type="checkbox"], .favorite, [role="button"]')
if (isInteractiveElement || hasTextSelected()) {
return
}
@ -536,6 +536,23 @@ defineExpose({
span {
display: none;
}
// Extend the hit target to >=44x44 without affecting layout (WCAG 2.5.5).
.base-checkbox__label {
position: relative;
&::before {
content: '';
position: absolute;
inset-block-start: 50%;
inset-inline-start: 50%;
min-block-size: 44px;
min-inline-size: 44px;
block-size: 100%;
inline-size: 100%;
transform: translate(-50%, -50%);
}
}
}
.tasktext.done {

View File

@ -0,0 +1,26 @@
import type {InjectionKey} from 'vue'
import type {ITaskComment} from '@/modelTypes/ITaskComment'
export interface CommentReplyContext {
findComment: (id: number) => ITaskComment | undefined
scrollToComment: (id: number) => void
}
export const commentReplyContextKey: InjectionKey<CommentReplyContext> = Symbol('commentReplyContext')
const HIGHLIGHT_CLASS = 'comment-highlight'
const HIGHLIGHT_DURATION_MS = 1500
export function scrollAndHighlightComment(id: number): void {
const el = document.getElementById(`comment-${id}`)
if (!el) {
return
}
el.scrollIntoView({behavior: 'smooth', block: 'center', inline: 'nearest'})
el.classList.remove(HIGHLIGHT_CLASS)
// Re-apply on next frame so the animation restarts even if already running.
requestAnimationFrame(() => {
el.classList.add(HIGHLIGHT_CLASS)
window.setTimeout(() => el.classList.remove(HIGHLIGHT_CLASS), HIGHLIGHT_DURATION_MS)
})
}

View File

@ -0,0 +1,405 @@
<script setup lang="ts">
import {computed, onMounted, ref} from 'vue'
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
import XButton from '@/components/input/Button.vue'
import ApiTokenService from '@/services/apiToken'
import ApiTokenModel from '@/models/apiTokenModel'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import {MILLISECONDS_A_DAY} from '@/constants/date'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import {useI18n} from 'vue-i18n'
import FormField from '@/components/input/FormField.vue'
import type {IApiToken} from '@/modelTypes/IApiToken'
import {useTimeFormat} from '@/composables/useTimeFormat'
import {TIME_FORMAT} from '@/constants/timeFormat'
const props = withDefaults(defineProps<{
ownerId?: number,
loading?: boolean,
initialTitle?: string,
initialScopes?: string,
}>(), {
ownerId: 0,
loading: false,
initialTitle: '',
initialScopes: '',
})
const emit = defineEmits<{
created: [token: IApiToken]
cancel: []
}>()
const service = new ApiTokenService()
const {t} = useI18n()
const {store: timeFormat} = useTimeFormat()
const now = new Date()
const availableRoutes = ref(null)
const newToken = ref<IApiToken>(new ApiTokenModel())
const newTokenExpiry = ref<string | number>(30)
const newTokenExpiryCustom = ref(new Date())
const newTokenPermissions = ref({})
const newTokenPermissionsGroup = ref({})
const newTokenTitleValid = ref(true)
const newTokenPermissionValid = ref(true)
const apiTokenTitle = ref()
interface TokenPreset {
id: string
groups: Record<string, string[] | '*'>
}
const presets: TokenPreset[] = [
{
id: 'readOnly',
groups: {
'*': ['read_one', 'read_all'],
},
},
{
id: 'tasks',
groups: {
'tasks': '*',
'tasks_attachments': '*',
'tasks_assignees': '*',
'tasks_labels': '*',
'tasks_comments': '*',
'tasks_relations': '*',
'labels': ['read_one', 'read_all', 'create'],
'projects': ['read_one', 'read_all', 'views_buckets_tasks'],
'projects_views': ['read_one', 'read_all'],
'projects_views_tasks': ['read_one', 'read_all'],
},
},
{
id: 'projects',
groups: {
'projects': '*',
'projects_views': '*',
'projects_teams': '*',
'projects_users': '*',
'projects_shares': '*',
'projects_webhooks': '*',
'projects_buckets': '*',
'projects_views_tasks': '*',
'tasks': ['read_one', 'read_all'],
'teams': ['read_one', 'read_all'],
},
},
{
id: 'fullAccess',
groups: {
'*': '*',
},
},
]
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: timeFormat.value === TIME_FORMAT.HOURS_24,
locale: useFlatpickrLanguage().value,
minDate: now,
}))
onMounted(async () => {
const allRoutes = await service.getAvailableRoutes()
const routesAvailable = {}
const keys = Object.keys(allRoutes)
keys.sort((a, b) => (a === 'other' ? 1 : b === 'other' ? -1 : 0))
keys.forEach(key => {
routesAvailable[key] = allRoutes[key]
})
availableRoutes.value = routesAvailable
resetPermissions()
// Apply initial values from props (e.g. from query parameters)
if (props.initialTitle) {
newToken.value.title = props.initialTitle
newTokenTitleValid.value = true
}
if (props.initialScopes) {
const requestedScopes: Record<string, string[]> = {}
for (const scope of props.initialScopes.split(',')) {
const [group, permission] = scope.split(':')
if (group && permission) {
if (!requestedScopes[group]) {
requestedScopes[group] = []
}
requestedScopes[group].push(permission)
}
}
for (const [group, permissions] of Object.entries(requestedScopes)) {
if (newTokenPermissions.value[group]) {
for (const permission of permissions) {
if (newTokenPermissions.value[group][permission] !== undefined) {
newTokenPermissions.value[group][permission] = true
}
}
toggleGroupPermissionsFromChild(group, true)
}
}
}
})
function resetPermissions() {
newTokenPermissions.value = {}
newTokenPermissionsGroup.value = {}
newTokenPermissionValid.value = true
Object.entries(availableRoutes.value).forEach(entry => {
const [group, routes] = entry
newTokenPermissions.value[group] = {}
newTokenPermissionsGroup.value[group] = false
Object.keys(routes).forEach(r => {
newTokenPermissions.value[group][r] = false
})
})
}
function applyPreset(preset: TokenPreset) {
resetPermissions()
for (const [groupKey, permissions] of Object.entries(preset.groups)) {
if (groupKey === '*') {
for (const group of Object.keys(availableRoutes.value)) {
applyPermissionsToGroup(group, permissions)
}
} else if (availableRoutes.value[groupKey]) {
applyPermissionsToGroup(groupKey, permissions)
}
}
}
function applyPermissionsToGroup(group: string, permissions: string[] | '*') {
if (permissions === '*') {
selectPermissionGroup(group, true)
newTokenPermissionsGroup.value[group] = true
} else {
for (const perm of permissions) {
if (newTokenPermissions.value[group]?.[perm] !== undefined) {
newTokenPermissions.value[group][perm] = true
}
}
toggleGroupPermissionsFromChild(group, true)
}
}
function selectPermissionGroup(group: string, checked: boolean) {
Object.entries(availableRoutes.value[group]).forEach(entry => {
const [key] = entry
newTokenPermissions.value[group][key] = checked
})
if (checked) {
newTokenPermissionValid.value = true
}
}
function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
if (checked) {
newTokenPermissionValid.value = true
let allChecked = true
Object.entries(availableRoutes.value[group]).forEach(entry => {
const [key] = entry
if (!newTokenPermissions.value[group][key]) {
allChecked = false
}
})
if (allChecked) {
newTokenPermissionsGroup.value[group] = true
}
} else {
newTokenPermissionsGroup.value[group] = false
}
}
function formatPermissionTitle(title: string): string {
return title.replaceAll('_', ' ')
}
async function createToken() {
newTokenTitleValid.value = newToken.value.title.trim() !== ''
if (!newTokenTitleValid.value) {
apiTokenTitle.value.focus()
return
}
let hasPermissions = false
newToken.value.permissions = {}
Object.entries(newTokenPermissions.value).forEach(([key, ps]) => {
const all = Object.entries(ps)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.filter(([_, v]) => v)
.map(p => p[0])
if (all.length > 0) {
newToken.value.permissions[key] = all
hasPermissions = true
}
})
if (!hasPermissions) {
newTokenPermissionValid.value = false
return
}
const expiry = Number(newTokenExpiry.value)
if (!isNaN(expiry)) {
newToken.value.expiresAt = new Date((+new Date()) + expiry * MILLISECONDS_A_DAY)
} else {
newToken.value.expiresAt = new Date(newTokenExpiryCustom.value)
}
if (props.ownerId > 0) {
(newToken.value as IApiToken & {ownerId: number}).ownerId = props.ownerId
}
const token = await service.create(newToken.value)
emit('created', token)
newToken.value = new ApiTokenModel()
newTokenExpiry.value = 30
newTokenExpiryCustom.value = new Date()
resetPermissions()
}
</script>
<template>
<form @submit.prevent="createToken">
<!-- Title -->
<FormField
id="apiTokenTitle"
ref="apiTokenTitle"
v-model="newToken.title"
v-focus
:label="$t('user.settings.apiTokens.attributes.title')"
type="text"
:placeholder="$t('user.settings.apiTokens.attributes.titlePlaceholder')"
:error="newTokenTitleValid ? null : $t('user.settings.apiTokens.titleRequired')"
@keyup="() => newTokenTitleValid = newToken.title !== ''"
@focusout="() => newTokenTitleValid = newToken.title !== ''"
/>
<!-- Expiry -->
<div class="field">
<label
class="label"
for="apiTokenExpiry"
>
{{ $t('user.settings.apiTokens.attributes.expiresAt') }}
</label>
<div class="is-flex">
<div class="control select">
<select
id="apiTokenExpiry"
v-model="newTokenExpiry"
class="select"
>
<option value="30">
{{ $t('user.settings.apiTokens.30d') }}
</option>
<option value="60">
{{ $t('user.settings.apiTokens.60d') }}
</option>
<option value="90">
{{ $t('user.settings.apiTokens.90d') }}
</option>
<option value="custom">
{{ $t('misc.custom') }}
</option>
</select>
</div>
<flat-pickr
v-if="newTokenExpiry === 'custom'"
v-model="newTokenExpiryCustom"
class="mis-2"
:config="flatPickerConfig"
/>
</div>
</div>
<!-- Permissions -->
<div class="field">
<label class="label">{{ $t('user.settings.apiTokens.attributes.permissions') }}</label>
<p>{{ $t('user.settings.apiTokens.permissionExplanation') }}</p>
<!-- Presets -->
<div class="preset-buttons mbe-4">
<label class="label">{{ $t('user.settings.apiTokens.presets.title') }}</label>
<div
class="is-flex"
style="gap: .5rem; flex-wrap: wrap;"
>
<XButton
v-for="preset in presets"
:key="preset.id"
variant="secondary"
type="button"
@click="applyPreset(preset)"
>
{{ $t(`user.settings.apiTokens.presets.${preset.id}`) }}
</XButton>
</div>
</div>
<div
v-for="(routes, group) in availableRoutes"
:key="group"
class="mbe-2"
>
<template
v-if="Object.keys(routes).length >= 1"
>
<FancyCheckbox
v-model="newTokenPermissionsGroup[group]"
class="mie-2 is-capitalized has-text-weight-bold"
@update:modelValue="checked => selectPermissionGroup(group, checked)"
>
{{ formatPermissionTitle(group) }}
</FancyCheckbox>
<br>
</template>
<template
v-for="(paths, permission) in routes"
:key="group+'-'+permission"
>
<FancyCheckbox
v-model="newTokenPermissions[group][permission]"
class="mis-4 mie-2 is-capitalized"
@update:modelValue="checked => toggleGroupPermissionsFromChild(group, checked)"
>
{{ formatPermissionTitle(permission) }}
</FancyCheckbox>
<br>
</template>
</div>
</div>
<p
v-if="!newTokenPermissionValid"
class="help is-danger"
>
{{ $t('user.settings.apiTokens.permissionRequired') }}
</p>
<XButton
:loading="loading"
type="submit"
>
{{ $t('user.settings.apiTokens.createToken') }}
</XButton>
<XButton
variant="tertiary"
type="button"
@click="emit('cancel')"
>
{{ $t('misc.cancel') }}
</XButton>
</form>
</template>

View File

@ -0,0 +1,49 @@
import type {Directive, DirectiveBinding} from 'vue'
import {vTooltip} from 'floating-vue'
// When a tooltip target lives inside a <dialog> opened via showModal(), the
// dialog is in the browser's top layer. floating-vue teleports tooltips to
// <body> by default, so they render *below* the dialog's ::backdrop and are
// not visible. Teleporting them into the dialog keeps them in the top layer.
function buildBinding(el: Element, binding: DirectiveBinding): DirectiveBinding {
const dialog = el.closest('dialog')
if (!dialog) {
return binding
}
const value = binding.value
let normalized: Record<string, unknown>
if (typeof value === 'string') {
normalized = {content: value}
} else if (value && typeof value === 'object') {
normalized = {...value as Record<string, unknown>}
} else {
return binding
}
if (normalized.container === undefined) {
normalized.container = dialog
}
return {...binding, value: normalized}
}
// Bind via `mounted` rather than `beforeMount` so the element is already
// attached to the DOM — otherwise `el.closest('dialog')` cannot find the
// dialog ancestor.
const tooltip: Directive<Element, unknown> = {
mounted(el, binding) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(vTooltip as any).beforeMount(el, buildBinding(el, binding))
},
updated(el, binding) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(vTooltip as any).updated(el, buildBinding(el, binding))
},
beforeUnmount(el) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(vTooltip as any).beforeUnmount(el)
},
}
export default tooltip

View File

@ -41,6 +41,7 @@ export const SUPPORTED_LOCALES = {
'fi-FI': 'Suomi',
'he-IL': 'עִבְרִית',
'sv-SE': 'Svenska',
'el-GR': 'Ελληνικά',
// IMPORTANT: Also add new languages to useDayjsLanguageSync
// IMPORTANT: Also add new languages to pkg/i18n/i18n.go
} as const

View File

@ -107,6 +107,19 @@
"registrationFailed": "Bei der Registrierung ist ein Fehler aufgetreten. Bitte prüfe deine Eingabe und versuche es erneut."
},
"settings": {
"bots": {
"title": "Bot-Accounts",
"description": "Bot-Accounts gehören zu dir und können nur die API nutzen. Sie können zu Projekten, zugewiesenen Aufgaben hinzugefügt und mit API-Token authentifiziert werden und sich nicht selbst anmelden.",
"namePlaceholder": "Mein Assistent",
"create": "Bot Erstellen",
"enable": "Aktivieren",
"badge": "Bot",
"delete": {
"header": "Diesen Bot-Account löschen",
"text1": "Bist Du sicher, dass Du den Bot-Account \"{username}\" löschen möchtest?",
"text2": "Dies ist unwiderruflich. Alle API-Token dieses Bots werden widerrufen."
}
},
"title": "Einstellungen",
"newPasswordTitle": "Aktualisiere dein Passwort",
"newPassword": "Neues Passwort",
@ -132,6 +145,11 @@
"weekStart": "Woche beginnt am",
"weekStartSunday": "Sonntag",
"weekStartMonday": "Montag",
"weekStartTuesday": "Dienstag",
"weekStartWednesday": "Mittwoch",
"weekStartThursday": "Donnerstag",
"weekStartFriday": "Freitag",
"weekStartSaturday": "Samstag",
"language": "Sprache",
"defaultProject": "Standardprojekt",
"defaultView": "Standardansicht",
@ -201,6 +219,13 @@
"usernameIs": "Dein Anmeldename für CalDAV lautet: {0}",
"apiTokenHint": "Du kannst auch ein API-Token mit CalDAV-Berechtigung verwenden. Erstelle eins unter {link}."
},
"feeds": {
"title": "Atom-Feed",
"howTo": "Du kannst deine Vikunja-Benachrichtigungen von jedem Atom-kompatiblen Feed-Reader abonnieren. Benutze die folgende URL:",
"usernameIs": "Dein Anmeldename für das Feed lautet: {0}",
"apiTokenHint": "Authentifiziere dich mit einem API-Token mit der {scope} Berechtigung. Erstellen eins unter {link}.",
"tokenTitle": "Atom-Feed"
},
"avatar": {
"title": "Avatar",
"initials": "Initialen",
@ -495,7 +520,8 @@
"bucketTitleSavedSuccess": "Der Spaltenname wurde erfolgreich gespeichert.",
"bucketLimitSavedSuccess": "Das Spaltenlimit wurde erfolgreich gespeichert.",
"collapse": "Spalte einklappen",
"bucketLimitReached": "Du hast das Limit dieses Buckets erreicht. Entferne Aufgaben oder erhöhe das Limit, um neue Aufgaben hinzuzufügen."
"bucketLimitReached": "Du hast das Limit dieses Buckets erreicht. Entferne Aufgaben oder erhöhe das Limit, um neue Aufgaben hinzuzufügen.",
"bucketOptions": "Bucketoptionen"
},
"pseudo": {
"favorites": {
@ -718,7 +744,9 @@
"upcoming": "Anstehend",
"settings": "Einstellungen",
"imprint": "Impressum",
"privacy": "Datenschutzerklärung"
"privacy": "Datenschutzerklärung",
"closeSidebar": "Seitenleiste schließen",
"home": "Vikunja Startseite"
},
"misc": {
"loading": "Wird geladen…",
@ -750,9 +778,15 @@
"createdBy": "Erstellt von {0}",
"actions": "Aktionen",
"cannotBeUndone": "Dies kann nicht rückgängig gemacht werden!",
"avatarOfUser": "{user}'s Profilbild"
"avatarOfUser": "{user}'s Profilbild",
"closeBanner": "Banner schließen",
"closeDialog": "Dialog schließen",
"closeQuickActions": "Schnellaktionen schließen",
"skipToContent": "Überspringen und zum Hauptinhalt gehen",
"sortBy": "Sortieren nach"
},
"input": {
"projectColor": "Projektfarbe",
"resetColor": "Farbe zurücksetzen",
"datepicker": {
"today": "Heute",
@ -828,6 +862,7 @@
"date": "Datum",
"ranges": {
"today": "Heute",
"tomorrow": "Morgen",
"thisWeek": "Diese Woche",
"restOfThisWeek": "Der Rest dieser Woche",
"nextWeek": "Nächste Woche",
@ -935,6 +970,9 @@
"belongsToProject": "Diese Aufgabe gehört zum Projekt „{project}“",
"back": "Zurück zum Projekt",
"due": "Fällig {at}",
"closeTaskDetail": "Aufgabendetails schließen",
"title": "Aufgabendetails",
"markAsDone": "'{task}' als erledigt markieren",
"scrollToBottom": "Nach unten scrollen",
"organization": "Organisation",
"management": "Verwaltung",
@ -1028,7 +1066,10 @@
"addedSuccess": "Der Kommentar wurde erfolgreich hinzugefügt.",
"permalink": "Permalink zu diesem Kommentar kopieren",
"sortNewestFirst": "Neueste zuerst",
"sortOldestFirst": "Älteste zuerst"
"sortOldestFirst": "Älteste zuerst",
"reply": "Antworten",
"jumpToOriginal": "Zum ursprünglichen Kommentar springen",
"deletedComment": "gelöschter Kommentar"
},
"mention": {
"noUsersFound": "Keine Nutzer:innen gefunden"
@ -1293,7 +1334,8 @@
"none": "Du hast keine Benachrichtigungen. Einen schönen Tag noch!",
"explainer": "Benachrichtigungen werden hier angezeigt, wenn Aktionen für Projekte oder Aufgaben, die du abonniert hast, ausgeführt werden.",
"markAllRead": "Alle Benachrichtigungen als gelesen markieren",
"markAllReadSuccess": "Alle Benachrichtigungen erfolgreich als gelesen markiert."
"markAllReadSuccess": "Alle Benachrichtigungen erfolgreich als gelesen markiert.",
"subscribeFeed": "Benachrichtigungen über Atom-Feed abonnieren"
},
"quickActions": {
"notLoggedIn": "Bitte melde dich zuerst im Hauptfenster von Vikunja an.",
@ -1429,5 +1471,66 @@
"weeks": "Woche|Wochen",
"years": "Jahr|Jahre"
}
},
"admin": {
"title": "Administration",
"labels": {
"users": "Accounts",
"tasks": "Aufgaben"
},
"overview": {
"shares": "Freigaben",
"linkSharesShort": "link",
"teamSharesShort": "Team",
"userSharesShort": "Benutzer:in",
"version": "Version",
"license": "Lizenz",
"licenseValidUntil": "Gültig bis",
"licenseExpiresIn": "in {days} Tagen",
"licenseLastVerified": "Zuletzt geprüft",
"licenseNever": "nie",
"licenseLastCheckFailed": "letzte Prüfung fehlgeschlagen",
"licenseFeatures": "Features",
"licenseInstance": "Instanz-ID",
"licenseManage": "Verwalten"
},
"searchUsersPlaceholder": "Suche nach Anmeldename oder E-Mail…",
"users": {
"status": "Status",
"details": "Details",
"detailsTitle": "Account: {username}",
"issuer": "Aussteller",
"issuerLocal": "Lokal",
"issuerUrl": "Aussteller-URL",
"subject": "Betreff",
"statusActive": "Aktiv",
"statusEmailConfirmation": "E-Mail-Bestätigung erforderlich",
"statusDisabled": "Deaktiviert",
"statusLocked": "Account gesperrt",
"isAdminLabel": "Administrator",
"addUser": "Account hinzufügen",
"createTitle": "Account erstellen",
"nameLabel": "Name",
"skipEmailConfirm": "E-Mail-Bestätigung überspringen",
"createSubmit": "Account erstellen",
"saveButton": "Änderungen speichern",
"createdSuccess": "Account {username} erstellt.",
"updatedSuccess": "Account {username} aktualisiert.",
"deletedSuccess": "Account {username} gelöscht.",
"deleteScheduledSuccess": "{username} erhält eine Bestätigungs-E-Mail, um die Löschung zu planen.",
"confirmDeleteTitle": "Account löschen?",
"confirmDeleteIntro": "Wie soll der Account {username} gelöscht werden?",
"deleteModeScheduled": "Löschung planen",
"deleteModeScheduledHelp": "\"Löschung planen\" sendet eine Bestätigungs-E-Mail, analog zu einer selbst ausgelösten Kontolöschung.",
"deleteModeNow": "Jetzt löschen",
"deleteModeNowHelp": "Sofort-Löschen entfernt den Account und seine Daten umgehend. Dies kann nicht rückgängig gemacht werden."
},
"projects": {
"ownerLabel": "Eigentümer:in",
"reassignOwner": "Eigentümer:in neu zuweisen",
"reassignTitle": "{title} erneut zuweisen",
"reassignedSuccess": "Projekteigentümer:in neu zugewiesen.",
"newOwnerLabel": "Neu:e Eigentümer:in"
}
}
}

View File

@ -107,6 +107,19 @@
"registrationFailed": "Bei der Registrierung ist ein Fehler aufgetreten. Bitte prüfe deine Eingabe und versuche es erneut."
},
"settings": {
"bots": {
"title": "Bot-Accounts",
"description": "Bot-Accounts gehören zu dir und können nur die API nutzen. Sie können zu Projekten, zugewiesenen Aufgaben hinzugefügt und mit API-Token authentifiziert werden und sich nicht selbst anmelden.",
"namePlaceholder": "Mein Assistent",
"create": "Bot Erstellen",
"enable": "Aktivieren",
"badge": "Bot",
"delete": {
"header": "Diesen Bot-Account löschen",
"text1": "Bist Du sicher, dass Du den Bot-Account \"{username}\" löschen möchtest?",
"text2": "Dies ist unwiderruflich. Alle API-Token dieses Bots werden widerrufen."
}
},
"title": "Iihstellige",
"newPasswordTitle": "Diis Passwort aktualisierä",
"newPassword": "Neues Passwort",
@ -132,6 +145,11 @@
"weekStart": "D'Wuche fangt ah am",
"weekStartSunday": "Sunntig",
"weekStartMonday": "Määntig",
"weekStartTuesday": "Dienstag",
"weekStartWednesday": "Mittwoch",
"weekStartThursday": "Donnerstag",
"weekStartFriday": "Freitag",
"weekStartSaturday": "Samstag",
"language": "Sproch",
"defaultProject": "Standardprojekt",
"defaultView": "Standardansicht",
@ -201,6 +219,13 @@
"usernameIs": "Dein Anmeldename für CalDAV lautet: {0}",
"apiTokenHint": "Du kannst auch ein API-Token mit CalDAV-Berechtigung verwenden. Erstelle eins unter {link}."
},
"feeds": {
"title": "Atom-Feed",
"howTo": "Du kannst deine Vikunja-Benachrichtigungen von jedem Atom-kompatiblen Feed-Reader abonnieren. Benutze die folgende URL:",
"usernameIs": "Dein Anmeldename für das Feed lautet: {0}",
"apiTokenHint": "Authentifiziere dich mit einem API-Token mit der {scope} Berechtigung. Erstellen eins unter {link}.",
"tokenTitle": "Atom-Feed"
},
"avatar": {
"title": "Herr Der Elemente",
"initials": "Initialä",
@ -495,7 +520,8 @@
"bucketTitleSavedSuccess": "Der Spaltenname wurde erfolgreich gespeichert.",
"bucketLimitSavedSuccess": "Das Spaltenlimit wurde erfolgreich gespeichert.",
"collapse": "Spalte einklappen",
"bucketLimitReached": "Du hast das Limit dieses Buckets erreicht. Entferne Aufgaben oder erhöhe das Limit, um neue Aufgaben hinzuzufügen."
"bucketLimitReached": "Du hast das Limit dieses Buckets erreicht. Entferne Aufgaben oder erhöhe das Limit, um neue Aufgaben hinzuzufügen.",
"bucketOptions": "Bucketoptionen"
},
"pseudo": {
"favorites": {
@ -718,7 +744,9 @@
"upcoming": "Ahstehänd",
"settings": "Iihstellige",
"imprint": "Immpressum",
"privacy": "Dateschutzerchlärig"
"privacy": "Dateschutzerchlärig",
"closeSidebar": "Seitenleiste schließen",
"home": "Vikunja Startseite"
},
"misc": {
"loading": "Ladä…",
@ -750,9 +778,15 @@
"createdBy": "Erstellt von {0}",
"actions": "Aktionen",
"cannotBeUndone": "Dies kann nicht rückgängig gemacht werden!",
"avatarOfUser": "{user}'s Profilbild"
"avatarOfUser": "{user}'s Profilbild",
"closeBanner": "Banner schließen",
"closeDialog": "Dialog schließen",
"closeQuickActions": "Schnellaktionen schließen",
"skipToContent": "Überspringen und zum Hauptinhalt gehen",
"sortBy": "Sortieren nach"
},
"input": {
"projectColor": "Projektfarbe",
"resetColor": "Farb zruggsetze",
"datepicker": {
"today": "Hütt",
@ -828,6 +862,7 @@
"date": "Datum",
"ranges": {
"today": "Heute",
"tomorrow": "Morgen",
"thisWeek": "Diese Woche",
"restOfThisWeek": "Der Rest dieser Woche",
"nextWeek": "Nächste Woche",
@ -935,6 +970,9 @@
"belongsToProject": "Diese Aufgabe gehört zum Projekt „{project}“",
"back": "Zurück zum Projekt",
"due": "Fällig bis {at}",
"closeTaskDetail": "Aufgabendetails schließen",
"title": "Aufgabendetails",
"markAsDone": "'{task}' als erledigt markieren",
"scrollToBottom": "Nach unten scrollen",
"organization": "Organisation",
"management": "Verwaltung",
@ -1028,7 +1066,10 @@
"addedSuccess": "Din Kommentar isch erfolgriich hinzuegfüegt worde.",
"permalink": "Permalink zu diesem Kommentar kopieren",
"sortNewestFirst": "Neueste zuerst",
"sortOldestFirst": "Älteste zuerst"
"sortOldestFirst": "Älteste zuerst",
"reply": "Antworten",
"jumpToOriginal": "Zum ursprünglichen Kommentar springen",
"deletedComment": "gelöschter Kommentar"
},
"mention": {
"noUsersFound": "Keine Nutzer:innen gefunden"
@ -1293,7 +1334,8 @@
"none": "Du hesch kei neui Benachrichtunge. Heb e schös Tägli!",
"explainer": "Benachrichtigungen werden hier angezeigt, wenn Aktionen für Projekte oder Aufgaben, die du abonniert hast, ausgeführt werden.",
"markAllRead": "Alle Benachrichtigungen als gelesen markieren",
"markAllReadSuccess": "Alle Benachrichtigungen erfolgreich als gelesen markiert."
"markAllReadSuccess": "Alle Benachrichtigungen erfolgreich als gelesen markiert.",
"subscribeFeed": "Benachrichtigungen über Atom-Feed abonnieren"
},
"quickActions": {
"notLoggedIn": "Bitte melde dich zuerst im Hauptfenster von Vikunja an.",
@ -1429,5 +1471,66 @@
"weeks": "Woche|Wochen",
"years": "Jahr|Jahre"
}
},
"admin": {
"title": "Administration",
"labels": {
"users": "Accounts",
"tasks": "Aufgaben"
},
"overview": {
"shares": "Freigaben",
"linkSharesShort": "link",
"teamSharesShort": "Team",
"userSharesShort": "Benutzer:in",
"version": "Version",
"license": "Lizenz",
"licenseValidUntil": "Gültig bis",
"licenseExpiresIn": "in {days} Tagen",
"licenseLastVerified": "Zuletzt geprüft",
"licenseNever": "nie",
"licenseLastCheckFailed": "letzte Prüfung fehlgeschlagen",
"licenseFeatures": "Features",
"licenseInstance": "Instanz-ID",
"licenseManage": "Verwalten"
},
"searchUsersPlaceholder": "Suche nach Anmeldename oder E-Mail…",
"users": {
"status": "Status",
"details": "Details",
"detailsTitle": "Account: {username}",
"issuer": "Aussteller",
"issuerLocal": "Lokal",
"issuerUrl": "Aussteller-URL",
"subject": "Betreff",
"statusActive": "Aktiv",
"statusEmailConfirmation": "E-Mail-Bestätigung erforderlich",
"statusDisabled": "Deaktiviert",
"statusLocked": "Account gesperrt",
"isAdminLabel": "Administrator",
"addUser": "Account hinzufügen",
"createTitle": "Account erstellen",
"nameLabel": "Name",
"skipEmailConfirm": "E-Mail-Bestätigung überspringen",
"createSubmit": "Account erstellen",
"saveButton": "Änderungen speichern",
"createdSuccess": "Account {username} erstellt.",
"updatedSuccess": "Account {username} aktualisiert.",
"deletedSuccess": "Account {username} gelöscht.",
"deleteScheduledSuccess": "{username} erhält eine Bestätigungs-E-Mail, um die Löschung zu planen.",
"confirmDeleteTitle": "Account löschen?",
"confirmDeleteIntro": "Wie soll der Account {username} gelöscht werden?",
"deleteModeScheduled": "Löschung planen",
"deleteModeScheduledHelp": "\"Löschung planen\" sendet eine Bestätigungs-E-Mail, analog zu einer selbst ausgelösten Kontolöschung.",
"deleteModeNow": "Jetzt löschen",
"deleteModeNowHelp": "Sofort-Löschen entfernt den Account und seine Daten umgehend. Dies kann nicht rückgängig gemacht werden."
},
"projects": {
"ownerLabel": "Eigentümer:in",
"reassignOwner": "Eigentümer:in neu zuweisen",
"reassignTitle": "{title} erneut zuweisen",
"reassignedSuccess": "Projekteigentümer:in neu zugewiesen.",
"newOwnerLabel": "Neu:e Eigentümer:in"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -107,6 +107,19 @@
"registrationFailed": "An error occurred during registration. Please check your input and try again."
},
"settings": {
"bots": {
"title": "Bot Users",
"description": "Bot users are API-only users you own. They can be added to projects, assigned tasks, and authenticated with API tokens. They cannot log in interactively.",
"namePlaceholder": "My Assistant",
"create": "Create bot",
"enable": "Enable",
"badge": "Bot",
"delete": {
"header": "Delete this bot user",
"text1": "Are you sure you want to delete the bot user \"{username}\"?",
"text2": "This is irreversible. Any API tokens belonging to this bot will be revoked."
}
},
"title": "Settings",
"newPasswordTitle": "Update Your Password",
"newPassword": "New password",
@ -206,6 +219,13 @@
"usernameIs": "Your username for CalDAV is: {0}",
"apiTokenHint": "You can also use an API token with CalDAV permission. Create one in {link}."
},
"feeds": {
"title": "Atom Feed",
"howTo": "You can subscribe to your Vikunja notifications from any Atom-compatible feed reader. Use the following URL:",
"usernameIs": "Your username for the feed is: {0}",
"apiTokenHint": "Authenticate with an API token that has the {scope} permission. Create one in {link}.",
"tokenTitle": "Atom feed"
},
"avatar": {
"title": "Avatar",
"initials": "Initials",
@ -842,6 +862,7 @@
"date": "Date",
"ranges": {
"today": "Today",
"tomorrow": "Tomorrow",
"thisWeek": "This Week",
"restOfThisWeek": "The Rest of This Week",
"nextWeek": "Next Week",
@ -1045,7 +1066,10 @@
"addedSuccess": "The comment was added successfully.",
"permalink": "Copy permalink to this comment",
"sortNewestFirst": "Newest first",
"sortOldestFirst": "Oldest first"
"sortOldestFirst": "Oldest first",
"reply": "Reply",
"jumpToOriginal": "Jump to original comment",
"deletedComment": "deleted comment"
},
"mention": {
"noUsersFound": "No users found"
@ -1310,7 +1334,8 @@
"none": "You don't have any notifications. Have a nice day!",
"explainer": "Notifications will appear here when actions, projects or tasks you subscribed to happen.",
"markAllRead": "Mark all notifications as read",
"markAllReadSuccess": "Successfully marked all notifications as read."
"markAllReadSuccess": "Successfully marked all notifications as read.",
"subscribeFeed": "Subscribe to notifications via Atom feed"
},
"quickActions": {
"notLoggedIn": "Please log in to the main Vikunja window first.",

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,9 @@
"home": {
"welcomeNight": "Hyvää Yötä {username}!",
"welcomeMorning": "Hyvää Huomenta {username}!",
"welcomeMorningBack": "Tervetuloa takaisin, {username}",
"welcomeTuesday": "Hyvää tiistaita, {username}",
"welcomeWednesdayMid": "On jo keskiviikko, {username}",
"welcomeDay": "Moi {username}!",
"welcomeEvening": "Hyvää Iltaa {username}!",
"lastViewed": "Viimeksi katsottu",

View File

@ -5,9 +5,36 @@
},
"home": {
"welcomeNight": "おやすみなさい、{username}さん",
"welcomeNightOwl": "夜更かしですか、{username}さん",
"welcomeNightBurning": "夜更かししていますね、{username}さん?",
"welcomeNightQuiet": "静かな時間ですね、{username}さん",
"welcomeNightLate": "もう遅い時間ですよ、{username}さん",
"welcomeNightMoonlit": "月明かりの下で計画ですか、{username}さん?",
"welcomeMorning": "おはようございます、{username}さん",
"welcomeMorningHey": "やあ {username}さん、準備はいいですか?",
"welcomeMorningFresh": "さわやかな朝ですね、{username}さん",
"welcomeMorningCoffee": "コーヒーとタスクはいかがですか、{username}さん?",
"welcomeMorningRise": "さあ、計画を立てましょう、{username}さん",
"welcomeMorningBack": "おかえりなさい、{username}さん",
"welcomeMondayFresh": "新しい一週間の始まりです、{username}さん",
"welcomeTuesday": "よい火曜日を、{username}さん",
"welcomeWednesdayMid": "もう週の半ばですね、{username}さん",
"welcomeThursday": "あと少しです、{username}さん",
"welcomeFridayPush": "金曜日、もうひと踏ん張りですね、{username}さん?",
"welcomeSaturday": "週末モードですね、{username}さん",
"welcomeSundaySession": "日曜日の作業ですか、{username}さん?",
"welcomeDay": "こんにちは、{username}さん",
"welcomeDayBack": "作業再開ですね、{username}さん",
"welcomeDayFocus": "集中していきましょう、{username}さん",
"welcomeDayKeepGoing": "その調子、{username}さん",
"welcomeDayWhatsNext": "次は何ですか、{username}さん?",
"welcomeDayGood": "こんにちは、{username}さん",
"welcomeEvening": "こんばんは、{username}さん",
"welcomeEveningWind": "そろそろ一段落ですか、{username}さん?",
"welcomeEveningReturns": "{username}さんのお戻りですね",
"welcomeEveningWrap": "そろそろ終わりにしませんか、{username}さん?",
"welcomeEveningOneMore": "あと一つだけいかがですか、{username}さん?",
"welcomeEveningStill": "まだ頑張っていますね、{username}さん?",
"lastViewed": "最近の表示",
"addToHomeScreen": "ホーム画面に追加すると、すぐにアクセスできて使いやすくなります。",
"goToOverview": "概要に移動",
@ -53,6 +80,15 @@
"authenticating": "認証中…",
"openIdStateError": "stateパラメータが一致しないため処理を中断しました。",
"openIdGeneralError": "認証中にエラーが発生しました。",
"openIdTotpRequired": "このアカウントには2要素認証が必要です。TOTPコードを入力して再度サインインしてください。",
"openIdTotpSubmit": "続ける",
"oauthMissingParams": "必要なOAuthパラメータがありません: {params}",
"oauthRedirectedToApp": "アプリにリダイレクトされました。このタブは閉じて構いません。",
"desktopTryDemo": "デモを試す",
"desktopCustomServer": "カスタムサーバーURL",
"desktopCustomServerDescription": "使用するVikunjaサーバーのURLを入力して始めましょう。",
"desktopWaitingForAuth": "認証を待機中…",
"desktopOAuthError": "認証に失敗しました: {error}",
"logout": "ログアウト",
"emailInvalid": "有効なメールアドレスを入力してください。",
"usernameRequired": "ユーザー名を入力してください。",
@ -71,6 +107,19 @@
"registrationFailed": "登録中にエラーが発生しました。入力内容を確認して、もう一度お試しください。"
},
"settings": {
"bots": {
"title": "ボットユーザー",
"description": "ボットユーザーは、あなたが所有する API 専用のユーザーです。プロジェクトに追加したり、タスクを割り当てたり、API トークンで認証したりできます。対話的にログインすることはできません。",
"namePlaceholder": "マイアシスタント",
"create": "ボットを作成",
"enable": "有効にする",
"badge": "ボット",
"delete": {
"header": "このボットユーザーを削除",
"text1": "ボットユーザー「{username}」を削除してもよろしいですか?",
"text2": "この操作は取り消せません。このボットに紐付く API トークンはすべて失効します。"
}
},
"title": "設定",
"newPasswordTitle": "パスワードの更新",
"newPassword": "新しいパスワード",
@ -96,12 +145,21 @@
"weekStart": "週の始まり",
"weekStartSunday": "日曜日",
"weekStartMonday": "月曜日",
"weekStartTuesday": "火曜日",
"weekStartWednesday": "水曜日",
"weekStartThursday": "木曜日",
"weekStartFriday": "金曜日",
"weekStartSaturday": "土曜日",
"language": "言語",
"defaultProject": "デフォルトのプロジェクト",
"defaultView": "デフォルトのビュー",
"timezone": "タイムゾーン",
"overdueTasksRemindersTime": "期限切れタスクのリマインダー送信時間",
"quickAddDefaultReminders": "クイック追加のデフォルトリマインダー",
"quickAddDefaultRemindersDescription": "期限日を持つクイック追加マジックで作成されたすべてのタスクに、これらのリマインダーが自動的に追加されます。",
"quickAddDefaultRemindersHint": "タスクの期限日を基準としたリマインダーを1つ以上追加してください。空にすると無効化されます。",
"filterUsedOnOverview": "概要ページの絞り込み条件",
"showLastViewed": "概要ページに最近表示したプロジェクトを表示",
"minimumPriority": "表示タスク優先度の最小値",
"dateDisplay": "日付表示形式",
"dateDisplayOptions": {
@ -125,7 +183,13 @@
"taskAndNotifications": "プロジェクトとタスク",
"privacy": "プライバシー設定",
"localization": "ローカライズ",
"appearance": "外観と動作"
"appearance": "外観と動作",
"desktop": "デスクトップアプリ"
},
"desktop": {
"quickEntryShortcut": "クイック入力ショートカット",
"shortcutRecorderPlaceholder": "クリックしてショートカットを設定",
"shortcutRecorderRecording": "キーの組み合わせを押してください…"
},
"totp": {
"title": "2要素認証",
@ -135,15 +199,32 @@
"scanQR": "あるいは、TOTPアプリでこのQRコードをスキャンして認証コードを入力してください:",
"passcode": "認証コード",
"passcodePlaceholder": "TOTPアプリで生成された認証コード",
"confirmNotice": "2要素認証を有効化すると、すべてのセッションからログアウトされ、再度ログインが必要になります。",
"setupSuccess": "2要素認証は正常に設定されました。",
"enterPassword": "パスワードを入力してください",
"disable": "2要素認証の無効化",
"confirmSuccess": "2要素認証を有効化しました",
"disableSuccess": "2要素認証は無効化されました。"
},
"caldav": {
"title": "CalDAV",
"howTo": "VikunjaをCalDAVクライアントに接続すると、さまざまなクライアントからすべてのタスクを表示・管理できます。以下のURLをクライアントに入力してください:",
"more": "VikunjaのCalDAVに関する詳細情報",
"tokens": "CalDAVトークン"
"tokens": "CalDAVトークン",
"tokensHowTo": "CalDAV認証には、通常のアカウントパスワードかCalDAV専用トークンのいずれかを利用できます。",
"createToken": "CalDAVトークンを作成",
"tokenCreated": "新しいトークンはこちらです: {token}",
"wontSeeItAgain": "書き留めるか安全に保管してください — 再度表示することはできません。",
"mustUseToken": "サードパーティ製クライアントでCalDAVを利用するには、CalDAVトークンを作成する必要があります。作成したトークンをクライアントのパスワード欄に入力してください。",
"usernameIs": "CalDAV用のユーザー名: {0}",
"apiTokenHint": "CalDAV権限を持つAPIトークンも利用できます。{link} で作成してください。"
},
"feeds": {
"title": "Atom フィード",
"howTo": "Atom 対応のフィードリーダーから Vikunja の通知を購読できます。以下の URL を使用してください:",
"usernameIs": "フィード用のユーザー名: {0}",
"apiTokenHint": "{scope} 権限を持つ API トークンで認証してください。{link} で作成できます。",
"tokenTitle": "Atom フィード"
},
"avatar": {
"title": "プロフィール画像",
@ -174,6 +255,10 @@
"backgroundBrightness": {
"title": "背景の明るさ"
},
"webhooks": {
"title": "Webhook通知",
"description": "リマインダーや期限切れイベント発生時にPOSTリクエストを受信するWebhook URLを設定します。これらのWebhookはすべてのプロジェクトからイベントを受信します。"
},
"apiTokens": {
"title": "APIトークン",
"general": "APIトークンを使うとログインせずにVikunjaのAPIを利用できます。",
@ -189,6 +274,13 @@
"expired": "このトークンは {ago} に期限切れしています。",
"tokenCreatedSuccess": "新しい API トークンはこちらです: {token}",
"tokenCreatedNotSeeAgain": "このトークンは二度と表示されません。安全な場所に保管してください。",
"presets": {
"title": "クイックプリセット",
"readOnly": "読み取り専用",
"tasks": "タスク管理",
"projects": "プロジェクト管理",
"fullAccess": "フルアクセス"
},
"delete": {
"header": "トークンの削除",
"text1": "トークン \"{token}\" を削除してよろしいですか?",
@ -368,7 +460,8 @@
"addPlaceholder": "タスクを追加…",
"empty": "このプロジェクトにはタスクが存在しません。",
"newTaskCta": "タスクを作成してください。",
"editTask": "タスクの編集"
"editTask": "タスクの編集",
"sort": "並べ替え"
},
"gantt": {
"title": "ガント",
@ -427,7 +520,8 @@
"bucketTitleSavedSuccess": "バケットのタイトルは正常に保存されました。",
"bucketLimitSavedSuccess": "バケットの上限は正常に保存されました。",
"collapse": "このバケットを折りたたむ",
"bucketLimitReached": "バケットの上限に達しました。新しいタスクを追加するには、既存のタスクを削除するか、上限を緩和してください。"
"bucketLimitReached": "バケットの上限に達しました。新しいタスクを追加するには、既存のタスクを削除するか、上限を緩和してください。",
"bucketOptions": "バケットオプション"
},
"pseudo": {
"favorites": {
@ -550,6 +644,29 @@
}
}
},
"sorting": {
"manually": "手動",
"apply": "並べ替えを適用",
"description": "このリスト内のタスクの並び順を選択します。手動並び替えを選ぶと、ドラッグ&ドロップでタスクの順序を変更できます。",
"options": {
"titleAsc": "タイトル (AZ)",
"titleDesc": "タイトル (ZA)",
"priorityDesc": "優先度 (高い順)",
"priorityAsc": "優先度 (低い順)",
"dueDateAsc": "期限日 (早い順)",
"dueDateDesc": "期限日 (遅い順)",
"startDateAsc": "開始日 (早い順)",
"startDateDesc": "開始日 (遅い順)",
"endDateAsc": "終了日 (早い順)",
"endDateDesc": "終了日 (遅い順)",
"percentDoneDesc": "完了率 (高い順)",
"percentDoneAsc": "完了率 (低い順)",
"createdDesc": "作成日時 (新しい順)",
"createdAsc": "作成日時 (古い順)",
"updatedDesc": "更新日時 (新しい順)",
"updatedAsc": "更新日時 (古い順)"
}
},
"migrate": {
"title": "他のサービスからのインポート",
"titleService": "{name}からVikunjaへのデータのインポート",
@ -564,7 +681,30 @@
"importUpload": "{name}からVikunjaにデータをインポートするには、以下のボタンをクリックしてファイルを選択してください。",
"upload": "ファイルのアップロード",
"migrationStartedWillReciveEmail": "{service}のリスト、タスク、メモ、リマインダー、ファイルをすべてVikunjaにインポートします。完了までしばらくお待ちください。メールでお知らせします。このウィンドウは閉じても構いません。",
"migrationInProgress": "現在移行中です。完了するまでしばらくお待ちください。"
"migrationInProgress": "現在移行中です。完了するまでしばらくお待ちください。",
"csv": {
"description": "カスタム列マッピングでCSVファイルからタスクをインポートします。",
"uploadDescription": "インポートするCSVファイルを選択してください。ファイルの1行目はヘッダーで、タスクデータを含んでいる必要があります。",
"selectFile": "CSVファイルを選択",
"columnMappingDescription": "CSVファイルの各列をタスク属性にマッピングします。Vikunjaが最も可能性の高いマッピングを自動検出しています。設定を変更すると、下部のプレビューが自動更新されます。",
"parsingOptions": "解析オプション",
"delimiter": "区切り文字",
"dateFormat": "日付形式",
"skipRows": "スキップする行数",
"mapColumns": "列のマッピング",
"example": "例:",
"preview": "プレビュー",
"previewDescription": "インポートされる {count} 件のタスクのうち先頭5件を表示しています。",
"import": "タスクをインポート",
"untitled": "無題のタスク",
"ignore": "無視",
"delimiters": {
"comma": "カンマ (,)",
"semicolon": "セミコロン (;)",
"tab": "タブ",
"pipe": "パイプ (|)"
}
}
},
"label": {
"title": "ラベル",
@ -604,7 +744,9 @@
"upcoming": "今後の予定",
"settings": "設定",
"imprint": "運営情報",
"privacy": "プライバシーポリシー"
"privacy": "プライバシーポリシー",
"closeSidebar": "サイドバーを閉じる",
"home": "Vikunja ホーム"
},
"misc": {
"loading": "読み込み中…",
@ -636,9 +778,15 @@
"createdBy": "{0} によって作成",
"actions": "アクション",
"cannotBeUndone": "この操作は元に戻せません!",
"avatarOfUser": "{user} のプロフィール画像"
"avatarOfUser": "{user} のプロフィール画像",
"closeBanner": "バナーを閉じる",
"closeDialog": "ダイアログを閉じる",
"closeQuickActions": "クイックアクションを閉じる",
"skipToContent": "メインコンテンツへスキップ",
"sortBy": "並び替え"
},
"input": {
"projectColor": "プロジェクトの色",
"resetColor": "色のリセット",
"datepicker": {
"today": "今日",
@ -698,6 +846,9 @@
"toggleHeaderCell": "選択中のセルのタイトル セル指定の有無の切り替え",
"mergeOrSplit": "結合または分割",
"fixTables": "テーブルの修正"
},
"emoji": {
"empty": "絵文字が見つかりません"
}
},
"multiselect": {
@ -711,6 +862,7 @@
"date": "日付",
"ranges": {
"today": "今日",
"tomorrow": "明日",
"thisWeek": "今週",
"restOfThisWeek": "今から週末まで",
"nextWeek": "来週",
@ -784,6 +936,7 @@
"addReminder": "リマイダーを作成…",
"doneSuccess": "タスクを完了にしました。",
"undoneSuccess": "タスクを未完了に戻しました。",
"readOnlyCheckbox": "このタスクには読み取り権限しかないため、完了にすることはできません。",
"movedToProject": "タスクは {project} に移動しました。",
"undo": "元に戻す",
"checklistTotal": "{total}件中{checked}件のタスク",
@ -796,7 +949,8 @@
"select": "期間の選択",
"noTasks": "タスクはありません — よい一日を!",
"filterByLabel": "ラベル {label} での絞り込み",
"clearLabelFilter": "ラベルでの絞り込みの解除"
"clearLabelFilter": "ラベルでの絞り込みの解除",
"savedFilterIgnored": "ラベルごとのタスク表示中は、保存されたホーム画面のフィルターは適用されません。"
},
"detail": {
"chooseDueDate": "期日を設定…",
@ -811,9 +965,14 @@
"updateSuccess": "タスクは正常に保存されました。",
"deleteSuccess": "タスクは正常に削除されました。",
"duplicateSuccess": "タスクは正常に複製されました。",
"noBucket": "バケットなし",
"bucketChangedSuccess": "タスクのバケットを変更しました。",
"belongsToProject": "このタスクはプロジェクト「{project}」に含まれています。",
"back": "プロジェクトに戻る",
"due": "期限: {at}",
"closeTaskDetail": "タスク詳細を閉じる",
"title": "タスクの詳細",
"markAsDone": "「{task}」を完了にする",
"scrollToBottom": "一番下まで移動",
"organization": "組織",
"management": "管理",
@ -907,7 +1066,10 @@
"addedSuccess": "コメントは正常に追加されました。",
"permalink": "コメントへのリンクをコピー",
"sortNewestFirst": "新しい順",
"sortOldestFirst": "古い順"
"sortOldestFirst": "古い順",
"reply": "返信",
"jumpToOriginal": "元のコメントへ移動",
"deletedComment": "削除されたコメント"
},
"mention": {
"noUsersFound": "ユーザーが見つかりません"
@ -990,6 +1152,7 @@
"mode": "繰り返しモード",
"monthly": "毎月",
"fromCurrentDate": "完了からの間隔",
"each": "毎",
"specifyAmount": "数字を入力…",
"hours": "時間ごと",
"days": "日ごと",
@ -998,6 +1161,7 @@
},
"quickAddMagic": {
"hint": "期日、担当者、その他の項目を追加するキーワードが使用できます。",
"quickEntryHint": "日付やラベルなどに使えるマジックプレフィックスを利用できます。詳細はVikunja本体のアプリを開き、タスク入力欄のツールチップをご確認ください。",
"title": "クイック追加",
"intro": "タスクを作成する際に特定のキーワードを使うことで項目を直接追加できます。よく使う項目とともにタスクをすぐ追加できます。",
"multiple": "複数使用できます。",
@ -1170,9 +1334,11 @@
"none": "通知はありません。よい一日を!",
"explainer": "購読中のアクション、プロジェクト、タスクへの変更が発生すると、通知がここに表示されます。",
"markAllRead": "通知をすべて既読にする",
"markAllReadSuccess": "通知をすべて既読にしました。"
"markAllReadSuccess": "通知をすべて既読にしました。",
"subscribeFeed": "Atom フィードで通知を購読"
},
"quickActions": {
"notLoggedIn": "まずVikunja本体のウィンドウにログインしてください。",
"commands": "コマンド",
"placeholder": "コマンドまたはキーワードを入力…",
"hint": "{project} を使うとプロジェクトを検索対象にできます。{project} または {label} (ラベル) を検索条件と組み合わせて使うとプロジェクト内のタスクやラベルの付いたタスクを検索できます。{assignee} を使うとチームを検索対象にできます。",
@ -1305,5 +1471,66 @@
"weeks": "週間",
"years": "年"
}
},
"admin": {
"title": "管理",
"labels": {
"users": "ユーザー",
"tasks": "タスク"
},
"overview": {
"shares": "共有",
"linkSharesShort": "リンク",
"teamSharesShort": "チーム",
"userSharesShort": "ユーザー",
"version": "バージョン",
"license": "ライセンス",
"licenseValidUntil": "有効期限",
"licenseExpiresIn": "あと {days} 日",
"licenseLastVerified": "最終検証日",
"licenseNever": "なし",
"licenseLastCheckFailed": "直近のチェックに失敗しました",
"licenseFeatures": "機能",
"licenseInstance": "インスタンスID",
"licenseManage": "管理"
},
"searchUsersPlaceholder": "ユーザー名またはメールアドレスで検索…",
"users": {
"status": "ステータス",
"details": "詳細",
"detailsTitle": "ユーザー: {username}",
"issuer": "発行者",
"issuerLocal": "ローカル",
"issuerUrl": "発行者URL",
"subject": "サブジェクト",
"statusActive": "有効",
"statusEmailConfirmation": "メール確認待ち",
"statusDisabled": "無効化済み",
"statusLocked": "アカウントロック中",
"isAdminLabel": "管理者",
"addUser": "ユーザーを追加",
"createTitle": "ユーザーを作成",
"nameLabel": "名前",
"skipEmailConfirm": "メール確認をスキップ",
"createSubmit": "ユーザーを作成",
"saveButton": "変更を保存",
"createdSuccess": "ユーザー {username} を作成しました。",
"updatedSuccess": "ユーザー {username} を更新しました。",
"deletedSuccess": "ユーザー {username} を削除しました。",
"deleteScheduledSuccess": "ユーザー {username} に削除スケジュール確認メールが送信されます。",
"confirmDeleteTitle": "ユーザーを削除しますか?",
"confirmDeleteIntro": "ユーザー {username} をどのように削除しますか?",
"deleteModeScheduled": "削除をスケジュール",
"deleteModeScheduledHelp": "削除のスケジュールでは、ユーザー自身によるアカウント削除と同様に、確認メールをユーザーに送信します。",
"deleteModeNow": "今すぐ削除",
"deleteModeNowHelp": "今すぐ削除は、ユーザーとそのすべてのデータを即時に削除します。この操作は取り消せません。"
},
"projects": {
"ownerLabel": "所有者",
"reassignOwner": "所有者を再割り当て",
"reassignTitle": "{title} を再割り当て",
"reassignedSuccess": "プロジェクトの所有者を再割り当てしました。",
"newOwnerLabel": "新しい所有者"
}
}
}

View File

@ -5,9 +5,36 @@
},
"home": {
"welcomeNight": "Goedenacht {username}!",
"welcomeNightOwl": "Hey {username}, nachtuil",
"welcomeNightBurning": "Tot diep in de nacht doorwerken {username}?",
"welcomeNightQuiet": "Stille uurtjes, {username}",
"welcomeNightLate": "Het is laat, {username}",
"welcomeNightMoonlit": "Maanverlichte planning, {username}?",
"welcomeMorning": "Goedemorgen {username}!",
"welcomeMorningHey": "Hey {username}, klaar om te gaan?",
"welcomeMorningFresh": "Verse start, {username}",
"welcomeMorningCoffee": "Koffie en taken, {username}?",
"welcomeMorningRise": "Opstaan en plannen, {username}",
"welcomeMorningBack": "Welkom terug, {username}",
"welcomeMondayFresh": "Verse week, {username}",
"welcomeTuesday": "Fijne dinsdag, {username}",
"welcomeWednesdayMid": "Midden van de week alweer, {username}",
"welcomeThursday": "Bijna klaar, {username}",
"welcomeFridayPush": "Vrijdag nog even door, {username}?",
"welcomeSaturday": "Weekendstand, {username}",
"welcomeSundaySession": "Zondagse sessie, {username}?",
"welcomeDay": "Hallo {username}!",
"welcomeDayBack": "Weer aan de slag, {username}",
"welcomeDayFocus": "Nu met focus, {username}",
"welcomeDayKeepGoing": "Blijf doorgaan, {username}",
"welcomeDayWhatsNext": "Wat komt er nu, {username}?",
"welcomeDayGood": "Goedemiddag {username}",
"welcomeEvening": "Goedenavond {username}!",
"welcomeEveningWind": "Nu tot rust komen, {username}?",
"welcomeEveningReturns": "{username} keert terug",
"welcomeEveningWrap": "Tijd om af te ronden, {username}?",
"welcomeEveningOneMore": "Nog één ding, {username}?",
"welcomeEveningStill": "Nog steeds bezig, {username}?",
"lastViewed": "Laatst bekeken",
"addToHomeScreen": "Voeg deze app toe aan je startscherm voor snellere toegang en verbeterde ervaring.",
"goToOverview": "Ga naar overzicht",
@ -53,6 +80,15 @@
"authenticating": "Authenticeren…",
"openIdStateError": "Status komt niet overeen, weigert door te gaan!",
"openIdGeneralError": "Er was een fout tijdens het authenticeren bij de externe applicatie.",
"openIdTotpRequired": "Je account vereist tweestapsverificatie. Voer je TOTP-code in en log opnieuw in.",
"openIdTotpSubmit": "Doorgaan",
"oauthMissingParams": "Ontbrekende OAuth parameters: {params}",
"oauthRedirectedToApp": "Je bent doorgestuurd naar de app. Je kunt dit tabblad nu sluiten.",
"desktopTryDemo": "Probeer de demo",
"desktopCustomServer": "Aangepaste server-URL",
"desktopCustomServerDescription": "Voer de URL in van je Vikunja server om te beginnen.",
"desktopWaitingForAuth": "Wachten op authenticatie…",
"desktopOAuthError": "Authenticatie mislukt: {error}",
"logout": "Uitloggen",
"emailInvalid": "Vul een geldig e-mailadres in.",
"usernameRequired": "Geef een gebruikersnaam op.",
@ -67,9 +103,23 @@
"alreadyHaveAnAccount": "Heb je al een account?",
"remember": "Ingelogd blijven",
"registrationDisabled": "Registratie is uitgeschakeld.",
"passwordResetTokenMissing": "Wachtwoord reset token ontbreekt."
"passwordResetTokenMissing": "Wachtwoord reset token ontbreekt.",
"registrationFailed": "Er is een fout opgetreden tijdens de registratie. Controleer je invoer en probeer opnieuw."
},
"settings": {
"bots": {
"title": "Bot-gebruikers",
"description": "Bot-gebruikers zijn API-only gebruikers waar jij eigenaar van bent. Ze kunnen worden toegevoegd aan projecten, taken krijgen en authenticeren met API-tokens. Ze kunnen niet interactief inloggen.",
"namePlaceholder": "Mijn Assistent",
"create": "Bot aanmaken",
"enable": "Inschakelen",
"badge": "Bot",
"delete": {
"header": "Verwijder deze bot-gebruiker",
"text1": "Weet je zeker dat je bot-gebruiker \"{username}\" wilt verwijderen?",
"text2": "Dit is onomkeerbaar. Alle API-tokens die bij deze bot horen, worden ingetrokken."
}
},
"title": "Instellingen",
"newPasswordTitle": "Je wachtwoord bijwerken",
"newPassword": "Nieuw wachtwoord",
@ -95,12 +145,21 @@
"weekStart": "Week begint op",
"weekStartSunday": "Zondag",
"weekStartMonday": "Maandag",
"weekStartTuesday": "Dinsdag",
"weekStartWednesday": "Woensdag",
"weekStartThursday": "Donderdag",
"weekStartFriday": "Vrijdag",
"weekStartSaturday": "Zaterdag",
"language": "Taal",
"defaultProject": "Standaardproject",
"defaultView": "Standaardweergave",
"timezone": "Tijdzone",
"overdueTasksRemindersTime": "Tijdstip herinneringsmail voor achterstallige taken",
"quickAddDefaultReminders": "Standaardherinneringen voor Snel Toevoegen",
"quickAddDefaultRemindersDescription": "Deze herinneringen worden automatisch toegevoegd aan elke taak die is gemaakt via Magisch Snel Toevoegen met een vervaldatum.",
"quickAddDefaultRemindersHint": "Voeg herinnering(en) toe ten opzichte van de vervaldatum van de taak. Laat leeg om uit te schakelen.",
"filterUsedOnOverview": "Opgeslagen filter toegepast op de overzichtspagina",
"showLastViewed": "Toon laatst bekeken projecten op de overzichtspagina",
"minimumPriority": "Minimale zichtbare taakprioriteit",
"dateDisplay": "Datumweergave",
"dateDisplayOptions": {
@ -124,7 +183,13 @@
"taskAndNotifications": "Projecten & taken",
"privacy": "Privacy",
"localization": "Lokalisatie",
"appearance": "Uiterlijk & gedrag"
"appearance": "Uiterlijk & gedrag",
"desktop": "Desktop app"
},
"desktop": {
"quickEntryShortcut": "Sneltoets voor snelle invoer",
"shortcutRecorderPlaceholder": "Klik om sneltoets in te stellen",
"shortcutRecorderRecording": "Druk op een toetscombinatie…"
},
"totp": {
"title": "Tweestapsverificatie",
@ -134,15 +199,32 @@
"scanQR": "Als alternatief kan je ook deze QR code scannen:",
"passcode": "Je toegangscode",
"passcodePlaceholder": "Een code gegenereerd door je TOTP-app",
"confirmNotice": "Na het inschakelen van tweestapsverificatie, wordt je uitgelogd uit alle sessies en moet je opnieuw inloggen.",
"setupSuccess": "Je hebt tweestapsverificatie succesvol ingesteld!",
"enterPassword": "Voer alsjeblieft je wachtwoord in",
"disable": "Tweestapsverificatie uitschakelen",
"confirmSuccess": "Je hebt tweestapsverificatie succesvol ingeschakeld!",
"disableSuccess": "Uitschakelen tweestapsverificatie is geslaagd."
},
"caldav": {
"title": "CalDAV",
"howTo": "Je kunt Vikunja verbinden met CalDAV-clients om taken te bekijken en beheren vanuit verschillende clients. Voer deze url in bij je client:",
"more": "Meer informatie over CalDAV in Vikunja",
"tokens": "CalDAV tokens"
"tokens": "CalDAV tokens",
"tokensHowTo": "Voor CalDAV-authenticatie gebruik je jouw normale accountwachtwoord of een speciaal CalDAV-token.",
"createToken": "Maak een CalDAV-token",
"tokenCreated": "Hier is je nieuwe token: {token}",
"wontSeeItAgain": "Schrijf het op of bewaar het veilig - je kunt het hierna niet meer inzien.",
"mustUseToken": "Je moet een CalDAV-token aanmaken om CalDAV te gebruiken met een externe client. Gebruik het token in het wachtwoordveld van uw client.",
"usernameIs": "Je gebruikersnaam voor CalDAV is: {0}",
"apiTokenHint": "Je kunt ook een API-token gebruiken met CalDAV-permissie. Maak er een aan in {link}."
},
"feeds": {
"title": "Atom Feed",
"howTo": "Je kunt je abonneren op je Vikunja meldingen met elke Atom-compatibele feedlezer. Gebruik deze URL:",
"usernameIs": "Je gebruikersnaam voor de feed is: {0}",
"apiTokenHint": "Authenticeer met een API-token die {scope} permissies heeft. Maak er een aan in {link}.",
"tokenTitle": "Atom feed"
},
"avatar": {
"title": "Avatar",
@ -173,6 +255,10 @@
"backgroundBrightness": {
"title": "Achtergrond helderheid"
},
"webhooks": {
"title": "Webhook notificaties",
"description": "Configureer webhook-URL's om POST aanvragen te ontvangen wanneer herinneringen of achterstallige gebeurtenissen worden geactiveerd. Deze webhooks ontvangen gebeurtenissen van al je projecten."
},
"apiTokens": {
"title": "API tokens",
"general": "Met API tokens kun je Vikunja's API gebruiken zonder gebruikersnaam en wachtwoord.",
@ -188,6 +274,13 @@
"expired": "Dit token is verlopen {ago}.",
"tokenCreatedSuccess": "Hier is je API-token: {token}",
"tokenCreatedNotSeeAgain": "Bewaar het op een veilige locatie, het wordt slechts één keer getoond!",
"presets": {
"title": "Snelle voorkeursinstellingen",
"readOnly": "Alleen-lezen",
"tasks": "Taakbeheer",
"projects": "Projectmanagement",
"fullAccess": "Volledige toegang"
},
"delete": {
"header": "Dit token verwijderen",
"text1": "Weet je zeker dat je token \"{token}\" wilt verwijderen?",
@ -199,6 +292,20 @@
"expiresAt": "Verloopt op",
"permissions": "Machtigingen"
}
},
"sessions": {
"title": "Sessies",
"description": "Dit zijn alle apparaten die momenteel zijn ingelogd op je account. Je kunt elke sessie intrekken om dat apparaat uit te loggen. Het kan tot 10 minuten duren voordat de intrekking volledig van kracht is.",
"deviceInfo": "Apparaat",
"ipAddress": "IP-adres",
"lastActive": "Laatst actief",
"current": "Huidige sessie",
"delete": {
"header": "Sessie intrekken",
"text": "Weet je zeker dat je deze sessie wilt intrekken? Het apparaat wordt uitgelogd. Het kan tot 10 minuten duren voordat de sessie volledig is verlopen."
},
"deleteSuccess": "De sessie is ingetrokken. Het kan tot 10 minuten duren voordat de sessie volledig is verlopen.",
"noOtherSessions": "Geen andere actieve sessies."
}
},
"deletion": {
@ -353,7 +460,8 @@
"addPlaceholder": "Taak toevoegen…",
"empty": "Dit project is momenteel leeg.",
"newTaskCta": "Taak aanmaken.",
"editTask": "Taak bewerken"
"editTask": "Taak bewerken",
"sort": "Sorteren"
},
"gantt": {
"title": "Gantt",
@ -379,7 +487,10 @@
"taskAriaLabel": "Taak: {task}",
"taskAriaLabelById": "Taak {id}",
"partialDatesStart": "Alleen startdatum (open-einde)",
"partialDatesEnd": "Alleen einddatum (open-einde)"
"partialDatesEnd": "Alleen einddatum (open-einde)",
"expandGroup": "Groep uitklappen: {task}",
"collapseGroup": "Groep inklappen: {task}",
"toggleRelationArrows": "Relatiepijlen wisselen"
},
"table": {
"title": "Tabel",
@ -409,7 +520,8 @@
"bucketTitleSavedSuccess": "De categorietitel is succesvol opgeslagen.",
"bucketLimitSavedSuccess": "De categorielimiet is succesvol opgeslagen.",
"collapse": "Deze categorie inklappen",
"bucketLimitReached": "U heeft de categorielimiet bereikt. Verwijder taken of verhoog de limiet om nieuwe taken toe te voegen."
"bucketLimitReached": "U heeft de categorielimiet bereikt. Verwijder taken of verhoog de limiet om nieuwe taken toe te voegen.",
"bucketOptions": "Categorie-opties"
},
"pseudo": {
"favorites": {
@ -532,6 +644,29 @@
}
}
},
"sorting": {
"manually": "Handmatig",
"apply": "Sortering toepassen",
"description": "Kies hoe taken in deze lijst worden gesorteerd. Bij handmatig sorteren kun je taken verslepen om ze anders te ordenen.",
"options": {
"titleAsc": "Titel (AZ)",
"titleDesc": "Titel (ZA)",
"priorityDesc": "Prioriteit (hoogste eerst)",
"priorityAsc": "Prioriteit (laagste eerst)",
"dueDateAsc": "Vervaldatum (vroegste eerst)",
"dueDateDesc": "Vervaldatum (laatste eerst)",
"startDateAsc": "Startdatum (vroegste eerst)",
"startDateDesc": "Startdatum (laatste eerst)",
"endDateAsc": "Einddatum (vroegste eerst)",
"endDateDesc": "Einddatum (laatste eerst)",
"percentDoneDesc": "% gereed (meest gereed eerst)",
"percentDoneAsc": "% gereed (minst gereed eerst)",
"createdDesc": "Aangemaakt (nieuwste eerst)",
"createdAsc": "Aangemaakt (oudste eerst)",
"updatedDesc": "Bijgewerkt (nieuwste eerst)",
"updatedAsc": "Bijgewerkt (oudste eerst)"
}
},
"migrate": {
"title": "Importeer vanuit een andere dienst",
"titleService": "Importeer je gegevens van {name} naar Vikunja",
@ -546,7 +681,30 @@
"importUpload": "Om gegevens van {name} te importeren in Vikunja, klik je op de knop hieronder om een bestand te kiezen.",
"upload": "Bestand uploaden",
"migrationStartedWillReciveEmail": "Vikunja gaat nu je lijsten/projecten, taken, notities, herinneringen en bestanden van {service} importeren. Omdat dit een tijdje zal duren, sturen we je een e-mail zodra het klaar is. Je kunt dit venster nu sluiten.",
"migrationInProgress": "Er is momenteel een migratie aan de gang. Wacht tot dit voltooid is."
"migrationInProgress": "Er is momenteel een migratie aan de gang. Wacht tot dit voltooid is.",
"csv": {
"description": "Importeer taken uit een CSV-bestand met aangepaste kolomtoewijzing.",
"uploadDescription": "Kies een CSV-bestand om te importeren. Het bestand moet taakgegevens bevatten met kolomkoppen in de eerste rij.",
"selectFile": "Kies CSV-bestand",
"columnMappingDescription": "Wijs elke kolom in je CSV-bestand toe aan een taakattribuut. Vikunja heeft de meest waarschijnlijke toewijzingen automatisch gedetecteerd. Het voorbeeld hieronder wordt automatisch bijgewerkt wanneer je de instellingen aanpast.",
"parsingOptions": "Opties voor verwerking",
"delimiter": "Scheidingsteken",
"dateFormat": "Datumnotatie",
"skipRows": "Rijen overslaan",
"mapColumns": "Kolommen toewijzen",
"example": "bijv.",
"preview": "Voorbeeld",
"previewDescription": "Toont de eerste 5 van {count} taken die zullen worden geïmporteerd.",
"import": "Taken importeren",
"untitled": "Naamloze taak",
"ignore": "Negeren",
"delimiters": {
"comma": "Komma (,)",
"semicolon": "Puntkomma (;)",
"tab": "Tab",
"pipe": "Pijp (|)"
}
}
},
"label": {
"title": "Labels",
@ -586,7 +744,9 @@
"upcoming": "Aankomend",
"settings": "Instellingen",
"imprint": "Imprint",
"privacy": "Privacybeleid"
"privacy": "Privacybeleid",
"closeSidebar": "Zijbalk sluiten",
"home": "Vikunja home"
},
"misc": {
"loading": "Bezig met laden…",
@ -618,9 +778,15 @@
"createdBy": "Aangemaakt door {0}",
"actions": "Acties",
"cannotBeUndone": "Dit kan niet ongedaan gemaakt worden!",
"avatarOfUser": "{user}'s profielfoto"
"avatarOfUser": "{user}'s profielfoto",
"closeBanner": "Banner sluiten",
"closeDialog": "Dialoogvenster sluiten",
"closeQuickActions": "Snelle acties sluiten",
"skipToContent": "Direct naar hoofdinhoud",
"sortBy": "Sorteren op"
},
"input": {
"projectColor": "Projectkleur",
"resetColor": "Kleur resetten",
"datepicker": {
"today": "Vandaag",
@ -680,6 +846,9 @@
"toggleHeaderCell": "Celkopteksten in-/uitschakelen",
"mergeOrSplit": "Samenvoegen of splitsen",
"fixTables": "Tabellen repareren"
},
"emoji": {
"empty": "Geen emoji gevonden"
}
},
"multiselect": {
@ -693,6 +862,7 @@
"date": "Datum",
"ranges": {
"today": "Vandaag",
"tomorrow": "Morgen",
"thisWeek": "Deze week",
"restOfThisWeek": "De rest van deze week",
"nextWeek": "Volgende week",
@ -766,6 +936,7 @@
"addReminder": "Herinnering toevoegen…",
"doneSuccess": "De taak is succesvol aangemerkt als voltooid.",
"undoneSuccess": "Het voltooien van de taak is succesvol teruggedraaid.",
"readOnlyCheckbox": "Je hebt alleen leestoegang tot deze taak en kunt deze niet als voltooid markeren.",
"movedToProject": "De taak werd verplaatst naar {project}.",
"undo": "Ongedaan maken",
"checklistTotal": "{checked} van {total} taken",
@ -778,7 +949,8 @@
"select": "Selecteer datumbereik",
"noTasks": "Niets te doen - fijne dag!",
"filterByLabel": "Gefilterd op label {label}",
"clearLabelFilter": "Wis labelfilter"
"clearLabelFilter": "Wis labelfilter",
"savedFilterIgnored": "Je opgeslagen startpagina-filter wordt niet toegepast als je taken bekijkt per label."
},
"detail": {
"chooseDueDate": "Klik hier om een vervaldatum in te stellen",
@ -792,9 +964,15 @@
"doneAt": "{0} voltooid",
"updateSuccess": "De taak is succesvol opgeslagen.",
"deleteSuccess": "De taak is succesvol verwijderd.",
"duplicateSuccess": "De taak is succesvol gedupliceerd.",
"noBucket": "Geen categorie",
"bucketChangedSuccess": "De taakcategorie is succesvol gewijzigd.",
"belongsToProject": "Deze taak hoort bij project '{project}'",
"back": "Terug naar project",
"due": "Vervalt {at}",
"closeTaskDetail": "Sluit taakdetails",
"title": "Taakdetails",
"markAsDone": "Markeer '{task}' als gereed",
"scrollToBottom": "Scroll naar beneden",
"organization": "Organisatie",
"management": "Beheer",
@ -817,6 +995,7 @@
"attachments": "Bijlagen toevoegen",
"relatedTasks": "Relatie toevoegen",
"moveProject": "Verplaatsen",
"duplicate": "Dupliceer",
"color": "Kleur instellen",
"delete": "Verwijder",
"favorite": "Toevoegen aan favorieten",
@ -839,8 +1018,8 @@
"relatedTasks": "Verwante Taken",
"reminders": "Herinneringen",
"repeat": "Herhalen",
"comment": "{count} opmerking | {count} opmerkingen",
"commentCount": "Aantal opmerkingen",
"comment": "{count} reactie | {count} reacties",
"commentCount": "Aantal reacties",
"startDate": "Begindatum",
"title": "Titel",
"updated": "Bijgewerkt",
@ -876,7 +1055,7 @@
},
"comment": {
"title": "Reacties",
"loading": "Bezig met laden van reacties…",
"loading": "Reacties laden…",
"edited": "bewerkt op {date}",
"creating": "Opmerking maken…",
"placeholder": "Voeg je reactie toe, druk op '/' voor meer opties…",
@ -887,7 +1066,10 @@
"addedSuccess": "De reactie is succesvol toegevoegd.",
"permalink": "Kopieer permalink naar deze reactie",
"sortNewestFirst": "Nieuwste eerst",
"sortOldestFirst": "Oudste eerst"
"sortOldestFirst": "Oudste eerst",
"reply": "Beantwoorden",
"jumpToOriginal": "Ga naar originele reactie",
"deletedComment": "verwijderde reactie"
},
"mention": {
"noUsersFound": "Geen gebruikers gevonden"
@ -979,6 +1161,7 @@
},
"quickAddMagic": {
"hint": "Gebruik magische prefixes om vervaldata, toegewezen personen en andere taakeigenschappen te definiëren.",
"quickEntryHint": "Gebruik magische voorvoegsels voor datums, labels en meer. Open de Vikunja hoofd-app en bekijk de tooltip op de taakinvoer voor meer details.",
"title": "Snel-toevoegen magie",
"intro": "Bij het aanmaken van een taak kun je speciale trefwoorden gebruiken om direct kenmerken toe te voegen aan de nieuwe taak. Hiermee kun je veelgebruikte kenmerken veel sneller toevoegen aan taken.",
"multiple": "Je kan dit meerdere keren gebruiken.",
@ -1151,9 +1334,11 @@
"none": "Je hebt geen meldingen. Fijne dag!",
"explainer": "Hier verschijnen meldingen wanneer acties, projecten of taken gebeuren waarop u bent geabonneerd.",
"markAllRead": "Markeer alle meldingen als gelezen",
"markAllReadSuccess": "Alle meldingen zijn als gelezen gemarkeerd."
"markAllReadSuccess": "Alle meldingen zijn als gelezen gemarkeerd.",
"subscribeFeed": "Abonneren op meldingen via Atom feed"
},
"quickActions": {
"notLoggedIn": "Log eerst in op het Vikunja hoofdscherm.",
"commands": "Opdrachten",
"placeholder": "Typ een opdracht of zoek…",
"hint": "Je kunt {project} gebruiken om het zoeken te beperken tot een project. Combineer {project} of {label} (labels) met een zoekopdracht om te zoeken naar een taak met deze labels of op dat project. Gebruik {assignee} om alleen te zoeken naar teams.",
@ -1286,5 +1471,66 @@
"weeks": "week|weken",
"years": "jaar|jaren"
}
},
"admin": {
"title": "Beheer",
"labels": {
"users": "Gebruikers",
"tasks": "Taken"
},
"overview": {
"shares": "Deelbare koppelingen",
"linkSharesShort": "link",
"teamSharesShort": "team",
"userSharesShort": "gebruiker",
"version": "Versie",
"license": "Licentie",
"licenseValidUntil": "Geldig tot",
"licenseExpiresIn": "over {days} dagen",
"licenseLastVerified": "Laatst geverifieerd",
"licenseNever": "nooit",
"licenseLastCheckFailed": "laatste controle mislukt",
"licenseFeatures": "Functionaliteiten",
"licenseInstance": "Instance ID",
"licenseManage": "Beheren"
},
"searchUsersPlaceholder": "Zoek op gebruikersnaam of e-mail…",
"users": {
"status": "Status",
"details": "Details",
"detailsTitle": "Gebruiker: {username}",
"issuer": "Uitgever",
"issuerLocal": "Lokaal",
"issuerUrl": "Uitgever URL",
"subject": "Onderwerp",
"statusActive": "Actief",
"statusEmailConfirmation": "E-mailbevestiging vereist",
"statusDisabled": "Uitgeschakeld",
"statusLocked": "Account vergrendeld",
"isAdminLabel": "Beheerder",
"addUser": "Gebruiker toevoegen",
"createTitle": "Gebruiker aanmaken",
"nameLabel": "Naam",
"skipEmailConfirm": "E-mailbevestiging overslaan",
"createSubmit": "Gebruiker aanmaken",
"saveButton": "Wijzigingen opslaan",
"createdSuccess": "Gebruiker {username} aangemaakt.",
"updatedSuccess": "Gebruiker {username} bijgewerkt.",
"deletedSuccess": "Gebruiker {username} verwijderd.",
"deleteScheduledSuccess": "Gebruiker {username} ontvangt een bevestigingsmail om de verwijdering te plannen.",
"confirmDeleteTitle": "Gebruiker verwijderen?",
"confirmDeleteIntro": "Hoe moet gebruiker {username} worden verwijderd?",
"deleteModeScheduled": "Verwijdering plannen",
"deleteModeScheduledHelp": "Bij 'verwijdering plannen' ontvangt de gebruiker een bevestigingsmail, lijkend op een zelfgestarte accountverwijdering.",
"deleteModeNow": "Nu verwijderen",
"deleteModeNowHelp": "'Nu verwijderen' verwijdert de gebruiker en hun data onmiddellijk. Dit kan niet ongedaan worden gemaakt."
},
"projects": {
"ownerLabel": "Eigenaar",
"reassignOwner": "Nieuwe eigenaar toewijzen",
"reassignTitle": "Opnieuw toewijzen {title}",
"reassignedSuccess": "Projecteigenaar opnieuw toegewezen.",
"newOwnerLabel": "Nieuwe eigenaar"
}
}
}

View File

@ -0,0 +1 @@
{}

View File

@ -10,7 +10,7 @@
"welcomeNightQuiet": "Тиха година, {username}",
"welcomeNightLate": "Вже пізно, {username}",
"welcomeNightMoonlit": "Нічне планування, {username}?",
"welcomeMorning": "Добрий ранок, {username}!",
"welcomeMorning": "Доброго ранку, {username}!",
"welcomeMorningHey": "Привіт {username}, можемо починати?",
"welcomeMorningFresh": "Новий старт, {username}",
"welcomeMorningCoffee": "Кава і завдання, {username}?",
@ -45,7 +45,7 @@
},
"demo": {
"title": "Цей екземпляр працює в демонстраційному режимі. Не використовуйте його для реальних даних!",
"everythingWillBeDeleted": "Все буде видалено з регулярними інтервалами!",
"everythingWillBeDeleted": "Усі дані регулярно видаляться!",
"accountWillBeDeleted": "Обліковий запис буде вилучено включно з усіма проєктами, завданнями та вкладеннями, які ви створили."
},
"ready": {
@ -107,6 +107,19 @@
"registrationFailed": "Ой, щось пішло не так при реєстрації. Перевірте, чи все заповнено правильно, і спробуйте знову."
},
"settings": {
"bots": {
"title": "Користувачі-боти",
"description": "Користувачі-боти — це облікові записи лише для API, якими ви володієте. Їх можна додавати до проєктів, призначати на завдання та автентифікувати за допомогою API-токенів. Вони не можуть входити в систему через інтерфейс.",
"namePlaceholder": "Мій асистент",
"create": "Створити бота",
"enable": "Увімкнути",
"badge": "Бот",
"delete": {
"header": "Видалити цього бота",
"text1": "Ви впевнені, що хочете видалити користувача-бота \"{username}\"?",
"text2": "Цю дію неможливо скасувати. Усі API-токени, що належать цьому боту, буде відкликано."
}
},
"title": "Налаштування",
"newPasswordTitle": "Зміна паролю",
"newPassword": "Новий пароль",
@ -206,6 +219,13 @@
"usernameIs": "Ваше ім'я користувача в CalDAV є: {0}",
"apiTokenHint": "Також можна використовувати токен API з дозволом CalDAV. Створіть його в {link}."
},
"feeds": {
"title": "Стрічка Atom",
"howTo": "Ви можете підписатися на сповіщення Vikunja у будь-якому читачі стрічок, сумісному з Atom. Використайте таку URL-адресу:",
"usernameIs": "Ваше ім'я користувача для стрічки: {0}",
"apiTokenHint": "Автентифікуйтеся за допомогою API-токена з дозволом {scope}. Створіть його в {link}.",
"tokenTitle": "Стрічка Atom"
},
"avatar": {
"title": "Зображення обліковки",
"initials": "Ініціали",
@ -745,7 +765,7 @@
"doit": "Зробити!",
"saving": "Зберігаю…",
"saved": "Збережено!",
"default": "Припис",
"default": "Стандартний",
"close": "Закрити",
"download": "Витягти",
"showMenu": "Показати список",
@ -791,8 +811,8 @@
"underline": "Підчеркнутий",
"code": "Код",
"codeTooltip": "Уривок коду.",
"quote": "Переповідь",
"quoteTooltip": "Дослівний уривок.",
"quote": "Цитата",
"quoteTooltip": "Блок цитати.",
"bulletList": "Список з мітками",
"bulletListTooltip": "Створює список з мітками.",
"orderedList": "Список з числами",
@ -800,7 +820,7 @@
"link": "Посилання",
"image": "Зображення",
"imageTooltip": "Додає зображення.",
"horizontalRule": "Черть",
"horizontalRule": "Горизонтальна лінія",
"horizontalRuleTooltip": "Ділить розділ.",
"text": "Введення",
"textTooltip": "Просто для введення чогось.",
@ -842,6 +862,7 @@
"date": "День",
"ranges": {
"today": "Сьогодні",
"tomorrow": "Завтра",
"thisWeek": "Цей тиждень",
"restOfThisWeek": "Залишок цього тижня",
"nextWeek": "Наступний тиждень",
@ -904,7 +925,7 @@
"today": "Сьогодні о 00:00",
"beginningOfThisWeek": "Початок цього тижня о 00:00",
"endOfThisWeek": "Кінець цього тижня",
"in30Days": "Встяж 30 днів",
"in30Days": "Упродовж 30 днів",
"datePlusMonth": "{0} додати один місяць о 00:00 цього дня"
}
}
@ -948,7 +969,7 @@
"bucketChangedSuccess": "Стрічку завдання успішно змінено.",
"belongsToProject": "Завдання міститься у проєкті '{project}'",
"back": "Назад до проєкту",
"due": "Виконати до {at}",
"due": "Виконати {at}",
"closeTaskDetail": "Закрити деталі завдання",
"title": "Деталі завдання",
"markAsDone": "Позначити '{task}' як виконане",
@ -968,7 +989,7 @@
"dueDate": "Встановити термін",
"startDate": "Почати",
"endDate": "Встановити дату завершення",
"reminders": "Нагадувати",
"reminders": "Нагадування",
"repeatAfter": "Повторювати",
"percentDone": "Встановити прогрес",
"attachments": "Вкласти",
@ -988,7 +1009,7 @@
"createdBy": "Створювач",
"description": "Опис",
"done": "Закінчено",
"dueDate": "Строк",
"dueDate": "Кінцева дата",
"endDate": "День закінчення",
"labels": "Позначки",
"percentDone": "Прогрес",
@ -1038,14 +1059,17 @@
"edited": "змінено: {date}",
"creating": "Створюю коментар…",
"placeholder": "Введіть коментар, натисніть '/' для додаткових опцій…",
"comment": "Залишити",
"delete": "Вилучається приписка",
"comment": "Зберегти коментар",
"delete": "Видалити коментар",
"deleteText1": "Справді впровадити?",
"deleteSuccess": "Коментар успішно видалено.",
"addedSuccess": "Коментар успішно додано.",
"permalink": "Одержати посилання",
"sortNewestFirst": "Спочатку новіші",
"sortOldestFirst": "Спочатку старіші"
"sortOldestFirst": "Спочатку старіші",
"reply": "Відповідь",
"jumpToOriginal": "Перейти до початкового коментаря",
"deletedComment": "видалений коментар"
},
"mention": {
"noUsersFound": "Користувачів не знайдено"
@ -1066,7 +1090,7 @@
"unassignSuccess": "З вживача знято дорученість."
},
"label": {
"placeholder": "Введіть щось, щоб знайти позначку…",
"placeholder": "Введіть назву мітки…",
"createPlaceholder": "Додати це як нову позначку",
"addSuccess": "Позначку додано.",
"removeSuccess": "Позначку вилучено.",
@ -1124,13 +1148,12 @@
"repeat": {
"everyDay": "Щодня",
"everyWeek": "Щотижня",
"every30d": "Щомісяця",
"mode": "Спосіб",
"monthly": "Щомісяця",
"fromCurrentDate": "Щодень закінчення",
"each": "Що",
"fromCurrentDate": "З дня закінчення",
"each": "Кожен",
"specifyAmount": "Вкажіть величину…",
"hours": "Годин",
"hours": "Години",
"days": "День",
"weeks": "Тижнів",
"invalidAmount": "Будь ласка, введіть більше нуля."
@ -1194,8 +1217,8 @@
"success": "Вживача успішно видалено зі спільноти."
},
"leave": {
"title": "Покинути спільноту",
"text1": "Справді покинути?",
"title": "Залишити спільноту",
"text1": "Ви впевнені, що хочете залишити цю спільноту?",
"text2": "Ви втратите доступ до всіх проєктів, до яких має доступ ця команда. Якщо передумаєте, вам знадобиться адміністратор команди, щоб додати вас знову.",
"success": "Ви покинули спільноту."
}
@ -1310,7 +1333,8 @@
"none": "У вас немає сповіщень. Гарного дня!",
"explainer": "Сповіщення з'являтимуться тут, коли відбуватимуться якісь дії з проєктами або завданнями, на які ви підписані.",
"markAllRead": "Позначити всі сповіщення як прочитані",
"markAllReadSuccess": "Всі сповіщення позначено прочитаними."
"markAllReadSuccess": "Всі сповіщення позначено прочитаними.",
"subscribeFeed": "Підписатися на сповіщення через стрічку Atom"
},
"quickActions": {
"notLoggedIn": "Будь ласка, спочатку авторизуйтесь в головному вікні Vikunja.",
@ -1433,18 +1457,18 @@
},
"about": {
"title": "Про програму",
"version": "Відміна: {version}",
"version": "Версія: {version}",
"frontendVersion": "Версія інтерфейсу: {version}",
"apiVersion": "API версія: {version}"
},
"time": {
"units": {
"seconds": "секунда|секунди|секунд",
"minutes": "хвилина|хвилини|хвилин",
"hours": "година|години|годин",
"days": "день|дні|днів",
"weeks": "тиждень|тижні|тижнів",
"years": "рік|роки|років"
"seconds": "секунда|секунд(и)",
"minutes": "хвилина|хвилин(и)",
"hours": "година|годин(и)",
"days": "день|дні",
"weeks": "тиждень|тижні",
"years": "рік|роки"
}
},
"admin": {

View File

@ -33,6 +33,7 @@ export const DAYJS_LOCALE_MAPPING = {
'fi-fi': 'fi',
'he-il': 'he',
'sv-se': 'sv',
'el-gr': 'el',
} as Record<SupportedLocale, ISOLanguage>
export const DAYJS_LANGUAGE_IMPORTS = {
@ -65,6 +66,7 @@ export const DAYJS_LANGUAGE_IMPORTS = {
'fi-fi': () => import('dayjs/locale/fi'),
'he-il': () => import('dayjs/locale/he'),
'sv-se': () => import('dayjs/locale/sv'),
'el-gr': () => import('dayjs/locale/el'),
} as Record<SupportedLocale, () => Promise<ILocale>>
export async function loadDayJsLocale(language: SupportedLocale) {

View File

@ -19,7 +19,6 @@ declare global {
API_URL: string;
SENTRY_ENABLED?: boolean;
SENTRY_DSN?: string;
ALLOW_ICON_CHANGES: boolean;
CUSTOM_LOGO_URL?: string;
CUSTOM_LOGO_URL_DARK?: string;
}
@ -38,7 +37,7 @@ if (window.API_URL.endsWith('/')) {
// directives
import focus from '@/directives/focus'
import {vTooltip} from 'floating-vue'
import tooltip from '@/directives/tooltip'
import 'floating-vue/dist/style.css'
import shortcut from '@/directives/shortcut'
import testid from '@/directives/testid'
@ -66,7 +65,7 @@ setLanguage(browserLanguage).then(() => {
app.use(Notifications)
app.directive('focus', focus)
app.directive('tooltip', vTooltip)
app.directive('tooltip', tooltip)
app.directive('shortcut', shortcut)
app.directive('cy', testid)

View File

@ -11,4 +11,5 @@ export interface IApiToken extends IAbstract {
permissions: IApiPermission
expiresAt: Date
created: Date
ownerId?: number
}

View File

@ -24,4 +24,5 @@ export interface IUser extends IAbstract {
isLocalUser: boolean
deletionScheduledAt: string | Date | null
isAdmin?: boolean
botOwnerId?: number
}

View File

@ -8,6 +8,7 @@ export default class ApiTokenModel extends AbstractModel<IApiToken> {
permissions = null
expiresAt: Date = null
created: Date = null
ownerId = 0
constructor(data: Partial<IApiToken> = {}) {
super()

View File

@ -82,6 +82,7 @@ export default class UserModel extends AbstractModel<IUser> implements IUser {
isLocalUser: boolean
deletionScheduledAt: null
isAdmin?: boolean
botOwnerId = 0
constructor(data: Partial<IUser> = {}) {
super()
@ -92,4 +93,8 @@ export default class UserModel extends AbstractModel<IUser> implements IUser {
this.settings = new UserSettingsModel(this.settings || {})
}
get isBot(): boolean {
return (this.botOwnerId ?? 0) > 0
}
}

View File

@ -117,6 +117,11 @@ const router = createRouter({
name: 'user.settings.data-export',
component: () => import('@/views/user/settings/DataExport.vue'),
},
{
path: '/user/settings/feeds',
name: 'user.settings.feeds',
component: () => import('@/views/user/settings/AtomFeed.vue'),
},
{
path: '/user/settings/deletion',
name: 'user.settings.deletion',
@ -163,6 +168,11 @@ const router = createRouter({
name: 'user.settings.webhooks',
component: () => import('@/views/user/settings/Webhooks.vue'),
},
{
path: '/user/settings/bots',
name: 'user.settings.bots',
component: () => import('@/views/user/settings/BotUsers.vue'),
},
{
path: '/user/settings/migrate',
name: 'migrate.start',

View File

@ -0,0 +1,19 @@
import AbstractService from '@/services/abstractService'
import type {IUser} from '@/modelTypes/IUser'
import UserModel from '@/models/user'
export default class BotUserService extends AbstractService<IUser> {
constructor() {
super({
create: '/user/bots',
getAll: '/user/bots',
get: '/user/bots/{id}',
update: '/user/bots/{id}',
delete: '/user/bots/{id}',
})
}
modelFactory(data: Partial<IUser>) {
return new UserModel(data)
}
}

View File

@ -44,6 +44,7 @@ export interface ConfigState {
},
},
publicTeamsEnabled: boolean,
allowIconChanges: boolean,
enabledProFeatures: string[],
}
@ -84,6 +85,7 @@ export const useConfigStore = defineStore('config', () => {
},
},
publicTeamsEnabled: false,
allowIconChanges: true,
enabledProFeatures: [],
})

View File

@ -344,11 +344,11 @@
</div>
<!-- Reactions -->
<Reactions
<Reactions
v-model="task.reactions"
entity-kind="tasks"
:entity-id="task.id"
class="details"
class="details d-print-none"
:disabled="!canWrite"
/>

View File

@ -113,7 +113,7 @@
<table class="table has-actions is-striped is-hoverable is-fullwidth">
<tbody>
<tr
v-for="m in team?.members"
v-for="m in sortedMembers"
:key="m.id"
>
<td>
@ -243,6 +243,7 @@ import FormField from '@/components/input/FormField.vue'
import Multiselect from '@/components/input/Multiselect.vue'
import User from '@/components/misc/User.vue'
import {getDisplayName} from '@/models/user'
import TeamService from '@/services/team'
import TeamMemberService from '@/services/teamMember'
import UserService from '@/services/user'
@ -273,6 +274,12 @@ const userIsAdmin = computed(() => {
})
const userInfo = computed(() => authStore.info)
const sortedMembers = computed(() => {
return [...(team.value?.members ?? [])].sort((a, b) =>
getDisplayName(a).localeCompare(getDisplayName(b), undefined, {sensitivity: 'base'}),
)
})
const teamService = ref<TeamService>(new TeamService())
const teamMemberService = ref<TeamMemberService>(new TeamMemberService())
const userService = ref<UserService>(new UserService())

View File

@ -67,6 +67,10 @@ const navigationItems = computed(() => {
routeName: 'user.settings.caldav',
condition: caldavEnabled.value,
},
{
title: t('user.settings.feeds.title'),
routeName: 'user.settings.feeds',
},
{
title: t('user.settings.apiTokens.title'),
routeName: 'user.settings.apiTokens',
@ -80,6 +84,10 @@ const navigationItems = computed(() => {
routeName: 'user.settings.webhooks',
condition: webhooksEnabled.value,
},
{
title: t('user.settings.bots.title'),
routeName: 'user.settings.bots',
},
{
title: t('user.deletion.title'),
routeName: 'user.settings.deletion',

View File

@ -1,35 +1,19 @@
<script setup lang="ts">
import ApiTokenService from '@/services/apiToken'
import {computed, onMounted, ref} from 'vue'
import {onMounted, ref} from 'vue'
import {useRoute} from 'vue-router'
import {parseScopesFromQuery} from '@/helpers/parseScopesFromQuery'
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
import {formatDateSince, formatDisplayDate} from '@/helpers/time/formatDate'
import XButton from '@/components/input/Button.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import ApiTokenModel from '@/models/apiTokenModel'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import {MILLISECONDS_A_DAY} from '@/constants/date'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import {useI18n} from 'vue-i18n'
import Message from '@/components/misc/Message.vue'
import FormField from '@/components/input/FormField.vue'
import type {IApiToken} from '@/modelTypes/IApiToken'
import ApiTokenForm from '@/components/token/ApiTokenForm.vue'
const service = new ApiTokenService()
const tokens = ref<IApiToken[]>([])
const apiDocsUrl = window.API_URL + '/docs'
const showCreateForm = ref(false)
const availableRoutes = ref(null)
const newToken = ref<IApiToken>(new ApiTokenModel())
const newTokenExpiry = ref<string | number>(30)
const newTokenExpiryCustom = ref(new Date())
const newTokenPermissions = ref({})
const newTokenPermissionsGroup = ref({})
const newTokenTitleValid = ref(true)
const newTokenPermissionValid = ref(true)
const apiTokenTitle = ref()
const tokenCreatedSuccessMessage = ref('')
const showDeleteModal = ref<boolean>(false)
@ -39,160 +23,26 @@ const {t} = useI18n()
const route = useRoute()
const now = new Date()
interface TokenPreset {
id: string
groups: Record<string, string[] | '*'>
}
const presets: TokenPreset[] = [
{
id: 'readOnly',
groups: {
'*': ['read_one', 'read_all'],
},
},
{
id: 'tasks',
groups: {
'tasks': '*',
'tasks_attachments': '*',
'tasks_assignees': '*',
'tasks_labels': '*',
'tasks_comments': '*',
'tasks_relations': '*',
'labels': ['read_one', 'read_all', 'create'],
'projects': ['read_one', 'read_all', 'views_buckets_tasks'],
'projects_views': ['read_one', 'read_all'],
'projects_views_tasks': ['read_one', 'read_all'],
},
},
{
id: 'projects',
groups: {
'projects': '*',
'projects_views': '*',
'projects_teams': '*',
'projects_users': '*',
'projects_shares': '*',
'projects_webhooks': '*',
'projects_buckets': '*',
'projects_views_tasks': '*',
'tasks': ['read_one', 'read_all'],
'teams': ['read_one', 'read_all'],
},
},
{
id: 'fullAccess',
groups: {
'*': '*',
},
},
]
function applyPreset(preset: TokenPreset) {
resetPermissions()
for (const [groupKey, permissions] of Object.entries(preset.groups)) {
if (groupKey === '*') {
// Apply to all groups
for (const group of Object.keys(availableRoutes.value)) {
applyPermissionsToGroup(group, permissions)
}
} else if (availableRoutes.value[groupKey]) {
applyPermissionsToGroup(groupKey, permissions)
}
}
}
function applyPermissionsToGroup(group: string, permissions: string[] | '*') {
if (permissions === '*') {
// Select all permissions in this group
selectPermissionGroup(group, true)
newTokenPermissionsGroup.value[group] = true
} else {
for (const perm of permissions) {
if (newTokenPermissions.value[group]?.[perm] !== undefined) {
newTokenPermissions.value[group][perm] = true
}
}
toggleGroupPermissionsFromChild(group, true)
}
}
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
locale: useFlatpickrLanguage().value,
minDate: now,
}))
const initialTitle = ref('')
const initialScopes = ref('')
onMounted(async () => {
tokens.value = await service.getAll()
const allRoutes = await service.getAvailableRoutes()
const routesAvailable = {}
const keys = Object.keys(allRoutes)
keys.sort((a, b) => (a === 'other' ? 1 : b === 'other' ? -1 : 0))
keys.forEach(key => {
routesAvailable[key] = allRoutes[key]
})
availableRoutes.value = routesAvailable
resetPermissions()
// Apply query parameters if present
applyQueryParams()
})
function resetPermissions() {
newTokenPermissions.value = {}
newTokenPermissionsGroup.value = {}
Object.entries(availableRoutes.value).forEach(entry => {
const [group, routes] = entry
newTokenPermissions.value[group] = {}
Object.keys(routes).forEach(r => {
newTokenPermissions.value[group][r] = false
})
})
}
function applyQueryParams() {
// Normalize query params - they can be string, string[], or null
const titleParam = Array.isArray(route.query.title) ? route.query.title[0] : route.query.title
const scopesParam = Array.isArray(route.query.scopes) ? route.query.scopes[0] : route.query.scopes
if (titleParam) {
initialTitle.value = titleParam
}
if (scopesParam) {
initialScopes.value = scopesParam
}
if (titleParam || scopesParam) {
showCreateForm.value = true
}
if (titleParam) {
newToken.value.title = titleParam
newTokenTitleValid.value = true
}
if (scopesParam) {
const requestedScopes = parseScopesFromQuery(scopesParam)
// Apply requested scopes to the permissions checkboxes
for (const [group, permissions] of Object.entries(requestedScopes)) {
if (newTokenPermissions.value[group]) {
for (const permission of permissions) {
if (newTokenPermissions.value[group][permission] !== undefined) {
newTokenPermissions.value[group][permission] = true
}
}
// Update group checkbox if all permissions in group are selected
toggleGroupPermissionsFromChild(group, true)
}
}
}
}
})
async function deleteToken() {
await service.delete(tokenToDelete.value)
@ -205,77 +55,14 @@ async function deleteToken() {
tokens.value.splice(index, 1)
}
async function createToken() {
if (!newTokenTitleValid.value) {
apiTokenTitle.value.focus()
return
}
let hasPermissions = false
newToken.value.permissions = {}
Object.entries(newTokenPermissions.value).forEach(([key, ps]) => {
const all = Object.entries(ps)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.filter(([_, v]) => v)
.map(p => p[0])
if (all.length > 0) {
newToken.value.permissions[key] = all
hasPermissions = true
}
})
if(!hasPermissions) {
newTokenPermissionValid.value = false
return
}
const expiry = Number(newTokenExpiry.value)
if (!isNaN(expiry)) {
// if it's a number, we assume it's the number of days in the future
newToken.value.expiresAt = new Date((+new Date()) + expiry * MILLISECONDS_A_DAY)
} else {
newToken.value.expiresAt = new Date(newTokenExpiryCustom.value)
}
const token = await service.create(newToken.value)
tokenCreatedSuccessMessage.value = t('user.settings.apiTokens.tokenCreatedSuccess', {token: token.token})
newToken.value = new ApiTokenModel()
newTokenExpiry.value = 30
newTokenExpiryCustom.value = new Date()
resetPermissions()
tokens.value.push(token)
showCreateForm.value = false
}
function formatPermissionTitle(title: string): string {
return title.replaceAll('_', ' ')
}
function selectPermissionGroup(group: string, checked: boolean) {
Object.entries(availableRoutes.value[group]).forEach(entry => {
const [key] = entry
newTokenPermissions.value[group][key] = checked
})
}
function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
if (checked) {
// Check if all permissions of that group are checked and check the "select all" checkbox in that case
let allChecked = true
Object.entries(availableRoutes.value[group]).forEach(entry => {
const [key] = entry
if (!newTokenPermissions.value[group][key]) {
allChecked = false
}
})
if (allChecked) {
newTokenPermissionsGroup.value[group] = true
}
} else {
newTokenPermissionsGroup.value[group] = false
}
function onTokenCreated(token: IApiToken) {
tokenCreatedSuccessMessage.value = t('user.settings.apiTokens.tokenCreatedSuccess', {token: token.token})
tokens.value.push(token)
showCreateForm.value = false
}
</script>
@ -354,132 +141,14 @@ function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
</table>
</div>
<form
<ApiTokenForm
v-if="showCreateForm"
@submit.prevent="createToken"
>
<!-- Title -->
<FormField
id="apiTokenTitle"
ref="apiTokenTitle"
v-model="newToken.title"
v-focus
:label="$t('user.settings.apiTokens.attributes.title')"
type="text"
:placeholder="$t('user.settings.apiTokens.attributes.titlePlaceholder')"
:error="newTokenTitleValid ? null : $t('user.settings.apiTokens.titleRequired')"
@keyup="() => newTokenTitleValid = newToken.title !== ''"
@focusout="() => newTokenTitleValid = newToken.title !== ''"
/>
<!-- Expiry -->
<div class="field">
<label
class="label"
for="apiTokenExpiry"
>
{{ $t('user.settings.apiTokens.attributes.expiresAt') }}
</label>
<div class="is-flex">
<div class="control select">
<select
id="apiTokenExpiry"
v-model="newTokenExpiry"
class="select"
>
<option value="30">
{{ $t('user.settings.apiTokens.30d') }}
</option>
<option value="60">
{{ $t('user.settings.apiTokens.60d') }}
</option>
<option value="90">
{{ $t('user.settings.apiTokens.90d') }}
</option>
<option value="custom">
{{ $t('misc.custom') }}
</option>
</select>
</div>
<flat-pickr
v-if="newTokenExpiry === 'custom'"
v-model="newTokenExpiryCustom"
class="mis-2"
:config="flatPickerConfig"
/>
</div>
</div>
<!-- Permissions -->
<div class="field">
<label class="label">{{ $t('user.settings.apiTokens.attributes.permissions') }}</label>
<p>{{ $t('user.settings.apiTokens.permissionExplanation') }}</p>
<!-- Presets -->
<div class="preset-buttons mbe-4">
<label class="label">{{ $t('user.settings.apiTokens.presets.title') }}</label>
<div
class="is-flex"
style="gap: .5rem; flex-wrap: wrap;"
>
<XButton
v-for="preset in presets"
:key="preset.id"
variant="secondary"
type="button"
@click="applyPreset(preset)"
>
{{ $t(`user.settings.apiTokens.presets.${preset.id}`) }}
</XButton>
</div>
</div>
<div
v-for="(routes, group) in availableRoutes"
:key="group"
class="mbe-2"
>
<template
v-if="Object.keys(routes).length >= 1"
>
<FancyCheckbox
v-model="newTokenPermissionsGroup[group]"
class="mie-2 is-capitalized has-text-weight-bold"
@update:modelValue="checked => selectPermissionGroup(group, checked)"
>
{{ formatPermissionTitle(group) }}
</FancyCheckbox>
<br>
</template>
<template
v-for="(paths, permission) in routes"
:key="group+'-'+permission"
>
<FancyCheckbox
v-model="newTokenPermissions[group][permission]"
class="mis-4 mie-2 is-capitalized"
@update:modelValue="checked => toggleGroupPermissionsFromChild(group, checked)"
>
{{ formatPermissionTitle(permission) }}
</FancyCheckbox>
<br>
</template>
</div>
</div>
<p
v-if="!newTokenPermissionValid"
class="help is-danger"
>
{{ $t('user.settings.apiTokens.permissionRequired') }}
</p>
<XButton
:loading="service.loading"
type="submit"
>
{{ $t('user.settings.apiTokens.createToken') }}
</XButton>
</form>
:loading="service.loading"
:initial-title="initialTitle"
:initial-scopes="initialScopes"
@created="onTokenCreated"
@cancel="showCreateForm = false"
/>
<XButton
v-else
@ -509,3 +178,9 @@ function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
</Modal>
</Card>
</template>
<style lang="scss" scoped>
.preset-buttons {
margin-block-start: 1rem;
}
</style>

View File

@ -0,0 +1,75 @@
<template>
<Card :title="$t('user.settings.feeds.title')">
<p>
{{ $t('user.settings.feeds.howTo') }}
</p>
<FormField
v-model="feedUrl"
type="text"
readonly
>
<template #addon>
<XButton
v-tooltip="$t('misc.copy')"
:shadow="false"
icon="paste"
@click="copy(feedUrl)"
/>
</template>
</FormField>
<p class="mbs-4">
<i18n-t
keypath="user.settings.feeds.usernameIs"
scope="global"
>
<strong>{{ username }}</strong>
</i18n-t>
</p>
<p class="mbs-2">
<i18n-t
keypath="user.settings.feeds.apiTokenHint"
scope="global"
>
<template #scope>
<code>feeds:access</code>
</template>
<template #link>
<RouterLink
:to="{
name: 'user.settings.apiTokens',
query: {
title: $t('user.settings.feeds.tokenTitle'),
scopes: 'feeds:access',
},
}"
>
{{ $t('user.settings.apiTokens.title') }}
</RouterLink>
</template>
</i18n-t>
</p>
</Card>
</template>
<script lang="ts" setup>
import {computed} from 'vue'
import {useI18n} from 'vue-i18n'
import {useTitle} from '@/composables/useTitle'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import FormField from '@/components/input/FormField.vue'
import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
const copy = useCopyToClipboard()
const {t} = useI18n({useScope: 'global'})
useTitle(() => `${t('user.settings.feeds.title')} - ${t('user.settings.title')}`)
const authStore = useAuthStore()
const configStore = useConfigStore()
const username = computed(() => authStore.info?.username)
const feedUrl = computed(() => `${configStore.apiBase}/feeds/notifications.atom`)
</script>

View File

@ -0,0 +1,362 @@
<script setup lang="ts">
import {onMounted, ref} from 'vue'
import {useI18n} from 'vue-i18n'
import {useTitle} from '@/composables/useTitle'
import XButton from '@/components/input/Button.vue'
import FormField from '@/components/input/FormField.vue'
import Message from '@/components/misc/Message.vue'
import ApiTokenForm from '@/components/token/ApiTokenForm.vue'
import BotUserService from '@/services/botUser'
import ApiTokenService from '@/services/apiToken'
import UserModel from '@/models/user'
import type {IUser} from '@/modelTypes/IUser'
import type {IApiToken} from '@/modelTypes/IApiToken'
import {formatDisplayDate} from '@/helpers/time/formatDate'
const STATUS_ACTIVE = 0
const STATUS_DISABLED = 2
const {t} = useI18n({useScope: 'global'})
useTitle(() => t('user.settings.bots.title'))
const botService = new BotUserService()
const tokenService = new ApiTokenService()
const bots = ref<IUser[]>([])
const newBotUsername = ref('')
const newBotName = ref('')
const createError = ref<string | null>(null)
const showCreateForm = ref(false)
const tokensByBot = ref<Record<number, IApiToken[]>>({})
const newTokensByBot = ref<Record<number, string>>({})
const showTokenForm = ref<Record<number, boolean>>({})
const editingName = ref<Record<number, boolean>>({})
const nameDraft = ref<Record<number, string>>({})
const showDeleteModal = ref<boolean>(false)
const botToDelete = ref<IUser>()
async function loadBots() {
bots.value = await botService.getAll() as IUser[]
for (const bot of bots.value) {
await loadTokens(bot.id)
}
}
async function loadTokens(botId: number) {
tokensByBot.value[botId] = await tokenService.getAll({}, {owner_id: botId}) as IApiToken[]
}
async function createBot() {
createError.value = null
const username = newBotUsername.value.startsWith('bot-') ? newBotUsername.value : `bot-${newBotUsername.value}`
const payload: Partial<IUser> = {username}
const trimmedName = newBotName.value.trim()
if (trimmedName !== '') {
payload.name = trimmedName
}
try {
const created = await botService.create(new UserModel(payload))
bots.value.push(created as IUser)
newBotUsername.value = ''
newBotName.value = ''
showCreateForm.value = false
} catch (e: unknown) {
const err = e as {response?: {data?: {message?: string}}}
createError.value = err?.response?.data?.message ?? String(e)
}
}
async function toggleBotStatus(bot: IUser) {
const updated = new UserModel({
...bot,
status: bot.status === STATUS_ACTIVE ? STATUS_DISABLED : STATUS_ACTIVE,
})
const result = await botService.update(updated) as IUser
const idx = bots.value.findIndex(b => b.id === bot.id)
if (idx >= 0) {
bots.value[idx] = result
}
}
function startEditName(bot: IUser) {
nameDraft.value[bot.id] = bot.name ?? ''
editingName.value[bot.id] = true
}
function cancelEditName(bot: IUser) {
editingName.value[bot.id] = false
delete nameDraft.value[bot.id]
}
async function saveBotName(bot: IUser) {
const updated = new UserModel({
...bot,
name: (nameDraft.value[bot.id] ?? '').trim(),
})
const result = await botService.update(updated) as IUser
const idx = bots.value.findIndex(b => b.id === bot.id)
if (idx >= 0) {
bots.value[idx] = result
}
editingName.value[bot.id] = false
delete nameDraft.value[bot.id]
}
async function deleteBot() {
const bot = botToDelete.value
if (!bot) {
return
}
await botService.delete(bot)
bots.value = bots.value.filter(b => b.id !== bot.id)
showDeleteModal.value = false
botToDelete.value = undefined
}
function onTokenCreated(bot: IUser, token: IApiToken) {
newTokensByBot.value[bot.id] = token.token
showTokenForm.value[bot.id] = false
loadTokens(bot.id)
}
async function deleteToken(bot: IUser, token: IApiToken) {
await tokenService.delete(token)
await loadTokens(bot.id)
}
onMounted(loadBots)
</script>
<template>
<div class="content">
<h2>{{ $t('user.settings.bots.title') }}</h2>
<p>{{ $t('user.settings.bots.description') }}</p>
<div
v-if="bots.length === 0 || showCreateForm"
class="create-form"
>
<FormField
:label="$t('user.auth.username')"
:error="createError"
>
<input
v-model="newBotUsername"
class="input"
placeholder="bot-myassistant"
>
</FormField>
<FormField :label="$t('admin.users.nameLabel')">
<input
v-model="newBotName"
class="input"
:placeholder="$t('user.settings.bots.namePlaceholder')"
>
</FormField>
<XButton @click="createBot">
{{ $t('user.settings.bots.create') }}
</XButton>
</div>
<XButton
v-else
icon="plus"
class="mbe-4"
@click="showCreateForm = true"
>
{{ $t('user.settings.bots.create') }}
</XButton>
<div
v-for="bot in bots"
:key="bot.id"
class="bot-card"
>
<div class="bot-header">
<strong>{{ bot.username }}</strong>
<template v-if="editingName[bot.id]">
<span class="bot-name-edit"></span>
<input
v-model="nameDraft[bot.id]"
v-focus
class="input bot-name-input"
:placeholder="$t('user.settings.bots.namePlaceholder')"
@keyup.enter="saveBotName(bot)"
@keyup.esc="cancelEditName(bot)"
>
<XButton
variant="secondary"
@click="saveBotName(bot)"
>
{{ $t('misc.save') }}
</XButton>
<XButton
variant="tertiary"
@click="cancelEditName(bot)"
>
{{ $t('misc.cancel') }}
</XButton>
</template>
<template v-else>
<span v-if="bot.name"> {{ bot.name }}</span>
<span
v-else
class="no-name"
>{{ $t('project.share.links.noName') }}</span>
<XButton
variant="tertiary"
icon="pencil-alt"
@click="startEditName(bot)"
>
{{ $t('menu.edit') }}
</XButton>
</template>
<span class="status">{{ bot.status === STATUS_ACTIVE ? $t('admin.users.statusActive') : $t('admin.users.statusDisabled') }}</span>
</div>
<div class="bot-actions">
<XButton
variant="secondary"
@click="toggleBotStatus(bot)"
>
{{ bot.status === STATUS_ACTIVE ? $t('misc.disable') : $t('user.settings.bots.enable') }}
</XButton>
<XButton
variant="tertiary"
class="is-danger"
@click="() => {botToDelete = bot; showDeleteModal = true}"
>
{{ $t('misc.delete') }}
</XButton>
</div>
<div class="tokens">
<h4>{{ $t('user.settings.apiTokens.title') }}</h4>
<Message
v-if="newTokensByBot[bot.id]"
variant="warning"
>
{{ $t('user.settings.apiTokens.tokenCreatedNotSeeAgain') }}
<code>{{ newTokensByBot[bot.id] }}</code>
</Message>
<div
v-if="(tokensByBot[bot.id] ?? []).length > 0"
class="has-horizontal-overflow"
>
<table class="table">
<thead>
<tr>
<th>{{ $t('user.settings.apiTokens.attributes.title') }}</th>
<th>{{ $t('user.settings.apiTokens.attributes.expiresAt') }}</th>
<th>{{ $t('misc.created') }}</th>
<th class="has-text-end">
{{ $t('misc.actions') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="token in tokensByBot[bot.id] ?? []"
:key="token.id"
>
<td>{{ token.title }}</td>
<td>{{ formatDisplayDate(token.expiresAt) }}</td>
<td>{{ formatDisplayDate(token.created) }}</td>
<td class="has-text-end">
<XButton
variant="secondary"
@click="deleteToken(bot, token)"
>
{{ $t('misc.delete') }}
</XButton>
</td>
</tr>
</tbody>
</table>
</div>
<ApiTokenForm
v-if="showTokenForm[bot.id]"
:owner-id="bot.id"
@created="(token: IApiToken) => onTokenCreated(bot, token)"
@cancel="showTokenForm[bot.id] = false"
/>
<XButton
v-else
icon="plus"
class="mbe-4"
@click="showTokenForm[bot.id] = true"
>
{{ $t('user.settings.apiTokens.createToken') }}
</XButton>
</div>
</div>
<Modal
:enabled="showDeleteModal"
@close="showDeleteModal = false"
@submit="deleteBot()"
>
<template #header>
{{ $t('user.settings.bots.delete.header') }}
</template>
<template #text>
<p>
{{ $t('user.settings.bots.delete.text1', {username: botToDelete?.username}) }}<br>
{{ $t('user.settings.bots.delete.text2') }}
</p>
</template>
</Modal>
</div>
</template>
<style lang="scss" scoped>
.bot-card {
padding: 1rem;
margin-block-start: 1rem;
border: 1px solid var(--grey-200);
border-radius: 4px;
}
.bot-header {
display: flex;
gap: .5rem;
align-items: center;
margin-block-end: .5rem;
}
.bot-name-input {
max-inline-size: 16rem;
}
.no-name {
font-style: italic;
color: var(--grey-500);
}
.status {
margin-inline-start: auto;
font-size: .85rem;
color: var(--grey-600);
}
.bot-actions {
display: flex;
gap: .5rem;
margin-block-end: 1rem;
}
.tokens {
margin-block-start: 1rem;
padding-block-start: 1rem;
border-block-start: 1px solid var(--grey-200);
}
.create-form {
display: flex;
flex-direction: column;
gap: .5rem;
margin-block-end: 1rem;
}
</style>

View File

@ -0,0 +1,56 @@
import {test, expect} from '../../support/fixtures'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {TaskCommentFactory} from '../../factories/task_comment'
import {createDefaultViews} from '../project/prepareProjects'
test.describe('Reply to a task comment', () => {
test.beforeEach(async ({authenticatedPage: page, currentUser}) => {
await ProjectFactory.create(1, {owner_id: currentUser.id})
await createDefaultViews(1)
await TaskFactory.create(1, {id: 1, created_by_id: currentUser.id})
})
test('Reply action prefills the editor with a quoted blockquote and the saved reply renders an author header + chevron that jumps to the original', async ({authenticatedPage: page, currentUser}) => {
await TaskCommentFactory.create(1, {
id: 1,
task_id: 1,
author_id: currentUser.id,
comment: 'Original message that we will quote.',
})
await page.goto('/tasks/1')
await page.waitForLoadState('networkidle')
const originalComment = page.locator('#comment-1')
await expect(originalComment).toBeVisible({timeout: 10000})
// The Reply action lives in the per-comment bottom-actions list.
await originalComment.getByRole('button', {name: 'Reply', exact: true}).click()
// The new-comment editor (the contenteditable one) should now contain
// the prefilled blockquote pointing back at comment 1.
const newCommentEditor = page.locator('.task-view .comments .media.comment .tiptap__editor .tiptap.ProseMirror[contenteditable="true"]').last()
await expect(newCommentEditor).toBeVisible()
await expect(newCommentEditor.locator('blockquote[data-comment-id="1"]')).toBeVisible()
await expect(newCommentEditor.locator('blockquote[data-comment-id="1"]')).toContainText('Original message that we will quote.')
// Append a reply body after the auto-inserted paragraph.
await newCommentEditor.click()
await page.keyboard.press('End')
await page.keyboard.type('Thanks for that!')
await page.getByRole('button', {name: 'Comment', exact: true}).click()
// The newly-rendered reply should carry the quote header + chevron.
const reply = page.locator('.task-view .comments .media.comment').nth(1)
await expect(reply).toBeVisible()
const quote = reply.locator('blockquote.comment-quote[data-comment-id="1"]')
await expect(quote).toBeVisible()
await expect(quote.locator('.comment-quote__jump')).toBeVisible()
// Clicking the chevron scrolls to and briefly highlights the original.
await quote.locator('.comment-quote__jump').click()
await expect(originalComment).toHaveClass(/comment-highlight/, {timeout: 2000})
})
})

View File

@ -0,0 +1,96 @@
import {test, expect} from '../../support/fixtures'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {UserFactory} from '../../factories/user'
import {createDefaultViews} from '../project/prepareProjects'
import {login} from '../../support/authenticateUser'
async function openRelatedTasksForm(page) {
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Add Relation'}).click()
const input = page.locator('.task-relations .multiselect input').first()
await expect(input).toBeVisible()
return input
}
test.describe('Related tasks quick add magic', () => {
test('Applies a label parsed via *prefix to the new related task', async ({authenticatedPage: page}) => {
const project = (await ProjectFactory.create(1, {id: 1, title: 'Project A'}))[0]
await createDefaultViews(project.id)
const parent = (await TaskFactory.create(1, {id: 1, title: 'Parent task', project_id: project.id}, false))[0]
await page.goto(`/tasks/${parent.id}`)
const input = await openRelatedTasksForm(page)
await input.fill('Subtask one *Urgent')
await input.press('Enter')
const relatedTaskLink = page.locator('.task-relations .related-tasks .task a').filter({hasText: 'Subtask one'})
await expect(relatedTaskLink).toBeVisible({timeout: 10000})
// Quick add magic strips the *Urgent prefix from the title
await expect(relatedTaskLink).not.toContainText('*Urgent')
await relatedTaskLink.click()
await expect(page).toHaveURL(/\/tasks\/\d+/)
await expect(page.locator('.task-view .details.labels-list .multiselect .input-wrapper span.tag').filter({hasText: 'Urgent'}))
.toBeVisible({timeout: 10000})
})
test('Applies a priority parsed via !prefix to the new related task', async ({authenticatedPage: page}) => {
const project = (await ProjectFactory.create(1, {id: 1, title: 'Project A'}))[0]
await createDefaultViews(project.id)
const parent = (await TaskFactory.create(1, {id: 1, title: 'Parent task', project_id: project.id}, false))[0]
await page.goto(`/tasks/${parent.id}`)
const input = await openRelatedTasksForm(page)
await input.fill('Important work !4')
await input.press('Enter')
const relatedTaskLink = page.locator('.task-relations .related-tasks .task a').filter({hasText: 'Important work'})
await expect(relatedTaskLink).toBeVisible({timeout: 10000})
await expect(relatedTaskLink).not.toContainText('!4')
await relatedTaskLink.click()
// Priority 4 is "Urgent"
await expect(page.locator('.task-view .columns.details select').first()).toHaveValue('4', {timeout: 10000})
})
test('Creates the related task in another project via +project prefix', async ({authenticatedPage: page}) => {
const projectA = (await ProjectFactory.create(1, {id: 1, title: 'Source'}))[0]
await createDefaultViews(projectA.id)
const projectB = (await ProjectFactory.create(1, {id: 2, title: 'TargetProject'}, false))[0]
await createDefaultViews(projectB.id, 5)
const parent = (await TaskFactory.create(1, {id: 1, title: 'Parent task', project_id: projectA.id}, false))[0]
await page.goto(`/tasks/${parent.id}`)
const input = await openRelatedTasksForm(page)
await input.fill('Cross task +TargetProject')
await input.press('Enter')
const relatedTaskRow = page.locator('.task-relations .related-tasks .task').filter({hasText: 'Cross task'})
await expect(relatedTaskRow).toBeVisible({timeout: 10000})
await expect(relatedTaskRow.locator('a')).not.toContainText('+TargetProject')
// Cross-project marker shows the other project name
await expect(relatedTaskRow.locator('.different-project')).toContainText('TargetProject')
})
test('Keeps the title literal when quick add magic is disabled', async ({page, apiContext}) => {
const user = (await UserFactory.create(1, {
frontend_settings: JSON.stringify({
quickAddMagicMode: 'disabled',
}),
}))[0]
const project = (await ProjectFactory.create(1, {id: 1, title: 'Project A', owner_id: user.id}))[0]
await createDefaultViews(project.id)
const parent = (await TaskFactory.create(1, {id: 1, title: 'Parent task', project_id: project.id, created_by_id: user.id}, false))[0]
await login(page, apiContext, user)
await page.goto(`/tasks/${parent.id}`)
const input = await openRelatedTasksForm(page)
await input.fill('Buy milk *Urgent')
await input.press('Enter')
// With magic disabled, the prefix stays in the title verbatim
await expect(page.locator('.task-relations .related-tasks .task a').filter({hasText: 'Buy milk *Urgent'}))
.toBeVisible({timeout: 10000})
})
})

22
go.mod
View File

@ -35,10 +35,11 @@ require (
github.com/coder/websocket v1.8.14
github.com/coreos/go-oidc/v3 v3.17.0
github.com/d4l3k/messagediff v1.2.1
github.com/danielgtaylor/huma/v2 v2.37.3
github.com/disintegration/imaging v1.6.2
github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b
github.com/fatih/color v1.18.0
github.com/gabriel-vasile/mimetype v1.4.12
github.com/gabriel-vasile/mimetype v1.4.13
github.com/ganigeorgiev/fexpr v0.5.0
github.com/getsentry/sentry-go v0.41.0
github.com/go-ldap/ldap/v3 v3.4.12
@ -47,6 +48,7 @@ require (
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/gorilla/feeds v1.2.0
github.com/hashicorp/go-version v1.8.0
github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346
github.com/huandu/go-clone/generic v1.7.3
@ -117,21 +119,24 @@ require (
github.com/boombuler/barcode v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/displaywidth v0.6.2 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/danielgtaylor/mexpr v1.9.1 // indirect
github.com/danielgtaylor/shorthand/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-chi/chi/v5 v5.2.2 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@ -141,7 +146,7 @@ require (
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/huandu/go-clone v1.7.3 // indirect
@ -152,7 +157,7 @@ require (
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/moby/api v1.53.0 // indirect
@ -185,6 +190,7 @@ require (
github.com/syndtr/goleveldb v1.0.0 // indirect
github.com/tj/assert v0.0.3 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
@ -197,7 +203,7 @@ require (
golang.org/x/mod v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect

48
go.sum
View File

@ -96,12 +96,10 @@ github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
@ -122,6 +120,12 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt9U=
github.com/d4l3k/messagediff v1.2.1/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
github.com/danielgtaylor/huma/v2 v2.37.3 h1:6Av0Vj45Vk5lDxRVfoO2iPlEdvCvwLc7pl5nbqGOkYM=
github.com/danielgtaylor/huma/v2 v2.37.3/go.mod h1:OeHHtCEAaNiuVbAVdYu4IQ0UOmnb4x3yMUOShNlZ53g=
github.com/danielgtaylor/mexpr v1.9.1 h1:nA9bsGRmNlJeVCPFgGf7WhrLuKag/+iWfOaJ03iKFPI=
github.com/danielgtaylor/mexpr v1.9.1/go.mod h1:kAivYNRnBeE/IJinqBvVFvLrX54xX//9zFYwADo4Bc8=
github.com/danielgtaylor/shorthand/v2 v2.2.0 h1:hVsemdRq6v3JocP6YRTfu9rOoghZI9PFmkngdKqzAVQ=
github.com/danielgtaylor/shorthand/v2 v2.2.0/go.mod h1:t5QfaNf7DPru9ZLIIhPQSO7Gyvajm3euw7LxB/MTUqE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@ -144,6 +148,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b h1:+0Xqob+onh+4l9TSWmFyZ4JHqGUiCy5P1muyH8Evfpw=
github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@ -154,16 +160,18 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/getsentry/sentry-go v0.41.0 h1:q/dQZOlEIb4lhxQSjJhQqtRr3vwrJ6Ahe1C9zv+ryRo=
github.com/getsentry/sentry-go v0.41.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
@ -205,8 +213,8 @@ github.com/goccy/go-json v0.8.1/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGF
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
@ -245,6 +253,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc=
github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
@ -328,8 +338,8 @@ github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6/go.mod h1:W
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kolaente/caldav-go v3.0.1-0.20260326091743-a55d55891017+incompatible h1:81Hr6g9bunxXhRv4AZv0anKcS1WwHLMgo6wbBjamJlY=
github.com/kolaente/caldav-go v3.0.1-0.20260326091743-a55d55891017+incompatible/go.mod h1:y1UhTNI4g0hVymJrI6yJ5/ohy09hNBeU8iJEZjgdDOw=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@ -378,8 +388,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
@ -532,6 +542,8 @@ github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 h1:0sw0nJM544SpsihWx1bkXdYLQDlzRflMgFJQ4Yih9ts=
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@ -698,8 +710,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -43,7 +43,6 @@ import (
"github.com/iancoleman/strcase"
"github.com/magefile/mage/mg"
"golang.org/x/sync/errgroup"
)
const (
@ -62,27 +61,21 @@ var (
// Aliases are mage aliases of targets
Aliases = map[string]any{
"build": Build.Build,
"check:got-swag": Check.GotSwag,
"release": Release.Release,
"release:os-package": Release.OsPackage,
"release:prepare-nfpm-config": Release.PrepareNFPMConfig,
"release:repo-apt": Release.RepoApt,
"release:repo-rpm": Release.RepoRpm,
"release:repo-pacman": Release.RepoPacman,
"dev:make-migration": Dev.MakeMigration,
"dev:make-event": Dev.MakeEvent,
"dev:make-listener": Dev.MakeListener,
"dev:make-notification": Dev.MakeNotification,
"dev:prepare-worktree": Dev.PrepareWorktree,
"dev:tag-release": Dev.TagRelease,
"test:e2e": Test.E2E,
"test:e2e-api": Test.E2EApi,
"plugins:build": Plugins.Build,
"lint": Check.Golangci,
"lint:fix": Check.GolangciFix,
"generate:config-yaml": Generate.ConfigYAML,
"generate:swagger-docs": Generate.SwaggerDocs,
"build": Build.Build,
"check:got-swag": Check.GotSwag,
"dev:make-migration": Dev.MakeMigration,
"dev:make-event": Dev.MakeEvent,
"dev:make-listener": Dev.MakeListener,
"dev:make-notification": Dev.MakeNotification,
"dev:prepare-worktree": Dev.PrepareWorktree,
"dev:tag-release": Dev.TagRelease,
"test:e2e": Test.E2E,
"test:e2e-api": Test.E2EApi,
"plugins:build": Plugins.Build,
"lint": Check.Golangci,
"lint:fix": Check.GolangciFix,
"generate:config-yaml": Generate.ConfigYAML,
"generate:swagger-docs": Generate.SwaggerDocs,
}
)
@ -268,45 +261,6 @@ func copyFile(src, dst string) error {
return out.Close()
}
// os.Rename has issues with moving files between docker volumes.
// Because of this limitation, it fails in drone.
// Source: https://gist.github.com/var23rav/23ae5d0d4d830aff886c3c970b8f6c6b
func moveFile(src, dst string) error {
inputFile, err := os.Open(src)
if err != nil {
return fmt.Errorf("couldn't open source file: %w", err)
}
defer inputFile.Close()
outputFile, err := os.Create(dst)
if err != nil {
return fmt.Errorf("couldn't open dest file: %w", err)
}
defer outputFile.Close()
_, err = io.Copy(outputFile, inputFile)
if err != nil {
return fmt.Errorf("writing to output file failed: %w", err)
}
// Make sure to copy copy the permissions of the original file as well
si, err := os.Stat(src)
if err != nil {
return err
}
if err := os.Chmod(dst, si.Mode()); err != nil {
return err
}
// The copy was successful, so now delete the original file
err = os.Remove(src)
if err != nil {
return fmt.Errorf("failed removing original file: %w", err)
}
return nil
}
func appendToFile(filename, content string) error {
f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600)
if err != nil {
@ -1180,624 +1134,6 @@ func (Build) SaveVersionToFile() error {
return nil
}
type Release mg.Namespace
// Release runs all steps in the right order to create release packages for various platforms
func (Release) Release(ctx context.Context) error {
mg.Deps(initVars)
mg.Deps(Release.Dirs, prepareXgo)
// Run compiling in parallel to speed it up
errs, _ := errgroup.WithContext(ctx)
errgroupGoWithContext(ctx, errs, (Release{}).Windows)
errgroupGoWithContext(ctx, errs, (Release{}).Linux)
errgroupGoWithContext(ctx, errs, (Release{}).Darwin)
if err := errs.Wait(); err != nil {
return err
}
if err := (Release{}).Compress(ctx); err != nil {
return err
}
if err := (Release{}).Copy(); err != nil {
return err
}
if err := (Release{}).Check(); err != nil {
return err
}
if err := (Release{}).OsPackage(); err != nil {
return err
}
if err := (Release{}).Zip(ctx); err != nil {
return err
}
return nil
}
func errgroupGoWithContext(ctx context.Context, errs *errgroup.Group, do func(context.Context) error) {
errs.Go(func() error {
return do(ctx)
})
}
// Dirs creates all directories needed to release vikunja
func (Release) Dirs() error {
for _, d := range []string{"binaries", "release", "zip"} {
if err := os.MkdirAll("./"+DIST+"/"+d, 0o755); err != nil {
return err
}
}
return nil
}
func prepareXgo(ctx context.Context) error {
mg.Deps(initVars)
if err := checkAndInstallGoTool(ctx, "xgo", "src.techknowlogick.com/xgo"); err != nil {
return err
}
fmt.Println("Pulling latest xgo docker image...")
return runAndStreamOutput(ctx, "docker", "pull", "ghcr.io/techknowlogick/xgo:latest")
}
func runXgo(ctx context.Context, targets string) error {
mg.Deps(initVars)
if err := checkAndInstallGoTool(ctx, "xgo", "src.techknowlogick.com/xgo"); err != nil {
return err
}
extraLdflags := `-linkmode external -extldflags "-static" `
// See https://github.com/techknowlogick/xgo/issues/79
if strings.HasPrefix(targets, "darwin") {
extraLdflags = ""
}
outName := os.Getenv("XGO_OUT_NAME")
if outName == "" {
outName = Executable + "-" + Version
}
if err := runAndStreamOutput(ctx, "xgo",
"-dest", "./"+DIST+"/binaries",
"-tags", "netgo "+Tags,
"-ldflags", extraLdflags+Ldflags,
"-targets", targets,
"-out", outName,
"."); err != nil {
return err
}
if os.Getenv("DRONE_WORKSPACE") != "" {
return filepath.Walk("/build/", func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip directories
if info.IsDir() {
return nil
}
return moveFile(path, "./"+DIST+"/binaries/"+info.Name())
})
}
return nil
}
// Windows builds binaries for windows
func (Release) Windows(ctx context.Context) error {
return runXgo(ctx, "windows/*")
}
// Linux builds binaries for linux
func (Release) Linux(ctx context.Context) error {
targets := []string{
"linux/amd64",
"linux/arm-5",
"linux/arm-6",
"linux/arm-7",
"linux/arm64",
"linux/mips",
"linux/mipsle",
"linux/mips64",
"linux/mips64le",
"linux/riscv64",
}
return runXgo(ctx, strings.Join(targets, ","))
}
// Darwin builds binaries for darwin
func (Release) Darwin(ctx context.Context) error {
return runXgo(ctx, "darwin-10.15/*")
}
func (Release) Xgo(ctx context.Context, target string) error {
parts := strings.Split(target, "/")
if len(parts) < 2 {
return fmt.Errorf("invalid target")
}
variant := ""
if len(parts) > 2 && parts[2] != "" {
variant = "-" + strings.ReplaceAll(parts[2], "v", "")
}
return runXgo(ctx, parts[0]+"/"+parts[1]+variant)
}
// Compress compresses the built binaries in dist/binaries/ to reduce their filesize
func (Release) Compress(ctx context.Context) error {
// $(foreach file,$(filter-out $(wildcard $(wildcard $(DIST)/binaries/$(EXECUTABLE)-*mips*)),$(wildcard $(DIST)/binaries/$(EXECUTABLE)-*)), upx -9 $(file);)
errs, _ := errgroup.WithContext(ctx)
walkErr := filepath.Walk("./"+DIST+"/binaries/", func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Only executable files
if !strings.Contains(info.Name(), Executable) {
return nil
}
if strings.Contains(info.Name(), "mips") ||
strings.Contains(info.Name(), "s390x") ||
strings.Contains(info.Name(), "riscv64") ||
strings.Contains(info.Name(), "darwin") ||
(strings.Contains(info.Name(), "windows") && strings.Contains(info.Name(), "arm64")) {
// not supported by upx
return nil
}
// Runs compressing in parallel since upx is single-threaded
errs.Go(func() error {
if err := runAndStreamOutput(ctx, "chmod", "+x", path); err != nil { // Make sure all binaries are executable. Sometimes the CI does weird things and they're not.
return err
}
return runAndStreamOutput(ctx, "upx", "-9", path)
})
return nil
})
if walkErr != nil {
return walkErr
}
return errs.Wait()
}
// Copy copies all built binaries to dist/release/ in preparation for creating the os packages
func (Release) Copy() error {
return filepath.Walk("./"+DIST+"/binaries/", func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Only executable files
if !strings.Contains(info.Name(), Executable) {
return nil
}
return copyFile(path, "./"+DIST+"/release/"+info.Name())
})
}
// Check creates sha256 checksum files for each binary in dist/release/
func (Release) Check() error {
p := "./" + DIST + "/release/"
return filepath.Walk(p, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
f, err := os.Create(p + info.Name() + ".sha256")
if err != nil {
return err
}
hash, err := calculateSha256FileHash(path)
if err != nil {
return err
}
_, err = f.WriteString(hash + " " + info.Name())
if err != nil {
return err
}
return f.Close()
})
}
// OsPackage creates a folder for each
func (Release) OsPackage() error {
p := "./" + DIST + "/release/"
// We first put all files in a map to then iterate over it since the walk function would otherwise also iterate
// over the newly created files, creating some kind of endless loop.
bins := make(map[string]os.FileInfo)
if err := filepath.Walk(p, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if strings.Contains(info.Name(), ".sha256") || info.IsDir() {
return nil
}
bins[path] = info
return nil
}); err != nil {
return err
}
generateConfigYAMLFromJSON("./"+DefaultConfigYAMLSamplePath, true)
for path, info := range bins {
folder := p + info.Name() + "-full/"
if err := os.Mkdir(folder, 0o755); err != nil {
return err
}
if err := moveFile(p+info.Name()+".sha256", folder+info.Name()+".sha256"); err != nil {
return err
}
if err := moveFile(path, folder+info.Name()); err != nil {
return err
}
if err := copyFile("./"+DefaultConfigYAMLSamplePath, folder+DefaultConfigYAMLSamplePath); err != nil {
return err
}
if err := copyFile("./LICENSE", folder+"LICENSE"); err != nil {
return err
}
}
return nil
}
// Zip creates a zip file from all os-package folders in dist/release
func (Release) Zip(ctx context.Context) error {
rootDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("could not get working directory: %w", err)
}
p := "./" + DIST + "/release/"
if err := filepath.Walk(p, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() || info.Name() == "release" {
return nil
}
fmt.Printf("Zipping %s...\n", info.Name())
zipFile := filepath.Join(rootDir, DIST, "zip", info.Name()+".zip")
c := exec.CommandContext(ctx, "zip", "-r", zipFile, ".", "-i", "*") //nolint:gosec // This mage task creates zips of every directory recursively, it must use the directory name in the resulting file path to distinguish output files.
c.Dir = path
out, err := c.Output()
fmt.Print(string(out))
return err
}); err != nil {
return err
}
return nil
}
// repoSuite returns a validated suite name from the REPO_SUITE env var.
// Only "stable" and "unstable" are allowed to prevent path traversal.
func repoSuite() string {
suite := os.Getenv("REPO_SUITE")
switch suite {
case "stable", "unstable":
return suite
default:
return "stable"
}
}
// RepoApt generates APT repository metadata using reprepro.
// It expects .deb files in <DIST>/repo-work/incoming/ and outputs to <DIST>/repo-output/apt/.
// The reprepro config is read from build/reprepro-dist-conf.
// Signing is done manually after reprepro finishes to avoid gpgme pinentry issues in CI.
// Environment: REPO_SUITE controls the target suite (default: "stable").
// Environment: RELEASE_GPG_KEY, RELEASE_GPG_PASSPHRASE must be set for signing.
func (Release) RepoApt(ctx context.Context) error {
mg.Deps(initVars)
suite := repoSuite()
incomingDir := filepath.Join(DIST, "repo-work", "incoming")
outputBase := filepath.Join(DIST, "repo-output", "apt")
// Set up reprepro conf directory
confDir := filepath.Join(outputBase, "conf")
if err := os.MkdirAll(confDir, 0o755); err != nil {
return fmt.Errorf("creating reprepro conf dir: %w", err)
}
// Copy distributions config
distConf, err := os.ReadFile("build/reprepro-dist-conf")
if err != nil {
return fmt.Errorf("reading reprepro-dist-conf: %w", err)
}
if err := os.WriteFile(filepath.Join(confDir, "distributions"), distConf, 0o600); err != nil {
return fmt.Errorf("writing distributions config: %w", err)
}
// Include all .deb files into the target suite
debs, err := filepath.Glob(filepath.Join(incomingDir, "*.deb"))
if err != nil {
return err
}
for _, deb := range debs {
abs, _ := filepath.Abs(deb)
if err := runAndStreamOutput(ctx, "reprepro",
"-b", outputBase,
"includedeb", suite,
abs,
); err != nil {
return fmt.Errorf("reprepro includedeb %s: %w", filepath.Base(deb), err)
}
}
// Sign Release files manually (reprepro's gpgme signing doesn't work in CI)
gpgKey := os.Getenv("RELEASE_GPG_KEY")
gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE")
releaseFile := filepath.Join(outputBase, "dists", suite, "Release")
if _, err := os.Stat(releaseFile); err == nil {
// Generate Release.gpg (detached signature)
if err := runAndStreamOutput(ctx, "gpg",
"--default-key", gpgKey,
"--batch", "--yes",
"--passphrase", gpgPassphrase,
"--pinentry-mode", "loopback",
"--detach-sign", "--armor",
"-o", releaseFile+".gpg",
releaseFile,
); err != nil {
return fmt.Errorf("signing Release (detached): %w", err)
}
// Generate InRelease (clearsigned)
if err := runAndStreamOutput(ctx, "gpg",
"--default-key", gpgKey,
"--batch", "--yes",
"--passphrase", gpgPassphrase,
"--pinentry-mode", "loopback",
"--clearsign",
"-o", filepath.Join(filepath.Dir(releaseFile), "InRelease"),
releaseFile,
); err != nil {
return fmt.Errorf("signing Release (clearsign): %w", err)
}
}
fmt.Println("APT repo metadata generated in", outputBase)
return nil
}
// RepoRpm generates RPM repository metadata for all .rpm files in the work directory.
// Expects .rpm files in <DIST>/repo-work/incoming/ and outputs to <DIST>/repo-output/rpm/<suite>/.
// Environment: RELEASE_GPG_KEY, RELEASE_GPG_PASSPHRASE must be set for signing.
// Environment: REPO_SUITE controls the target suite (default: "stable").
func (Release) RepoRpm(ctx context.Context) error {
mg.Deps(initVars)
suite := repoSuite()
incomingDir := filepath.Join(DIST, "repo-work", "incoming")
outputBase := filepath.Join(DIST, "repo-output", "rpm", suite)
archMap := map[string]string{
"x86_64": "x86_64",
"aarch64": "aarch64",
"armv7": "armv7",
}
gpgKey := os.Getenv("RELEASE_GPG_KEY")
gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE")
for pkgArch, repoArch := range archMap {
repoDir := filepath.Join(outputBase, repoArch)
if err := os.MkdirAll(repoDir, 0o755); err != nil {
return err
}
// Symlink matching RPMs
pattern := filepath.Join(incomingDir, "*-"+pkgArch+".rpm")
rpms, _ := filepath.Glob(pattern)
if len(rpms) == 0 {
continue
}
for _, rpm := range rpms {
abs, _ := filepath.Abs(rpm)
dst := filepath.Join(repoDir, filepath.Base(rpm))
os.Remove(dst)
if err := os.Symlink(abs, dst); err != nil {
return err
}
}
// createrepo_c (--update if repodata already exists)
args := []string{repoDir}
if _, err := os.Stat(filepath.Join(repoDir, "repodata")); err == nil {
args = []string{"--update", repoDir}
}
if err := runAndStreamOutput(ctx, "createrepo_c", args...); err != nil {
return fmt.Errorf("createrepo_c for %s: %w", repoArch, err)
}
// Sign repomd.xml
if err := runAndStreamOutput(ctx, "gpg",
"--default-key", gpgKey,
"--batch", "--yes",
"--passphrase", gpgPassphrase,
"--pinentry-mode", "loopback",
"--detach-sign", "--armor",
"-o", filepath.Join(repoDir, "repodata", "repomd.xml.asc"),
filepath.Join(repoDir, "repodata", "repomd.xml"),
); err != nil {
return fmt.Errorf("signing repomd.xml for %s: %w", repoArch, err)
}
}
fmt.Println("RPM repo metadata generated in", outputBase)
return nil
}
// RepoPacman generates Pacman repository database for all .archlinux files in the work directory.
// Expects .archlinux files in <DIST>/repo-work/incoming/ and outputs to <DIST>/repo-output/pacman/<suite>/.
// Environment: RELEASE_GPG_KEY, RELEASE_GPG_PASSPHRASE must be set for signing.
// Environment: REPO_SUITE controls the target suite (default: "stable").
func (Release) RepoPacman(ctx context.Context) error {
mg.Deps(initVars)
suite := repoSuite()
incomingDir := filepath.Join(DIST, "repo-work", "incoming")
outputBase := filepath.Join(DIST, "repo-output", "pacman", suite)
archMap := map[string]string{
"x86_64": "x86_64",
"aarch64": "aarch64",
"armv7": "armv7",
}
gpgKey := os.Getenv("RELEASE_GPG_KEY")
gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE")
for pkgArch, repoArch := range archMap {
repoDir := filepath.Join(outputBase, repoArch)
if err := os.MkdirAll(repoDir, 0o755); err != nil {
return err
}
pattern := filepath.Join(incomingDir, "*-"+pkgArch+".archlinux")
pkgs, _ := filepath.Glob(pattern)
if len(pkgs) == 0 {
continue
}
for _, pkg := range pkgs {
abs, _ := filepath.Abs(pkg)
dst := filepath.Join(repoDir, filepath.Base(pkg))
os.Remove(dst)
if err := os.Symlink(abs, dst); err != nil {
return err
}
}
// repo-add creates vikunja.db.tar.gz and vikunja.files.tar.gz
dbPath := filepath.Join(repoDir, "vikunja.db.tar.gz")
repoPkgs, _ := filepath.Glob(filepath.Join(repoDir, "*.archlinux"))
repoAddArgs := append([]string{dbPath}, repoPkgs...)
if err := runAndStreamOutput(ctx, "repo-add", repoAddArgs...); err != nil {
return fmt.Errorf("repo-add for %s: %w", repoArch, err)
}
// Create conventional symlinks (vikunja.db -> vikunja.db.tar.gz)
for _, name := range []string{"vikunja.db", "vikunja.files"} {
link := filepath.Join(repoDir, name)
os.Remove(link)
if err := os.Symlink(name+".tar.gz", link); err != nil {
return fmt.Errorf("creating symlink %s: %w", name, err)
}
}
// Sign the database
if err := runAndStreamOutput(ctx, "gpg",
"--default-key", gpgKey,
"--batch", "--yes",
"--passphrase", gpgPassphrase,
"--pinentry-mode", "loopback",
"--detach-sign",
"-o", filepath.Join(repoDir, "vikunja.db.sig"),
dbPath,
); err != nil {
return fmt.Errorf("signing db for %s: %w", repoArch, err)
}
}
fmt.Println("Pacman repo metadata generated in", outputBase)
return nil
}
// PrepareNFPMConfig prepares the nfpm config
func (Release) PrepareNFPMConfig() error {
mg.Deps(initVars)
var err error
// Because nfpm does not support templating, we replace the values in the config file and restore it after running
nfpmConfigPath := "./nfpm.yaml"
nfpmconfig, err := os.ReadFile(nfpmConfigPath)
if err != nil {
return err
}
var nfpmArch string
switch os.Getenv("NFPM_ARCH") {
case "arm64":
nfpmArch = "arm64"
case "arm7":
nfpmArch = "arm7"
case "386":
nfpmArch = "386"
default:
nfpmArch = "amd64"
}
fixedConfig := strings.ReplaceAll(string(nfpmconfig), "<version>", VersionNumber)
fixedConfig = strings.ReplaceAll(fixedConfig, "<binlocation>", BinLocation)
fixedConfig = strings.ReplaceAll(fixedConfig, "<arch>", nfpmArch)
if err := os.WriteFile(nfpmConfigPath, []byte(fixedConfig), 0); err != nil {
return err
}
generateConfigYAMLFromJSON(DefaultConfigYAMLSamplePath, true)
return nil
}
// Packages creates deb, rpm and apk packages
func (Release) Packages(ctx context.Context) error {
mg.Deps(initVars)
var err error
binpath := os.Getenv("NFPM_BIN_PATH")
if binpath == "" {
binpath = "nfpm"
}
err = exec.CommandContext(ctx, binpath).Run()
if err != nil && strings.Contains(err.Error(), "executable file not found") {
binpath = "/usr/bin/nfpm"
err = exec.CommandContext(ctx, binpath).Run()
}
if err != nil && strings.Contains(err.Error(), "executable file not found") {
return fmt.Errorf("executable %s not found: please manually install nfpm by running the command: curl -sfL https://install.goreleaser.com/github.com/goreleaser/nfpm.sh | sh -s -- -b $(go env GOPATH)/bin", binpath)
}
err = (Release{}).PrepareNFPMConfig()
if err != nil {
return err
}
releasePath := "./" + DIST + "/os-packages/"
if err := os.MkdirAll(releasePath, 0o755); err != nil {
return err
}
if err := runAndStreamOutput(ctx, binpath, "pkg", "--packager", "deb", "--target", releasePath); err != nil {
return err
}
if err := runAndStreamOutput(ctx, binpath, "pkg", "--packager", "rpm", "--target", releasePath); err != nil {
return err
}
if err := runAndStreamOutput(ctx, binpath, "pkg", "--packager", "apk", "--target", releasePath); err != nil {
return err
}
return nil
}
type Dev mg.Namespace
// MakeMigration creates a new bare db migration skeleton in pkg/migration.
@ -2174,6 +1510,46 @@ func (Generate) ConfigYAML(commented bool) {
generateConfigYAMLFromJSON(DefaultConfigYAMLSamplePath, commented)
}
// ScalarBundle downloads the Scalar API reference standalone JS bundle into
// pkg/routes/api/v2/scalar/. Version is pinned to match the Scalar version
// used in Huma's internal docs at the time of last update.
func (Generate) ScalarBundle() error {
const (
version = "1.44.20"
dest = "pkg/routes/api/v2/scalar/scalar.standalone.js"
)
url := fmt.Sprintf("https://unpkg.com/@scalar/api-reference@%s/dist/browser/standalone.js", version)
fmt.Printf("Downloading Scalar bundle %s from %s\n", version, url)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) //nolint:gosec // This is a dev-only mage task and the URL is hard-coded above.
if err != nil {
return fmt.Errorf("build scalar bundle request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("download scalar bundle: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download scalar bundle: unexpected status %s", resp.Status)
}
var buf bytes.Buffer
if _, err := io.Copy(&buf, resp.Body); err != nil {
return fmt.Errorf("read scalar bundle body: %w", err)
}
if err := os.WriteFile(dest, buf.Bytes(), 0o600); err != nil {
return fmt.Errorf("write %s: %w", dest, err)
}
fmt.Printf("Wrote %d bytes to %s\n", buf.Len(), dest)
return nil
}
func localBranchExists(ctx context.Context, name string) bool {
return exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+name).Run() == nil //nolint:gosec // This is a dev-only mage task and the branch name is supplied by the developer running it.
}

4
mise.toml Normal file
View File

@ -0,0 +1,4 @@
[tools]
node = "24.13.0" # keep in sync with frontend/.nvmrc
pnpm = "10.28.1" # keep in sync with frontend/package.json#packageManager
go = "1.25.7" # keep in sync with go.mod

View File

@ -68,3 +68,13 @@
owner_id: 15
created: 2024-01-01 00:00:00
# token in plaintext is tk_nocaldav_token_test_000000005678efab
- id: 8
title: 'feeds access token for user 13'
token_salt: fEdRTk9sR2
token_hash: c1231ac23940702dcbdf20ae4c125a904780788b091f6d6c56f94f3620a634ec00aac5288659e04174a69a60b20ea86cdfa5
token_last_eight: feed0013
permissions: '{"feeds":["access"]}'
expires_at: 2099-01-01 00:00:00
owner_id: 13
created: 2024-01-01 00:00:00
# token in plaintext is tk_feeds_access_token_user_0013_feed0013

View File

@ -0,0 +1,21 @@
- id: 1
notifiable_id: 1
notification: '{"test":"notification one"}'
name: test.notification
subject_id: 1
read_at: null
created: 2022-01-01 00:00:00
- id: 2
notifiable_id: 1
notification: '{"test":"notification two"}'
name: test.notification
subject_id: 2
read_at: null
created: 2022-01-02 00:00:00
- id: 3
notifiable_id: 2
notification: '{"test":"other user"}'
name: test.notification
subject_id: 3
read_at: null
created: 2022-01-03 00:00:00

View File

@ -2,7 +2,7 @@
id: 1
title: Test1
description: Lorem Ipsum
identifier: test1
identifier: TEST1
owner_id: 1
position: 3
updated: 2018-12-02 15:13:12
@ -11,7 +11,7 @@
id: 2
title: Test2
description: Lorem Ipsum
identifier: test2
identifier: TEST2
owner_id: 3
position: 2
updated: 2018-12-02 15:13:12
@ -20,7 +20,7 @@
id: 3
title: Test3
description: Lorem Ipsum
identifier: test3
identifier: TEST3
owner_id: 3
position: 1
updated: 2018-12-02 15:13:12
@ -29,7 +29,7 @@
id: 4
title: Test4
description: Lorem Ipsum
identifier: test4
identifier: TEST4
owner_id: 3
position: 4
updated: 2018-12-02 15:13:12
@ -38,7 +38,7 @@
id: 5
title: Test5
description: Lorem Ipsum
identifier: test5
identifier: TEST5
owner_id: 5
position: 5
updated: 2018-12-02 15:13:12
@ -47,7 +47,7 @@
id: 6
title: Test6
description: Lorem Ipsum
identifier: test6
identifier: TEST6
owner_id: 6
position: 6
updated: 2018-12-02 15:13:12
@ -56,7 +56,7 @@
id: 7
title: Test7
description: Lorem Ipsum
identifier: test7
identifier: TEST7
owner_id: 6
position: 7
updated: 2018-12-02 15:13:12
@ -65,7 +65,7 @@
id: 8
title: Test8
description: Lorem Ipsum
identifier: test8
identifier: TEST8
owner_id: 6
position: 8
updated: 2018-12-02 15:13:12
@ -74,7 +74,7 @@
id: 9
title: Test9
description: Lorem Ipsum
identifier: test9
identifier: TEST9
owner_id: 6
position: 9
updated: 2018-12-02 15:13:12
@ -83,7 +83,7 @@
id: 10
title: Test10
description: Lorem Ipsum
identifier: test10
identifier: TEST10
owner_id: 6
position: 10
updated: 2018-12-02 15:13:12
@ -92,7 +92,7 @@
id: 11
title: Test11
description: Lorem Ipsum
identifier: test11
identifier: TEST11
owner_id: 6
position: 11
updated: 2018-12-02 15:13:12
@ -101,7 +101,7 @@
id: 12
title: Test12
description: Lorem Ipsum
identifier: test12
identifier: TEST12
owner_id: 6
position: 12
parent_project_id: 27
@ -111,7 +111,7 @@
id: 13
title: Test13
description: Lorem Ipsum
identifier: test13
identifier: TEST13
owner_id: 6
position: 13
parent_project_id: 28
@ -121,7 +121,7 @@
id: 14
title: Test14
description: Lorem Ipsum
identifier: test14
identifier: TEST14
owner_id: 6
position: 14
parent_project_id: 29
@ -131,7 +131,7 @@
id: 15
title: Test15
description: Lorem Ipsum
identifier: test15
identifier: TEST15
owner_id: 6
position: 15
parent_project_id: 32
@ -141,7 +141,7 @@
id: 16
title: Test16
description: Lorem Ipsum
identifier: test16
identifier: TEST16
owner_id: 6
position: 16
parent_project_id: 33
@ -151,7 +151,7 @@
id: 17
title: Test17
description: Lorem Ipsum
identifier: test17
identifier: TEST17
owner_id: 6
position: 17
parent_project_id: 34
@ -163,7 +163,7 @@
id: 18
title: Test18
description: Lorem Ipsum
identifier: test18
identifier: TEST18
owner_id: 7
position: 18
updated: 2018-12-02 15:13:12
@ -172,7 +172,7 @@
id: 19
title: Test19
description: Lorem Ipsum
identifier: test19
identifier: TEST19
owner_id: 7
position: 19
parent_project_id: 29
@ -183,7 +183,7 @@
id: 20
title: Test20
description: Lorem Ipsum
identifier: test20
identifier: TEST20
owner_id: 13
position: 20
updated: 2018-12-02 15:13:12
@ -192,7 +192,7 @@
id: 21
title: Test21 archived through parent list
description: Lorem Ipsum
identifier: test21
identifier: TEST21
owner_id: 1
position: 21
parent_project_id: 22
@ -202,7 +202,7 @@
id: 22
title: Test22 archived individually
description: Lorem Ipsum
identifier: test22
identifier: TEST22
owner_id: 1
is_archived: 1
position: 22
@ -212,7 +212,7 @@
id: 23
title: Test23
description: Lorem Ipsum
identifier: test23
identifier: TEST23
owner_id: 12
position: 23
updated: 2018-12-02 15:13:12
@ -221,7 +221,7 @@
id: 24
title: Test24
description: Lorem Ipsum
identifier: test6
identifier: TEST6
owner_id: 6
position: 7
updated: 2018-12-02 15:13:12
@ -302,7 +302,7 @@
id: 35
title: Test35 with background
description: Lorem Ipsum
identifier: test6
identifier: TEST6
owner_id: 6
background_file_id: 1
position: 8
@ -312,7 +312,7 @@
id: 36
title: Project 36 for Caldav tests
description: Lorem Ipsum
identifier: test36
identifier: TEST36
owner_id: 15
position: 1
updated: 2018-12-02 15:13:12
@ -321,7 +321,7 @@
id: 37
title: Project 37
description: Lorem Ipsum
identifier: test37
identifier: TEST37
owner_id: 16
position: 1
updated: 2018-12-02 15:13:12
@ -330,7 +330,7 @@
id: 38
title: Project 38 for Caldav tests
description: Lorem Ipsum
identifier: test38
identifier: TEST38
owner_id: 15
position: 2
updated: 2018-12-02 15:13:12
@ -341,7 +341,7 @@
id: 39
title: Orphaned project with deleted parent
description: This project has a parent_project_id pointing to a non-existent project
identifier: orph1
identifier: ORPH1
owner_id: 1
parent_project_id: 999999
is_archived: 1
@ -354,7 +354,7 @@
id: 40
title: Test40 child archived individually
description: Lorem Ipsum
identifier: test40
identifier: TEST40
owner_id: 1
parent_project_id: 3
is_archived: 1
@ -366,7 +366,7 @@
id: 41
title: HierarchyParent
description: Parent project for subtask permission hierarchy test
identifier: hier1
identifier: HIER1
owner_id: 6
position: 41
updated: 2018-12-02 15:13:12
@ -376,7 +376,7 @@
id: 42
title: HierarchyChild
description: Child project for subtask permission hierarchy test
identifier: hier2
identifier: HIER2
owner_id: 6
parent_project_id: 41
position: 42

View File

@ -164,3 +164,38 @@
avatar_provider: 'openid'
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
# User 21 and user 22 below are a pair of bot owners used by the user-search
# tests: user 21 has a bot (23), user 22 has a bot (24). Putting the bots on
# dedicated owners keeps the pre-existing search tests (which mostly use user1)
# unaffected.
- id: 21
username: 'user_bot_owner_a'
password: '$2a$04$X4aRMEt0ytgPwMIgv36cI..7X9.nhY/.tYwxpqSi0ykRHx2CwQ0S6'
email: 'user_bot_owner_a@example.com'
issuer: local
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
- id: 22
username: 'user_bot_owner_b'
password: '$2a$04$X4aRMEt0ytgPwMIgv36cI..7X9.nhY/.tYwxpqSi0ykRHx2CwQ0S6'
email: 'user_bot_owner_b@example.com'
issuer: local
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
# Bot owned by user 21 — used to assert own bots are always returned
# regardless of the search string.
- id: 23
username: 'bot-owner-a-assistant'
name: 'Owner A Assistant'
issuer: local
bot_owner_id: 21
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
# Bot owned by user 22 — used to assert other users' bots are never leaked.
- id: 24
username: 'bot-owner-b-assistant'
name: 'Owner B Assistant'
issuer: local
bot_owner_id: 22
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12

View File

@ -8,3 +8,36 @@
created_by_id: 1
created: 2024-01-01 00:00:00
updated: 2024-01-01 00:00:00
# Webhooks 2-4 back the v2 permission matrix: project 9 is shared to user1
# read-only, 10 write, 11 admin. Update/Delete gate on Project.CanWrite, so the
# read-share webhook (#2) must be forbidden while the write/admin ones pass.
- id: 2
target_url: "https://example.com/webhook-read-share"
events: '["task.updated"]'
project_id: 9
created_by_id: 6
created: 2024-01-01 00:00:00
updated: 2024-01-01 00:00:00
- id: 3
target_url: "https://example.com/webhook-write-share"
events: '["task.updated"]'
project_id: 10
created_by_id: 6
created: 2024-01-01 00:00:00
updated: 2024-01-01 00:00:00
- id: 4
target_url: "https://example.com/webhook-admin-share"
events: '["task.updated"]'
project_id: 11
created_by_id: 6
created: 2024-01-01 00:00:00
updated: 2024-01-01 00:00:00
# Webhook #5 lives in project 2 (owned by user3, not shared to user1) so the
# fully-forbidden update/delete path can be exercised under its real parent.
- id: 5
target_url: "https://example.com/webhook-forbidden"
events: '["task.updated"]'
project_id: 2
created_by_id: 3
created: 2024-01-01 00:00:00
updated: 2024-01-01 00:00:00

View File

@ -28,8 +28,6 @@ import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/modules/keyvalue"
"code.vikunja.io/api/pkg/web"
"github.com/c2h5oh/datasize"
@ -205,7 +203,7 @@ func (f *File) Delete(s *xorm.Session) (err error) {
return err
}
return keyvalue.DecrBy(metrics.FilesCountKey, 1)
return nil
}
// Save saves a file to storage
@ -214,5 +212,5 @@ func (f *File) Save(fcontent io.ReadSeeker) error {
if err != nil {
return fmt.Errorf("failed to save file: %w", err)
}
return keyvalue.IncrBy(metrics.FilesCountKey, 1)
return nil
}

View File

@ -77,6 +77,7 @@ var availableLanguages = map[string]bool{
"fi-FI": true,
"he-IL": true,
"sv-SE": true,
"el-GR": true,
// IMPORTANT: Also add new languages to the frontend
}

View File

@ -173,5 +173,10 @@
"since_hours": "einer Stunde|%[1]d Stunden",
"since_minutes": "einer Minute|%[1]d Minuten",
"list_last_separator": "und"
},
"feeds": {
"notifications": {
"title": "Vikunja Benachrichtigungen für %[1]s"
}
}
}

View File

@ -173,5 +173,10 @@
"since_hours": "einer Stunde|%[1]d Stunden",
"since_minutes": "einer Minute|%[1]d Minuten",
"list_last_separator": "und"
},
"feeds": {
"notifications": {
"title": "Vikunja Benachrichtigungen für %[1]s"
}
}
}

182
pkg/i18n/lang/el-GR.json Normal file
View File

@ -0,0 +1,182 @@
{
"notifications": {
"greeting": "Γεια σου %[1]s,",
"email_confirm": {
"subject": "%[1]s, παρακαλώ επιβεβαίωσε τη διεύθυνση email σου στο Vikunja",
"subject_new": "%[1]s + Vikunja = <3",
"welcome": "Καλωσορίσατε στο Vikunja!",
"confirm": "Για να επιβεβαιώσεις τη διεύθυνση email σου, κάνε κλικ στον παρακάτω σύνδεσμο:"
},
"password": {
"changed": {
"subject": "Ο κωδικός σου πρόσβασης στο Vikunja άλλαξε",
"success": "Ο κωδικός πρόσβασης του λογαριασμού σου άλλαξε με επιτυχία.",
"warning": "Αν δεν ήσουν εσύ, αυτό θα μπορούσε να σημαίνει ότι κάποιος παραβίασε το λογαριασμό σου. Σε αυτή την περίπτωση επικοινώνησε με το διαχειριστή του διακομιστή σου."
},
"reset": {
"subject": "Επανάφερε τον κωδικό σου στο Vikunja",
"instructions": "Για να επαναφέρεις τον κωδικό σου, πάτησε στον παρακάτω σύνδεσμο:",
"valid_duration": "Ο σύνδεσμος θα ισχύει για 24 ώρες."
}
},
"totp": {
"invalid": {
"subject": "Κάποιος μόλις προσπάθησε να εισέλθει στο λογαριασμό σου στο Vikunja, αλλά απέτυχε",
"message": "Κάποιος μόλις προσπάθησε να συνδεθεί στο λογαριασμό σου με το σωστό όνομα χρήστη και κωδικό πρόσβασης, αλλά με λάθος κωδικό TOTP.",
"warning": "**Εάν δεν ήσουν εσύ, κάποιος άλλος γνωρίζει τον κωδικό σου πρόσβασης. Θα πρέπει να ορίσεις ένα νέο αμέσως!**"
},
"account_locked": {
"subject": "Έχουμε απενεργοποιήσει το λογαριασμό σου στο Vikunja",
"message": "Κάποιος προσπάθησε να συνδεθεί με τα διαπιστευτήριά σας αλλά απέτυχε να δώσει έγκυρο κωδικό πρόσβασης TOTP.",
"disabled": "Μετά από 10 αποτυχημένες προσπάθειες, έχουμε απενεργοποιήσει το λογαριασμό σας και επαναφέραμε τον κωδικό σας πρόσβασης. Για να ορίσετε ένα νέο, ακολουθήστε τις οδηγίες στο email επαναφοράς που μόλις σας στείλαμε.",
"reset_instructions": "Εάν δε λάβατε ένα email με οδηγίες επαναφοράς, μπορείτε πάντα να ζητήσετε ένα νέο στο [%[1]s](%[2]s)."
}
},
"login": {
"failed": {
"subject": "Κάποιος μόλις προσπάθησε να εισέλθει στο λογαριασμό σας στο Vikunja, αλλά απέτυχε να δώσει σωστό κωδικό πρόσβασης",
"message": "Κάποιος μόλις προσπάθησε να συνδεθεί στο λογαριασμό σας με λάθος κωδικό πρόσβασης τρεις φορές συνεχόμενα.",
"warning": "Αν δεν ήσασταν εσείς, θα μπορούσε να είναι κάποιος άλλος που προσπαθεί να εισέλθει κακόβουλα στο λογαριασμό σας.",
"enhance_security": "Για να ενισχύσετε την ασφάλεια του λογαριασμού σας μπορείτε να ορίσετε έναν ισχυρότερο κωδικό πρόσβασης ή να ενεργοποιήσετε τον έλεγχο ταυτοποίησης TOTP στις ρυθμίσεις:"
}
},
"account": {
"deletion": {
"confirm": {
"subject": "Παρακαλώ επιβεβαιώστε τη διαγραφή του λογαριασμού σας στο Vikunja",
"request": "Ζητήσατε τη διαγραφή του λογαριασμού σας. Για να επιβεβαιώσετε την ενέργεια, κάντε κλικ στον παρακάτω σύνδεσμο:",
"valid_duration": "Ο σύνδεσμος θα ισχύει για 24 ώρες.",
"schedule_info": "Μόλις επιβεβαιώσετε τη διαγραφή, θα προγραμματίσουμε τη διαγραφή του λογαριασμού σας σε τρεις ημέρες και θα σας στείλουμε ένα άλλο email μέχρι τότε.",
"consequences": "Αν προχωρήσετε με τη διαγραφή του λογαριασμού σας, θα καταργήσουμε όλα τα έργα και τις εργασίες που δημιουργήσατε. Η κυριότητα όλων όσων μοιραστήκατε με άλλο χρήστη ή ομάδα θα μεταφερθεί σε αυτούς.",
"changed_mind": "Αν δεν αιτηθήκατε τη διαγραφή ή αλλάξατε γνώμη, μπορείτε απλά να αγνοήσετε αυτό το email."
},
"scheduled": {
"subject_days": "Ο λογαριασμός σας στο Vikunja θα διαγραφεί σε %[1]ημέρες",
"subject_tomorrow": "Ο λογαριασμός σας στο Vikunja θα διαγραφεί αύριο",
"request_reminder": "Αιτηθήκατε πρόσφατα τη διαγραφή του λογαριασμού σας στο Vikunja.",
"deletion_time_days": "Θα διαγράψουμε τον λογαριασμό σας σε %[1]s ημέρες.",
"deletion_time_tomorrow": "Θα διαγράψουμε τον λογαριασμό σας αύριο.",
"changed_mind": "Αν αλλάξατε γνώμη, απλά κάντε κλικ στον παρακάτω σύνδεσμο για να ακυρώσετε τη διαγραφή και ακολουθήστε τις οδηγίες:"
},
"completed": {
"subject": "Ο λογαριασμός σας στο Vikunja έχει διαγραφεί",
"confirmation": "Όπως ζητήθηκε, έχουμε διαγράψει το λογαριασμό σας στο Vikunja.",
"permanent": "Η διαγραφή είναι μόνιμη. Αν δε δημιουργήσατε αντίγραφο ασφαλείας και χρειάζεστε τα δεδομένα σας αυτή τη στιγμή, επικοινωνήστε με το διαχειριστή σας."
}
}
},
"task": {
"reminder": {
"subject": "Υπενθύμιση για \"%[1]s\" (%[2]s)",
"message": "Αυτή είναι μια φιλική υπενθύμιση για την εργασία \"%[1]s\" (%[2]s)."
},
"comment": {
"subject": "Σχ: %[1]s (%[2]s)",
"mentioned_subject": "Ο/Η %[1]s σας ανέφερε σε ένα σχόλιο στο \"%[2]s\" (%[3]s)"
},
"assigned": {
"subject_to_assignee": "Σας έχει ανατεθεί το \"%[1]s\" (%[2]s)",
"message_to_assignee": "Ο/Η %[1]s σας έχει αναθέσει το \"%[2]s\".",
"subject_to_others": "Το \"%[1]s\" (%[2]s) έχει ανατεθεί στον/στην %[3]s",
"message_to_others": "Ο/Η %[1]s έχει αναθέσει την εργασία στον/στην %[2]s.",
"subject_to_others_self": "Το \"%[1]s\" (%[2]s) έχει ανατεθεί από τον/την %[3]s στον εαυτό τους",
"message_to_others_self": "Ο/Η %[1]s έχει αναθέσει την εργασία στον εαυτό τους."
},
"deleted": {
"subject": "Το \"%[1]s\" (%[2]s) έχει διαγραφεί",
"message": "Ο/Η %[1]s έχει διαγράψει την εργασία \"%[2]s\" (%[3]s)"
},
"mentioned": {
"subject_new": "Ο/Η %[1]s σας ανέφερε σε μια νέα εργασία \"%[2]s\" (%[3]s)",
"subject": "Ο/Η %[1]s σας ανέφερε σε μια εργασία \"%[2]s\" (%[3]s)"
},
"overdue": {
"subject": "Η εργασία \"%[1]s\" (%[2]s) είναι ληξιπρόθεσμη",
"message": "Αυτή είναι μια φιλική υπενθύμιση για την εργασία \"%[1]s\" (%[2]s) που είναι ληξιπρόθεσμη %[3]s και δεν έχει ακόμη παραδοθεί.",
"multiple_subject": "Οι εκπρόθεσμες εργασίες σας",
"multiple_message": "Έχετε τις παρακάτω εκπρόθεσμες εργασίες:",
"overdue_since": "από %[1]s",
"overdue_now": "τώρα",
"overdue": "εκπρόθεσμη %[1]s"
}
},
"project": {
"created": "Ο/Η %[1]s δημιούργησε το έργο \"%[2]s\""
},
"team": {
"member_added": {
"subject": "Ο/Η %[1]s σας πρόσθεσε στην ομάδα \"%[2]s\" στο Vikunja",
"message": "Ο/Η %[1]s σας πρόσθεσε στην ομάδα %[2]s στο Vikunja."
}
},
"data_export": {
"ready": {
"subject": "Η εξαγωγή δεδομένων σας από το Vikunja είναι έτοιμη",
"message": "Η εξαγωγή δεδομένων σας από το Vikunja είναι έτοιμη για λήψη. Κάντε κλικ στο πλήκτρο παρακάτω για τα κατεβάσετε:",
"availability": "Η λήψη θα είναι διαθέσιμη για τις επόμενες 7 ημέρες."
}
},
"migration": {
"done": {
"subject": "Η μετάβαση από το %[1]s στο Vikunja ολοκληρώθηκε",
"imported": "Το Vikunja έχει εισαγάγει όλες τις λίστες / έργα, εργασίες, σημειώσεις, υπενθυμίσεις και αρχεία από το %[1]s που έχετε πρόσβαση.",
"have_fun": "Καλή διασκέδαση με τα νέα (παλιά) έργα σας!"
},
"failed": {
"subject": "Η μετάβαση από το %[1]s στο Vikunja απέτυχε",
"message": "Φαίνεται ότι η μετάβαση από το %[1]s δεν πήγε όπως θέλαμε αυτή τη φορά.",
"retry": "Μην ανησυχείτε, όμως! Απλά δώστε μια ακόμη ευκαιρία ξεκινώντας με τον ίδιο τρόπο όπως και πριν. Μερικές φορές, αυτές οι αναποδιές συμβαίνουν λόγω προβλημάτων από την πλευρά του %[1]s και δοκιμάζοντας ξανά πολλές φορές λύνει το πρόβλημα.",
"error": "Εντοπίσαμε ένα μικρό σφάλμα στην πορεία: `%[2]s`.",
"report": "Παρακαλώ αφήστε μια σημείωση σχετικά με αυτό [στο φόρουμ](https://community.vikunja.io/) ή σε οποιοδήποτε από τα συνηθισμένα μέρη, έτσι ώστε να μπορούμε να ρίξουμε μια ματιά στο γιατί απέτυχε.",
"working_on_it": "Έχουμε το μήνυμα σφάλματος στο ραντάρ μας και είμαστε έτοιμοι να το τακτοποιήσουμε σύντομα."
}
},
"api_token": {
"expiring": {
"week": {
"subject": "Το API τεκμήριό σας \"%[1]s\" λήγει σύντομα",
"message": "Το τεκμήριό σας API \"%[1]s\" θα λήξει στις %[2]s. Αν εξακολουθείτε να το χρειάζεστε, παρακαλώ δημιουργήστε ένα νέο προτού λήξει."
},
"day": {
"subject": "Το API τεκμήριό σας \"%[1]s\" λήγει αύριο",
"message": "Το τεκμήριό σας API \"%[1]s\" θα λήξει στις %[2]s. Αν εξακολουθείτε να το χρειάζεστε, παρακαλώ δημιουργήστε ένα νέο προτού λήξει."
},
"action": "Διαχείριση Τεκμηρίων API"
}
},
"common": {
"have_nice_day": "Να έχεις μια όμορφη μέρα!",
"copy_url": "Αν το παραπάνω κουμπί δε λειτουργεί, αντιγράψτε το παρακάτω url και επικολλήστε το στη γραμμή διευθύνσεων του προγράμματός σας πλοήγησης:",
"actions": {
"open_task": "Άνοιγμα Εργασίας στο Vikunja",
"open_vikunja": "Άνοιγμα του Vikunja",
"open_project": "Άνοιγμα Έργου",
"open_team": "Άνοιγμα Ομάδας",
"download": "Λήψη",
"reset_password": "Επαναφορά του κωδικού σας πρόσβασης",
"go_to_settings": "Μετάβαση στις ρυθμίσεις",
"confirm_email": "Επιβεβαιώστε τη διεύθυνση email σας",
"abort_deletion": "Ματαίωση της διαγραφής",
"confirm_account_deletion": "Επιβεβαίωση της διαγραφής του λογαριασμού μου",
"change_notification_settings_link": "Μπορείτε να αλλάξετε τις ρυθμίσεις σας ειδοποίησης [here](%[1]s).",
"left_comment": "Ο/Η %[1]s άφησε ένα σχόλιο",
"mentioned_you_comment": "Ο/Η %[1]s σας ανέφερε σε ένα σχόλιο",
"mentioned_you": "Ο/Η %[1]s σας ανέφερε",
"mentioned_you_new_task": "Ο/Η %[1]s σας ανέφερε σε μια νέα εργασία"
}
}
},
"time": {
"since_years": "ένα έτος|%[1]d έτη",
"since_weeks": "μία εβδομάδα|%[1]d εβδομάδες",
"since_days": "μία ημέρα|%[1]d ημέρες",
"since_hours": "μία ώρα|%[1]d ώρες",
"since_minutes": "ένα λεπτό|%[1]d λεπτά",
"list_last_separator": "και"
},
"feeds": {
"notifications": {
"title": "Ειδοποιήσεις του Vikunja για %[1]s"
}
}
}

View File

@ -173,5 +173,10 @@
"since_hours": "one hour|%[1]d hours",
"since_minutes": "one minute|%[1]d minutes",
"list_last_separator": "and"
},
"feeds": {
"notifications": {
"title": "Vikunja notifications for %[1]s"
}
}
}

1
pkg/i18n/lang/fa-IR.json Normal file
View File

@ -0,0 +1 @@
{}

View File

@ -173,5 +173,10 @@
"since_hours": "%[1]d 時間",
"since_minutes": "%[1]d 分",
"list_last_separator": ", "
},
"feeds": {
"notifications": {
"title": "%[1]s の Vikunja 通知"
}
}
}

1
pkg/i18n/lang/th-TH.json Normal file
View File

@ -0,0 +1 @@
{}

View File

@ -173,5 +173,10 @@
"since_hours": "одна година|%[1]d години|%[1]d годин",
"since_minutes": "одна хвилина|%[1]d хвилини|%[1]d хвилин",
"list_last_separator": "і"
},
"feeds": {
"notifications": {
"title": "Vikunja сповіщення для %[1]s"
}
}
}

View File

@ -145,7 +145,6 @@ func FullInit() {
// Start processing events
go func() {
models.RegisterListeners()
user.RegisterListeners()
migrationHandler.RegisterListeners()
ws.RegisterListeners()
err := events.InitEvents()

View File

@ -46,3 +46,17 @@ func AssertSent(t *testing.T, opts *Opts) {
assert.True(t, found, "Failed to assert mail '%v' has been sent.", opts)
}
// LastSent returns the most recently captured mail when running under Fake(),
// or nil if no mail has been sent. Intended for tests.
func LastSent() *Opts {
if len(sentMails) == 0 {
return nil
}
return sentMails[len(sentMails)-1]
}
// ResetSent clears the captured mail buffer. Intended for tests.
func ResetSent() {
sentMails = nil
}

View File

@ -17,8 +17,9 @@
package metrics
import (
"strconv"
"time"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/modules/keyvalue"
@ -36,6 +37,22 @@ const (
AttachmentsCountKey = `attachments_count`
)
// countCacheTTL is how long a cached entity count is served before it is recomputed
// from the database. The counts are inherently approximate (Prometheus samples them),
// so a short staleness window is fine and keeps the cache self-healing — a missed
// InvalidateCount call costs at most this much staleness, never a permanent drift.
const countCacheTTL = 30 * time.Second
// countTables maps each count metric key to the database table it counts.
var countTables = map[string]string{
ProjectCountKey: "projects",
UserCountKey: "users",
TaskCountKey: "tasks",
TeamCountKey: "teams",
FilesCountKey: "files",
AttachmentsCountKey: "task_attachments",
}
var registry *prometheus.Registry
func GetRegistry() *prometheus.Registry {
@ -53,7 +70,10 @@ func registerPromMetric(key, description string) {
Name: "vikunja_" + key,
Help: description,
}, func() float64 {
count, _ := GetCount(key)
count, err := GetCount(key)
if err != nil {
log.Errorf("Could not get count for metric %s: %s", key, err)
}
return float64(count)
}))
if err != nil {
@ -65,8 +85,8 @@ func registerPromMetric(key, description string) {
func InitMetrics() {
GetRegistry()
registerPromMetric(ProjectCountKey, "The number of projects on this instance")
registerPromMetric(UserCountKey, "The total number of shares on this instance")
registerPromMetric(ProjectCountKey, "The total number of projects on this instance")
registerPromMetric(UserCountKey, "The total number of users on this instance")
registerPromMetric(TaskCountKey, "The total number of tasks on this instance")
registerPromMetric(TeamCountKey, "The total number of teams on this instance")
registerPromMetric(FilesCountKey, "The total number of files on this instance")
@ -76,26 +96,31 @@ func InitMetrics() {
setupActiveLinkSharesMetric()
}
// GetCount returns the current count from keyvalue
func GetCount(key string) (count int64, err error) {
cnt, exists, err := keyvalue.Get(key)
if err != nil {
return 0, err
}
if !exists {
// GetCount returns the current count for the given metric key. The value is counted
// directly from the database and cached for countCacheTTL, so repeated scrapes don't
// hit the database on every request.
func GetCount(key string) (int64, error) {
return keyvalue.RememberFor(key, countCacheTTL, func() (int64, error) {
return countFromDatabase(key)
})
}
// countFromDatabase runs a COUNT(*) for the table backing the given metric key.
func countFromDatabase(key string) (int64, error) {
table, has := countTables[key]
if !has {
return 0, nil
}
if s, is := cnt.(string); is {
count, err = strconv.ParseInt(s, 10, 64)
} else {
count = cnt.(int64)
}
s := db.NewSession()
defer s.Close()
return
return s.Table(table).Count()
}
// SetCount sets the project count to a given value
func SetCount(count int64, key string) error {
return keyvalue.Put(key, count)
// InvalidateCount drops the cached count for a key so the next read recomputes it from
// the database. Use it where instant freshness is worth the extra COUNT(*); everywhere
// else the countCacheTTL keeps the value reasonably up to date on its own.
func InvalidateCount(key string) error {
return keyvalue.Del(key)
}

View File

@ -0,0 +1,43 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type users20260405194817 struct {
BotOwnerID int64 `xorm:"bigint null index"`
}
func (users20260405194817) TableName() string {
return "users"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20260405194817",
Description: "Add bot_owner_id column to users",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync(users20260405194817{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -0,0 +1,108 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"fmt"
"code.vikunja.io/api/pkg/log"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20260519120000",
Description: "uppercase existing project identifiers",
Migrate: func(tx *xorm.Engine) error {
s := tx.NewSession()
defer s.Close()
if err := s.Begin(); err != nil {
return err
}
// Postgres/SQLite default to case-sensitive comparisons, so
// projects like "foo" and "FOO" may coexist today. Uppercasing
// them blindly would create duplicate identifiers and break the
// invariant that task identifiers built from them are unique.
// Detect each colliding group, keep the oldest project's
// identifier and clear the rest so the operator can re-assign
// them after the migration runs.
type collidingGroup struct {
UpperIdentifier string `xorm:"upper_identifier"`
}
var groups []collidingGroup
err := s.SQL(`
SELECT UPPER(identifier) AS upper_identifier FROM projects
WHERE identifier IS NOT NULL AND identifier <> ''
GROUP BY UPPER(identifier)
HAVING COUNT(*) > 1
`).Find(&groups)
if err != nil {
_ = s.Rollback()
return fmt.Errorf("failed to scan for colliding project identifiers: %w", err)
}
for _, g := range groups {
type projectRow struct {
ID int64
Identifier string
}
var rows []projectRow
err := s.SQL(
"SELECT id, identifier FROM projects WHERE UPPER(identifier) = ? ORDER BY id ASC",
g.UpperIdentifier,
).Find(&rows)
if err != nil {
_ = s.Rollback()
return err
}
if len(rows) < 2 {
continue
}
kept := rows[0]
for i := 1; i < len(rows); i++ {
log.Warningf(
"Project identifier collision during uppercase migration: clearing identifier %q on project %d (kept %q on project %d). Re-assign a unique identifier after the migration.",
rows[i].Identifier, rows[i].ID, kept.Identifier, kept.ID,
)
if _, err := s.Exec(
"UPDATE projects SET identifier = ? WHERE id = ?",
"", rows[i].ID,
); err != nil {
_ = s.Rollback()
return err
}
}
}
// UPPER() is supported by MySQL, PostgreSQL and SQLite.
if _, err := s.Exec("UPDATE projects SET identifier = UPPER(identifier) WHERE identifier IS NOT NULL AND identifier <> UPPER(identifier)"); err != nil {
_ = s.Rollback()
return err
}
return s.Commit()
},
Rollback: func(_ *xorm.Engine) error {
return nil
},
})
}

View File

@ -27,14 +27,27 @@ import (
var apiTokenRoutes = map[string]APITokenRoute{}
// apiTokenRoutesV2 holds /api/v2 routes under the same (group, permission)
// keys as v1, so a token granted e.g. labels.read_one authorises both
// versions. The frontend token UI still reads only apiTokenRoutes;
// CanDoAPIRoute consults both tables.
var apiTokenRoutesV2 = map[string]APITokenRoute{}
func init() {
apiTokenRoutes = make(map[string]APITokenRoute)
apiTokenRoutesV2 = make(map[string]APITokenRoute)
apiTokenRoutes["caldav"] = APITokenRoute{
"access": &RouteDetail{
Path: "/dav/*",
Method: "ANY",
},
}
apiTokenRoutes["feeds"] = APITokenRoute{
"access": &RouteDetail{
Path: "/feeds/*",
Method: "GET",
},
}
}
type APITokenRoute map[string]*RouteDetail
@ -44,8 +57,25 @@ type RouteDetail struct {
Method string `json:"method"`
}
// isV2Path reports whether the given route path lives under /api/v2.
func isV2Path(path string) bool {
return strings.HasPrefix(path, "/api/v2/") || path == "/api/v2"
}
// stripAPIVersion removes the /api/v1/ or /api/v2/ prefix so both
// versions normalise to the same token-permission group name.
func stripAPIVersion(path string) string {
if stripped := strings.TrimPrefix(path, "/api/v1/"); stripped != path {
return stripped
}
if stripped := strings.TrimPrefix(path, "/api/v2/"); stripped != path {
return stripped
}
return path
}
func getRouteGroupName(path string) (finalName string, filteredParts []string) {
parts := strings.Split(strings.TrimPrefix(path, "/api/v1/"), "/")
parts := strings.Split(stripAPIVersion(path), "/")
filteredParts = []string{}
for _, part := range parts {
if strings.HasPrefix(part, ":") {
@ -69,6 +99,9 @@ func getRouteGroupName(path string) (finalName string, filteredParts []string) {
// getRouteDetail determines the API permission type from the route's HTTP method and path.
// In Echo v5, route.Name is auto-generated as METHOD:PATH, so we derive permissions from
// the HTTP method and path structure instead of the handler function name.
//
// v1 and v2 have inverted create/update verbs: v1 uses PUT for create and POST
// for update, v2 follows REST conventions (POST create, PUT/PATCH update).
func getRouteDetail(route echo.RouteInfo) (method string, detail *RouteDetail) {
detail = &RouteDetail{
Path: route.Path,
@ -82,6 +115,7 @@ func getRouteDetail(route echo.RouteInfo) (method string, detail *RouteDetail) {
lastPart = pathParts[len(pathParts)-1]
}
endsWithParam := strings.HasPrefix(lastPart, ":")
v2 := isV2Path(route.Path)
switch route.Method {
case http.MethodGet:
@ -90,10 +124,21 @@ func getRouteDetail(route echo.RouteInfo) (method string, detail *RouteDetail) {
}
return "read_all", detail
case http.MethodPut:
// PUT is used for creating resources in this codebase
if v2 {
// v2: PUT replaces an existing resource → update.
return "update", detail
}
// v1: PUT is used for creating resources.
return "create", detail
case http.MethodPost:
// POST is used for updating resources
if v2 {
// v2: POST creates a new resource on the collection.
return "create", detail
}
// v1: POST is used for updating resources.
return "update", detail
case http.MethodPatch:
// Both versions use PATCH for partial updates.
return "update", detail
case http.MethodDelete:
return "delete", detail
@ -102,9 +147,9 @@ func getRouteDetail(route echo.RouteInfo) (method string, detail *RouteDetail) {
return "", detail
}
func ensureAPITokenRoutesGroup(group string) {
if _, has := apiTokenRoutes[group]; !has {
apiTokenRoutes[group] = make(APITokenRoute)
func ensureAPITokenRoutesGroup(target map[string]APITokenRoute, group string) {
if _, has := target[group]; !has {
target[group] = make(APITokenRoute)
}
}
@ -177,8 +222,10 @@ func isStandardCRUDRoute(routeGroupName string, routeParts []string, _ string) b
return false
}
// CollectRoutesForAPITokenUsage gets called for every added APITokenRoute and builds a list of all routes we can use for the api tokens.
// The requiresJWT parameter indicates if this route is protected by JWT authentication.
// CollectRoutesForAPITokenUsage records a route for token authorisation.
// v1 and v2 share group/permission keys derived from the prefix-stripped
// path; v2 entries land in apiTokenRoutesV2 so the v1-only frontend UI is
// unchanged while CanDoAPIRoute consults both tables.
func CollectRoutesForAPITokenUsage(route echo.RouteInfo, requiresJWT bool) {
if route.Method == "echo_route_not_found" {
@ -199,6 +246,17 @@ func CollectRoutesForAPITokenUsage(route echo.RouteInfo, requiresJWT bool) {
return
}
target := apiTokenRoutes
if isV2Path(route.Path) {
target = apiTokenRoutesV2
// AutoPatch's synthesised PATCH and the original PUT both derive the
// "update" permission and would clobber each other on the map. Store
// only PUT; CanDoAPIRoute accepts PATCH as its alias on the same path.
if route.Method == http.MethodPatch {
return
}
}
// Check if this is a standard CRUD route using path-based heuristics
// In Echo v5, we can no longer rely on route.Name containing "(*WebHandler)"
isCRUD := isStandardCRUDRoute(routeGroupName, routeParts, route.Method)
@ -218,67 +276,67 @@ func CollectRoutesForAPITokenUsage(route echo.RouteInfo, requiresJWT bool) {
// Otherwise, we add it to the "other" key.
if len(routeParts) == 1 {
if routeGroupName == "notifications" && route.Method == http.MethodPost {
ensureAPITokenRoutesGroup("notifications")
ensureAPITokenRoutesGroup(target, "notifications")
apiTokenRoutes["notifications"]["mark_all_as_read"] = routeDetail
target["notifications"]["mark_all_as_read"] = routeDetail
return
}
ensureAPITokenRoutesGroup("other")
ensureAPITokenRoutesGroup(target, "other")
_, exists := apiTokenRoutes["other"][routeGroupName]
_, exists := target["other"][routeGroupName]
if exists {
routeGroupName += "_" + strings.ToLower(route.Method)
}
apiTokenRoutes["other"][routeGroupName] = routeDetail
target["other"][routeGroupName] = routeDetail
return
}
subkey := strings.Join(routeParts[1:], "_")
if _, has := apiTokenRoutes[routeParts[0]]; !has {
apiTokenRoutes[routeParts[0]] = make(APITokenRoute)
if _, has := target[routeParts[0]]; !has {
target[routeParts[0]] = make(APITokenRoute)
}
if _, has := apiTokenRoutes[routeParts[0]][subkey]; has {
if _, has := target[routeParts[0]][subkey]; has {
subkey += "_" + strings.ToLower(route.Method)
}
apiTokenRoutes[routeParts[0]][subkey] = routeDetail
target[routeParts[0]][subkey] = routeDetail
return
}
if strings.HasSuffix(routeGroupName, "_bulk") {
parent := strings.TrimSuffix(routeGroupName, "_bulk")
ensureAPITokenRoutesGroup(parent)
ensureAPITokenRoutesGroup(target, parent)
method, routeDetail := getRouteDetail(route)
apiTokenRoutes[parent][method+"_bulk"] = routeDetail
target[parent][method+"_bulk"] = routeDetail
return
}
_, has := apiTokenRoutes[routeGroupName]
_, has := target[routeGroupName]
if !has {
apiTokenRoutes[routeGroupName] = make(APITokenRoute)
target[routeGroupName] = make(APITokenRoute)
}
method, routeDetail := getRouteDetail(route)
if method != "" {
apiTokenRoutes[routeGroupName][method] = routeDetail
target[routeGroupName][method] = routeDetail
}
// Handle task attachments specially - they use custom handlers not WebHandler
if routeGroupName == "tasks_attachments" {
// PUT is upload (create), GET with :attachment param is download (read_one)
if route.Method == http.MethodPut {
apiTokenRoutes[routeGroupName]["create"] = &RouteDetail{
target[routeGroupName]["create"] = &RouteDetail{
Path: route.Path,
Method: route.Method,
}
}
if route.Method == http.MethodGet && strings.HasSuffix(route.Path, ":attachment") {
apiTokenRoutes[routeGroupName]["read_one"] = &RouteDetail{
target[routeGroupName]["read_one"] = &RouteDetail{
Path: route.Path,
Method: route.Method,
}
@ -311,6 +369,10 @@ func GetAvailableAPIRoutesForToken(c *echo.Context) error {
// stored (Path, Method) for that permission matches exactly. This closes
// GHSA-v479-vf79-mg83 and the wider method/sub-resource confusion it
// enabled. The one exception is the tasks.read_all quirk handled below.
// One (group, permission) pair can legitimately match both v1 and v2
// routes; we walk apiTokenRoutes and apiTokenRoutesV2 in turn. On v2,
// PATCH is accepted as an alias for the stored PUT on the same path
// (AutoPatch collapses both onto the "update" permission).
func CanDoAPIRoute(c *echo.Context, token *APIToken) (can bool) {
path := c.Path()
if path == "" {
@ -321,23 +383,32 @@ func CanDoAPIRoute(c *echo.Context, token *APIToken) (can bool) {
method := c.Request().Method
for group, perms := range token.APIPermissions {
routes, has := apiTokenRoutes[group]
if !has {
continue
}
for _, p := range perms {
rd := routes[p]
if rd == nil {
tables := []APITokenRoute{apiTokenRoutes[group], apiTokenRoutesV2[group]}
for _, routes := range tables {
if routes == nil {
continue
}
if rd.Method == method && rd.Path == path {
return true
}
// Two list endpoints share tasks.read_all but only one
// survives collection, so allow either explicitly.
if group == "tasks" && p == "read_all" && method == http.MethodGet &&
(path == "/api/v1/tasks" || path == "/api/v1/projects/:project/tasks") {
return true
for _, p := range perms {
rd := routes[p]
if rd == nil {
continue
}
if rd.Method == method && rd.Path == path {
return true
}
// v2: AutoPatch mirrors every PUT as a PATCH on the same
// path. PATCH isn't stored (it would clobber PUT under
// the same "update" key), so accept it as an alias here.
if isV2Path(rd.Path) && rd.Method == http.MethodPut &&
method == http.MethodPatch && rd.Path == path {
return true
}
// Two list endpoints share tasks.read_all but only one
// survives collection, so allow either explicitly.
if group == "tasks" && p == "read_all" && method == http.MethodGet &&
(path == "/api/v1/tasks" || path == "/api/v1/projects/:project/tasks") {
return true
}
}
}
}
@ -351,15 +422,20 @@ func CanDoAPIRoute(c *echo.Context, token *APIToken) (can bool) {
func PermissionsAreValid(permissions APIPermissions) (err error) {
for key, methods := range permissions {
routes, has := apiTokenRoutes[key]
if !has {
// A permission is valid if the group exists in either table. v2-only
// resources (no v1 counterpart) live solely in apiTokenRoutesV2, so
// validating against the union lets tokens grant them. CanDoAPIRoute
// already consults both tables when authorising.
v1Routes := apiTokenRoutes[key]
v2Routes := apiTokenRoutesV2[key]
if v1Routes == nil && v2Routes == nil {
return &ErrInvalidAPITokenPermission{
Group: key,
}
}
for _, method := range methods {
if routes[method] == nil {
if v1Routes[method] == nil && v2Routes[method] == nil {
return &ErrInvalidAPITokenPermission{
Group: key,
Permission: method,

View File

@ -17,6 +17,7 @@
package models
import (
"net/http/httptest"
"testing"
"github.com/labstack/echo/v5"
@ -54,3 +55,148 @@ func TestCanDoAPIRoute_BulkLabelTask(t *testing.T) {
assert.Equal(t, "/api/v1/tasks/:projecttask/labels/bulk", bulkRoute.Path)
assert.Equal(t, "POST", bulkRoute.Method)
}
func TestIsV2Path(t *testing.T) {
cases := map[string]bool{
"/api/v2": true,
"/api/v2/": true,
"/api/v2/labels": true,
"/api/v1/labels": false,
"/api/v1/api/v2": false, // prefix is authoritative
"": false,
"/api/v20/labels": false, // only exact /api/v2 prefix counts
"/api/v2labels": false,
}
for path, want := range cases {
t.Run(path, func(t *testing.T) {
assert.Equal(t, want, isV2Path(path))
})
}
}
func TestStripAPIVersion(t *testing.T) {
cases := map[string]string{
"/api/v1/labels": "labels",
"/api/v2/labels": "labels",
"/api/v2/labels/42": "labels/42",
"/api/v1/tasks/bulk": "tasks/bulk",
"/api/v3/labels": "/api/v3/labels", // unknown versions pass through
"/labels": "/labels",
"": "",
}
for path, want := range cases {
t.Run(path, func(t *testing.T) {
assert.Equal(t, want, stripAPIVersion(path))
})
}
}
// TestCollectRoutesV2 verifies that /api/v2 routes are stored in the v2
// shadow table under the same (group, permission) keys their v1 counterparts
// would use. This is what lets a token scoped on `labels.read_one` authorise
// both /api/v1/labels/{id} and /api/v2/labels/{id}.
func TestCollectRoutesV2(t *testing.T) {
apiTokenRoutes = make(map[string]APITokenRoute)
apiTokenRoutesV2 = make(map[string]APITokenRoute)
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/labels"}, true)
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/labels/:id"}, true)
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "POST", Path: "/api/v2/labels"}, true)
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "PUT", Path: "/api/v2/labels/:id"}, true)
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "DELETE", Path: "/api/v2/labels/:id"}, true)
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "PATCH", Path: "/api/v2/labels/:id"}, true)
// v1 map stays untouched.
assert.Empty(t, apiTokenRoutes, "v2 routes must not land in the v1 table")
labels, has := apiTokenRoutesV2["labels"]
require.True(t, has, "labels group should exist in v2 table")
assert.Equal(t, "GET", labels["read_all"].Method)
assert.Equal(t, "/api/v2/labels", labels["read_all"].Path)
assert.Equal(t, "GET", labels["read_one"].Method)
assert.Equal(t, "POST", labels["create"].Method)
// PUT is the authoritative update verb for API tokens — PATCH is
// skipped during collection so it doesn't clobber PUT.
assert.Equal(t, "PUT", labels["update"].Method)
assert.Equal(t, "DELETE", labels["delete"].Method)
}
// TestGetRouteDetail_V2Verbs verifies the v2 verb mapping: POST→create,
// PUT/PATCH→update. v1 inverts POST and PUT so we need a separate mapping
// path.
func TestGetRouteDetail_V2Verbs(t *testing.T) {
cases := []struct {
method, path, wantPerm string
}{
{"GET", "/api/v2/labels", "read_all"},
{"GET", "/api/v2/labels/:id", "read_one"},
{"POST", "/api/v2/labels", "create"},
{"PUT", "/api/v2/labels/:id", "update"},
{"PATCH", "/api/v2/labels/:id", "update"},
{"DELETE", "/api/v2/labels/:id", "delete"},
}
for _, c := range cases {
t.Run(c.method+" "+c.path, func(t *testing.T) {
perm, _ := getRouteDetail(echo.RouteInfo{Method: c.method, Path: c.path})
assert.Equal(t, c.wantPerm, perm)
})
}
}
// TestCanDoAPIRoute_V2PatchAliasesPut verifies that a token granted the
// "update" permission on a v2 resource can issue PATCH requests against
// the same path as the stored PUT route. Huma's AutoPatch synthesises
// PATCH for every PUT — the matcher accepts it as an alias so token
// holders aren't forced to use PUT exclusively.
func TestCanDoAPIRoute_V2PatchAliasesPut(t *testing.T) {
apiTokenRoutes = make(map[string]APITokenRoute)
apiTokenRoutesV2 = make(map[string]APITokenRoute)
apiTokenRoutes["caldav"] = APITokenRoute{
"access": &RouteDetail{Path: "/dav/*", Method: "ANY"},
}
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "PUT", Path: "/api/v2/labels/:id"}, true)
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "PATCH", Path: "/api/v2/labels/:id"}, true)
token := &APIToken{
APIPermissions: APIPermissions{"labels": []string{"update"}},
}
e := echo.New()
t.Run("PUT is allowed (stored verb)", func(t *testing.T) {
req := httptest.NewRequest("PUT", "/api/v2/labels/:id", nil)
c := e.NewContext(req, httptest.NewRecorder())
assert.True(t, CanDoAPIRoute(c, token))
})
t.Run("PATCH is allowed via alias", func(t *testing.T) {
req := httptest.NewRequest("PATCH", "/api/v2/labels/:id", nil)
c := e.NewContext(req, httptest.NewRecorder())
assert.True(t, CanDoAPIRoute(c, token))
})
t.Run("PATCH on a different path is rejected", func(t *testing.T) {
req := httptest.NewRequest("PATCH", "/api/v2/projects/:id", nil)
c := e.NewContext(req, httptest.NewRecorder())
assert.False(t, CanDoAPIRoute(c, token))
})
t.Run("v1 PATCH stays rejected", func(t *testing.T) {
// The alias must not bleed onto v1 — v1 has no AutoPatch and
// never registers PATCH on update routes.
apiTokenRoutes["labels"] = APITokenRoute{
"update": &RouteDetail{Path: "/api/v1/labels/:id", Method: "POST"},
}
v1Token := &APIToken{
APIPermissions: APIPermissions{"labels": []string{"update"}},
}
req := httptest.NewRequest("PATCH", "/api/v1/labels/:id", nil)
c := e.NewContext(req, httptest.NewRecorder())
assert.False(t, CanDoAPIRoute(c, v1Token))
})
}
// End-to-end CanDoAPIRoute coverage for /api/v2 is provided by the Label
// integration test in pkg/webtests/huma_label_test.go (see the token-auth
// scenarios in that file) which exercises the full auth pipeline.

Some files were not shown because too many files have changed in this diff Show More