Merge branch 'main' into main
This commit is contained in:
commit
0b8e4b4b61
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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/*
|
||||
|
|
@ -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 }}/*
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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*
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ docs/resources/
|
|||
pkg/static/templates_vfsdata.go
|
||||
files/
|
||||
!pkg/files/
|
||||
!pkg/web/files/
|
||||
vikunja-dump*
|
||||
vendor/
|
||||
os-packages/
|
||||
|
|
|
|||
|
|
@ -145,6 +145,13 @@ linters:
|
|||
- revive
|
||||
path: pkg/utils/*
|
||||
text: 'var-naming: avoid meaningless package names'
|
||||
- linters:
|
||||
- revive
|
||||
path: pkg/routes/api/shared/*
|
||||
text: 'var-naming: avoid meaningless package names'
|
||||
- linters:
|
||||
- contextcheck
|
||||
path: pkg/routes/api/v2/backgrounds.go # the unsplash provider intentionally uses context.Background(); its interface is shared with v1 and can't take a context
|
||||
- linters:
|
||||
- revive
|
||||
text: 'var-naming: avoid package names that conflict with Go standard library package names'
|
||||
|
|
|
|||
33
AGENTS.md
33
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
module code.vikunja.io/build
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/magefile/mage v1.17.2
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
|
||||
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -997,6 +997,37 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "audit",
|
||||
"comment": "Audit logging writes structured JSONL records of authentication, authorization and data lifecycle events. Requires the licensed `audit_logs` feature — with `audit.enabled: true` but no active license, listeners are registered but nothing is written until a license with the feature becomes active.",
|
||||
"children": [
|
||||
{
|
||||
"key": "enabled",
|
||||
"default_value": "false",
|
||||
"comment": "Whether to enable audit logging."
|
||||
},
|
||||
{
|
||||
"key": "logfile",
|
||||
"default_value": "",
|
||||
"comment": "The file audit log entries are written to, one JSON object per line. If empty, defaults to `audit.log` in the configured log path."
|
||||
},
|
||||
{
|
||||
"key": "rotation",
|
||||
"children": [
|
||||
{
|
||||
"key": "maxsizemb",
|
||||
"default_value": "100",
|
||||
"comment": "Rotate the audit log file once it exceeds this size in megabytes. Set to 0 to disable size-based rotation."
|
||||
},
|
||||
{
|
||||
"key": "maxage",
|
||||
"default_value": "30",
|
||||
"comment": "Delete rotated audit log files older than this many days. This only applies to the local rotated files, it is not a retention policy. Set to 0 to keep rotated files forever."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "outgoingrequests",
|
||||
"children": [
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@
|
|||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "40.9.3",
|
||||
"electron-builder": "26.8.1",
|
||||
"electron": "40.10.4",
|
||||
"electron-builder": "26.15.3",
|
||||
"unzipper": "0.12.3"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -74,9 +74,13 @@
|
|||
],
|
||||
"overrides": {
|
||||
"minimatch": "^10.2.3",
|
||||
"tar": "^7.5.11",
|
||||
"tar": ">=7.5.16",
|
||||
"@tootallnate/once": "^3.0.1",
|
||||
"picomatch": ">=4.0.4"
|
||||
"picomatch": ">=4.0.4",
|
||||
"tmp": ">=0.2.7",
|
||||
"ip-address": ">=10.1.1",
|
||||
"form-data": ">=4.0.6",
|
||||
"js-yaml": ">=4.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
64
devenv.lock
64
devenv.lock
|
|
@ -16,62 +16,6 @@
|
|||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1767039857,
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772893680,
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "8baab586afc9c9b57645a734c820e4ac0a604af9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1762808025,
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"inputs": {
|
||||
"nixpkgs-src": "nixpkgs-src"
|
||||
|
|
@ -125,15 +69,11 @@
|
|||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable",
|
||||
"pre-commit-hooks": [
|
||||
"git-hooks"
|
||||
]
|
||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,12 +77,12 @@
|
|||
"@tiptap/vue-3": "3.17.0",
|
||||
"@vueuse/core": "14.1.0",
|
||||
"@vueuse/router": "14.1.0",
|
||||
"axios": "1.15.2",
|
||||
"axios": "1.16.0",
|
||||
"blurhash": "2.0.5",
|
||||
"bulma-css-variables": "0.9.33",
|
||||
"change-case": "5.4.4",
|
||||
"dayjs": "1.11.19",
|
||||
"dompurify": "3.4.0",
|
||||
"dompurify": "3.4.11",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"flatpickr": "4.6.13",
|
||||
"floating-vue": "5.2.2",
|
||||
|
|
@ -104,60 +105,60 @@
|
|||
"zhyswan-vuedraggable": "4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "10.4.0",
|
||||
"@faker-js/faker": "10.5.0",
|
||||
"@histoire/plugin-screenshot": "1.0.0-beta.1",
|
||||
"@histoire/plugin-vue": "1.0.0-beta.1",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@sentry/vite-plugin": "3.6.1",
|
||||
"@tailwindcss/vite": "4.3.0",
|
||||
"@tailwindcss/vite": "4.3.1",
|
||||
"@tsconfig/node24": "24.0.4",
|
||||
"@types/codemirror": "5.60.17",
|
||||
"@types/is-touch-device": "1.0.3",
|
||||
"@types/node": "24.12.3",
|
||||
"@types/node": "24.13.2",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.2",
|
||||
"@typescript-eslint/parser": "8.59.2",
|
||||
"@vitejs/plugin-vue": "6.0.6",
|
||||
"@vue/eslint-config-typescript": "14.7.0",
|
||||
"@vue/test-utils": "2.4.10",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.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.3.0",
|
||||
"autoprefixer": "10.5.0",
|
||||
"browserslist": "4.28.2",
|
||||
"caniuse-lite": "1.0.30001792",
|
||||
"caniuse-lite": "1.0.30001799",
|
||||
"csstype": "3.2.3",
|
||||
"esbuild": "0.28.0",
|
||||
"esbuild": "0.28.1",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-plugin-depend": "1.5.0",
|
||||
"eslint-plugin-vue": "10.9.1",
|
||||
"happy-dom": "20.9.0",
|
||||
"eslint-plugin-vue": "10.9.2",
|
||||
"happy-dom": "20.10.6",
|
||||
"histoire": "1.0.0-beta.1",
|
||||
"otplib": "12.0.1",
|
||||
"postcss": "8.5.14",
|
||||
"postcss": "8.5.15",
|
||||
"postcss-easing-gradients": "3.0.1",
|
||||
"postcss-html": "1.8.1",
|
||||
"postcss-preset-env": "11.2.1",
|
||||
"rollup": "4.60.3",
|
||||
"postcss-preset-env": "11.3.1",
|
||||
"rollup": "4.62.2",
|
||||
"rollup-plugin-visualizer": "6.0.11",
|
||||
"sass-embedded": "1.99.0",
|
||||
"stylelint": "17.11.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.3.0",
|
||||
"tailwindcss": "4.3.1",
|
||||
"typescript": "5.9.3",
|
||||
"unplugin-inject-preload": "3.0.0",
|
||||
"vite": "7.3.3",
|
||||
"vite": "7.3.5",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vite-plugin-vue-devtools": "8.1.2",
|
||||
"vite-plugin-vue-devtools": "8.1.3",
|
||||
"vite-svg-loader": "5.1.1",
|
||||
"vitest": "4.1.5",
|
||||
"vue-tsc": "3.2.8",
|
||||
"wait-on": "9.0.5",
|
||||
"vitest": "4.1.9",
|
||||
"vue-tsc": "3.3.5",
|
||||
"wait-on": "9.0.10",
|
||||
"workbox-cli": "7.4.1",
|
||||
"ws": "8.20.0"
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
|
@ -172,7 +173,16 @@
|
|||
"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.7",
|
||||
"esbuild": ">=0.28.1",
|
||||
"form-data": ">=4.0.6",
|
||||
"markdown-it": ">=14.2.0",
|
||||
"launch-editor": ">=2.14.1",
|
||||
"@babel/core": ">=7.29.6",
|
||||
"js-yaml@4": ">=4.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
|
|
@ -61,6 +61,7 @@ import {useAuthStore} from '@/stores/auth'
|
|||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
import {useColorScheme} from '@/composables/useColorScheme'
|
||||
import {useTimeTrackingFavicon} from '@/composables/useTimeTrackingFavicon'
|
||||
import {useBodyClass} from '@/composables/useBodyClass'
|
||||
import QuickAddOverlay from '@/components/quick-actions/QuickAddOverlay.vue'
|
||||
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
|
||||
|
|
@ -107,6 +108,7 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
|
|||
|
||||
setLanguage(authStore.settings.language ?? DEFAULT_LANGUAGE)
|
||||
useColorScheme()
|
||||
useTimeTrackingFavicon()
|
||||
</script>
|
||||
|
||||
<style src="@/styles/tailwind.css" />
|
||||
|
|
|
|||
|
|
@ -36,4 +36,18 @@ describe('DatepickerWithRange predefined ranges', () => {
|
|||
const last = wrapper.emitted('update:modelValue')?.pop()?.[0]
|
||||
expect(last).toEqual({dateFrom: 'now/M-1M', dateTo: 'now/M'})
|
||||
})
|
||||
|
||||
// A cleared range (the Custom option) comes back as null via v-model; the
|
||||
// modelValue watcher must coerce it, not call null.toISOString().
|
||||
it('accepts a null modelValue without crashing', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await wrapper.setProps({modelValue: {dateFrom: 'now/w', dateTo: 'now/w+1w'}})
|
||||
await wrapper.vm.$nextTick()
|
||||
expect((wrapper.vm as any).from).toBe('now/w')
|
||||
|
||||
await wrapper.setProps({modelValue: {dateFrom: null, dateTo: null}})
|
||||
await wrapper.vm.$nextTick()
|
||||
expect((wrapper.vm as any).from).toBe('')
|
||||
expect((wrapper.vm as any).to).toBe('')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -114,16 +114,17 @@ import DatemathHelp from '@/components/date/DatemathHelp.vue'
|
|||
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
||||
|
||||
const props = defineProps<{
|
||||
// null for a side that's been cleared (the Custom option) — emitted, so accepted too.
|
||||
modelValue: {
|
||||
dateFrom: Date | string,
|
||||
dateTo: Date | string,
|
||||
dateFrom: Date | string | null,
|
||||
dateTo: Date | string | null,
|
||||
},
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: {
|
||||
dateFrom: Date | string,
|
||||
dateTo: Date | string
|
||||
dateFrom: Date | string | null,
|
||||
dateTo: Date | string | null
|
||||
}]
|
||||
}>()
|
||||
|
||||
|
|
@ -149,8 +150,8 @@ const to = ref('')
|
|||
watch(
|
||||
() => props.modelValue,
|
||||
newValue => {
|
||||
from.value = typeof newValue.dateFrom === 'string' ? newValue.dateFrom : newValue.dateFrom.toISOString()
|
||||
to.value = typeof newValue.dateTo === 'string' ? newValue.dateTo : newValue.dateTo.toISOString()
|
||||
from.value = typeof newValue.dateFrom === 'string' ? newValue.dateFrom : (newValue.dateFrom?.toISOString() ?? '')
|
||||
to.value = typeof newValue.dateTo === 'string' ? newValue.dateTo : (newValue.dateTo?.toISOString() ?? '')
|
||||
// Only set the date back to flatpickr when it's an actual date.
|
||||
// Otherwise flatpickr runs in an endless loop and slows down the browser.
|
||||
const dateFrom = parseDateOrString(from.value, false)
|
||||
|
|
@ -208,14 +209,22 @@ const customRangeActive = computed<boolean>(() => {
|
|||
})
|
||||
|
||||
const buttonText = computed<string>(() => {
|
||||
if (from.value !== '' && to.value !== '') {
|
||||
return t('input.datepickerRange.fromto', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
})
|
||||
if (from.value === '' || to.value === '') {
|
||||
return t('task.show.select')
|
||||
}
|
||||
|
||||
return t('task.show.select')
|
||||
// Show the preset's name when the range matches one, rather than the raw datemath.
|
||||
const preset = Object.entries(DATE_RANGES).find(
|
||||
([, range]) => from.value === range[0] && to.value === range[1],
|
||||
)
|
||||
if (preset) {
|
||||
return t(`input.datepickerRange.ranges.${preset[0]}`)
|
||||
}
|
||||
|
||||
return t('input.datepickerRange.fromto', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -730,7 +730,7 @@ function focusTaskBar(rowId: string) {
|
|||
setTimeout(() => {
|
||||
const taskBarElement = document.querySelector(`[data-row-id="${rowId}"] [role="slider"]`) as HTMLElement
|
||||
if (taskBarElement) {
|
||||
taskBarElement.focus()
|
||||
taskBarElement.focus({preventScroll: true})
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,15 @@
|
|||
</ProjectSettingsDropdown>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="pageTitle"
|
||||
class="project-title-wrapper"
|
||||
>
|
||||
<span class="project-title">{{ pageTitle }}</span>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
<TimerBadge />
|
||||
<OpenQuickActions />
|
||||
<Notifications />
|
||||
<Dropdown>
|
||||
|
|
@ -121,13 +129,17 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { PERMISSIONS as Permissions } from '@/constants/permissions'
|
||||
import { PRO_FEATURE } from '@/constants/proFeatures'
|
||||
|
||||
import ProjectSettingsDropdown from '@/components/project/ProjectSettingsDropdown.vue'
|
||||
import Dropdown from '@/components/misc/Dropdown.vue'
|
||||
import DropdownItem from '@/components/misc/DropdownItem.vue'
|
||||
import Notifications from '@/components/notifications/Notifications.vue'
|
||||
import TimerBadge from '@/components/time-tracking/TimerBadge.vue'
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import MenuButton from '@/components/home/MenuButton.vue'
|
||||
|
|
@ -151,12 +163,20 @@ const background = computed(() => baseStore.background)
|
|||
const canWriteCurrentProject = computed(() => baseStore.currentProject?.maxPermission !== null && baseStore.currentProject?.maxPermission !== undefined && baseStore.currentProject.maxPermission > Permissions.READ)
|
||||
const menuActive = computed(() => baseStore.menuActive)
|
||||
|
||||
// Standalone pages (no project) surface their route's title in the header.
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const pageTitle = computed(() => {
|
||||
const title = route.meta.title as string | undefined
|
||||
return title ? t(title) : ''
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const imprintUrl = computed(() => configStore.legal.imprintUrl)
|
||||
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
|
||||
const adminPanelEnabled = computed(() => configStore.isProFeatureEnabled('admin_panel'))
|
||||
const adminPanelEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.ADMIN_PANEL))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -71,6 +71,14 @@
|
|||
{{ $t('team.title') }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li v-if="timeTrackingEnabled">
|
||||
<RouterLink :to="{ name: 'time-tracking'}">
|
||||
<span class="menu-item-icon icon">
|
||||
<Icon :icon="['far', 'clock']" />
|
||||
</span>
|
||||
{{ $t('timeTracking.title') }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
</menu>
|
||||
</nav>
|
||||
|
||||
|
|
@ -133,12 +141,17 @@ import Loading from '@/components/misc/Loading.vue'
|
|||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {PRO_FEATURE} from '@/constants/proFeatures'
|
||||
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import {useSidebarResize} from '@/composables/useSidebarResize'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const projectStore = useProjectStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const timeTrackingEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING))
|
||||
|
||||
const {sidebarWidth, isResizing, startResize, isMobile} = useSidebarResize()
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@
|
|||
:disabled="disabled || undefined"
|
||||
@click.stop="toggleDatePopup"
|
||||
>
|
||||
{{ date === null ? chooseDateLabel : formatDisplayDate(date) }}
|
||||
<i v-if="date === null && emptyLabel !== ''">{{ emptyLabel }}</i>
|
||||
<template v-else>
|
||||
{{ date === null ? chooseDateLabel : formatDisplayDate(date) }}
|
||||
</template>
|
||||
</SimpleButton>
|
||||
|
||||
<CustomTransition name="fade">
|
||||
|
|
@ -16,6 +19,7 @@
|
|||
>
|
||||
<DatepickerInline
|
||||
v-model="date"
|
||||
:show-shortcuts="showShortcuts"
|
||||
@update:modelValue="updateData"
|
||||
/>
|
||||
|
||||
|
|
@ -48,12 +52,17 @@ const props = withDefaults(defineProps<{
|
|||
modelValue: Date | null | string,
|
||||
chooseDateLabel?: string,
|
||||
disabled?: boolean,
|
||||
showShortcuts?: boolean,
|
||||
// When the value is null, show this (italic) instead of chooseDateLabel.
|
||||
emptyLabel?: string,
|
||||
}>(), {
|
||||
chooseDateLabel: () => {
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
return t('input.datepicker.chooseDate')
|
||||
},
|
||||
disabled: false,
|
||||
showShortcuts: true,
|
||||
emptyLabel: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
|
|||
|
|
@ -1,66 +1,68 @@
|
|||
<template>
|
||||
<BaseButton
|
||||
v-if="(new Date()).getHours() < 21"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('today')"
|
||||
>
|
||||
<span class="icon"><Icon :icon="['far', 'calendar-alt']" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.today') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('tomorrow')"
|
||||
>
|
||||
<span class="icon"><Icon :icon="['far', 'sun']" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.tomorrow') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextMonday')"
|
||||
>
|
||||
<span class="icon"><Icon icon="coffee" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextMonday') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="!((new Date()).getDay() === 0 && (new Date()).getHours() >= 21)"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('thisWeekend')"
|
||||
>
|
||||
<span class="icon"><Icon icon="cocktail" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('laterThisWeek')"
|
||||
>
|
||||
<span class="icon"><Icon icon="chess-knight" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextWeek')"
|
||||
>
|
||||
<span class="icon"><Icon icon="forward" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<template v-if="showShortcuts">
|
||||
<BaseButton
|
||||
v-if="(new Date()).getHours() < 21"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('today')"
|
||||
>
|
||||
<span class="icon"><Icon :icon="['far', 'calendar-alt']" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.today') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('tomorrow')"
|
||||
>
|
||||
<span class="icon"><Icon :icon="['far', 'sun']" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.tomorrow') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextMonday')"
|
||||
>
|
||||
<span class="icon"><Icon icon="coffee" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextMonday') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="!((new Date()).getDay() === 0 && (new Date()).getHours() >= 21)"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('thisWeekend')"
|
||||
>
|
||||
<span class="icon"><Icon icon="cocktail" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('laterThisWeek')"
|
||||
>
|
||||
<span class="icon"><Icon icon="chess-knight" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextWeek')"
|
||||
>
|
||||
<span class="icon"><Icon icon="forward" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<div class="flatpickr-container">
|
||||
<flat-pickr
|
||||
|
|
@ -87,9 +89,12 @@ import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
|||
import {useTimeFormat} from '@/composables/useTimeFormat'
|
||||
import {TIME_FORMAT} from '@/constants/timeFormat'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: Date | null | string
|
||||
}>()
|
||||
showShortcuts?: boolean
|
||||
}>(), {
|
||||
showShortcuts: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [Date | null],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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),
|
||||
|
|
@ -719,7 +722,7 @@ async function addImage(event: Event) {
|
|||
return
|
||||
}
|
||||
|
||||
const url = await inputPrompt(event.target.getBoundingClientRect())
|
||||
const url = await inputPrompt(event.target.getBoundingClientRect(), '', editor.value)
|
||||
|
||||
if (url) {
|
||||
editor.value?.chain().focus().setImage({src: url}).run()
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
|
|
@ -5,6 +5,7 @@ import {PluginKey, type EditorState} from '@tiptap/pm/state'
|
|||
|
||||
import EmojiList from './EmojiList.vue'
|
||||
import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData'
|
||||
import {getPopupContainer} from '../popupContainer'
|
||||
|
||||
export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion')
|
||||
|
||||
|
|
@ -78,7 +79,7 @@ export default function emojiSuggestionSetup() {
|
|||
popupElement.style.left = '0'
|
||||
popupElement.style.zIndex = '4700'
|
||||
popupElement.appendChild(component.element!)
|
||||
document.body.appendChild(popupElement)
|
||||
getPopupContainer(props.editor).appendChild(popupElement)
|
||||
|
||||
const rect = props.clientRect()
|
||||
if (!rect) {
|
||||
|
|
@ -108,7 +109,7 @@ export default function emojiSuggestionSetup() {
|
|||
cleanupFloating = null
|
||||
}
|
||||
if (popupElement) {
|
||||
document.body.removeChild(popupElement)
|
||||
popupElement.remove()
|
||||
popupElement = null
|
||||
}
|
||||
component?.destroy()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import inputPrompt from '@/helpers/inputPrompt'
|
|||
|
||||
export async function setLinkInEditor(pos: DOMRect, editor: Editor | null | undefined) {
|
||||
const previousUrl = editor?.getAttributes('link').href || ''
|
||||
const url = await inputPrompt(pos, previousUrl)
|
||||
const url = await inputPrompt(pos, previousUrl, editor ?? undefined)
|
||||
|
||||
// empty
|
||||
if (url === '') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {library} from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faAlignLeft,
|
||||
faAngleLeft,
|
||||
faAngleRight,
|
||||
faAnglesUp,
|
||||
faArchive,
|
||||
|
|
@ -58,6 +59,7 @@ import {
|
|||
faPlay,
|
||||
faPlus,
|
||||
faPowerOff,
|
||||
faRss,
|
||||
faSearch,
|
||||
faShareAlt,
|
||||
faSignOutAlt,
|
||||
|
|
@ -120,6 +122,7 @@ library.add(faCode)
|
|||
library.add(faQuoteRight)
|
||||
library.add(faListUl)
|
||||
library.add(faAlignLeft)
|
||||
library.add(faAngleLeft)
|
||||
library.add(faAngleRight)
|
||||
library.add(faArchive)
|
||||
library.add(faArrowLeft)
|
||||
|
|
@ -168,6 +171,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)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
>
|
||||
<BaseButton
|
||||
:aria-label="$t('misc.closeDialog')"
|
||||
class="close"
|
||||
class="close d-print-none"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<Icon icon="times" />
|
||||
|
|
@ -62,13 +62,13 @@
|
|||
|
||||
<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,
|
||||
overflow?: boolean,
|
||||
wide?: boolean,
|
||||
variant?: 'default' | 'hint-modal' | 'scrolling',
|
||||
variant?: 'default' | 'hint-modal' | 'scrolling' | 'top',
|
||||
}>(), {
|
||||
enabled: true,
|
||||
overflow: false,
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -178,7 +211,13 @@ $modal-width: 1024px;
|
|||
// Reset UA dialog styles
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
// The scrim lives on the dialog element, not on ::backdrop: Chromium
|
||||
// intermittently stops painting a styled ::backdrop (e.g. after the
|
||||
// dialog's subtree re-renders, or while display is transitioned) even
|
||||
// though getComputedStyle still reports the color. The dialog fills the
|
||||
// viewport anyway, and its opacity transition fades the scrim with it —
|
||||
// same as the old div-based .modal-mask.
|
||||
background: rgba(0, 0, 0, .8);
|
||||
color: #ffffff;
|
||||
// Fill viewport
|
||||
position: fixed;
|
||||
|
|
@ -188,10 +227,12 @@ $modal-width: 1024px;
|
|||
max-inline-size: 100%;
|
||||
max-block-size: 100%;
|
||||
|
||||
// Transitions
|
||||
// Transitions. No display/allow-discrete transition needed: the close
|
||||
// fade runs while the dialog is still [open] (data-closing + timer in
|
||||
// closeDialog), and transitioning display triggers the Chromium paint
|
||||
// bug above.
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease,
|
||||
display 150ms ease allow-discrete;
|
||||
transition: opacity 150ms ease;
|
||||
|
||||
&[open]:not([data-closing]) {
|
||||
opacity: 1;
|
||||
|
|
@ -203,16 +244,11 @@ $modal-width: 1024px;
|
|||
|
||||
&::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
transition: background-color 150ms ease,
|
||||
display 150ms ease allow-discrete;
|
||||
}
|
||||
|
||||
&[open]:not([data-closing])::backdrop {
|
||||
background-color: rgba(0, 0, 0, .8);
|
||||
|
||||
@starting-style {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
// in quick-add mode the Electron window itself is the overlay — no scrim
|
||||
&:has(.is-quick-add-mode) {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -228,13 +264,20 @@ $modal-width: 1024px;
|
|||
}
|
||||
|
||||
.default .modal-content,
|
||||
.hint-modal .modal-content {
|
||||
.hint-modal .modal-content,
|
||||
.top .modal-content {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
// fine to use top/left since we're only using this to position it centered
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
// Cap centered content to the viewport and scroll inside it. Without this a
|
||||
// taller-than-viewport modal centres its top edge above the viewport, where
|
||||
// the container's overflow can't scroll to it (the .top variant overrides
|
||||
// both values below).
|
||||
max-block-size: calc(100dvh - 2rem);
|
||||
overflow: auto;
|
||||
|
||||
[dir="rtl"] & {
|
||||
transform: translate(50%, -50%);
|
||||
|
|
@ -244,6 +287,9 @@ $modal-width: 1024px;
|
|||
margin: 0;
|
||||
position: static;
|
||||
transform: none;
|
||||
// the fullscreen mobile layout flows and scrolls in .modal-container
|
||||
max-block-size: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
|
|
@ -256,11 +302,31 @@ $modal-width: 1024px;
|
|||
}
|
||||
}
|
||||
|
||||
// anchored below the top edge instead of centered, used for QuickActions
|
||||
.top .modal-content {
|
||||
inset-block-start: 3rem;
|
||||
transform: translate(-50%, 0);
|
||||
max-block-size: calc(100dvh - 6rem);
|
||||
overflow: auto;
|
||||
|
||||
[dir="rtl"] & {
|
||||
transform: translate(50%, 0);
|
||||
}
|
||||
|
||||
// the fullscreen mobile layout flows and scrolls in .modal-container
|
||||
@media screen and (max-width: $tablet) {
|
||||
transform: none;
|
||||
max-block-size: none;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
.hint-modal .modal-content:not(.is-wide),
|
||||
.top .modal-content:not(.is-wide) {
|
||||
inline-size: calc(100% - 2rem);
|
||||
max-inline-size: 640px;
|
||||
|
||||
|
|
@ -361,6 +427,32 @@ $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;
|
||||
background: transparent;
|
||||
|
||||
&::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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
<div
|
||||
class="user"
|
||||
:class="{'is-inline': isInline}"
|
||||
:style="{'--avatar-size': `${avatarSize}px`}"
|
||||
>
|
||||
<span class="avatar-wrapper">
|
||||
<img
|
||||
|
|
@ -74,6 +75,8 @@ watch(() => [props.user, props.avatarSize], loadAvatar, { immediate: true })
|
|||
}
|
||||
|
||||
.avatar {
|
||||
inline-size: var(--avatar-size);
|
||||
block-size: var(--avatar-size);
|
||||
border-radius: 100%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@
|
|||
<template #default>
|
||||
<Card :has-content="false">
|
||||
<div class="gantt-options">
|
||||
<FormField :label="$t('project.gantt.range')">
|
||||
<FormField :label="$t('misc.dateRange')">
|
||||
<Foo
|
||||
id="range"
|
||||
ref="flatPickerEl"
|
||||
v-model="flatPickerDateRange"
|
||||
:config="flatPickerConfig"
|
||||
class="input"
|
||||
:placeholder="$t('project.gantt.range')"
|
||||
:placeholder="$t('misc.dateRange')"
|
||||
/>
|
||||
</FormField>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@
|
|||
@click.stop="showSetLimitInput = true"
|
||||
>
|
||||
{{
|
||||
$t('project.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('project.kanban.noLimit')})
|
||||
$t('project.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('misc.notSet')})
|
||||
}}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
<Modal
|
||||
:enabled="active"
|
||||
:overflow="isNewTaskCommand"
|
||||
variant="top"
|
||||
@close="closeQuickActions"
|
||||
>
|
||||
<div
|
||||
|
|
@ -704,15 +705,16 @@ function reset() {
|
|||
|
||||
<style lang="scss" scoped>
|
||||
.quick-actions {
|
||||
// global Bulma .card styles are gone (ported into Card.vue, scoped),
|
||||
// so this bare .card div needs its own card visuals
|
||||
background-color: var(--white);
|
||||
border-radius: $radius;
|
||||
border: 1px solid var(--card-border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
justify-content: flex-start !important;
|
||||
|
||||
// FIXME: changed position should be an option of the modal
|
||||
:deep(.modal-content) {
|
||||
inset-block-start: 3rem;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
&.is-quick-add-mode {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
rows="1"
|
||||
@keydown="resetEmptyTitleError"
|
||||
@keydown.enter="handleEnter"
|
||||
@keydown.esc="blurTaskInput"
|
||||
/>
|
||||
<QuickAddMagic
|
||||
:highlight-hint-icon="taskAddHovered"
|
||||
|
|
@ -282,6 +283,10 @@ function focusTaskInput() {
|
|||
newTaskInput.value?.focus()
|
||||
}
|
||||
|
||||
function blurTaskInput() {
|
||||
newTaskInput.value?.blur()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focusTaskInput,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@
|
|||
</XButton>
|
||||
|
||||
<!-- Dropzone -->
|
||||
<Teleport to="body">
|
||||
<Teleport :to="dropzoneTeleportTarget">
|
||||
<div
|
||||
v-if="editEnabled"
|
||||
:class="{hidden: !showDropzone}"
|
||||
|
|
@ -185,7 +185,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, shallowReactive, computed, watch} from 'vue'
|
||||
import {ref, shallowReactive, computed, watch, onMounted, onBeforeUnmount} from 'vue'
|
||||
import {useDropZone} from '@vueuse/core'
|
||||
|
||||
import User from '@/components/misc/User.vue'
|
||||
|
|
@ -322,6 +322,34 @@ const showDropzone = computed(() =>
|
|||
props.editEnabled && isDraggingFiles.value && !isDragOverEditor.value,
|
||||
)
|
||||
|
||||
// A <dialog> opened with showModal() (e.g. the Kanban task detail) renders in
|
||||
// the browser's top layer, so the full-screen dropzone overlay teleported to
|
||||
// <body> would paint behind it regardless of z-index. Teleport it into the
|
||||
// topmost open dialog instead, mirroring Notification.vue.
|
||||
const dropzoneTeleportTarget = ref<string | HTMLElement>('body')
|
||||
let dialogObserver: MutationObserver | null = null
|
||||
|
||||
function syncDropzoneTeleportTarget() {
|
||||
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.modal-dialog[open]')
|
||||
dropzoneTeleportTarget.value = dialogs.item(dialogs.length - 1) ?? 'body'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncDropzoneTeleportTarget()
|
||||
dialogObserver = new MutationObserver(syncDropzoneTeleportTarget)
|
||||
dialogObserver.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['open'],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
dialogObserver?.disconnect()
|
||||
dialogObserver = null
|
||||
})
|
||||
|
||||
watch(() => props.editEnabled, enabled => {
|
||||
if (!enabled) {
|
||||
resetDragState()
|
||||
|
|
@ -478,7 +506,7 @@ defineExpose({
|
|||
inset-inline-start: 0;
|
||||
inset-block-end: 0;
|
||||
inset-inline-end: 0;
|
||||
z-index: 4001; // modal z-index is 4000
|
||||
z-index: 4001; // above app chrome when teleported to body (no modal open)
|
||||
text-align: center;
|
||||
|
||||
&.hidden {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
{{ currentBucketTitle }}
|
||||
<Icon
|
||||
icon="pencil-alt"
|
||||
class="change-indicator"
|
||||
class="change-indicator d-print-none"
|
||||
/>
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
v-if="editEnabled && Object.keys(relatedTasks).length > 0"
|
||||
id="showRelatedTasksFormButton"
|
||||
v-tooltip="$t('task.relation.add')"
|
||||
class="is-pulled-right add-task-relation-button d-print-none"
|
||||
class="is-pulled-end add-task-relation-button d-print-none"
|
||||
:class="{'is-active': showNewRelationForm}"
|
||||
variant="secondary"
|
||||
icon="plus"
|
||||
|
|
@ -77,7 +77,7 @@
|
|||
</span>
|
||||
</template>
|
||||
</Multiselect>
|
||||
<QuickAddMagic class="task-relation-quick-add-magic" />
|
||||
<QuickAddMagic />
|
||||
</div>
|
||||
<div
|
||||
key="field-kind"
|
||||
|
|
@ -463,13 +463,13 @@ async function toggleTaskDone(task: ITask) {
|
|||
|
||||
.task-relation-search-field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.task-relation-quick-add-magic {
|
||||
position: absolute;
|
||||
inset-block-start: .5rem;
|
||||
inset-inline-end: .75rem;
|
||||
z-index: 4;
|
||||
: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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div class="task-time-tracking">
|
||||
<XButton
|
||||
v-if="entries.length > 0"
|
||||
v-tooltip="$t('timeTracking.logTime')"
|
||||
v-cy="'addTaskTimeEntry'"
|
||||
class="is-pulled-right d-print-none"
|
||||
:class="{'is-active': showForm}"
|
||||
variant="secondary"
|
||||
icon="plus"
|
||||
:shadow="false"
|
||||
@click="showForm = !showForm"
|
||||
/>
|
||||
<h3 class="title is-5">
|
||||
{{ $t('timeTracking.title') }}
|
||||
</h3>
|
||||
<TimeEntryForm
|
||||
v-if="formVisible"
|
||||
:task-id="taskId"
|
||||
:entry="editingEntry"
|
||||
:recent-entries="entries"
|
||||
@saved="onSaved"
|
||||
@cancel="editingEntry = null"
|
||||
/>
|
||||
<TimeEntryList
|
||||
class="mbs-4"
|
||||
:entries="entries"
|
||||
:card="false"
|
||||
:empty-text="$t('timeTracking.list.emptyTask')"
|
||||
hide-label-column
|
||||
@edit="editingEntry = $event"
|
||||
@delete="onDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watch} from 'vue'
|
||||
|
||||
import TimeEntryForm from '@/components/time-tracking/TimeEntryForm.vue'
|
||||
import TimeEntryList from '@/components/time-tracking/TimeEntryList.vue'
|
||||
|
||||
import {useTimeEntryService} from '@/services/timeEntry'
|
||||
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
||||
|
||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
}>()
|
||||
|
||||
const timeTrackingStore = useTimeTrackingStore()
|
||||
const entries = ref<ITimeEntry[]>([])
|
||||
const editingEntry = ref<ITimeEntry | null>(null)
|
||||
const showForm = ref(false)
|
||||
|
||||
// Like related tasks: the form is implicit when empty, otherwise behind the +.
|
||||
const formVisible = computed(() => entries.value.length === 0 || showForm.value || editingEntry.value !== null)
|
||||
|
||||
async function load() {
|
||||
const {items} = await useTimeEntryService().getAll({
|
||||
filter: `task_id = ${props.taskId}`,
|
||||
perPage: 250,
|
||||
})
|
||||
entries.value = items
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
editingEntry.value = null
|
||||
showForm.value = false
|
||||
await load()
|
||||
}
|
||||
|
||||
async function onDelete(id: number) {
|
||||
await timeTrackingStore.removeEntry(id)
|
||||
await load()
|
||||
}
|
||||
|
||||
watch(() => props.taskId, load, {immediate: true})
|
||||
// The header badge can start/stop the timer without going through this form;
|
||||
// reload so the row reflects the stop (its new end time).
|
||||
watch(() => timeTrackingStore.activeTimer, load)
|
||||
</script>
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
<template>
|
||||
<form
|
||||
ref="formEl"
|
||||
v-cy="'timeEntryForm'"
|
||||
class="time-entry-form"
|
||||
@submit.prevent="saveEntry"
|
||||
>
|
||||
<div
|
||||
v-if="taskId === undefined"
|
||||
class="field-columns"
|
||||
>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.project') }}</label>
|
||||
<ProjectSearch v-model="selectedProject" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('timeTracking.form.task') }}</label>
|
||||
<Multiselect
|
||||
v-model="selectedTask"
|
||||
:placeholder="$t('timeTracking.form.taskSearch')"
|
||||
:loading="taskService.loading"
|
||||
:search-results="foundTasks"
|
||||
label="title"
|
||||
@search="findTasks"
|
||||
>
|
||||
<template #searchResult="{option}">
|
||||
{{ option.title }}
|
||||
</template>
|
||||
</Multiselect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.comment.comment') }}</label>
|
||||
<input
|
||||
v-model="comment"
|
||||
v-cy="'timeEntryComment'"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t('timeTracking.form.commentPlaceholder')"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped from-to-row">
|
||||
<div class="control is-expanded">
|
||||
<label class="label">{{ $t('input.datepickerRange.from') }}</label>
|
||||
<Datepicker
|
||||
v-model="from"
|
||||
:show-shortcuts="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<label class="label">{{ $t('input.datepickerRange.to') }}</label>
|
||||
<Datepicker
|
||||
v-model="to"
|
||||
:show-shortcuts="false"
|
||||
:empty-label="$t('misc.notSet')"
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<BaseButton
|
||||
v-tooltip="$t('timeTracking.form.smartFill')"
|
||||
v-cy="'smartFill'"
|
||||
class="smart-fill"
|
||||
:aria-label="$t('timeTracking.form.smartFill')"
|
||||
@click="smartFill"
|
||||
>
|
||||
<Icon :icon="['far', 'clock']" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field form-actions">
|
||||
<template v-if="isEditing">
|
||||
<XButton
|
||||
v-cy="'updateTimeEntry'"
|
||||
:disabled="!canSubmit"
|
||||
:loading="isSaving"
|
||||
@click="saveEntry"
|
||||
>
|
||||
{{ $t('timeTracking.form.update') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
variant="secondary"
|
||||
:disabled="isSaving"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
{{ $t('misc.cancel') }}
|
||||
</XButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<XButton
|
||||
v-cy="'saveTimeEntry'"
|
||||
:disabled="!canSubmit"
|
||||
:loading="isSaving"
|
||||
@click="saveEntry"
|
||||
>
|
||||
{{ $t('timeTracking.form.save') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
v-cy="'startTimer'"
|
||||
variant="secondary"
|
||||
:disabled="!canSubmit"
|
||||
:loading="isSaving"
|
||||
@click="startTimer"
|
||||
>
|
||||
{{ $t('timeTracking.form.startTimer') }}
|
||||
</XButton>
|
||||
</template>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, shallowReactive, watch, nextTick} from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Multiselect from '@/components/input/Multiselect.vue'
|
||||
import Datepicker from '@/components/input/Datepicker.vue'
|
||||
import ProjectSearch from '@/components/tasks/partials/ProjectSearch.vue'
|
||||
|
||||
import TaskService from '@/services/task'
|
||||
import TaskModel from '@/models/task'
|
||||
import {smartFillStart} from '@/helpers/time/smartFillStart'
|
||||
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
// When set, the entry is locked to this task and the project/task pickers are hidden.
|
||||
taskId?: number
|
||||
// When set, the form edits this entry (Update + Cancel) instead of creating.
|
||||
entry?: ITimeEntry | null
|
||||
// Entries the smart-clock looks at to continue from the last one's end.
|
||||
recentEntries?: ITimeEntry[]
|
||||
}>(), {
|
||||
taskId: undefined,
|
||||
entry: undefined,
|
||||
recentEntries: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const timeTrackingStore = useTimeTrackingStore()
|
||||
const authStore = useAuthStore()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const isEditing = computed(() => props.entry != null)
|
||||
|
||||
const formEl = ref<HTMLFormElement | null>(null)
|
||||
const selectedProject = ref<IProject | null>(null)
|
||||
const selectedTask = ref<ITask | null>(null)
|
||||
const from = ref<Date | null>(new Date())
|
||||
const to = ref<Date | null>(null)
|
||||
const comment = ref('')
|
||||
const isSaving = ref(false)
|
||||
|
||||
// Task and project are mutually exclusive (XOR) — selecting one clears the other,
|
||||
// so applyTarget never picks a stale target the user has since changed.
|
||||
watch(selectedTask, task => {
|
||||
if (task !== null) {
|
||||
selectedProject.value = null
|
||||
}
|
||||
})
|
||||
watch(selectedProject, project => {
|
||||
if (project !== null) {
|
||||
selectedTask.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const taskService = shallowReactive(new TaskService())
|
||||
const foundTasks = ref<ITask[]>([])
|
||||
async function findTasks(query: string) {
|
||||
if (query === '') {
|
||||
foundTasks.value = []
|
||||
return
|
||||
}
|
||||
const result = await taskService.getAll({}, {s: query, sort_by: 'done'}) as ITask[]
|
||||
foundTasks.value = selectedProject.value === null
|
||||
? result
|
||||
: result.filter(task => task.projectId === selectedProject.value?.id)
|
||||
}
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
// In edit mode the entry already has a valid container; an update that sends
|
||||
// neither keeps it, so don't block submit if the prefill lookup failed.
|
||||
isEditing.value || props.taskId !== undefined || selectedTask.value !== null || selectedProject.value !== null,
|
||||
)
|
||||
|
||||
function smartFill() {
|
||||
from.value = smartFillStart(
|
||||
props.recentEntries,
|
||||
authStore.settings.frontendSettings.timeTrackingDefaultStart ?? '09:00',
|
||||
new Date(),
|
||||
)
|
||||
to.value = new Date()
|
||||
}
|
||||
|
||||
// Whichever of task / project is set lands on the payload (XOR — enforced by canSubmit).
|
||||
function applyTarget(payload: Partial<ITimeEntry>) {
|
||||
if (props.taskId !== undefined) {
|
||||
payload.taskId = props.taskId
|
||||
} else if (selectedTask.value !== null) {
|
||||
payload.taskId = selectedTask.value.id
|
||||
} else if (selectedProject.value !== null) {
|
||||
payload.projectId = selectedProject.value.id
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(includeEnd: boolean): Partial<ITimeEntry> {
|
||||
const payload: Partial<ITimeEntry> = {
|
||||
comment: comment.value,
|
||||
startTime: from.value ?? new Date(),
|
||||
}
|
||||
applyTarget(payload)
|
||||
// Saving a manual entry always has an end (an empty "To" means "until now");
|
||||
// only the Start-timer path omits it to create a running timer.
|
||||
if (includeEnd) {
|
||||
payload.endTime = to.value ?? new Date()
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
function reset() {
|
||||
selectedTask.value = null
|
||||
selectedProject.value = null
|
||||
comment.value = ''
|
||||
from.value = new Date()
|
||||
to.value = null
|
||||
}
|
||||
|
||||
// Prefill from the entry being edited; a null entry returns the form to create mode.
|
||||
watch(() => props.entry, async entry => {
|
||||
if (entry == null) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
comment.value = entry.comment
|
||||
from.value = entry.startTime
|
||||
to.value = entry.endTime
|
||||
// Bring the form into view — the edit button may be far down the list.
|
||||
await nextTick()
|
||||
formEl.value?.scrollIntoView({behavior: 'smooth', block: 'center'})
|
||||
if (props.taskId !== undefined) {
|
||||
return
|
||||
}
|
||||
if (entry.taskId > 0) {
|
||||
selectedProject.value = null
|
||||
try {
|
||||
selectedTask.value = await taskService.get(new TaskModel({id: entry.taskId})) as ITask
|
||||
} catch {
|
||||
selectedTask.value = null
|
||||
}
|
||||
} else if (entry.projectId > 0) {
|
||||
selectedTask.value = null
|
||||
selectedProject.value = (projectStore.projects[entry.projectId] as IProject) ?? null
|
||||
}
|
||||
}, {immediate: true})
|
||||
|
||||
async function submit(includeEnd: boolean) {
|
||||
if (!canSubmit.value) {
|
||||
return
|
||||
}
|
||||
isSaving.value = true
|
||||
try {
|
||||
const payload = buildPayload(includeEnd)
|
||||
// A started timer begins now (click time), not when the form first loaded.
|
||||
if (!includeEnd) {
|
||||
payload.startTime = new Date()
|
||||
}
|
||||
await timeTrackingStore.createEntry(payload)
|
||||
reset()
|
||||
emit('saved')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUpdate() {
|
||||
const entry = props.entry
|
||||
if (!canSubmit.value || entry == null) {
|
||||
return
|
||||
}
|
||||
isSaving.value = true
|
||||
try {
|
||||
const payload: Partial<ITimeEntry> & {id: number} = {
|
||||
id: entry.id,
|
||||
comment: comment.value,
|
||||
startTime: from.value ?? entry.startTime,
|
||||
// A running entry stays running (null); a completed one can't be reopened,
|
||||
// so keep its end if "To" was cleared (the API rejects clearing it).
|
||||
endTime: entry.endTime === null ? to.value : (to.value ?? entry.endTime),
|
||||
taskId: 0,
|
||||
projectId: 0,
|
||||
}
|
||||
applyTarget(payload)
|
||||
await timeTrackingStore.updateEntry(payload)
|
||||
emit('saved')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveEntry = () => (isEditing.value ? submitUpdate() : submit(true))
|
||||
const startTimer = () => submit(false)
|
||||
function cancelEdit() {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.field-columns {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
||||
> .field {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.from-to-row {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.smart-fill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
block-size: 2.5em;
|
||||
inline-size: 2.5em;
|
||||
border-radius: $radius;
|
||||
color: var(--primary);
|
||||
transition: background-color $transition;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--grey-100);
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
<template>
|
||||
<p
|
||||
v-if="rows.length === 0"
|
||||
class="has-text-centered has-text-grey is-italic"
|
||||
>
|
||||
{{ emptyText }}
|
||||
</p>
|
||||
<component
|
||||
:is="card ? Card : 'div'"
|
||||
v-else
|
||||
v-bind="card ? {padding: false, hasContent: false} : {}"
|
||||
>
|
||||
<div class="has-horizontal-overflow">
|
||||
<table class="table has-actions is-hoverable is-fullwidth mbe-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="!hideLabelColumn">
|
||||
{{ $t('task.attributes.project') }}
|
||||
</th>
|
||||
<th v-if="!hideLabelColumn">
|
||||
{{ $t('timeTracking.form.task') }}
|
||||
</th>
|
||||
<th>{{ $t('task.comment.comment') }}</th>
|
||||
<th class="nowrap">
|
||||
{{ $t('timeTracking.list.time') }}
|
||||
</th>
|
||||
<th class="nowrap has-text-right">
|
||||
{{ $t('timeTracking.list.duration') }}
|
||||
</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in rows"
|
||||
:key="row.entry.id"
|
||||
v-cy="'timeEntry'"
|
||||
>
|
||||
<td v-if="!hideLabelColumn">
|
||||
<template
|
||||
v-for="(project, i) in row.projectChain"
|
||||
:key="project.id"
|
||||
>
|
||||
<RouterLink :to="{ name: 'project.index', params: { projectId: project.id } }">
|
||||
{{ project.title }}
|
||||
</RouterLink>
|
||||
<span
|
||||
v-if="i < row.projectChain.length - 1"
|
||||
class="has-text-grey"
|
||||
> > </span>
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="!hideLabelColumn">
|
||||
<RouterLink
|
||||
v-if="row.entry.taskId > 0"
|
||||
:to="{ name: 'task.detail', params: { id: row.entry.taskId } }"
|
||||
>
|
||||
{{ row.taskIdentifier }}{{ row.taskTitle ? ` - ${row.taskTitle}` : '' }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td class="has-text-grey">
|
||||
{{ row.entry.comment }}
|
||||
</td>
|
||||
<td class="nowrap has-text-grey">
|
||||
{{ timeRange(row.entry) }}
|
||||
</td>
|
||||
<td class="nowrap has-text-right has-text-weight-semibold">
|
||||
{{ row.seconds === null ? '' : formatDuration(row.seconds) }}
|
||||
</td>
|
||||
<td class="nowrap has-text-right">
|
||||
<template v-if="row.entry.userId === currentUserId">
|
||||
<BaseButton
|
||||
v-tooltip="$t('menu.edit')"
|
||||
v-cy="'editTimeEntry'"
|
||||
class="entry-action"
|
||||
:aria-label="$t('menu.edit')"
|
||||
@click="emit('edit', row.entry)"
|
||||
>
|
||||
<Icon icon="pen" />
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('misc.delete')"
|
||||
v-cy="'deleteTimeEntry'"
|
||||
class="entry-action entry-delete"
|
||||
:aria-label="$t('misc.delete')"
|
||||
@click="emit('delete', row.entry.id)"
|
||||
>
|
||||
<Icon icon="trash-alt" />
|
||||
</BaseButton>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td
|
||||
:colspan="hideLabelColumn ? 2 : 4"
|
||||
class="has-text-weight-bold"
|
||||
>
|
||||
{{ $t('timeTracking.list.total') }}
|
||||
</td>
|
||||
<td class="nowrap has-text-right has-text-weight-bold">
|
||||
{{ formatDuration(totalSeconds) }}
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watch} from 'vue'
|
||||
|
||||
import Card from '@/components/misc/Card.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
import TaskService from '@/services/task'
|
||||
import TaskModel from '@/models/task'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||
import {formatDate} from '@/helpers/time/formatDate'
|
||||
import {useTimeFormat} from '@/composables/useTimeFormat'
|
||||
import {TIME_FORMAT} from '@/constants/timeFormat'
|
||||
|
||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
entries: ITimeEntry[]
|
||||
// Drop the project + task columns when every entry belongs to the same task
|
||||
// (e.g. the task-detail page).
|
||||
hideLabelColumn?: boolean
|
||||
// Wrap the table in a Card box; set false to render it inline (no card background).
|
||||
card?: boolean
|
||||
// Override the empty-state message (defaults to the per-day wording).
|
||||
emptyText?: string
|
||||
}>(), {
|
||||
hideLabelColumn: false,
|
||||
card: true,
|
||||
emptyText: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [id: number]
|
||||
edit: [entry: ITimeEntry]
|
||||
}>()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const {store: timeFormat} = useTimeFormat()
|
||||
|
||||
// Only the author can update/delete (enforced server-side); shared lists include
|
||||
// others' entries, so hide the controls on rows the current user doesn't own.
|
||||
const authStore = useAuthStore()
|
||||
const currentUserId = computed(() => authStore.info?.id)
|
||||
|
||||
// Task entries carry only a task id; resolve the full task lazily (for its
|
||||
// title, identifier, and parent project) and cache it.
|
||||
const taskService = new TaskService()
|
||||
const tasks = ref<Record<number, ITask>>({})
|
||||
const inFlight = new Set<number>()
|
||||
async function ensureTask(taskId: number) {
|
||||
if (taskId === 0 || tasks.value[taskId] !== undefined || inFlight.has(taskId)) {
|
||||
return
|
||||
}
|
||||
inFlight.add(taskId)
|
||||
try {
|
||||
tasks.value[taskId] = await taskService.get(new TaskModel({id: taskId}))
|
||||
} catch {
|
||||
// Leave unresolved — the row falls back to #<id>.
|
||||
} finally {
|
||||
inFlight.delete(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.entries, entries => {
|
||||
entries.forEach(entry => ensureTask(entry.taskId))
|
||||
}, {immediate: true})
|
||||
|
||||
function entrySeconds(entry: ITimeEntry): number {
|
||||
const end = entry.endTime ?? new Date()
|
||||
return Math.floor((end.getTime() - entry.startTime.getTime()) / 1000)
|
||||
}
|
||||
|
||||
const rows = computed(() => props.entries.map(entry => {
|
||||
const task = entry.taskId > 0 ? tasks.value[entry.taskId] : undefined
|
||||
const projectId = task?.projectId ?? (entry.projectId > 0 ? entry.projectId : 0)
|
||||
const project = projectId > 0 ? projectStore.projects[projectId] as IProject | undefined : undefined
|
||||
const ancestors = project ? projectStore.getAncestors(project) : []
|
||||
|
||||
return {
|
||||
entry,
|
||||
// Full ancestor chain (root → leaf), each link-able.
|
||||
projectChain: ancestors.map(p => ({id: p.id, title: getProjectTitle(p)})),
|
||||
taskIdentifier: task ? (task.identifier || `#${task.index}`) : (entry.taskId > 0 ? `#${entry.taskId}` : ''),
|
||||
taskTitle: task?.title ?? '',
|
||||
// A running entry (no end) has no settled duration — leave it blank.
|
||||
seconds: entry.endTime !== null ? entrySeconds(entry) : null,
|
||||
}
|
||||
}))
|
||||
|
||||
const totalSeconds = computed(() => rows.value.reduce((sum, row) => sum + (row.seconds ?? 0), 0))
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return formatDate(date, timeFormat.value === TIME_FORMAT.HOURS_24 ? 'HH:mm' : 'hh:mm A')
|
||||
}
|
||||
|
||||
function timeRange(entry: ITimeEntry): string {
|
||||
const start = formatTime(entry.startTime)
|
||||
if (entry.endTime === null) {
|
||||
return `${start} – …`
|
||||
}
|
||||
return `${start} – ${formatTime(entry.endTime)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-action {
|
||||
color: var(--grey-400);
|
||||
transition: color $transition;
|
||||
|
||||
& + & {
|
||||
margin-inline-start: .5rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.entry-delete:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="timeTrackingStore.hasActiveTimer"
|
||||
v-cy="'timerBadge'"
|
||||
class="timer-badge"
|
||||
>
|
||||
<RouterLink
|
||||
:to="{ name: 'time-tracking' }"
|
||||
class="timer-badge__elapsed"
|
||||
:title="$t('timeTracking.title')"
|
||||
>
|
||||
{{ elapsed }}
|
||||
</RouterLink>
|
||||
<BaseButton
|
||||
v-tooltip="$t('timeTracking.stop')"
|
||||
v-cy="'stopTimer'"
|
||||
class="timer-badge__stop"
|
||||
:aria-label="$t('timeTracking.stop')"
|
||||
@click="stop"
|
||||
>
|
||||
<Icon icon="stop" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, onMounted, onUnmounted} from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {PRO_FEATURE} from '@/constants/proFeatures'
|
||||
|
||||
const timeTrackingStore = useTimeTrackingStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const now = ref(new Date())
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const elapsed = computed(() => {
|
||||
const timer = timeTrackingStore.activeTimer
|
||||
if (timer === null) {
|
||||
return ''
|
||||
}
|
||||
const seconds = Math.max(0, Math.floor((now.value.getTime() - timer.startTime.getTime()) / 1000))
|
||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const mmss = `${pad(Math.floor((seconds % 3600) / 60))}:${pad(seconds % 60)}`
|
||||
return hours >= 1 ? `${hours}:${mmss}` : mmss
|
||||
})
|
||||
|
||||
const isStopping = ref(false)
|
||||
async function stop() {
|
||||
if (isStopping.value) {
|
||||
return
|
||||
}
|
||||
isStopping.value = true
|
||||
try {
|
||||
await timeTrackingStore.stopTimer()
|
||||
} finally {
|
||||
isStopping.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// The badge lives in the always-mounted header, so it owns the app-wide timer
|
||||
// sync. Subscribing is harmless when the feature is off (no events are emitted);
|
||||
// only the hydrate hits the gated endpoint, so guard that.
|
||||
timeTrackingStore.subscribeToTimerEvents()
|
||||
if (configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING)) {
|
||||
timeTrackingStore.hydrateActiveTimer()
|
||||
}
|
||||
interval = setInterval(() => {
|
||||
now.value = new Date()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
timeTrackingStore.unsubscribeFromTimerEvents()
|
||||
if (interval !== undefined) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.timer-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timer-badge__elapsed {
|
||||
padding-inline: .75rem .25rem;
|
||||
color: var(--primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timer-badge__stop {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-inline: .5rem;
|
||||
color: var(--grey-400);
|
||||
transition: color $transition;
|
||||
|
||||
&:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ref } from 'vue'
|
||||
import { getCurrentInstance, ref } from 'vue'
|
||||
import { createGlobalState, useIntervalFn } from '@vueuse/core'
|
||||
import { onBeforeRouteUpdate } from 'vue-router'
|
||||
|
||||
|
|
@ -18,10 +18,14 @@ export const useGlobalNow = createGlobalState(() => {
|
|||
|
||||
useIntervalFn(update, GLOBAL_NOW_INTERVAL, { immediate: true })
|
||||
|
||||
// ensure the now value is refreshed when the route changes
|
||||
onBeforeRouteUpdate(() => {
|
||||
update()
|
||||
})
|
||||
// Now that this state can be initialised from a plain helper (formatDateSince), the
|
||||
// first caller is not guaranteed to be a component — guard the route hook accordingly.
|
||||
if (getCurrentInstance()) {
|
||||
// ensure the now value is refreshed when the route changes
|
||||
onBeforeRouteUpdate(() => {
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
now,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import {describe, it, expect} from 'vitest'
|
||||
import {buildStoredQuery} from './useTaskList'
|
||||
|
||||
describe('buildStoredQuery', () => {
|
||||
it('includes sort when set', () => {
|
||||
expect(buildStoredQuery({sort: 'due_date:asc', filter: undefined, s: undefined, page: 1}))
|
||||
.toEqual({sort: 'due_date:asc'})
|
||||
})
|
||||
|
||||
it('includes filter and search when set', () => {
|
||||
expect(buildStoredQuery({sort: undefined, filter: 'done = false', s: 'foo', page: 1}))
|
||||
.toEqual({filter: 'done = false', s: 'foo'})
|
||||
})
|
||||
|
||||
it('omits page when it equals the default of 1', () => {
|
||||
expect(buildStoredQuery({sort: 'id:desc', filter: undefined, s: undefined, page: 1}))
|
||||
.toEqual({sort: 'id:desc'})
|
||||
})
|
||||
|
||||
it('includes page when greater than 1', () => {
|
||||
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 3}))
|
||||
.toEqual({page: '3'})
|
||||
})
|
||||
|
||||
it('returns an empty object when nothing is set', () => {
|
||||
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 1}))
|
||||
.toEqual({})
|
||||
})
|
||||
|
||||
it('skips empty strings', () => {
|
||||
expect(buildStoredQuery({sort: '', filter: '', s: '', page: 1}))
|
||||
.toEqual({})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
|
||||
import {useRouter, isNavigationFailure} from 'vue-router'
|
||||
import type {LocationQueryRaw} from 'vue-router'
|
||||
import {useRouteQuery} from '@vueuse/router'
|
||||
|
||||
import TaskCollectionService, {
|
||||
|
|
@ -10,6 +12,7 @@ import type {ITask} from '@/modelTypes/ITask'
|
|||
import {error} from '@/message'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useViewFiltersStore} from '@/stores/viewFilters'
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
|
||||
export type Order = 'asc' | 'desc' | 'none'
|
||||
|
|
@ -59,6 +62,22 @@ const SORT_BY_DEFAULT: SortBy = {
|
|||
id: 'desc',
|
||||
}
|
||||
|
||||
interface TaskListQueryState {
|
||||
sort: string | undefined
|
||||
filter: string | undefined
|
||||
s: string | undefined
|
||||
page: number
|
||||
}
|
||||
|
||||
export function buildStoredQuery(state: TaskListQueryState): LocationQueryRaw {
|
||||
const query: LocationQueryRaw = {}
|
||||
if (state.sort) query.sort = state.sort
|
||||
if (state.filter) query.filter = state.filter
|
||||
if (state.s) query.s = state.s
|
||||
if (state.page > 1) query.page = String(state.page)
|
||||
return query
|
||||
}
|
||||
|
||||
// This makes sure an id sort order is always sorted last.
|
||||
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
|
||||
// precedence over everything else, making any other sort columns pretty useless.
|
||||
|
|
@ -94,6 +113,9 @@ export function useTaskList(
|
|||
const projectId = computed(() => projectIdGetter())
|
||||
const projectViewId = computed(() => projectViewIdGetter())
|
||||
|
||||
const router = useRouter()
|
||||
const viewFiltersStore = useViewFiltersStore()
|
||||
|
||||
const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()})
|
||||
|
||||
const page = useRouteQuery('page', '1', { transform: Number })
|
||||
|
|
@ -119,6 +141,55 @@ export function useTaskList(
|
|||
},
|
||||
})
|
||||
|
||||
// Mirror the URL query bits this composable owns into the store so
|
||||
// in-project tab switches and sidebar re-visits can restore them.
|
||||
//
|
||||
// `ProjectList`/`ProjectTable` are reused across project switches (no
|
||||
// `:key` on them in ProjectView.vue), so setup runs only once. We track
|
||||
// the last viewId we synced — on every viewId transition, if the URL has
|
||||
// none of our params and the store has an entry, restore it via
|
||||
// `router.replace` and skip writing back the empty state we'd otherwise
|
||||
// clobber the saved entry with.
|
||||
let lastSyncedViewId: number | undefined
|
||||
watch(
|
||||
[projectViewId, sortQuery, filter, s, page],
|
||||
([viewId, sortValue, filterValue, sValue, pageValue]) => {
|
||||
const viewIdChanged = viewId !== lastSyncedViewId
|
||||
lastSyncedViewId = viewId
|
||||
|
||||
// An invalid `?page=` becomes NaN via `transform: Number`; treat it as
|
||||
// the default so it neither blocks restoration nor wipes stored state.
|
||||
const currentPage = Number.isInteger(pageValue) ? pageValue : 1
|
||||
const urlIsEmpty = !sortValue && !filterValue && !sValue && currentPage === 1
|
||||
if (viewIdChanged && urlIsEmpty) {
|
||||
const storedQuery = viewFiltersStore.getViewQuery(viewId)
|
||||
if (Object.keys(storedQuery).length > 0) {
|
||||
// Merge so unrelated query params on the route survive the restore.
|
||||
// Swallow navigation failures (e.g. aborted/duplicated) so the
|
||||
// ignored promise can't surface as an unhandled rejection.
|
||||
router.replace({query: {...router.currentRoute.value.query, ...storedQuery}})
|
||||
.catch(failure => {
|
||||
if (!isNavigationFailure(failure)) throw failure
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const query = buildStoredQuery({
|
||||
sort: sortValue as string | undefined,
|
||||
filter: filterValue as string | undefined,
|
||||
s: sValue as string | undefined,
|
||||
page: currentPage,
|
||||
})
|
||||
if (Object.keys(query).length > 0) {
|
||||
viewFiltersStore.setViewQuery(viewId, query)
|
||||
} else {
|
||||
viewFiltersStore.clearViewQuery(viewId)
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const allParams = computed(() => {
|
||||
const loadParams = {...params.value}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import {watch} from 'vue'
|
||||
import {createSharedComposable, tryOnMounted} from '@vueuse/core'
|
||||
import {storeToRefs} from 'pinia'
|
||||
|
||||
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
||||
import {getFullBaseUrl} from '@/helpers/getFullBaseUrl'
|
||||
|
||||
const TRACKING_FAVICON = `${getFullBaseUrl()}images/icons/favicon-tracking-32x32.png`
|
||||
|
||||
function getFaviconLink(): HTMLLinkElement | null {
|
||||
return document.querySelector<HTMLLinkElement>('link[rel="icon"]')
|
||||
}
|
||||
|
||||
// Swaps in a favicon with a small red dot in the lower left corner while a timer
|
||||
// is running, so an active time tracking session is visible even when the tab
|
||||
// isn't focused.
|
||||
export const useTimeTrackingFavicon = createSharedComposable(() => {
|
||||
const {hasActiveTimer} = storeToRefs(useTimeTrackingStore())
|
||||
|
||||
const originalHref = getFaviconLink()?.getAttribute('href') ?? '/favicon.ico'
|
||||
|
||||
function update(active: boolean) {
|
||||
const link = getFaviconLink()
|
||||
if (link === null) {
|
||||
return
|
||||
}
|
||||
link.href = active ? TRACKING_FAVICON : originalHref
|
||||
}
|
||||
|
||||
watch(hasActiveTimer, update, {flush: 'post'})
|
||||
tryOnMounted(() => update(hasActiveTimer.value))
|
||||
})
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// Licensed "pro" features the server may advertise via /info's enabled_pro_features.
|
||||
// Use these instead of bare strings when calling configStore.isProFeatureEnabled.
|
||||
export const PRO_FEATURE = {
|
||||
ADMIN_PANEL: 'admin_panel',
|
||||
TIME_TRACKING: 'time_tracking',
|
||||
} as const
|
||||
|
||||
export type ProFeature = typeof PRO_FEATURE[keyof typeof PRO_FEATURE]
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Hash-fragment prefix used to carry a post-login destination in the URL.
|
||||
*
|
||||
* Unlike the localStorage redirect, this lives in the address bar so the URL
|
||||
* stays copyable between browsers (needed for native OAuth clients that open
|
||||
* /oauth/authorize, see #2654). It uses the hash – not a query param – so the
|
||||
* embedded OAuth parameters never reach server or proxy access logs.
|
||||
*
|
||||
* Must stay distinct from LINK_SHARE_HASH_PREFIX, which router.beforeEach
|
||||
* special-cases.
|
||||
*/
|
||||
export const REDIRECT_HASH_PREFIX = '#redirect='
|
||||
|
|
@ -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
|
||||
|
|
@ -2,10 +2,17 @@ import {createRandomID} from '@/helpers/randomId'
|
|||
import {computePosition, flip, shift, offset} from '@floating-ui/dom'
|
||||
import {nextTick} from 'vue'
|
||||
import {eventToShortcutString} from '@/helpers/shortcut'
|
||||
import type {Editor} from '@tiptap/core'
|
||||
import {getPopupContainer} from '@/components/input/editor/popupContainer'
|
||||
|
||||
export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Promise<string> {
|
||||
export default function inputPrompt(pos: ClientRect, oldValue: string = '', editor?: Editor): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const id = 'link-input-' + createRandomID()
|
||||
// Append inside the open task <dialog> (top-layer) when present, otherwise
|
||||
// document.body. A body-level popup is painted behind a showModal() dialog
|
||||
// and unfocusable through its focus trap, breaking the link prompt in the
|
||||
// Kanban task popup (#2940).
|
||||
const container = getPopupContainer(editor)
|
||||
|
||||
// Create popup element
|
||||
const popupElement = document.createElement('div')
|
||||
|
|
@ -26,7 +33,7 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
|
|||
inputElement.value = oldValue
|
||||
wrapperDiv.appendChild(inputElement)
|
||||
popupElement.appendChild(wrapperDiv)
|
||||
document.body.appendChild(popupElement)
|
||||
container.appendChild(popupElement)
|
||||
|
||||
// Create a local mutable copy of the position for scroll tracking
|
||||
let currentRect = new DOMRect(pos.left, pos.top, pos.width, pos.height)
|
||||
|
|
@ -82,15 +89,41 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
|
|||
|
||||
nextTick(() => document.getElementById(id)?.focus())
|
||||
|
||||
// The prompt is a sub-modal of the enclosing task <dialog>. Native modal
|
||||
// dialogs close themselves on Escape ("cancel"); swallow that while the
|
||||
// prompt is open so Escape only dismisses the prompt, not the task dialog.
|
||||
const dialog = container.closest('dialog') as HTMLDialogElement | null
|
||||
const handleDialogCancel = (event: Event) => event.preventDefault()
|
||||
dialog?.addEventListener('cancel', handleDialogCancel)
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!popupElement.contains(event.target as Node)) {
|
||||
resolve('')
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
if (document.body.contains(popupElement)) {
|
||||
document.body.removeChild(popupElement)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
dialog?.removeEventListener('cancel', handleDialogCancel)
|
||||
if (container.contains(popupElement)) {
|
||||
container.removeChild(popupElement)
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById(id)?.addEventListener('keydown', event => {
|
||||
const shortcutString = eventToShortcutString(event)
|
||||
|
||||
if (shortcutString === 'Escape') {
|
||||
// Stop the native <dialog> from closing on Escape; cancel the prompt only.
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resolve('')
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
if (shortcutString !== 'Enter') {
|
||||
return
|
||||
}
|
||||
|
|
@ -105,15 +138,6 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
|
|||
cleanup()
|
||||
})
|
||||
|
||||
// Close on click outside
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!popupElement.contains(event.target as Node)) {
|
||||
resolve('')
|
||||
cleanup()
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}
|
||||
|
||||
// Add slight delay to prevent immediate closing
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {i18n} from '@/i18n'
|
|||
import {createSharedComposable} from '@vueuse/core'
|
||||
import {computed, toValue, type MaybeRefOrGetter} from 'vue'
|
||||
import {useDateDisplay} from '@/composables/useDateDisplay'
|
||||
import {useGlobalNow} from '@/composables/useGlobalNow'
|
||||
import {useTimeFormat} from '@/composables/useTimeFormat'
|
||||
import {DATE_DISPLAY, type DateDisplay} from '@/constants/dateDisplay'
|
||||
import {TIME_FORMAT, type TimeFormat} from '@/constants/timeFormat'
|
||||
|
|
@ -49,8 +50,13 @@ export const formatDateSince = (date: Date | string | null) => {
|
|||
|
||||
const locale = DAYJS_LOCALE_MAPPING[i18n.global.locale.value.toLowerCase()] ?? 'en'
|
||||
|
||||
// Computing the relative string against the shared, ticking `now` (instead of fromNow's
|
||||
// internal Date.now()) makes every reactive caller re-render on the 60s tick, so open views
|
||||
// don't keep showing a stale "x minutes ago".
|
||||
const {now} = useGlobalNow()
|
||||
|
||||
return date
|
||||
? dayjs(date).locale(locale).fromNow()
|
||||
? dayjs(date).locale(locale).from(now.value)
|
||||
: ''
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import {describe, it, expect} from 'vitest'
|
||||
|
||||
import {smartFillStart} from './smartFillStart'
|
||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
|
||||
function entry(startTime: Date, endTime: Date | null): ITimeEntry {
|
||||
return {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
taskId: 0,
|
||||
projectId: 0,
|
||||
startTime,
|
||||
endTime,
|
||||
comment: '',
|
||||
created: startTime,
|
||||
updated: startTime,
|
||||
maxPermission: null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('smartFillStart', () => {
|
||||
const now = new Date('2026-06-07T15:30:00')
|
||||
|
||||
it('continues from the latest entry end time', () => {
|
||||
const entries = [
|
||||
entry(new Date('2026-06-07T09:00:00'), new Date('2026-06-07T10:00:00')),
|
||||
entry(new Date('2026-06-07T11:00:00'), new Date('2026-06-07T12:30:00')),
|
||||
]
|
||||
expect(smartFillStart(entries, '09:00', now)).toEqual(new Date('2026-06-07T12:30:00'))
|
||||
})
|
||||
|
||||
it('ignores still-running entries (no end) when picking the latest end', () => {
|
||||
const entries = [
|
||||
entry(new Date('2026-06-07T09:00:00'), new Date('2026-06-07T10:00:00')),
|
||||
entry(new Date('2026-06-07T13:00:00'), null),
|
||||
]
|
||||
expect(smartFillStart(entries, '09:00', now)).toEqual(new Date('2026-06-07T10:00:00'))
|
||||
})
|
||||
|
||||
it('falls back to the default start time on the current day when there are no entries', () => {
|
||||
expect(smartFillStart([], '08:15', now)).toEqual(new Date('2026-06-07T08:15:00'))
|
||||
})
|
||||
|
||||
it('falls back to 09:00 when no default is configured', () => {
|
||||
expect(smartFillStart([], '', now)).toEqual(new Date('2026-06-07T09:00:00'))
|
||||
})
|
||||
|
||||
it('caps the default start at now when it would be in the future (before 09:00)', () => {
|
||||
const beforeNine = new Date('2026-06-07T07:30:00')
|
||||
expect(smartFillStart([], '09:00', beforeNine)).toEqual(beforeNine)
|
||||
})
|
||||
|
||||
it('caps a future last-entry end at now', () => {
|
||||
const entries = [entry(new Date('2026-06-07T16:00:00'), new Date('2026-06-07T17:00:00'))]
|
||||
expect(smartFillStart(entries, '09:00', now)).toEqual(now)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
|
||||
// The smart-clock start time: continue from the most recent entry's end so
|
||||
// consecutive entries don't overlap or leave gaps; with no completed entry to
|
||||
// continue from, fall back to the user's configured default start (HH:MM) on
|
||||
// the given day.
|
||||
export function smartFillStart(recentEntries: ITimeEntry[], defaultStart: string, now: Date): Date {
|
||||
// The filled range ends at now, so a start after now would be inverted (and
|
||||
// rejected on save). Cap at now — e.g. the 09:00 fallback before 9am.
|
||||
const cap = (start: Date) => (start.getTime() > now.getTime() ? new Date(now) : start)
|
||||
|
||||
const lastEnd = recentEntries
|
||||
.map(entry => entry.endTime)
|
||||
.filter((end): end is Date => end !== null)
|
||||
.sort((a, b) => b.getTime() - a.getTime())[0]
|
||||
if (lastEnd !== undefined) {
|
||||
return cap(new Date(lastEnd))
|
||||
}
|
||||
|
||||
const [hours, minutes] = (defaultStart || '09:00').split(':').map(Number)
|
||||
const start = new Date(now)
|
||||
start.setHours(hours || 0, minutes || 0, 0, 0)
|
||||
return cap(start)
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ export const SUPPORTED_LOCALES = {
|
|||
'ja-JP': '日本語',
|
||||
'hu-HU': 'Magyar',
|
||||
'ar-SA': 'اَلْعَرَبِيَّةُ',
|
||||
'fa-IR': 'فارسی',
|
||||
'sl-SI': 'Slovenščina',
|
||||
'pt-BR': 'Português Brasileiro',
|
||||
'hr-HR': 'Hrvatski',
|
||||
|
|
@ -41,6 +42,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
|
||||
|
|
@ -51,7 +53,7 @@ export const DEFAULT_LANGUAGE: SupportedLocale= 'en'
|
|||
|
||||
export type ISOLanguage = string
|
||||
|
||||
const RTL_LANGUAGES = ['ar-SA', 'he-IL'] as const
|
||||
const RTL_LANGUAGES = ['ar-SA', 'he-IL', 'fa-IR'] as const
|
||||
|
||||
export function isRTLLanguage(locale: SupportedLocale): boolean {
|
||||
return RTL_LANGUAGES.includes(locale as typeof RTL_LANGUAGES[number])
|
||||
|
|
|
|||
|
|
@ -284,8 +284,7 @@
|
|||
"default": "افتراضي",
|
||||
"month": "شهر",
|
||||
"day": "يوم",
|
||||
"hour": "ساعة",
|
||||
"range": "نطاق التاريخ"
|
||||
"hour": "ساعة"
|
||||
},
|
||||
"table": {
|
||||
"title": "جدول",
|
||||
|
|
@ -294,7 +293,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "الحد: {limit}",
|
||||
"noLimit": "غير محدد",
|
||||
"doneBucket": "حافظة المهام المكتملة",
|
||||
"doneBucketHint": "سيتم تلقائياً وضع علامة مكتمل على جميع المهام التي تم نقلها إلى هذه الحافظة.",
|
||||
"doneBucketHintExtended": "سيتم وضع علامة مكتمل على جميع المهام التي تم نقلها إلى حافظة المهام المكتملة. كما سيتم نقل جميع المهام المكتملة من أماكن أخرى.",
|
||||
|
|
|
|||
|
|
@ -314,8 +314,7 @@
|
|||
"default": "По подразбиране",
|
||||
"month": "Месец",
|
||||
"day": "Ден",
|
||||
"hour": "Час",
|
||||
"range": "Времеви диапазон"
|
||||
"hour": "Час"
|
||||
},
|
||||
"table": {
|
||||
"title": "Таблица",
|
||||
|
|
@ -324,7 +323,6 @@
|
|||
"kanban": {
|
||||
"title": "Канбан",
|
||||
"limit": "Лимит: {limit}",
|
||||
"noLimit": "Не е зададен",
|
||||
"doneBucket": "Колона за завършени",
|
||||
"doneBucketHint": "Всички задачи, преместени в тази колона, автоматично ще бъдат маркирани като завършени.",
|
||||
"doneBucketHintExtended": "Всички задачи, преместени в колоната за завършени, ще бъдат автоматично маркирани като завършени. Всички задачи, маркирани като завършени от другаде, също ще бъдат преместени тук.",
|
||||
|
|
|
|||
|
|
@ -383,7 +383,6 @@
|
|||
"month": "Měsíc",
|
||||
"day": "Den",
|
||||
"hour": "Hodina",
|
||||
"range": "Časové období",
|
||||
"chartLabel": "Projektový Ganttův diagram",
|
||||
"taskBarsForRow": "Chlívky pro řádek {rowId}",
|
||||
"taskBarLabel": "Úkol: {task}. Od {startDate} do {endDate}. {dateType}. Klikněte pro úpravu, přetáhněte pro přesun.",
|
||||
|
|
@ -412,7 +411,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limit: {limit}",
|
||||
"noLimit": "Nenastaveno",
|
||||
"doneBucket": "Sloupec \"Hotovo\"",
|
||||
"doneBucketHint": "Všechny úkoly přesunuté do tohoto sloupce budou automaticky označeny jako dokončené.",
|
||||
"doneBucketHintExtended": "Všechny úkoly přesunuté do sloupce \"Hotovo\" budou označeny jako dokončené automaticky. Všechny úkoly označené jako dokončené jinde sem budou přesunuty také.",
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@
|
|||
"yyyy/mm/dd": "JJJJ/MM/TT"
|
||||
},
|
||||
"timeFormat": "Zeitformat",
|
||||
"timeTrackingDefaultStart": "Startzeit für die Zeiterfassung",
|
||||
"timeFormatOptions": {
|
||||
"12h": "12 Stunden (AM/PM)",
|
||||
"24h": "24 Stunden (HH:mm)"
|
||||
|
|
@ -219,6 +220,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",
|
||||
|
|
@ -385,6 +393,7 @@
|
|||
"title": "Dupliziere dieses Projekt",
|
||||
"label": "Duplizieren",
|
||||
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:",
|
||||
"shares": "Freigaben kopieren (Benutzer:innen, Teams und Linkfreigaben)",
|
||||
"success": "Das Projekt wurde erfolgreich dupliziert."
|
||||
},
|
||||
"edit": {
|
||||
|
|
@ -463,7 +472,6 @@
|
|||
"month": "Monat",
|
||||
"day": "Tag",
|
||||
"hour": "Stunde",
|
||||
"range": "Zeitraum",
|
||||
"chartLabel": "Projekt Gantt-Diagramm",
|
||||
"taskBarsForRow": "Aufgabenleisten für Zeile {rowId}",
|
||||
"taskBarLabel": "Aufgabe: {task}. Von {startDate} bis {endDate}. {dateType}. Klicke zum Bearbeiten, ziehe zum Verschieben.",
|
||||
|
|
@ -492,7 +500,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limit: {limit}",
|
||||
"noLimit": "Nicht gesetzt",
|
||||
"doneBucket": "Erledigt Spalte",
|
||||
"doneBucketHint": "Alle Aufgaben, die in diese Spalte verschoben werden, werden automatisch als erledigt markiert.",
|
||||
"doneBucketHintExtended": "Alle Aufgaben, die in die Erledigt Spalte verschoben wurden, werden automatisch als erledigt markiert. Aufgaben, die in einer anderen Spalte als Erledigt markiert wurden, werden auch in diese Spalte verschoben.",
|
||||
|
|
@ -776,7 +783,10 @@
|
|||
"closeDialog": "Dialog schließen",
|
||||
"closeQuickActions": "Schnellaktionen schließen",
|
||||
"skipToContent": "Überspringen und zum Hauptinhalt gehen",
|
||||
"sortBy": "Sortieren nach"
|
||||
"sortBy": "Sortieren nach",
|
||||
"dateRange": "Zeitraum",
|
||||
"notSet": "Nicht festgelegt",
|
||||
"user": "Benutzer:in"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "Projektfarbe",
|
||||
|
|
@ -855,6 +865,7 @@
|
|||
"date": "Datum",
|
||||
"ranges": {
|
||||
"today": "Heute",
|
||||
"tomorrow": "Morgen",
|
||||
"thisWeek": "Diese Woche",
|
||||
"restOfThisWeek": "Der Rest dieser Woche",
|
||||
"nextWeek": "Nächste Woche",
|
||||
|
|
@ -985,6 +996,7 @@
|
|||
"repeatAfter": "Wiederholung setzen",
|
||||
"percentDone": "Fortschritt einstellen",
|
||||
"attachments": "Anhänge hinzufügen",
|
||||
"timeTracking": "Zeit erfassen",
|
||||
"relatedTasks": "Beziehung hinzufügen",
|
||||
"moveProject": "Verschieben",
|
||||
"duplicate": "Duplizieren",
|
||||
|
|
@ -1058,7 +1070,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"
|
||||
|
|
@ -1323,7 +1338,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.",
|
||||
|
|
@ -1450,6 +1466,32 @@
|
|||
"frontendVersion": "Frontend-Version: {version}",
|
||||
"apiVersion": "API-Version: {version}"
|
||||
},
|
||||
"timeTracking": {
|
||||
"title": "Zeiterfassung",
|
||||
"stop": "Timer stoppen",
|
||||
"logTime": "Zeit buchen",
|
||||
"editEntry": "Eintrag bearbeiten",
|
||||
"form": {
|
||||
"task": "Aufgabe",
|
||||
"taskSearch": "Nach einer Aufgabe suchen…",
|
||||
"commentPlaceholder": "Woran hast du gearbeitet?",
|
||||
"save": "Speichern",
|
||||
"startTimer": "Timer starten",
|
||||
"update": "Eintrag aktualisieren",
|
||||
"smartFill": "Vom letzten Eintrag ausfüllen"
|
||||
},
|
||||
"list": {
|
||||
"emptyTask": "Noch keine Zeit für diese Aufgabe getrackt.",
|
||||
"emptyFiltered": "Keine Zeiterfassung innerhalb der ausgewählten Filter.",
|
||||
"total": "Gesamt",
|
||||
"time": "Uhrzeit",
|
||||
"duration": "Dauer"
|
||||
},
|
||||
"browse": {
|
||||
"selectRange": "Bereich wählen",
|
||||
"userSearch": "Nach einer:m Benutzer:in suchen…"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"units": {
|
||||
"seconds": "Sekunde|Sekunden",
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@
|
|||
"yyyy/mm/dd": "JJJJ/MM/TT"
|
||||
},
|
||||
"timeFormat": "Zeitformat",
|
||||
"timeTrackingDefaultStart": "Startzeit für die Zeiterfassung",
|
||||
"timeFormatOptions": {
|
||||
"12h": "12 Stunden (AM/PM)",
|
||||
"24h": "24 Stunden (HH:mm)"
|
||||
|
|
@ -219,6 +220,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ä",
|
||||
|
|
@ -385,6 +393,7 @@
|
|||
"title": "Dupliziere dieses Projekt",
|
||||
"label": "Duplizieren",
|
||||
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:",
|
||||
"shares": "Freigaben kopieren (Benutzer:innen, Teams und Linkfreigaben)",
|
||||
"success": "Das Projekt wurde erfolgreich dupliziert."
|
||||
},
|
||||
"edit": {
|
||||
|
|
@ -463,7 +472,6 @@
|
|||
"month": "Monat",
|
||||
"day": "Tag",
|
||||
"hour": "Stunde",
|
||||
"range": "Zeitraum",
|
||||
"chartLabel": "Projekt Gantt-Diagramm",
|
||||
"taskBarsForRow": "Aufgabenleisten für Zeile {rowId}",
|
||||
"taskBarLabel": "Aufgabe: {task}. Von {startDate} bis {endDate}. {dateType}. Klicke zum Bearbeiten, ziehe zum Verschieben.",
|
||||
|
|
@ -492,7 +500,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limit: {limit}",
|
||||
"noLimit": "Nicht gesetzt",
|
||||
"doneBucket": "Erledigt Spalte",
|
||||
"doneBucketHint": "Alle Aufgaben, die in diese Spalte verschoben werden, werden automatisch als erledigt markiert.",
|
||||
"doneBucketHintExtended": "Alle Aufgaben, die in die Erledigt Spalte verschoben wurden, werden automatisch als erledigt markiert. Aufgaben, die in einer anderen Spalte als Erledigt markiert wurden, werden auch in diese Spalte verschoben.",
|
||||
|
|
@ -776,7 +783,10 @@
|
|||
"closeDialog": "Dialog schließen",
|
||||
"closeQuickActions": "Schnellaktionen schließen",
|
||||
"skipToContent": "Überspringen und zum Hauptinhalt gehen",
|
||||
"sortBy": "Sortieren nach"
|
||||
"sortBy": "Sortieren nach",
|
||||
"dateRange": "Zeitraum",
|
||||
"notSet": "Nicht festgelegt",
|
||||
"user": "Benutzer:in"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "Projektfarbe",
|
||||
|
|
@ -855,6 +865,7 @@
|
|||
"date": "Datum",
|
||||
"ranges": {
|
||||
"today": "Heute",
|
||||
"tomorrow": "Morgen",
|
||||
"thisWeek": "Diese Woche",
|
||||
"restOfThisWeek": "Der Rest dieser Woche",
|
||||
"nextWeek": "Nächste Woche",
|
||||
|
|
@ -985,6 +996,7 @@
|
|||
"repeatAfter": "Wiederholung setzen",
|
||||
"percentDone": "Fortschritt einstellen",
|
||||
"attachments": "Anhänge hinzufügen",
|
||||
"timeTracking": "Zeit erfassen",
|
||||
"relatedTasks": "Beziehung hinzufügen",
|
||||
"moveProject": "Verschieben",
|
||||
"duplicate": "Duplizieren",
|
||||
|
|
@ -1058,7 +1070,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"
|
||||
|
|
@ -1323,7 +1338,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.",
|
||||
|
|
@ -1450,6 +1466,32 @@
|
|||
"frontendVersion": "Frontend-Version: {version}",
|
||||
"apiVersion": "API-Version: {version}"
|
||||
},
|
||||
"timeTracking": {
|
||||
"title": "Zeiterfassung",
|
||||
"stop": "Timer stoppen",
|
||||
"logTime": "Zeit buchen",
|
||||
"editEntry": "Eintrag bearbeiten",
|
||||
"form": {
|
||||
"task": "Aufgabe",
|
||||
"taskSearch": "Nach einer Aufgabe suchen…",
|
||||
"commentPlaceholder": "Woran hast du gearbeitet?",
|
||||
"save": "Speichern",
|
||||
"startTimer": "Timer starten",
|
||||
"update": "Eintrag aktualisieren",
|
||||
"smartFill": "Vom letzten Eintrag ausfüllen"
|
||||
},
|
||||
"list": {
|
||||
"emptyTask": "Noch keine Zeit für diese Aufgabe getrackt.",
|
||||
"emptyFiltered": "Keine Zeiterfassung innerhalb der ausgewählten Filter.",
|
||||
"total": "Gesamt",
|
||||
"time": "Uhrzeit",
|
||||
"duration": "Dauer"
|
||||
},
|
||||
"browse": {
|
||||
"selectRange": "Bereich wählen",
|
||||
"userSearch": "Nach einer:m Benutzer:in suchen…"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"units": {
|
||||
"seconds": "Sekunde|Sekunden",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -172,6 +172,7 @@
|
|||
"yyyy\/mm\/dd": "YYYY\/MM\/DD"
|
||||
},
|
||||
"timeFormat": "Time format",
|
||||
"timeTrackingDefaultStart": "Time tracking smart-fill start time",
|
||||
"timeFormatOptions": {
|
||||
"12h": "12-hour (AM/PM)",
|
||||
"24h": "24-hour (HH:mm)"
|
||||
|
|
@ -219,6 +220,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",
|
||||
|
|
@ -385,6 +393,7 @@
|
|||
"title": "Duplicate this project",
|
||||
"label": "Duplicate",
|
||||
"text": "Select a parent project which should hold the duplicated project:",
|
||||
"shares": "Copy shares (users, teams and link shares) to the duplicate",
|
||||
"success": "The project was successfully duplicated."
|
||||
},
|
||||
"edit": {
|
||||
|
|
@ -463,7 +472,6 @@
|
|||
"month": "Month",
|
||||
"day": "Day",
|
||||
"hour": "Hour",
|
||||
"range": "Date Range",
|
||||
"chartLabel": "Project Gantt Chart",
|
||||
"taskBarsForRow": "Task bars for row {rowId}",
|
||||
"taskBarLabel": "Task: {task}. From {startDate} to {endDate}. {dateType}. Click to edit, drag to move.",
|
||||
|
|
@ -492,7 +500,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limit: {limit}",
|
||||
"noLimit": "Not Set",
|
||||
"doneBucket": "Done bucket",
|
||||
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
|
||||
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
|
||||
|
|
@ -776,7 +783,10 @@
|
|||
"closeDialog": "Close dialog",
|
||||
"closeQuickActions": "Close quick actions",
|
||||
"skipToContent": "Skip to main content",
|
||||
"sortBy": "Sort by"
|
||||
"sortBy": "Sort by",
|
||||
"dateRange": "Date range",
|
||||
"notSet": "Not set",
|
||||
"user": "User"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "Project color",
|
||||
|
|
@ -855,6 +865,7 @@
|
|||
"date": "Date",
|
||||
"ranges": {
|
||||
"today": "Today",
|
||||
"tomorrow": "Tomorrow",
|
||||
"thisWeek": "This Week",
|
||||
"restOfThisWeek": "The Rest of This Week",
|
||||
"nextWeek": "Next Week",
|
||||
|
|
@ -986,6 +997,7 @@
|
|||
"repeatAfter": "Set Repeating Interval",
|
||||
"percentDone": "Set Progress",
|
||||
"attachments": "Add Attachments",
|
||||
"timeTracking": "Track time",
|
||||
"relatedTasks": "Add Relation",
|
||||
"moveProject": "Move",
|
||||
"duplicate": "Duplicate",
|
||||
|
|
@ -1059,7 +1071,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"
|
||||
|
|
@ -1324,7 +1339,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.",
|
||||
|
|
@ -1451,6 +1467,32 @@
|
|||
"frontendVersion": "Frontend version: {version}",
|
||||
"apiVersion": "API version: {version}"
|
||||
},
|
||||
"timeTracking": {
|
||||
"title": "Time tracking",
|
||||
"stop": "Stop timer",
|
||||
"logTime": "Log time",
|
||||
"editEntry": "Edit entry",
|
||||
"form": {
|
||||
"task": "Task",
|
||||
"taskSearch": "Search for a task…",
|
||||
"commentPlaceholder": "What did you work on?",
|
||||
"save": "Save entry",
|
||||
"startTimer": "Start timer",
|
||||
"update": "Update entry",
|
||||
"smartFill": "Fill from last entry"
|
||||
},
|
||||
"list": {
|
||||
"emptyTask": "No time tracked for this task yet.",
|
||||
"emptyFiltered": "No time tracked for the selected filters.",
|
||||
"total": "Total",
|
||||
"time": "Time",
|
||||
"duration": "Duration"
|
||||
},
|
||||
"browse": {
|
||||
"selectRange": "Select a range",
|
||||
"userSearch": "Search for a user…"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"units": {
|
||||
"seconds": "second|seconds",
|
||||
|
|
|
|||
|
|
@ -251,8 +251,7 @@
|
|||
"default": "Predeterminado",
|
||||
"month": "Mes",
|
||||
"day": "Día",
|
||||
"hour": "Hora",
|
||||
"range": "Rango de fechas"
|
||||
"hour": "Hora"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tabla",
|
||||
|
|
@ -261,7 +260,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Límite: {limit}",
|
||||
"noLimit": "No Establecido",
|
||||
"doneBucket": "Contenedor completado",
|
||||
"doneBucketHint": "Todas las tareas movidas a este contenedor se marcarán automáticamente como finalizadas.",
|
||||
"doneBucketHintExtended": "Todas las tareas movidas al contenedor completado se marcarán como finalizadas automáticamente. Todas las tareas marcadas como finalizadas desde otro lugar también se moverán.",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -347,8 +347,7 @@
|
|||
"default": "Oletus",
|
||||
"month": "Kuukausi",
|
||||
"day": "Päivä",
|
||||
"hour": "Tunti",
|
||||
"range": "Ajanjakso"
|
||||
"hour": "Tunti"
|
||||
},
|
||||
"table": {
|
||||
"title": "Taulukko",
|
||||
|
|
@ -357,7 +356,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Raja: {limit}",
|
||||
"noLimit": "Ei Asetettu",
|
||||
"doneBucket": "Valmiit sarake",
|
||||
"doneBucketHint": "Kaikki tähän sarakkeeseen lisätyt tehtävät merkitään automaattisesti valmiiksi.",
|
||||
"doneBucketHintExtended": "Kaikki tähän sarakkeeseen lisätyt tehtävät merkitään automaattisesti valmiiksi. Muualla valmiiksi merkityt tehtävät siirretään myös.",
|
||||
|
|
|
|||
|
|
@ -346,7 +346,6 @@
|
|||
"month": "Mois",
|
||||
"day": "Jour",
|
||||
"hour": "Heure",
|
||||
"range": "Intervalle",
|
||||
"chartLabel": "Diagramme de Gantt du projet",
|
||||
"taskBarsForRow": "Barres de tâches pour la ligne {rowId}",
|
||||
"taskBarLabel": "Tâche : {task}. De {startDate} à {endDate}. {dateType}. Cliquez pour modifier, faites glisser pour déplacer.",
|
||||
|
|
@ -370,7 +369,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limite : {limit}",
|
||||
"noLimit": "Non défini",
|
||||
"doneBucket": "Colonne des tâches terminées",
|
||||
"doneBucketHint": "Toute tâche déplacée dans cette colonne sera automatiquement marquée comme terminée.",
|
||||
"doneBucketHintExtended": "Toute tâche déplacée dans cette colonne sera automatiquement marquée comme terminée. Toute tâche marquée comme terminée ailleurs sera également déplacée.",
|
||||
|
|
|
|||
|
|
@ -318,8 +318,7 @@
|
|||
"default": "ברירת מחדל",
|
||||
"month": "חודש",
|
||||
"day": "יום",
|
||||
"hour": "שעה",
|
||||
"range": "טווח תאריכים"
|
||||
"hour": "שעה"
|
||||
},
|
||||
"table": {
|
||||
"title": "טבלה",
|
||||
|
|
@ -328,7 +327,6 @@
|
|||
"kanban": {
|
||||
"title": "קאנבאן",
|
||||
"limit": "הגבלה: {limit}",
|
||||
"noLimit": "לא נקבע",
|
||||
"doneBucket": "דלי גמורים",
|
||||
"doneBucketHint": "דלי גמורים נשמר בהצלחה.",
|
||||
"doneBucketHintExtended": "כל המטלות המוכנסות לדלי הגמורים יסומנו אוטומטית כגמורים. כל המטלות המסומנות כגמורים מבחוץ יוזזו גם.",
|
||||
|
|
|
|||
|
|
@ -289,16 +289,14 @@
|
|||
"default": "Zadano",
|
||||
"month": "Mjesec",
|
||||
"day": "Dan",
|
||||
"hour": "Sat",
|
||||
"range": "Raspon datuma"
|
||||
"hour": "Sat"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tablica",
|
||||
"columns": "Stupci"
|
||||
},
|
||||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"noLimit": "Nije postavljeno"
|
||||
"title": "Kanban"
|
||||
},
|
||||
"pseudo": {
|
||||
"favorites": {
|
||||
|
|
|
|||
|
|
@ -290,8 +290,7 @@
|
|||
"default": "Alapértelmezett",
|
||||
"month": "Hónap",
|
||||
"day": "Nap",
|
||||
"hour": "Óra",
|
||||
"range": "Időintervallum"
|
||||
"hour": "Óra"
|
||||
},
|
||||
"table": {
|
||||
"title": "Táblázat",
|
||||
|
|
@ -300,7 +299,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Korlát: {limit}",
|
||||
"noLimit": "Nincs beállítva",
|
||||
"doneBucket": "Kész vödör",
|
||||
"doneBucketHint": "Az ebbe a csoportba helyezett összes feladat automatikusan készként lesz megjelölve.",
|
||||
"doneBucketHintExtended": "A kész csoportba áthelyezett összes feladat automatikusan készként lesz megjelölve. A máshonnan elvégzettként megjelölt összes feladat is átkerül.",
|
||||
|
|
|
|||
|
|
@ -362,7 +362,6 @@
|
|||
"month": "Mese",
|
||||
"day": "Giorno",
|
||||
"hour": "Ora",
|
||||
"range": "Intervallo di date",
|
||||
"chartLabel": "Progetto diagramma di Gantt",
|
||||
"taskBarsForRow": "Barre delle attività per riga {rowId}",
|
||||
"taskBarLabel": "Attività: {task}. Da {startDate} a {endDate}. {dateType}. Clicca per modificare, trascina per spostare.",
|
||||
|
|
@ -386,7 +385,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limite: {limit}",
|
||||
"noLimit": "Non Impostato",
|
||||
"doneBucket": "Colonna attività completate",
|
||||
"doneBucketHint": "Tutte le attività spostate in questa colonna verranno automaticamente contrassegnate come completate.",
|
||||
"doneBucketHintExtended": "Tutte le attività spostate nella colonna attività completate saranno contrassegnate automaticamente come completate. Anche tutte le attività contrassegnate come completate altrove verranno spostate.",
|
||||
|
|
|
|||
|
|
@ -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": "ガント",
|
||||
|
|
@ -377,7 +470,6 @@
|
|||
"month": "月",
|
||||
"day": "日",
|
||||
"hour": "時間",
|
||||
"range": "期間",
|
||||
"chartLabel": "プロジェクトガントチャート",
|
||||
"taskBarsForRow": "行 {rowId} のタスクバー",
|
||||
"taskBarLabel": "タスク: {task}。{startDate} から {endDate} まで。{dateType}。クリックして編集、ドラッグして移動。",
|
||||
|
|
@ -406,7 +498,6 @@
|
|||
"kanban": {
|
||||
"title": "カンバン",
|
||||
"limit": "上限: {limit}",
|
||||
"noLimit": "未設定",
|
||||
"doneBucket": "バケットを完了",
|
||||
"doneBucketHint": "このバケットに移動されたすべてのタスクは自動的に完了としてマークされます。",
|
||||
"doneBucketHintExtended": "完了バケットに移動されたすべてのタスクは自動的に完了としてマークされます。他の場所にあるタスクも完了としてマークされるとこのバケットに移動されます。",
|
||||
|
|
@ -427,7 +518,8 @@
|
|||
"bucketTitleSavedSuccess": "バケットのタイトルは正常に保存されました。",
|
||||
"bucketLimitSavedSuccess": "バケットの上限は正常に保存されました。",
|
||||
"collapse": "このバケットを折りたたむ",
|
||||
"bucketLimitReached": "バケットの上限に達しました。新しいタスクを追加するには、既存のタスクを削除するか、上限を緩和してください。"
|
||||
"bucketLimitReached": "バケットの上限に達しました。新しいタスクを追加するには、既存のタスクを削除するか、上限を緩和してください。",
|
||||
"bucketOptions": "バケットオプション"
|
||||
},
|
||||
"pseudo": {
|
||||
"favorites": {
|
||||
|
|
@ -550,6 +642,29 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"sorting": {
|
||||
"manually": "手動",
|
||||
"apply": "並べ替えを適用",
|
||||
"description": "このリスト内のタスクの並び順を選択します。手動並び替えを選ぶと、ドラッグ&ドロップでタスクの順序を変更できます。",
|
||||
"options": {
|
||||
"titleAsc": "タイトル (A–Z)",
|
||||
"titleDesc": "タイトル (Z–A)",
|
||||
"priorityDesc": "優先度 (高い順)",
|
||||
"priorityAsc": "優先度 (低い順)",
|
||||
"dueDateAsc": "期限日 (早い順)",
|
||||
"dueDateDesc": "期限日 (遅い順)",
|
||||
"startDateAsc": "開始日 (早い順)",
|
||||
"startDateDesc": "開始日 (遅い順)",
|
||||
"endDateAsc": "終了日 (早い順)",
|
||||
"endDateDesc": "終了日 (遅い順)",
|
||||
"percentDoneDesc": "完了率 (高い順)",
|
||||
"percentDoneAsc": "完了率 (低い順)",
|
||||
"createdDesc": "作成日時 (新しい順)",
|
||||
"createdAsc": "作成日時 (古い順)",
|
||||
"updatedDesc": "更新日時 (新しい順)",
|
||||
"updatedAsc": "更新日時 (古い順)"
|
||||
}
|
||||
},
|
||||
"migrate": {
|
||||
"title": "他のサービスからのインポート",
|
||||
"titleService": "{name}からVikunjaへのデータのインポート",
|
||||
|
|
@ -564,7 +679,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 +742,9 @@
|
|||
"upcoming": "今後の予定",
|
||||
"settings": "設定",
|
||||
"imprint": "運営情報",
|
||||
"privacy": "プライバシーポリシー"
|
||||
"privacy": "プライバシーポリシー",
|
||||
"closeSidebar": "サイドバーを閉じる",
|
||||
"home": "Vikunja ホーム"
|
||||
},
|
||||
"misc": {
|
||||
"loading": "読み込み中…",
|
||||
|
|
@ -636,9 +776,15 @@
|
|||
"createdBy": "{0} によって作成",
|
||||
"actions": "アクション",
|
||||
"cannotBeUndone": "この操作は元に戻せません!",
|
||||
"avatarOfUser": "{user} のプロフィール画像"
|
||||
"avatarOfUser": "{user} のプロフィール画像",
|
||||
"closeBanner": "バナーを閉じる",
|
||||
"closeDialog": "ダイアログを閉じる",
|
||||
"closeQuickActions": "クイックアクションを閉じる",
|
||||
"skipToContent": "メインコンテンツへスキップ",
|
||||
"sortBy": "並び替え"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "プロジェクトの色",
|
||||
"resetColor": "色のリセット",
|
||||
"datepicker": {
|
||||
"today": "今日",
|
||||
|
|
@ -698,6 +844,9 @@
|
|||
"toggleHeaderCell": "選択中のセルのタイトル セル指定の有無の切り替え",
|
||||
"mergeOrSplit": "結合または分割",
|
||||
"fixTables": "テーブルの修正"
|
||||
},
|
||||
"emoji": {
|
||||
"empty": "絵文字が見つかりません"
|
||||
}
|
||||
},
|
||||
"multiselect": {
|
||||
|
|
@ -711,6 +860,7 @@
|
|||
"date": "日付",
|
||||
"ranges": {
|
||||
"today": "今日",
|
||||
"tomorrow": "明日",
|
||||
"thisWeek": "今週",
|
||||
"restOfThisWeek": "今から週末まで",
|
||||
"nextWeek": "来週",
|
||||
|
|
@ -784,6 +934,7 @@
|
|||
"addReminder": "リマイダーを作成…",
|
||||
"doneSuccess": "タスクを完了にしました。",
|
||||
"undoneSuccess": "タスクを未完了に戻しました。",
|
||||
"readOnlyCheckbox": "このタスクには読み取り権限しかないため、完了にすることはできません。",
|
||||
"movedToProject": "タスクは {project} に移動しました。",
|
||||
"undo": "元に戻す",
|
||||
"checklistTotal": "{total}件中{checked}件のタスク",
|
||||
|
|
@ -796,7 +947,8 @@
|
|||
"select": "期間の選択",
|
||||
"noTasks": "タスクはありません — よい一日を!",
|
||||
"filterByLabel": "ラベル {label} での絞り込み",
|
||||
"clearLabelFilter": "ラベルでの絞り込みの解除"
|
||||
"clearLabelFilter": "ラベルでの絞り込みの解除",
|
||||
"savedFilterIgnored": "ラベルごとのタスク表示中は、保存されたホーム画面のフィルターは適用されません。"
|
||||
},
|
||||
"detail": {
|
||||
"chooseDueDate": "期日を設定…",
|
||||
|
|
@ -811,9 +963,14 @@
|
|||
"updateSuccess": "タスクは正常に保存されました。",
|
||||
"deleteSuccess": "タスクは正常に削除されました。",
|
||||
"duplicateSuccess": "タスクは正常に複製されました。",
|
||||
"noBucket": "バケットなし",
|
||||
"bucketChangedSuccess": "タスクのバケットを変更しました。",
|
||||
"belongsToProject": "このタスクはプロジェクト「{project}」に含まれています。",
|
||||
"back": "プロジェクトに戻る",
|
||||
"due": "期限: {at}",
|
||||
"closeTaskDetail": "タスク詳細を閉じる",
|
||||
"title": "タスクの詳細",
|
||||
"markAsDone": "「{task}」を完了にする",
|
||||
"scrollToBottom": "一番下まで移動",
|
||||
"organization": "組織",
|
||||
"management": "管理",
|
||||
|
|
@ -907,7 +1064,10 @@
|
|||
"addedSuccess": "コメントは正常に追加されました。",
|
||||
"permalink": "コメントへのリンクをコピー",
|
||||
"sortNewestFirst": "新しい順",
|
||||
"sortOldestFirst": "古い順"
|
||||
"sortOldestFirst": "古い順",
|
||||
"reply": "返信",
|
||||
"jumpToOriginal": "元のコメントへ移動",
|
||||
"deletedComment": "削除されたコメント"
|
||||
},
|
||||
"mention": {
|
||||
"noUsersFound": "ユーザーが見つかりません"
|
||||
|
|
@ -990,6 +1150,7 @@
|
|||
"mode": "繰り返しモード",
|
||||
"monthly": "毎月",
|
||||
"fromCurrentDate": "完了からの間隔",
|
||||
"each": "毎",
|
||||
"specifyAmount": "数字を入力…",
|
||||
"hours": "時間ごと",
|
||||
"days": "日ごと",
|
||||
|
|
@ -998,6 +1159,7 @@
|
|||
},
|
||||
"quickAddMagic": {
|
||||
"hint": "期日、担当者、その他の項目を追加するキーワードが使用できます。",
|
||||
"quickEntryHint": "日付やラベルなどに使えるマジックプレフィックスを利用できます。詳細はVikunja本体のアプリを開き、タスク入力欄のツールチップをご確認ください。",
|
||||
"title": "クイック追加",
|
||||
"intro": "タスクを作成する際に特定のキーワードを使うことで項目を直接追加できます。よく使う項目とともにタスクをすぐ追加できます。",
|
||||
"multiple": "複数使用できます。",
|
||||
|
|
@ -1170,9 +1332,11 @@
|
|||
"none": "通知はありません。よい一日を!",
|
||||
"explainer": "購読中のアクション、プロジェクト、タスクへの変更が発生すると、通知がここに表示されます。",
|
||||
"markAllRead": "通知をすべて既読にする",
|
||||
"markAllReadSuccess": "通知をすべて既読にしました。"
|
||||
"markAllReadSuccess": "通知をすべて既読にしました。",
|
||||
"subscribeFeed": "Atom フィードで通知を購読"
|
||||
},
|
||||
"quickActions": {
|
||||
"notLoggedIn": "まずVikunja本体のウィンドウにログインしてください。",
|
||||
"commands": "コマンド",
|
||||
"placeholder": "コマンドまたはキーワードを入力…",
|
||||
"hint": "{project} を使うとプロジェクトを検索対象にできます。{project} または {label} (ラベル) を検索条件と組み合わせて使うとプロジェクト内のタスクやラベルの付いたタスクを検索できます。{assignee} を使うとチームを検索対象にできます。",
|
||||
|
|
@ -1305,5 +1469,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": "新しい所有者"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -323,8 +323,7 @@
|
|||
"default": "기본값",
|
||||
"month": "월",
|
||||
"day": "일",
|
||||
"hour": "시",
|
||||
"range": "날짜 범위"
|
||||
"hour": "시"
|
||||
},
|
||||
"table": {
|
||||
"title": "테이블",
|
||||
|
|
@ -333,7 +332,6 @@
|
|||
"kanban": {
|
||||
"title": "칸반",
|
||||
"limit": "제한: {limit}",
|
||||
"noLimit": "설정 안함",
|
||||
"doneBucket": "완료 버킷",
|
||||
"doneBucketHint": "이 버킷으로 이동한 모든 할 일은 자동으로 완료로 표시됩니다.",
|
||||
"doneBucketHintExtended": "완료 버킷으로 이동된 모든 할 일은 자동으로 완료로 표시됩니다. 다른 곳에서 완료로 표시된 모든 할 일도 함께 이동됩니다.",
|
||||
|
|
|
|||
|
|
@ -320,8 +320,7 @@
|
|||
"default": "Numatytasis",
|
||||
"month": "Mėnuo",
|
||||
"day": "Diena",
|
||||
"hour": "Valanda",
|
||||
"range": "Datos intervalas"
|
||||
"hour": "Valanda"
|
||||
},
|
||||
"table": {
|
||||
"title": "Lentelė",
|
||||
|
|
@ -330,7 +329,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanbanas",
|
||||
"limit": "Limitas: {limit}",
|
||||
"noLimit": "Nenustatytas",
|
||||
"doneBucket": "Atliktųjų telkinys",
|
||||
"doneBucketHint": "Visos užduotys nukreiptos į šį telkinį bus automatiškai pažymėtos kaip atliktos.",
|
||||
"doneBucketHintExtended": "Visos užduotys perkeltos į atliktą telkiny bus automatiškai pažymėtos kaip atliktos. Visos užduotys pažymėtos kaip atliktos iš kitur bus perkeltos taip pat.",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -362,7 +470,6 @@
|
|||
"month": "Maand",
|
||||
"day": "Dag",
|
||||
"hour": "Uur",
|
||||
"range": "Datumbereik",
|
||||
"chartLabel": "Project Gantt-diagram",
|
||||
"taskBarsForRow": "Taakbalken voor rij {rowId}",
|
||||
"taskBarLabel": "Taak: {task}. Van {startDate} tot {endDate}. {dateType}. Klik om te bewerken, sleep om te verplaatsen.",
|
||||
|
|
@ -379,7 +486,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",
|
||||
|
|
@ -388,7 +498,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limiet: {limit}",
|
||||
"noLimit": "Niet ingesteld",
|
||||
"doneBucket": "Categorie 'voltooid'",
|
||||
"doneBucketHint": "Alle taken die je naar deze categorie verplaatst worden automatisch als 'voltooid' gemarkeerd.",
|
||||
"doneBucketHintExtended": "Taken die je verplaatst naar de categorie 'voltooid' worden automatisch gemarkeerd als voltooid. Ook taken die je elders als voltooid aanmerkt, worden hierheen verplaatst.",
|
||||
|
|
@ -409,7 +518,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 +642,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 (A–Z)",
|
||||
"titleDesc": "Titel (Z–A)",
|
||||
"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 +679,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 +742,9 @@
|
|||
"upcoming": "Aankomend",
|
||||
"settings": "Instellingen",
|
||||
"imprint": "Imprint",
|
||||
"privacy": "Privacybeleid"
|
||||
"privacy": "Privacybeleid",
|
||||
"closeSidebar": "Zijbalk sluiten",
|
||||
"home": "Vikunja home"
|
||||
},
|
||||
"misc": {
|
||||
"loading": "Bezig met laden…",
|
||||
|
|
@ -618,9 +776,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 +844,9 @@
|
|||
"toggleHeaderCell": "Celkopteksten in-/uitschakelen",
|
||||
"mergeOrSplit": "Samenvoegen of splitsen",
|
||||
"fixTables": "Tabellen repareren"
|
||||
},
|
||||
"emoji": {
|
||||
"empty": "Geen emoji gevonden"
|
||||
}
|
||||
},
|
||||
"multiselect": {
|
||||
|
|
@ -693,6 +860,7 @@
|
|||
"date": "Datum",
|
||||
"ranges": {
|
||||
"today": "Vandaag",
|
||||
"tomorrow": "Morgen",
|
||||
"thisWeek": "Deze week",
|
||||
"restOfThisWeek": "De rest van deze week",
|
||||
"nextWeek": "Volgende week",
|
||||
|
|
@ -766,6 +934,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 +947,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 +962,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 +993,7 @@
|
|||
"attachments": "Bijlagen toevoegen",
|
||||
"relatedTasks": "Relatie toevoegen",
|
||||
"moveProject": "Verplaatsen",
|
||||
"duplicate": "Dupliceer",
|
||||
"color": "Kleur instellen",
|
||||
"delete": "Verwijder",
|
||||
"favorite": "Toevoegen aan favorieten",
|
||||
|
|
@ -839,8 +1016,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 +1053,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 +1064,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 +1159,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 +1332,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 +1469,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -353,7 +353,6 @@
|
|||
"month": "Måned",
|
||||
"day": "Dag",
|
||||
"hour": "Time",
|
||||
"range": "Datointervall",
|
||||
"chartLabel": "Gantt-kart for prosjekt",
|
||||
"taskBarsForRow": "Oppgavelinjer for rad {rowId}",
|
||||
"taskBarLabel": "Oppgave: {task}. Fra {startDate} til {endDate}. {dateType}. Klikk for å redigere, dra for å flytte.",
|
||||
|
|
@ -377,7 +376,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Begrens: {limit}",
|
||||
"noLimit": "Ikke angitt",
|
||||
"doneBucket": "Ferdigkurv",
|
||||
"doneBucketHint": "Alle oppgaver som flyttes til denne kurven vil automatisk bli markert som ferdige.",
|
||||
"doneBucketHintExtended": "Alle oppgaver som er flyttet til ferdigkurven, vil automatisk bli markert som ferdige. Alle oppgaver markert som ferdige fra andre steder vil også bli flyttet.",
|
||||
|
|
|
|||
|
|
@ -300,8 +300,7 @@
|
|||
"default": "Domyślnie",
|
||||
"month": "Miesiąc",
|
||||
"day": "Dzień",
|
||||
"hour": "Godzina",
|
||||
"range": "Zakres dat"
|
||||
"hour": "Godzina"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tabela",
|
||||
|
|
@ -310,7 +309,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limit: {limit}",
|
||||
"noLimit": "Nie ustawiony",
|
||||
"doneBucket": "Zakończone zadania",
|
||||
"doneBucketHint": "Wszystkie zadania przeniesione do tej kolumny zostaną automatycznie oznaczone jako zakończone.",
|
||||
"doneBucketHintExtended": "Wszystkie zadania przeniesione do kolumny zakończonych zostaną automatycznie oznaczone jako zakończone. Wszystkie zadania oznaczone jako zakończone z innych miejsc zostaną przeniesione.",
|
||||
|
|
|
|||
|
|
@ -286,8 +286,7 @@
|
|||
"default": "Padrão",
|
||||
"month": "Mês",
|
||||
"day": "Dia",
|
||||
"hour": "Hora",
|
||||
"range": "Período"
|
||||
"hour": "Hora"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tabela",
|
||||
|
|
@ -296,7 +295,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limite: {limit}",
|
||||
"noLimit": "Não definido",
|
||||
"doneBucket": "Bucket concluído",
|
||||
"doneBucketHint": "Todas as tarefas movidas para este bucket serão marcadas automaticamente como concluídas.",
|
||||
"doneBucketHintExtended": "Todas as tarefas movidas para o bucket concluído serão automaticamente concluídas também. Todas as tarefas concluídas de outro lugar serão movidas também.",
|
||||
|
|
|
|||
|
|
@ -362,7 +362,6 @@
|
|||
"month": "Mês",
|
||||
"day": "Dia",
|
||||
"hour": "Hora",
|
||||
"range": "Intervalo de Datas",
|
||||
"chartLabel": "Gráfico de Gantt do projeto",
|
||||
"taskBarsForRow": "Barras de tarefas para a linha {rowId}",
|
||||
"taskBarLabel": "Tarefa: {task}. De {startDate} a {endDate}. {dateType}. Clica para editar, arrasta para mover.",
|
||||
|
|
@ -386,7 +385,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limite: {limit}",
|
||||
"noLimit": "Não Definido",
|
||||
"doneBucket": "Conjunto concluído",
|
||||
"doneBucketHint": "Todas as tarefas movidas para este conjunto serão automaticamente marcadas como concluídas.",
|
||||
"doneBucketHintExtended": "Todas as tarefas movidas para o conjunto concluído serão marcadas automaticamente como concluídas. Todas as tarefas marcadas como concluídas em outro lugar também serão movidas.",
|
||||
|
|
|
|||
|
|
@ -407,7 +407,6 @@
|
|||
"month": "Месяц",
|
||||
"day": "День",
|
||||
"hour": "Час",
|
||||
"range": "Диапазон",
|
||||
"chartLabel": "Диаграмма Ганта",
|
||||
"taskBarsForRow": "Задачи в строке {rowId}",
|
||||
"taskBarLabel": "Задача: {task}. С {startDate} по {endDate}. {dateType}. Нажмите для изменения, потяните для перемещения.",
|
||||
|
|
@ -435,7 +434,6 @@
|
|||
"kanban": {
|
||||
"title": "Канбан",
|
||||
"limit": "Лимит: {limit}",
|
||||
"noLimit": "не установлен",
|
||||
"doneBucket": "Колонка завершённых",
|
||||
"doneBucketHint": "Все задачи, помещённые в эту колонку, автоматически отмечаются как завершённые.",
|
||||
"doneBucketHintExtended": "Все задачи, перенесённые в колонку завершённых, будут помечены как завершённые. Все задачи, помеченные как завершённые, также будут перемещены в эту колонку.",
|
||||
|
|
|
|||
|
|
@ -314,8 +314,7 @@
|
|||
"default": "Privzeto",
|
||||
"month": "Mesec",
|
||||
"day": "Dan",
|
||||
"hour": "Ura",
|
||||
"range": "Datumski obseg"
|
||||
"hour": "Ura"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tabela",
|
||||
|
|
@ -324,7 +323,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Omejitev: {limit}",
|
||||
"noLimit": "Ni nastavljeno",
|
||||
"doneBucket": "Vedro končanih nalog",
|
||||
"doneBucketHint": "Vse naloge, premaknjene v to vedro, bodo samodejno označene kot opravljene.",
|
||||
"doneBucketHintExtended": "Vse naloge, premaknjene v vedro končanih nalog, bodo samodejno označene kot opravljene. Premaknjene bodo tudi vse naloge, ki so od drugje označena kot opravljene.",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue