Compare commits
8 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
b0bd8ab888 | |
|
|
ecd4d786f7 | |
|
|
8fbc6b62a2 | |
|
|
e423167ce1 | |
|
|
dbf352cc96 | |
|
|
a0116749d1 | |
|
|
3ec2d89543 | |
|
|
49934adaaf |
|
|
@ -1,186 +0,0 @@
|
|||
---
|
||||
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,3 @@
|
|||
source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0="
|
||||
|
||||
use devenv
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
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.0.0
|
||||
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.0.0
|
||||
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@c5fe29eb0efdf1cf4186b9f7fcbbcbc0cf025662 # v5.1.0
|
||||
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@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # 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@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # 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.0.0
|
||||
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.0.0
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_ZIPS_NAME }}
|
||||
path: ./${{ env.DIST_PREFIX }}/zip/*
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
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.0.0
|
||||
with:
|
||||
name: ${{ env.BINARIES_ARTIFACT_NAME }}
|
||||
path: ${{ env.BINARIES_DOWNLOAD_PATH }}
|
||||
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
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@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # 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@08460c16ce3baaa48eaf94d51eea0e653b15d955 # 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@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # 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.0.0
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
path: ${{ env.PACKAGE_OUTPUT_DIR }}/*
|
||||
|
|
@ -16,11 +16,11 @@ runs:
|
|||
echo "CYPRESS_INSTALL_BINARY=0" >> $GITHUB_ENV
|
||||
echo "PUPPETEER_SKIP_DOWNLOAD=true" >> $GITHUB_ENV
|
||||
echo "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1" >> $GITHUB_ENV
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||
with:
|
||||
run_install: false
|
||||
package_json_file: frontend/package.json
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
cache: 'pnpm'
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout (for prompt template)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/workflows/auto-label.prompt.md
|
||||
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
|
||||
- name: Render system prompt from live labels
|
||||
id: render
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
PROMPT_TEMPLATE_PATH: .github/workflows/auto-label.prompt.md
|
||||
with:
|
||||
|
|
@ -122,7 +122,7 @@ jobs:
|
|||
|
||||
- name: Classify with AI
|
||||
id: classify
|
||||
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
|
||||
with:
|
||||
model: openai/gpt-4.1-mini
|
||||
# GPT-5 is a reasoning model: output tokens include reasoning, so budget generously.
|
||||
|
|
@ -132,7 +132,7 @@ jobs:
|
|||
prompt-file: ${{ steps.prep.outputs.prompt_path }}
|
||||
|
||||
- name: Apply labels
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
AI_RESPONSE: ${{ steps.classify.outputs.response }}
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -9,19 +9,19 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
persist-credentials: true
|
||||
- name: push source files
|
||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2
|
||||
with:
|
||||
command: 'push'
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
- name: pull translations
|
||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2
|
||||
with:
|
||||
command: 'download'
|
||||
command_args: '--export-only-approved --skip-untranslated-strings'
|
||||
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- name: Ensure file permissions
|
||||
|
|
@ -55,7 +55,7 @@ jobs:
|
|||
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@881a6320fdb16eb5318c5054f31c218aec2b324c # master
|
||||
uses: ad-m/github-push-action@master
|
||||
with:
|
||||
ssh: true
|
||||
branch: ${{ github.ref }}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ jobs:
|
|||
directory: [frontend, desktop]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Create Diff
|
||||
uses: e18e/action-dependency-diff@8e9b8c1957ab066d36235a43f4c1ff1522e1bdbc # v1.6.1
|
||||
uses: e18e/action-dependency-diff@v1
|
||||
with:
|
||||
working-directory: ${{ matrix.directory }}
|
||||
|
||||
|
|
@ -33,11 +33,11 @@ jobs:
|
|||
directory: [frontend, desktop]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check provenance downgrades
|
||||
uses: danielroe/provenance-action@81568f71211c1839d6d3583c6a93037f5348c816 # main
|
||||
uses: danielroe/provenance-action@main
|
||||
with:
|
||||
workspace-path: ${{ matrix.directory }}
|
||||
fail-on-provenance-change: true
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@ jobs:
|
|||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
with:
|
||||
app-id: ${{ secrets.BOT_APP_ID }}
|
||||
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Find closing PR or commit
|
||||
id: find-closer
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
|
|
@ -82,7 +82,7 @@ jobs:
|
|||
|
||||
- name: Comment on issue
|
||||
if: steps.find-closer.outputs.closed_by_code == 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
docker-images: false
|
||||
swap-storage: false
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
# For pull_request_target, we need to explicitly fetch the PR ref from forks
|
||||
# since the PR's commit SHA is not reachable in the base repository.
|
||||
|
|
@ -34,27 +34,27 @@ jobs:
|
|||
ref: refs/pull/${{ github.event.pull_request.number }}/head
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
uses: proudust/gh-describe@v2
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
with:
|
||||
version: latest
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=sha,format=long
|
||||
- name: Build and push PR image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
|
|
@ -66,7 +66,7 @@ jobs:
|
|||
build-args: |
|
||||
RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
|
||||
- name: Comment on PR
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
DOCKER_META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -4,53 +4,19 @@ on:
|
|||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build-mage:
|
||||
runs-on: ubuntu-latest
|
||||
name: prepare-build-mage
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Cache build mage
|
||||
id: cache-build-mage
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
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.0.0
|
||||
with:
|
||||
name: build_mage_bin
|
||||
path: ./build/build-mage-static
|
||||
|
||||
docker:
|
||||
runs-on: namespace-profile-default
|
||||
steps:
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
uses: proudust/gh-describe@v2
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
|
@ -58,7 +24,7 @@ jobs:
|
|||
- name: Docker meta version
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
with:
|
||||
images: |
|
||||
vikunja/vikunja
|
||||
|
|
@ -70,7 +36,7 @@ jobs:
|
|||
type=raw,value=latest
|
||||
- name: Build and push unstable
|
||||
if: ${{ github.ref_type != 'tag' }}
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||
push: true
|
||||
|
|
@ -81,7 +47,7 @@ jobs:
|
|||
RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
|
||||
- name: Build and push version
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||
push: true
|
||||
|
|
@ -93,40 +59,87 @@ jobs:
|
|||
binaries:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- uses: ./.github/actions/release-binaries
|
||||
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
|
||||
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 }}
|
||||
|
||||
veans-binaries:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- uses: ./.github/actions/release-binaries
|
||||
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
|
||||
with:
|
||||
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 }}
|
||||
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/*
|
||||
|
||||
os-package:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -134,7 +147,11 @@ jobs:
|
|||
- binaries
|
||||
strategy:
|
||||
matrix:
|
||||
package: [rpm, deb, apk, archlinux]
|
||||
package:
|
||||
- rpm
|
||||
- deb
|
||||
- apk
|
||||
- archlinux
|
||||
arch:
|
||||
- go_name: linux-amd64
|
||||
nfpm: amd64
|
||||
|
|
@ -147,71 +164,77 @@ jobs:
|
|||
pkg: armv7
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- 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@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- uses: ./.github/actions/release-os-package
|
||||
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
|
||||
with:
|
||||
project: vikunja
|
||||
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 }}
|
||||
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:
|
||||
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 }}
|
||||
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- uses: ./.github/actions/release-os-package
|
||||
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
|
||||
with:
|
||||
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 }}
|
||||
name: vikunja_os_package_${{ matrix.package }}_${{ matrix.arch.pkg }}
|
||||
path: ./dist/os-packages/*
|
||||
|
||||
publish-repos:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-mage
|
||||
- os-package
|
||||
- veans-os-package
|
||||
- desktop
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
|
@ -235,36 +258,22 @@ jobs:
|
|||
REPO_SUITE: ${{ github.ref_type == 'tag' && 'stable' || 'unstable' }}
|
||||
RELEASE_VERSION: unstable
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- 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.0.0
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: build_mage_bin
|
||||
path: build
|
||||
name: mage_bin
|
||||
|
||||
- name: Download all server OS packages
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
pattern: vikunja_os_package_*
|
||||
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.0.0
|
||||
with:
|
||||
pattern: veans_os_package_*
|
||||
merge-multiple: true
|
||||
path: dist/repo-work/incoming
|
||||
|
||||
- name: Download desktop packages (Linux)
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: vikunja_desktop_packages_ubuntu-latest
|
||||
path: dist/repo-work/incoming-desktop
|
||||
|
|
@ -309,7 +318,7 @@ jobs:
|
|||
|
||||
- name: GPG setup
|
||||
if: matrix.format != 'apk'
|
||||
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main
|
||||
uses: kolaente/action-gpg@main
|
||||
with:
|
||||
gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}"
|
||||
gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}"
|
||||
|
|
@ -329,13 +338,12 @@ 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 ./build-mage-static
|
||||
./build-mage-static ${{ matrix.mage_target }}
|
||||
chmod +x ./mage-static
|
||||
./mage-static ${{ matrix.mage_target }}
|
||||
|
||||
- name: Generate APK repo metadata
|
||||
if: matrix.format == 'apk'
|
||||
|
|
@ -384,7 +392,7 @@ jobs:
|
|||
find dist/repo-output -type d -empty -delete 2>/dev/null || true
|
||||
|
||||
- name: Upload to R2
|
||||
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||
uses: kolaente/s3-action@main
|
||||
with:
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
|
|
@ -398,12 +406,12 @@ jobs:
|
|||
config-yaml:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
uses: proudust/gh-describe@v2
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: generate
|
||||
|
|
@ -411,7 +419,7 @@ jobs:
|
|||
chmod +x ./mage-static
|
||||
./mage-static generate:config-yaml 1
|
||||
- name: Upload to S3
|
||||
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||
uses: kolaente/s3-action@main
|
||||
with:
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
|
|
@ -431,16 +439,16 @@ jobs:
|
|||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
uses: proudust/gh-describe@v2
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||
with:
|
||||
package_json_file: desktop/package.json
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
cache: pnpm
|
||||
|
|
@ -451,7 +459,7 @@ jobs:
|
|||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y libopenjp2-tools rpm libarchive-tools
|
||||
- name: get frontend
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: frontend_dist
|
||||
path: frontend/dist
|
||||
|
|
@ -461,7 +469,7 @@ jobs:
|
|||
pnpm install --frozen-lockfile --prefer-offline --fetch-timeout 100000
|
||||
node build.js "${{ steps.ghd.outputs.describe }}" ${{ github.ref_type == 'tag' }}
|
||||
- name: Upload to S3
|
||||
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||
uses: kolaente/s3-action@main
|
||||
with:
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
|
|
@ -473,7 +481,7 @@ jobs:
|
|||
strip-path-prefix: desktop/dist/
|
||||
exclude: "desktop/dist/*.blockmap"
|
||||
- name: Store Desktop Package
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: vikunja_desktop_packages_${{ matrix.os }}
|
||||
path: |
|
||||
|
|
@ -486,16 +494,16 @@ jobs:
|
|||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
persist-credentials: true
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: generate
|
||||
|
|
@ -520,7 +528,7 @@ jobs:
|
|||
git commit -am "[skip ci] Updated swagger docs"
|
||||
- name: Push changes
|
||||
if: steps.check_changes.outputs.changes_exist != '0'
|
||||
uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # master
|
||||
uses: ad-m/github-push-action@master
|
||||
with:
|
||||
ssh: true
|
||||
branch: ${{ github.ref }}
|
||||
|
|
@ -530,8 +538,6 @@ jobs:
|
|||
needs:
|
||||
- binaries
|
||||
- os-package
|
||||
- veans-binaries
|
||||
- veans-os-package
|
||||
- desktop
|
||||
- publish-repos
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
|
|
@ -539,44 +545,33 @@ jobs:
|
|||
contents: write
|
||||
steps:
|
||||
- name: Download Binaries
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: vikunja_bin_packages
|
||||
|
||||
- name: Download OS Packages
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
pattern: vikunja_os_package_*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Veans Binaries
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: veans_bin_packages
|
||||
|
||||
- name: Download Veans OS Packages
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
pattern: veans_os_package_*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Desktop Package Linux
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: vikunja_desktop_packages_ubuntu-latest
|
||||
|
||||
- name: Download Desktop Package MacOS
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: vikunja_desktop_packages_macos-latest
|
||||
|
||||
- name: Download Desktop Package Windows
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: vikunja_desktop_packages_windows-latest
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
if: github.ref_type == 'tag'
|
||||
with:
|
||||
draft: true
|
||||
|
|
@ -586,9 +581,4 @@ jobs:
|
|||
vikunja*.deb
|
||||
vikunja*.apk
|
||||
vikunja*.archlinux
|
||||
veans*.zip
|
||||
veans*.rpm
|
||||
veans*.deb
|
||||
veans*.apk
|
||||
veans*.archlinux
|
||||
Vikunja Desktop*
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
only-labels: 'waiting for reply'
|
||||
days-before-issue-stale: 30
|
||||
|
|
@ -24,7 +24,6 @@ jobs:
|
|||
questions. If you're still seeing this on a recent version, just
|
||||
drop a comment with the requested info and we'll reopen. Thanks
|
||||
for the report!
|
||||
stale-pr-label: 'waiting for reply'
|
||||
days-before-pr-stale: 30
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 100
|
||||
|
|
|
|||
|
|
@ -8,26 +8,26 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
name: prepare-mage
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Cache Mage
|
||||
id: cache-mage
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
|
||||
with:
|
||||
key: ${{ runner.os }}-build-mage-${{ hashFiles('magefile.go') }}
|
||||
path: |
|
||||
./mage-static
|
||||
- name: Compile Mage
|
||||
if: ${{ steps.cache-mage.outputs.cache-hit != 'true' }}
|
||||
uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b # v3.1.0
|
||||
uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b # v3
|
||||
with:
|
||||
version: latest
|
||||
args: -compile ./mage-static
|
||||
- name: Store Mage Binary
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: mage_bin
|
||||
path: ./mage-static
|
||||
|
|
@ -36,16 +36,16 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
needs: mage
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
uses: proudust/gh-describe@v2
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Build
|
||||
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
chmod +x ./mage-static
|
||||
./mage-static build
|
||||
- name: Store Vikunja Binary
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: vikunja_bin
|
||||
path: ./vikunja
|
||||
|
|
@ -65,8 +65,8 @@ jobs:
|
|||
api-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: prepare frontend files
|
||||
|
|
@ -74,50 +74,17 @@ jobs:
|
|||
mkdir -p frontend/dist
|
||||
touch frontend/dist/index.html
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
|
||||
with:
|
||||
version: v2.10.1
|
||||
|
||||
veans-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
|
||||
with:
|
||||
version: v2.10.1
|
||||
working-directory: veans
|
||||
|
||||
veans-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
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
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Check
|
||||
|
|
@ -152,7 +119,7 @@ jobs:
|
|||
ports:
|
||||
- 3306:3306
|
||||
migration-smoke-db-postgres:
|
||||
image: postgres:18@sha256:4aabea78cf39b90e834caf3af7d602a18565f6fe2508705c8d01aa63245c2e20
|
||||
image: postgres:18@sha256:5773fe724c49c42a7a9ca70202e11e1dff21fb7235b335a73f39297d200b73a2
|
||||
env:
|
||||
POSTGRES_PASSWORD: vikunjatest
|
||||
POSTGRES_DB: vikunjatest
|
||||
|
|
@ -164,7 +131,7 @@ jobs:
|
|||
wget https://dl.vikunja.io/vikunja/unstable/vikunja-unstable-linux-amd64-full.zip -q -O vikunja-latest.zip
|
||||
unzip vikunja-latest.zip vikunja-unstable-linux-amd64
|
||||
- name: Download Vikunja Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: vikunja_bin
|
||||
- name: run migration
|
||||
|
|
@ -254,13 +221,13 @@ jobs:
|
|||
ports:
|
||||
- 389:389
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Configure Postgres for faster tests
|
||||
|
|
@ -300,13 +267,13 @@ jobs:
|
|||
needs:
|
||||
- mage
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: test
|
||||
|
|
@ -321,13 +288,13 @@ jobs:
|
|||
needs:
|
||||
- mage
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: test
|
||||
|
|
@ -351,13 +318,13 @@ jobs:
|
|||
ports:
|
||||
- 9000:9000
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: test S3 file storage integration
|
||||
|
|
@ -382,7 +349,7 @@ jobs:
|
|||
frontend-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Lint
|
||||
working-directory: frontend
|
||||
|
|
@ -391,7 +358,7 @@ jobs:
|
|||
frontend-stylelint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Lint styles
|
||||
working-directory: frontend
|
||||
|
|
@ -400,7 +367,7 @@ jobs:
|
|||
frontend-typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Typecheck
|
||||
continue-on-error: true
|
||||
|
|
@ -410,7 +377,7 @@ jobs:
|
|||
test-frontend-unit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Run unit tests
|
||||
working-directory: frontend
|
||||
|
|
@ -419,11 +386,11 @@ jobs:
|
|||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
uses: proudust/gh-describe@v2
|
||||
- name: Inject frontend version
|
||||
working-directory: frontend
|
||||
run: |
|
||||
|
|
@ -432,81 +399,11 @@ jobs:
|
|||
working-directory: frontend
|
||||
run: pnpm build
|
||||
- name: Store Frontend
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: frontend_dist
|
||||
path: ./frontend/dist
|
||||
|
||||
test-veans-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- api-build
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download Vikunja Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
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.0.0
|
||||
with:
|
||||
name: veans-e2e-vikunja-log
|
||||
path: /tmp/vikunja.log
|
||||
retention-days: 7
|
||||
|
||||
test-frontend-e2e-playwright:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
|
|
@ -523,19 +420,19 @@ jobs:
|
|||
ports:
|
||||
- 5556:5556
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-jammy@sha256:7b86926fff94374389e8e1f4fdc5c76d050d4a06a7886bb537bf412b20e2b71e
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy@sha256:4698a73749c5848d3f5fcd42a2174d172fcad2b2283e087843b115424303a565
|
||||
options: --user 1001
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Vikunja Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: vikunja_bin
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
install-e2e-binaries: false # Playwright browsers already in container
|
||||
- name: Download Frontend
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: frontend_dist
|
||||
path: ./frontend/dist
|
||||
|
|
@ -570,14 +467,14 @@ jobs:
|
|||
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTID: vikunja
|
||||
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTSECRET: secret
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report-${{ matrix.shard }}
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 30
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-test-results-${{ matrix.shard }}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ docs/resources/
|
|||
pkg/static/templates_vfsdata.go
|
||||
files/
|
||||
!pkg/files/
|
||||
!pkg/web/files/
|
||||
vikunja-dump*
|
||||
vendor/
|
||||
os-packages/
|
||||
|
|
|
|||
|
|
@ -145,13 +145,6 @@ 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,24 +11,12 @@ 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
|
||||
|
||||
|
|
@ -184,10 +172,11 @@ 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 (invoke the `crudable` skill)
|
||||
2. Add database migration if needed: `mage dev:make-migration <StructName>` (invoke the `migration` skill)
|
||||
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>`
|
||||
3. Create/update services in `pkg/services/` for complex business logic
|
||||
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)
|
||||
4. Add API routes in `pkg/routes/api/v1/` following existing patterns
|
||||
5. Update Swagger annotations
|
||||
|
||||
**Frontend Changes:**
|
||||
1. Create TypeScript interfaces in `src/modelTypes/` matching backend models
|
||||
|
|
@ -203,11 +192,10 @@ Modern Vue 3 composition API application with TypeScript:
|
|||
4. Update TypeScript interfaces in frontend `src/modelTypes/`
|
||||
|
||||
### API Development
|
||||
- **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.
|
||||
- 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
|
||||
|
||||
### Testing
|
||||
- Backend: Feature tests alongside source files, web tests in `pkg/webtests/`
|
||||
|
|
@ -262,8 +250,6 @@ 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:**
|
||||
|
|
@ -275,13 +261,12 @@ 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, snake_case in JSON
|
||||
- API endpoints: kebab-case in URLs, camelCase in JSON
|
||||
|
||||
**Permissions and Permissions:**
|
||||
- Always implement Permissions interface for new models
|
||||
|
|
|
|||
10
Dockerfile
10
Dockerfile
|
|
@ -1,5 +1,5 @@
|
|||
# syntax=docker/dockerfile:1@sha256:87999aa3d42bdc6bea60565083ee17e86d1f3339802f543c0d03998580f9cb89
|
||||
FROM --platform=$BUILDPLATFORM node:24.18.0-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS frontendbuilder
|
||||
# syntax=docker/dockerfile:1@sha256:b6afd42430b15f2d2a4c5a02b919e98a525b785b1aaff16747d2f623364e39b6
|
||||
FROM --platform=$BUILDPLATFORM node:24.13.0-alpine@sha256:931d7d57f8c1fd0e2179dbff7cc7da4c9dd100998bc2b32afc85142d8efbc213 AS frontendbuilder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ COPY frontend/ ./
|
|||
ARG RELEASE_VERSION=dev
|
||||
RUN echo "{\"VERSION\": \"${RELEASE_VERSION/-g/-}\"}" > src/version.json && pnpm run build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/techknowlogick/xgo:go-1.26.x@sha256:57c62857168cee9213045d65044e990d8b181ed6df30ba7097d2dcddd42b9908 AS apibuilder
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/techknowlogick/xgo:go-1.25.x@sha256:11ac5e6cb8767caea0c62c420e053cb69554638ec255f9bbef8ed411e70c9eec AS apibuilder
|
||||
|
||||
RUN go install github.com/magefile/mage@latest && \
|
||||
mv /go/bin/mage /usr/local/go/bin
|
||||
|
|
@ -28,7 +28,7 @@ ENV RELEASE_VERSION=$RELEASE_VERSION
|
|||
|
||||
RUN export PATH=$PATH:$GOPATH/bin && \
|
||||
mage build:clean && \
|
||||
(cd build && mage release:xgo vikunja "${TARGETOS}/${TARGETARCH}/${TARGETVARIANT}")
|
||||
mage release:xgo "${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 --chmod=1777 /tmp /tmp
|
||||
COPY --from=apibuilder --chown=1000:1000 /tmp /tmp
|
||||
|
||||
USER 1000
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
rc-update add vikunja default
|
||||
|
||||
# Fix the config to contain proper values
|
||||
NEW_SECRET=$(head -c 512 /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||
NEW_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)
|
||||
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=$(head -c 512 /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||
NEW_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
module code.vikunja.io/build
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require github.com/magefile/mage v1.17.2
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
|
||||
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
|
||||
|
|
@ -1,757 +0,0 @@
|
|||
// 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,
|
||||
}
|
||||
|
|
@ -849,11 +849,6 @@
|
|||
"default_value": "(&(objectclass=*)(|(objectclass=group)(objectclass=groupOfNames)))",
|
||||
"comment": "The filter to search for group objects in the ldap directory. Only used when `groupsyncenabled` is set to `true`."
|
||||
},
|
||||
{
|
||||
"key": "groupsyncuseserviceaccount",
|
||||
"default_value": "false",
|
||||
"comment": "If true, Vikunja re-binds as the service account (binddn/bindpassword) before searching for groups during group sync. Enable this when the authenticating user does not have sufficient rights to enumerate group membership in the directory."
|
||||
},
|
||||
{
|
||||
"key": "avatarsyncattribute",
|
||||
"default_value": "",
|
||||
|
|
@ -1002,37 +997,6 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
|
|
|
|||
BIN
desktop/icon.png
BIN
desktop/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB |
|
|
@ -100,15 +100,10 @@ app.on('second-instance', (_event, argv) => {
|
|||
return
|
||||
}
|
||||
|
||||
// Reveal the main window. It may be hidden in the tray (not just minimized),
|
||||
// so show() is required — focus() alone won't surface a hidden window, which
|
||||
// made the app look dead when relaunched while running in the tray.
|
||||
// Focus the main window
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
} else if (serverPort) {
|
||||
createMainWindow()
|
||||
}
|
||||
|
||||
// Find the deep link URL in argv
|
||||
|
|
@ -241,11 +236,6 @@ function createMainWindow() {
|
|||
mainWindow = new BrowserWindow({
|
||||
width: 1680,
|
||||
height: 960,
|
||||
// Without an explicit window icon, X11/XWayland compositors (e.g. KDE
|
||||
// Plasma) fall back to a generic placeholder when WM_CLASS doesn't match
|
||||
// an installed .desktop file. icon.png lives at the app root because
|
||||
// build/ is electron-builder's buildResources dir and isn't packaged.
|
||||
icon: path.join(__dirname, 'icon.png'),
|
||||
webPreferences: {
|
||||
...BASE_WEB_PREFERENCES,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
|
|
@ -407,11 +397,7 @@ function toggleQuickEntry() {
|
|||
// ─── System tray ─────────────────────────────────────────────────────
|
||||
function setupTray() {
|
||||
if (!tray) {
|
||||
// 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 iconPath = path.join(__dirname, 'build', 'icon.png')
|
||||
const icon = nativeImage.createFromPath(iconPath).resize({width: 16, height: 16})
|
||||
tray = new Tray(icon)
|
||||
tray.setToolTip('Vikunja')
|
||||
|
|
@ -553,14 +539,3 @@ app.on('window-all-closed', () => {
|
|||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
// Quit on termination signals (DE/systemd shutdown, `kill`). Without an explicit
|
||||
// handler the app ignores SIGTERM because the tray and express server keep the
|
||||
// event loop alive — leaving users to `kill -9`. isQuitting must be set first so
|
||||
// the hide-to-tray close handler doesn't swallow the quit.
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => {
|
||||
isQuitting = true
|
||||
app.quit()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"main": "main.js",
|
||||
"repository": "https://code.vikunja.io/desktop",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"packageManager": "pnpm@10.34.4",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"author": {
|
||||
"email": "maintainers@vikunja.io",
|
||||
"name": "Vikunja Team"
|
||||
|
|
@ -61,9 +61,9 @@
|
|||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "40.10.5",
|
||||
"electron-builder": "26.15.3",
|
||||
"unzipper": "0.12.5"
|
||||
"electron": "40.10.0",
|
||||
"electron-builder": "26.8.1",
|
||||
"unzipper": "0.12.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "5.2.1"
|
||||
|
|
@ -73,16 +73,10 @@
|
|||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
"minimatch": "10.2.5",
|
||||
"tar": "7.5.17",
|
||||
"@tootallnate/once": "3.0.1",
|
||||
"picomatch": "4.0.4",
|
||||
"tmp": "0.2.7",
|
||||
"ip-address": "10.2.0",
|
||||
"form-data": "4.0.6",
|
||||
"js-yaml": "5.2.0",
|
||||
"undici@6": "6.27.0",
|
||||
"undici@7": "7.28.0"
|
||||
"minimatch": "^10.2.3",
|
||||
"tar": "^7.5.11",
|
||||
"@tootallnate/once": "^3.0.1",
|
||||
"picomatch": ">=4.0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
85
devenv.lock
85
devenv.lock
|
|
@ -3,11 +3,10 @@
|
|||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1782492839,
|
||||
"narHash": "sha256-j9wrcB4al5QhMelEghJ0Qs+RQPT+wyCcI4070NEgPLQ=",
|
||||
"lastModified": 1773012232,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "3d39d0817d62069f7b18821c34a617b5141cb278",
|
||||
"rev": "46a4bd0299a26ad948b71d3053174ba7b90522f7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -17,16 +16,71 @@
|
|||
"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"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1782132010,
|
||||
"narHash": "sha256-ZnAVHdVrotp80iIMm5CSR1fdxPlw7Uwmwxb+O/wsgZ8=",
|
||||
"lastModified": 1772749504,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"rev": "12866ae2dddbc0ab8b329915f8072bb9c75bde89",
|
||||
"rev": "08543693199362c1fddb8f52126030d0d374ba2e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -39,11 +93,11 @@
|
|||
"nixpkgs-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1781607440,
|
||||
"narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=",
|
||||
"lastModified": 1769922788,
|
||||
"narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3e41b24abd260e8f71dbe2f5737d24122f972158",
|
||||
"rev": "207d15f1a6603226e1e223dc79ac29c7846da32e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -55,11 +109,10 @@
|
|||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1782467914,
|
||||
"narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
|
||||
"lastModified": 1772773019,
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e73de5be04e0eff4190a1432b946d469c794e7b4",
|
||||
"rev": "aca4d95fce4914b3892661bcb80b8087293536c6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -72,11 +125,15 @@
|
|||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||
"nixpkgs-unstable": "nixpkgs-unstable",
|
||||
"pre-commit-hooks": [
|
||||
"git-hooks"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
24.18.0
|
||||
24.13.0
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
// 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>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
},
|
||||
"homepage": "https://vikunja.io/",
|
||||
"funding": "https://opencollective.com/vikunja",
|
||||
"packageManager": "pnpm@10.34.4",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
|
|
@ -51,114 +51,113 @@
|
|||
"story:preview": "histoire preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "1.7.6",
|
||||
"@fortawesome/fontawesome-svg-core": "7.3.0",
|
||||
"@fortawesome/free-regular-svg-icons": "7.3.0",
|
||||
"@fortawesome/free-solid-svg-icons": "7.3.0",
|
||||
"@fortawesome/vue-fontawesome": "3.3.0",
|
||||
"@intlify/unplugin-vue-i18n": "11.2.4",
|
||||
"@floating-ui/dom": "1.7.4",
|
||||
"@fortawesome/fontawesome-svg-core": "7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "7.1.0",
|
||||
"@fortawesome/vue-fontawesome": "3.1.3",
|
||||
"@intlify/unplugin-vue-i18n": "11.0.3",
|
||||
"@kyvg/vue3-notification": "3.4.2",
|
||||
"@sentry/vue": "10.62.0",
|
||||
"@tiptap/core": "3.27.1",
|
||||
"@tiptap/extension-blockquote": "3.27.1",
|
||||
"@tiptap/extension-code-block-lowlight": "3.27.1",
|
||||
"@tiptap/extension-hard-break": "3.27.1",
|
||||
"@tiptap/extension-image": "3.27.1",
|
||||
"@tiptap/extension-link": "3.27.1",
|
||||
"@tiptap/extension-list": "3.27.1",
|
||||
"@tiptap/extension-mention": "3.27.1",
|
||||
"@tiptap/extension-table": "3.27.1",
|
||||
"@tiptap/extension-typography": "3.27.1",
|
||||
"@tiptap/extension-underline": "3.27.1",
|
||||
"@tiptap/extensions": "3.27.1",
|
||||
"@tiptap/pm": "3.27.1",
|
||||
"@tiptap/starter-kit": "3.27.1",
|
||||
"@tiptap/suggestion": "3.27.1",
|
||||
"@tiptap/vue-3": "3.27.1",
|
||||
"@vueuse/core": "14.3.0",
|
||||
"@vueuse/router": "14.3.0",
|
||||
"axios": "1.18.1",
|
||||
"@sentry/vue": "10.36.0",
|
||||
"@tiptap/core": "3.17.0",
|
||||
"@tiptap/extension-code-block-lowlight": "3.17.0",
|
||||
"@tiptap/extension-hard-break": "3.17.0",
|
||||
"@tiptap/extension-image": "3.17.0",
|
||||
"@tiptap/extension-link": "3.17.0",
|
||||
"@tiptap/extension-list": "3.17.0",
|
||||
"@tiptap/extension-mention": "3.17.0",
|
||||
"@tiptap/extension-table": "3.17.0",
|
||||
"@tiptap/extension-typography": "3.17.0",
|
||||
"@tiptap/extension-underline": "3.17.0",
|
||||
"@tiptap/extensions": "3.17.0",
|
||||
"@tiptap/pm": "3.17.0",
|
||||
"@tiptap/starter-kit": "3.17.0",
|
||||
"@tiptap/suggestion": "3.17.0",
|
||||
"@tiptap/vue-3": "3.17.0",
|
||||
"@vueuse/core": "14.1.0",
|
||||
"@vueuse/router": "14.1.0",
|
||||
"axios": "1.15.2",
|
||||
"blurhash": "2.0.5",
|
||||
"bulma-css-variables": "0.9.33",
|
||||
"change-case": "5.4.4",
|
||||
"dayjs": "1.11.21",
|
||||
"dompurify": "3.4.11",
|
||||
"dayjs": "1.11.19",
|
||||
"dompurify": "3.4.0",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"flatpickr": "4.6.13",
|
||||
"floating-vue": "5.2.2",
|
||||
"is-touch-device": "1.0.1",
|
||||
"klona": "2.0.6",
|
||||
"lowlight": "3.3.0",
|
||||
"marked": "17.0.6",
|
||||
"nanoid": "5.1.16",
|
||||
"marked": "17.0.1",
|
||||
"nanoid": "5.1.6",
|
||||
"pinia": "3.0.4",
|
||||
"register-service-worker": "1.7.2",
|
||||
"sortablejs": "1.15.7",
|
||||
"ufo": "1.6.4",
|
||||
"vue": "3.5.39",
|
||||
"sortablejs": "1.15.6",
|
||||
"ufo": "1.6.3",
|
||||
"vue": "3.5.27",
|
||||
"vue-advanced-cropper": "2.8.9",
|
||||
"vue-flatpickr-component": "11.0.5",
|
||||
"vue-i18n": "11.4.6",
|
||||
"vue-i18n": "11.2.8",
|
||||
"vue-router": "4.6.4",
|
||||
"vuemoji-picker": "0.3.2",
|
||||
"workbox-precaching": "7.4.1",
|
||||
"zhyswan-vuedraggable": "4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "10.5.0",
|
||||
"@faker-js/faker": "10.4.0",
|
||||
"@histoire/plugin-screenshot": "1.0.0-beta.1",
|
||||
"@histoire/plugin-vue": "1.0.0-beta.1",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@sentry/vite-plugin": "3.6.1",
|
||||
"@tailwindcss/vite": "4.3.1",
|
||||
"@tailwindcss/vite": "4.3.0",
|
||||
"@tsconfig/node24": "24.0.4",
|
||||
"@types/codemirror": "5.60.17",
|
||||
"@types/is-touch-device": "1.0.3",
|
||||
"@types/node": "24.13.2",
|
||||
"@types/node": "24.12.4",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.62.0",
|
||||
"@typescript-eslint/parser": "8.62.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.4",
|
||||
"@typescript-eslint/parser": "8.59.4",
|
||||
"@vitejs/plugin-vue": "6.0.7",
|
||||
"@vue/eslint-config-typescript": "14.9.0",
|
||||
"@vue/test-utils": "2.4.11",
|
||||
"@vue/eslint-config-typescript": "14.7.0",
|
||||
"@vue/test-utils": "2.4.10",
|
||||
"@vue/tsconfig": "0.9.1",
|
||||
"@vueuse/shared": "14.3.0",
|
||||
"autoprefixer": "10.5.2",
|
||||
"browserslist": "4.28.4",
|
||||
"caniuse-lite": "1.0.30001799",
|
||||
"autoprefixer": "10.5.0",
|
||||
"browserslist": "4.28.2",
|
||||
"caniuse-lite": "1.0.30001793",
|
||||
"csstype": "3.2.3",
|
||||
"esbuild": "0.28.1",
|
||||
"esbuild": "0.28.0",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-plugin-depend": "1.5.0",
|
||||
"eslint-plugin-vue": "10.9.2",
|
||||
"happy-dom": "20.10.6",
|
||||
"eslint-plugin-vue": "10.9.1",
|
||||
"happy-dom": "20.9.0",
|
||||
"histoire": "1.0.0-beta.1",
|
||||
"otplib": "12.0.1",
|
||||
"postcss": "8.5.15",
|
||||
"postcss": "8.5.14",
|
||||
"postcss-easing-gradients": "3.0.1",
|
||||
"postcss-html": "1.8.1",
|
||||
"postcss-preset-env": "11.3.1",
|
||||
"rollup": "4.62.2",
|
||||
"postcss-preset-env": "11.3.0",
|
||||
"rollup": "4.60.4",
|
||||
"rollup-plugin-visualizer": "6.0.11",
|
||||
"sass-embedded": "1.100.0",
|
||||
"stylelint": "17.13.0",
|
||||
"sass-embedded": "1.99.0",
|
||||
"stylelint": "17.11.1",
|
||||
"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.1",
|
||||
"tailwindcss": "4.3.0",
|
||||
"typescript": "5.9.3",
|
||||
"unplugin-inject-preload": "3.0.0",
|
||||
"vite": "7.3.6",
|
||||
"vite": "7.3.3",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vite-plugin-vue-devtools": "8.1.4",
|
||||
"vite-plugin-vue-devtools": "8.1.2",
|
||||
"vite-svg-loader": "5.1.1",
|
||||
"vitest": "4.1.9",
|
||||
"vue-tsc": "3.3.5",
|
||||
"vitest": "4.1.6",
|
||||
"vue-tsc": "3.3.0",
|
||||
"wait-on": "9.0.10",
|
||||
"workbox-cli": "7.4.1",
|
||||
"ws": "8.21.0"
|
||||
"ws": "8.20.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
|
@ -169,20 +168,11 @@
|
|||
"vue-demi"
|
||||
],
|
||||
"overrides": {
|
||||
"minimatch": "10.2.5",
|
||||
"minimatch": "^10.2.3",
|
||||
"rollup": "$rollup",
|
||||
"basic-ftp": "6.0.1",
|
||||
"serialize-javascript": "7.0.6",
|
||||
"flatted": "3.4.2",
|
||||
"ip-address": "10.2.0",
|
||||
"postcss": "8.5.15",
|
||||
"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": "8.0.1",
|
||||
"js-yaml@4": "5.2.0"
|
||||
"basic-ftp": ">=5.2.2",
|
||||
"serialize-javascript": "^7.0.5",
|
||||
"flatted": "^3.4.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 KiB |
|
|
@ -61,7 +61,6 @@ 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'
|
||||
|
|
@ -108,7 +107,6 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
|
|||
|
||||
setLanguage(authStore.settings.language ?? DEFAULT_LANGUAGE)
|
||||
useColorScheme()
|
||||
useTimeTrackingFavicon()
|
||||
</script>
|
||||
|
||||
<style src="@/styles/tailwind.css" />
|
||||
|
|
|
|||
|
|
@ -36,18 +36,4 @@ 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,17 +114,16 @@ 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 | null,
|
||||
dateTo: Date | string | null,
|
||||
dateFrom: Date | string,
|
||||
dateTo: Date | string,
|
||||
},
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: {
|
||||
dateFrom: Date | string | null,
|
||||
dateTo: Date | string | null
|
||||
dateFrom: Date | string,
|
||||
dateTo: Date | string
|
||||
}]
|
||||
}>()
|
||||
|
||||
|
|
@ -150,8 +149,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)
|
||||
|
|
@ -209,22 +208,14 @@ const customRangeActive = computed<boolean>(() => {
|
|||
})
|
||||
|
||||
const buttonText = computed<string>(() => {
|
||||
if (from.value === '' || to.value === '') {
|
||||
return t('task.show.select')
|
||||
if (from.value !== '' && to.value !== '') {
|
||||
return t('input.datepickerRange.fromto', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
})
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
return t('task.show.select')
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ 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'],
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@
|
|||
<div class="gantt-chart-wrapper">
|
||||
<GanttTimelineHeader
|
||||
:timeline-data="timelineData"
|
||||
:day-width-pixels="dayWidthPixels"
|
||||
:day-width-pixels="DAY_WIDTH_PIXELS"
|
||||
/>
|
||||
|
||||
<GanttVerticalGridLines
|
||||
:timeline-data="timelineData"
|
||||
:total-width="totalWidth"
|
||||
:height="ganttRows.length * 40"
|
||||
:day-width-pixels="dayWidthPixels"
|
||||
:day-width-pixels="DAY_WIDTH_PIXELS"
|
||||
/>
|
||||
|
||||
<GanttChartBody
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
:total-width="totalWidth"
|
||||
:date-from-date="dateFromDate"
|
||||
:date-to-date="dateToDate"
|
||||
:day-width-pixels="dayWidthPixels"
|
||||
:day-width-pixels="DAY_WIDTH_PIXELS"
|
||||
:is-dragging="isDragging"
|
||||
:is-resizing="isResizing"
|
||||
:drag-state="dragState"
|
||||
|
|
@ -89,7 +89,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch, toRefs, nextTick, onMounted, onBeforeUnmount, onUnmounted} from 'vue'
|
||||
import {computed, ref, watch, toRefs, onUnmounted} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync'
|
||||
|
|
@ -126,9 +126,7 @@ const emit = defineEmits<{
|
|||
(e: 'update:task', task: ITaskPartialWithId): void
|
||||
}>()
|
||||
|
||||
const DAY_WIDTH_PIXELS_MIN = 30
|
||||
const dayWidthPixels = ref(0)
|
||||
let resizeObserver: ResizeObserver
|
||||
const DAY_WIDTH_PIXELS = 30
|
||||
|
||||
const {tasks, filters} = toRefs(props)
|
||||
|
||||
|
|
@ -160,7 +158,7 @@ const dateToDate = computed(() => dayjs(filters.value.dateTo).endOf('day').toDat
|
|||
|
||||
const totalWidth = computed(() => {
|
||||
const dateDiff = Math.ceil((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY)
|
||||
return dateDiff * dayWidthPixels.value
|
||||
return dateDiff * DAY_WIDTH_PIXELS
|
||||
})
|
||||
|
||||
const timelineData = computed(() => {
|
||||
|
|
@ -299,55 +297,6 @@ function transformTaskToGanttBar(node: GanttTaskTreeNode): GanttBarModel {
|
|||
}
|
||||
}
|
||||
|
||||
function updateDayWidthPixels() {
|
||||
const node = ganttContainer.value
|
||||
if (!node) return
|
||||
|
||||
const rect = node.getBoundingClientRect()
|
||||
const styles = window.getComputedStyle(node)
|
||||
|
||||
const marginLeft = parseFloat(styles.marginLeft) || 0
|
||||
const marginRight = parseFloat(styles.marginRight) || 0
|
||||
|
||||
// max width without overflow
|
||||
const maxWidth = rect.width - marginLeft - marginRight
|
||||
|
||||
const dayCount = Math.ceil(
|
||||
(dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY,
|
||||
)
|
||||
|
||||
dayWidthPixels.value = Math.max(
|
||||
maxWidth / dayCount,
|
||||
DAY_WIDTH_PIXELS_MIN,
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
updateDayWidthPixels()
|
||||
|
||||
if (ganttContainer.value) {
|
||||
resizeObserver = new ResizeObserver(updateDayWidthPixels)
|
||||
resizeObserver.observe(ganttContainer.value)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateDayWidthPixels)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
window.removeEventListener('resize', updateDayWidthPixels)
|
||||
})
|
||||
|
||||
watch(
|
||||
[dateFromDate, dateToDate],
|
||||
async () => {
|
||||
await nextTick()
|
||||
updateDayWidthPixels()
|
||||
},
|
||||
{flush: 'post'},
|
||||
)
|
||||
|
||||
// Build the task tree when tasks change
|
||||
watch(
|
||||
[tasks, filters],
|
||||
|
|
@ -402,7 +351,7 @@ const ROW_HEIGHT = 40
|
|||
const barPositions = computed(() => {
|
||||
const positions = new Map<number, GanttBarPosition>()
|
||||
const ds = dragState.value
|
||||
const dragPixelOffset = ds ? ds.currentDays * dayWidthPixels.value : 0
|
||||
const dragPixelOffset = ds ? ds.currentDays * DAY_WIDTH_PIXELS : 0
|
||||
|
||||
ganttBars.value.forEach((rowBars, rowIndex) => {
|
||||
for (const bar of rowBars) {
|
||||
|
|
@ -437,7 +386,7 @@ function computeBarX(date: Date): number {
|
|||
(roundToNaturalDayBoundary(date, true).getTime() - dateFromDate.value.getTime()) /
|
||||
MILLISECONDS_A_DAY,
|
||||
)
|
||||
return diff * dayWidthPixels.value
|
||||
return diff * DAY_WIDTH_PIXELS
|
||||
}
|
||||
|
||||
function computeBarWidth(bar: GanttBarModel): number {
|
||||
|
|
@ -445,7 +394,7 @@ function computeBarWidth(bar: GanttBarModel): number {
|
|||
(roundToNaturalDayBoundary(bar.end).getTime() - roundToNaturalDayBoundary(bar.start, true).getTime()) /
|
||||
MILLISECONDS_A_DAY,
|
||||
)
|
||||
return diff * dayWidthPixels.value
|
||||
return diff * DAY_WIDTH_PIXELS
|
||||
}
|
||||
|
||||
// Compute relation arrows
|
||||
|
|
@ -641,7 +590,7 @@ function startDrag(bar: GanttBarModel, event: PointerEvent) {
|
|||
if (!dragState.value || !isDragging.value) return
|
||||
|
||||
const diff = e.clientX - dragState.value.startX
|
||||
const days = Math.round(diff / dayWidthPixels.value)
|
||||
const days = Math.round(diff / DAY_WIDTH_PIXELS)
|
||||
|
||||
if (days !== dragState.value.currentDays) {
|
||||
dragState.value.currentDays = days
|
||||
|
|
@ -703,7 +652,7 @@ function startResize(bar: GanttBarModel, edge: 'start' | 'end', event: PointerEv
|
|||
if (!dragState.value || !isResizing.value) return
|
||||
|
||||
const diff = e.clientX - dragState.value.startX
|
||||
const days = Math.round(diff / dayWidthPixels.value)
|
||||
const days = Math.round(diff / DAY_WIDTH_PIXELS)
|
||||
|
||||
if (edge === 'start') {
|
||||
const newStart = new Date(dragState.value.originalStart)
|
||||
|
|
@ -781,7 +730,7 @@ function focusTaskBar(rowId: string) {
|
|||
setTimeout(() => {
|
||||
const taskBarElement = document.querySelector(`[data-row-id="${rowId}"] [role="slider"]`) as HTMLElement
|
||||
if (taskBarElement) {
|
||||
taskBarElement.focus({preventScroll: true})
|
||||
taskBarElement.focus()
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,15 +54,7 @@
|
|||
</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>
|
||||
|
|
@ -129,17 +121,13 @@
|
|||
|
||||
<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'
|
||||
|
|
@ -163,20 +151,12 @@ 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(PRO_FEATURE.ADMIN_PANEL))
|
||||
const adminPanelEnabled = computed(() => configStore.isProFeatureEnabled('admin_panel'))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
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'
|
||||
|
|
@ -14,10 +13,9 @@ const now = useNow({
|
|||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const configStore = useConfigStore()
|
||||
const { isDark } = useColorScheme()
|
||||
|
||||
const Logo = computed(() => configStore.allowIconChanges
|
||||
const Logo = computed(() => window.ALLOW_ICON_CHANGES
|
||||
&& authStore.settings.frontendSettings.allowIconChanges
|
||||
&& now.value.getMonth() === 5
|
||||
? LogoFullPride
|
||||
|
|
|
|||
|
|
@ -71,14 +71,6 @@
|
|||
{{ $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>
|
||||
|
||||
|
|
@ -141,17 +133,12 @@ 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,10 +5,7 @@
|
|||
:disabled="disabled || undefined"
|
||||
@click.stop="toggleDatePopup"
|
||||
>
|
||||
<i v-if="date === null && emptyLabel !== ''">{{ emptyLabel }}</i>
|
||||
<template v-else>
|
||||
{{ date === null ? chooseDateLabel : formatDisplayDate(date) }}
|
||||
</template>
|
||||
{{ date === null ? chooseDateLabel : formatDisplayDate(date) }}
|
||||
</SimpleButton>
|
||||
|
||||
<CustomTransition name="fade">
|
||||
|
|
@ -19,7 +16,6 @@
|
|||
>
|
||||
<DatepickerInline
|
||||
v-model="date"
|
||||
:show-shortcuts="showShortcuts"
|
||||
@update:modelValue="updateData"
|
||||
/>
|
||||
|
||||
|
|
@ -52,17 +48,12 @@ 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,68 +1,66 @@
|
|||
<template>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div class="flatpickr-container">
|
||||
<flat-pickr
|
||||
|
|
@ -89,12 +87,9 @@ import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
|||
import {useTimeFormat} from '@/composables/useTimeFormat'
|
||||
import {TIME_FORMAT} from '@/constants/timeFormat'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
const props = defineProps<{
|
||||
modelValue: Date | null | string
|
||||
showShortcuts?: boolean
|
||||
}>(), {
|
||||
showShortcuts: true,
|
||||
})
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [Date | null],
|
||||
|
|
|
|||
|
|
@ -1,161 +0,0 @@
|
|||
<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,7 +166,6 @@ 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'
|
||||
|
|
@ -418,9 +417,7 @@ const extensions : Extensions = [
|
|||
StarterKit.configure({
|
||||
codeBlock: false,
|
||||
hardBreak: false,
|
||||
blockquote: false,
|
||||
}),
|
||||
BlockquoteWithCommentId,
|
||||
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight: createLowlight(common),
|
||||
|
|
@ -722,7 +719,7 @@ async function addImage(event: Event) {
|
|||
return
|
||||
}
|
||||
|
||||
const url = await inputPrompt(event.target.getBoundingClientRect(), '', editor.value)
|
||||
const url = await inputPrompt(event.target.getBoundingClientRect())
|
||||
|
||||
if (url) {
|
||||
editor.value?.chain().focus().setImage({src: url}).run()
|
||||
|
|
@ -778,24 +775,6 @@ 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) {
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
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,7 +5,6 @@ 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')
|
||||
|
||||
|
|
@ -79,7 +78,7 @@ export default function emojiSuggestionSetup() {
|
|||
popupElement.style.left = '0'
|
||||
popupElement.style.zIndex = '4700'
|
||||
popupElement.appendChild(component.element!)
|
||||
getPopupContainer(props.editor).appendChild(popupElement)
|
||||
document.body.appendChild(popupElement)
|
||||
|
||||
const rect = props.clientRect()
|
||||
if (!rect) {
|
||||
|
|
@ -109,7 +108,7 @@ export default function emojiSuggestionSetup() {
|
|||
cleanupFloating = null
|
||||
}
|
||||
if (popupElement) {
|
||||
popupElement.remove()
|
||||
document.body.removeChild(popupElement)
|
||||
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, editor ?? undefined)
|
||||
const url = await inputPrompt(pos, previousUrl)
|
||||
|
||||
// empty
|
||||
if (url === '') {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import {library} from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faAlignLeft,
|
||||
faAngleLeft,
|
||||
faAngleRight,
|
||||
faAnglesUp,
|
||||
faArchive,
|
||||
|
|
@ -122,7 +121,6 @@ library.add(faCode)
|
|||
library.add(faQuoteRight)
|
||||
library.add(faListUl)
|
||||
library.add(faAlignLeft)
|
||||
library.add(faAngleLeft)
|
||||
library.add(faAngleRight)
|
||||
library.add(faArchive)
|
||||
library.add(faArrowLeft)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
>
|
||||
<BaseButton
|
||||
:aria-label="$t('misc.closeDialog')"
|
||||
class="close d-print-none"
|
||||
class="close"
|
||||
@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, onMounted} from 'vue'
|
||||
import {ref, useAttrs, watch, onBeforeUnmount} from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
enabled?: boolean,
|
||||
overflow?: boolean,
|
||||
wide?: boolean,
|
||||
variant?: 'default' | 'hint-modal' | 'scrolling' | 'top',
|
||||
variant?: 'default' | 'hint-modal' | 'scrolling',
|
||||
}>(), {
|
||||
enabled: true,
|
||||
overflow: false,
|
||||
|
|
@ -158,37 +158,6 @@ 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)
|
||||
|
|
@ -198,8 +167,6 @@ onBeforeUnmount(() => {
|
|||
if (previouslyFocused.value instanceof HTMLElement) {
|
||||
previouslyFocused.value.focus()
|
||||
}
|
||||
window.removeEventListener('beforeprint', handleBeforePrint)
|
||||
window.removeEventListener('afterprint', handleAfterPrint)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -211,13 +178,7 @@ $modal-width: 1024px;
|
|||
// Reset UA dialog styles
|
||||
padding: 0;
|
||||
border: none;
|
||||
// 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);
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
// Fill viewport
|
||||
position: fixed;
|
||||
|
|
@ -227,12 +188,10 @@ $modal-width: 1024px;
|
|||
max-inline-size: 100%;
|
||||
max-block-size: 100%;
|
||||
|
||||
// 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.
|
||||
// Transitions
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
transition: opacity 150ms ease,
|
||||
display 150ms ease allow-discrete;
|
||||
|
||||
&[open]:not([data-closing]) {
|
||||
opacity: 1;
|
||||
|
|
@ -244,11 +203,16 @@ $modal-width: 1024px;
|
|||
|
||||
&::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
transition: background-color 150ms ease,
|
||||
display 150ms ease allow-discrete;
|
||||
}
|
||||
|
||||
// in quick-add mode the Electron window itself is the overlay — no scrim
|
||||
&:has(.is-quick-add-mode) {
|
||||
background: transparent;
|
||||
&[open]:not([data-closing])::backdrop {
|
||||
background-color: rgba(0, 0, 0, .8);
|
||||
|
||||
@starting-style {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -264,20 +228,13 @@ $modal-width: 1024px;
|
|||
}
|
||||
|
||||
.default .modal-content,
|
||||
.hint-modal .modal-content,
|
||||
.top .modal-content {
|
||||
.hint-modal .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%);
|
||||
|
|
@ -287,9 +244,6 @@ $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 {
|
||||
|
|
@ -302,31 +256,11 @@ $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),
|
||||
.top .modal-content:not(.is-wide) {
|
||||
.hint-modal .modal-content:not(.is-wide) {
|
||||
inline-size: calc(100% - 2rem);
|
||||
max-inline-size: 640px;
|
||||
|
||||
|
|
@ -427,32 +361,6 @@ $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,96 +1,66 @@
|
|||
<template>
|
||||
<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 -->
|
||||
<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()"
|
||||
>
|
||||
<div
|
||||
class="vue-notification-template vue-notification"
|
||||
:class="[
|
||||
item.type,
|
||||
]"
|
||||
@click="close()"
|
||||
v-if="item.title"
|
||||
class="notification-title"
|
||||
>
|
||||
<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 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"
|
||||
>
|
||||
<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>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</template>
|
||||
</Notifications>
|
||||
</Teleport>
|
||||
<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 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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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;
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@
|
|||
<template #default>
|
||||
<Card :has-content="false">
|
||||
<div class="gantt-options">
|
||||
<FormField :label="$t('misc.dateRange')">
|
||||
<FormField :label="$t('project.gantt.range')">
|
||||
<Foo
|
||||
id="range"
|
||||
ref="flatPickerEl"
|
||||
v-model="flatPickerDateRange"
|
||||
:config="flatPickerConfig"
|
||||
class="input"
|
||||
:placeholder="$t('misc.dateRange')"
|
||||
:placeholder="$t('project.gantt.range')"
|
||||
/>
|
||||
</FormField>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@
|
|||
@click.stop="showSetLimitInput = true"
|
||||
>
|
||||
{{
|
||||
$t('project.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('misc.notSet')})
|
||||
$t('project.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('project.kanban.noLimit')})
|
||||
}}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
<Modal
|
||||
:enabled="active"
|
||||
:overflow="isNewTaskCommand"
|
||||
variant="top"
|
||||
@close="closeQuickActions"
|
||||
>
|
||||
<div
|
||||
|
|
@ -705,16 +704,15 @@ 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,7 +25,6 @@
|
|||
rows="1"
|
||||
@keydown="resetEmptyTitleError"
|
||||
@keydown.enter="handleEnter"
|
||||
@keydown.esc="blurTaskInput"
|
||||
/>
|
||||
<QuickAddMagic
|
||||
:highlight-hint-icon="taskAddHovered"
|
||||
|
|
@ -283,10 +282,6 @@ function focusTaskInput() {
|
|||
newTaskInput.value?.focus()
|
||||
}
|
||||
|
||||
function blurTaskInput() {
|
||||
newTaskInput.value?.blur()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focusTaskInput,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@
|
|||
</XButton>
|
||||
|
||||
<!-- Dropzone -->
|
||||
<Teleport :to="dropzoneTeleportTarget">
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="editEnabled"
|
||||
:class="{hidden: !showDropzone}"
|
||||
|
|
@ -185,7 +185,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, shallowReactive, computed, watch, onMounted, onBeforeUnmount} from 'vue'
|
||||
import {ref, shallowReactive, computed, watch} from 'vue'
|
||||
import {useDropZone} from '@vueuse/core'
|
||||
|
||||
import User from '@/components/misc/User.vue'
|
||||
|
|
@ -322,34 +322,6 @@ 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()
|
||||
|
|
@ -506,7 +478,7 @@ defineExpose({
|
|||
inset-inline-start: 0;
|
||||
inset-block-end: 0;
|
||||
inset-inline-end: 0;
|
||||
z-index: 4001; // above app chrome when teleported to body (no modal open)
|
||||
z-index: 4001; // modal z-index is 4000
|
||||
text-align: center;
|
||||
|
||||
&.hidden {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
{{ currentBucketTitle }}
|
||||
<Icon
|
||||
icon="pencil-alt"
|
||||
class="change-indicator d-print-none"
|
||||
class="change-indicator"
|
||||
/>
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@
|
|||
/>
|
||||
<Reactions
|
||||
v-model="c.reactions"
|
||||
class="mbs-2 d-print-none"
|
||||
class="mbs-2"
|
||||
entity-kind="comments"
|
||||
:entity-id="c.id"
|
||||
:disabled="!canWrite"
|
||||
|
|
@ -173,7 +173,6 @@
|
|||
<div class="field">
|
||||
<Editor
|
||||
v-if="editorActive"
|
||||
ref="newCommentEditor"
|
||||
v-model="newCommentText"
|
||||
:class="{
|
||||
'is-loading':
|
||||
|
|
@ -223,7 +222,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, reactive, computed, nextTick, provide, shallowReactive, watch} from 'vue'
|
||||
import {ref, reactive, computed, shallowReactive, watch} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
|
@ -247,7 +246,6 @@ 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,
|
||||
|
|
@ -306,19 +304,15 @@ const actions = computed(() => {
|
|||
if (!props.canWrite) {
|
||||
return {}
|
||||
}
|
||||
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({
|
||||
return Object.fromEntries(comments.value.map((comment) => ([
|
||||
comment.id,
|
||||
comment.author.id === currentUserId.value
|
||||
? [{
|
||||
action: () => toggleDelete(comment.id),
|
||||
title: t('misc.delete'),
|
||||
})
|
||||
}
|
||||
return [comment.id, list]
|
||||
}))
|
||||
}]
|
||||
: [],
|
||||
])))
|
||||
})
|
||||
|
||||
const frontendUrl = computed(() => configStore.frontendUrl)
|
||||
|
|
@ -327,55 +321,6 @@ 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[]>) {
|
||||
|
|
@ -572,10 +517,11 @@ function getCommentUrl(commentId: string) {
|
|||
align-items: flex-start;
|
||||
display: flex;
|
||||
text-align: inherit;
|
||||
padding-block-start: .5rem;
|
||||
|
||||
& + .media {
|
||||
margin-block-start: .5rem;
|
||||
border-block-start: 1px solid rgba(var(--border-rgb), 0.5);
|
||||
margin-block-start: 1rem;
|
||||
padding-block-start: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -583,7 +529,7 @@ function getCommentUrl(commentId: string) {
|
|||
flex-basis: auto;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
margin: 0 .5rem !important;
|
||||
margin: 0 1rem !important;
|
||||
}
|
||||
|
||||
.comment-info {
|
||||
|
|
@ -659,15 +605,4 @@ 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,7 +1,5 @@
|
|||
<template>
|
||||
<div
|
||||
:class="{'d-print-none': isEmpty}"
|
||||
>
|
||||
<div>
|
||||
<h3>
|
||||
<span class="icon is-grey">
|
||||
<Icon icon="align-left" />
|
||||
|
|
@ -50,7 +48,6 @@ 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'
|
||||
|
||||
|
|
@ -85,8 +82,6 @@ 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 d-print-none"
|
||||
class="close"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<Icon icon="times" />
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
<BaseButton
|
||||
v-if="hasClose"
|
||||
:aria-label="$t('task.detail.closeTaskDetail')"
|
||||
class="close d-print-none"
|
||||
class="close"
|
||||
@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-end add-task-relation-button d-print-none"
|
||||
class="is-pulled-right add-task-relation-button d-print-none"
|
||||
:class="{'is-active': showNewRelationForm}"
|
||||
variant="secondary"
|
||||
icon="plus"
|
||||
|
|
|
|||
|
|
@ -326,17 +326,9 @@ const isOverdue = computed(() => (
|
|||
let oldTask
|
||||
|
||||
async function markAsDone(checked: boolean, wasReverted: boolean = false) {
|
||||
oldTask = {...task.value}
|
||||
|
||||
// Fire the request immediately and with the intended done value snapshotted, so a re-render or
|
||||
// teardown during the animation delay can neither drop the save nor make it send a stale state.
|
||||
const updatePromise = taskStore.update({
|
||||
...task.value,
|
||||
done: checked,
|
||||
})
|
||||
|
||||
const finish = async () => {
|
||||
const newTask = await updatePromise
|
||||
const updateFunc = async () => {
|
||||
oldTask = {...task.value}
|
||||
const newTask = await taskStore.update(task.value)
|
||||
task.value = newTask
|
||||
|
||||
updateDueDate()
|
||||
|
|
@ -362,9 +354,9 @@ async function markAsDone(checked: boolean, wasReverted: boolean = false) {
|
|||
}
|
||||
|
||||
if (checked) {
|
||||
setTimeout(finish, 300) // Delay only the follow-up to show the animation when marking a task as done
|
||||
setTimeout(updateFunc, 300) // Delay it to show the animation when marking a task as done
|
||||
} else {
|
||||
await finish() // Don't delay it when un-marking it as it doesn't have an animation the other way around
|
||||
await updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
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)
|
||||
})
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,353 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
<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 { getCurrentInstance, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { createGlobalState, useIntervalFn } from '@vueuse/core'
|
||||
import { onBeforeRouteUpdate } from 'vue-router'
|
||||
|
||||
|
|
@ -18,14 +18,10 @@ export const useGlobalNow = createGlobalState(() => {
|
|||
|
||||
useIntervalFn(update, GLOBAL_NOW_INTERVAL, { immediate: true })
|
||||
|
||||
// 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()
|
||||
})
|
||||
}
|
||||
// ensure the now value is refreshed when the route changes
|
||||
onBeforeRouteUpdate(() => {
|
||||
update()
|
||||
})
|
||||
|
||||
return {
|
||||
now,
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
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,6 +1,4 @@
|
|||
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, {
|
||||
|
|
@ -12,7 +10,6 @@ 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'
|
||||
|
|
@ -62,22 +59,6 @@ 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.
|
||||
|
|
@ -113,9 +94,6 @@ 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 })
|
||||
|
|
@ -141,55 +119,6 @@ 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}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
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))
|
||||
})
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
// 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]
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
/**
|
||||
* 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='
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
|
||||
|
||||
import {refreshToken, removeToken} from './auth'
|
||||
|
||||
// Count how many times the refresh endpoint is actually POSTed. The whole point
|
||||
// of the in-flight dedup is that concurrent refreshToken() calls share a single
|
||||
// underlying POST, independent of the Web Locks API.
|
||||
let postCallCount = 0
|
||||
let resolvePost: ((value: unknown) => void) | null = null
|
||||
|
||||
vi.mock('@/helpers/fetcher', () => ({
|
||||
HTTPFactory: () => ({
|
||||
post: vi.fn(() => {
|
||||
postCallCount++
|
||||
return new Promise((resolve) => {
|
||||
resolvePost = resolve
|
||||
})
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/helpers/desktopAuth', () => ({
|
||||
isDesktopApp: () => false,
|
||||
refreshDesktopToken: vi.fn(),
|
||||
}))
|
||||
|
||||
const FAKE_TOKEN = 'header.payload.signature'
|
||||
|
||||
function settlePost() {
|
||||
resolvePost?.({data: {token: FAKE_TOKEN}})
|
||||
}
|
||||
|
||||
describe('refreshToken in-flight dedup', () => {
|
||||
const originalLocks = navigator.locks
|
||||
|
||||
beforeEach(() => {
|
||||
postCallCount = 0
|
||||
resolvePost = null
|
||||
removeToken()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
value: originalLocks,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('coalesces concurrent calls into a single POST when Web Locks is available', async () => {
|
||||
// Stub a minimal Web Locks API: happy-dom leaves navigator.locks
|
||||
// undefined, so without this the test would silently fall through to
|
||||
// the insecure-HTTP branch and never exercise navigator.locks.request.
|
||||
const requestSpy = vi.fn((_name: string, cb: () => unknown) => cb())
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
value: {request: requestSpy},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const p1 = refreshToken(true)
|
||||
const p2 = refreshToken(true)
|
||||
|
||||
// Both calls share one underlying request.
|
||||
expect(postCallCount).toBe(1)
|
||||
|
||||
settlePost()
|
||||
await Promise.all([p1, p2])
|
||||
|
||||
// The Web Locks branch actually ran...
|
||||
expect(requestSpy).toHaveBeenCalledWith('vikunja-token-refresh', expect.any(Function))
|
||||
// ...and the in-flight dedup still collapsed both calls into one POST.
|
||||
expect(postCallCount).toBe(1)
|
||||
})
|
||||
|
||||
it('coalesces concurrent calls into a single POST on insecure HTTP (no Web Locks)', async () => {
|
||||
// Simulate an insecure HTTP context where navigator.locks is undefined.
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const p1 = refreshToken(true)
|
||||
const p2 = refreshToken(true)
|
||||
const p3 = refreshToken(true)
|
||||
|
||||
expect(postCallCount).toBe(1)
|
||||
|
||||
settlePost()
|
||||
await Promise.all([p1, p2, p3])
|
||||
|
||||
expect(postCallCount).toBe(1)
|
||||
})
|
||||
|
||||
it('allows a fresh refresh after the previous one settled', async () => {
|
||||
const p1 = refreshToken(true)
|
||||
settlePost()
|
||||
await p1
|
||||
expect(postCallCount).toBe(1)
|
||||
|
||||
// The in-flight promise was reset, so a later refresh runs anew.
|
||||
const p2 = refreshToken(true)
|
||||
expect(postCallCount).toBe(2)
|
||||
settlePost()
|
||||
await p2
|
||||
})
|
||||
|
||||
it('does not re-persist the token when logout happens during an in-flight refresh', async () => {
|
||||
const p1 = refreshToken(true)
|
||||
expect(postCallCount).toBe(1)
|
||||
|
||||
// User logs out while the refresh POST is still in flight.
|
||||
removeToken()
|
||||
|
||||
// The in-flight POST resolves afterwards — it must not undo the logout.
|
||||
settlePost()
|
||||
await p1
|
||||
|
||||
expect(localStorage.getItem('token')).toBeNull()
|
||||
})
|
||||
|
||||
it('an older refresh settling does not clobber a newer in-flight one', async () => {
|
||||
// Refresh A starts and stays in flight.
|
||||
const pA = refreshToken(true)
|
||||
expect(postCallCount).toBe(1)
|
||||
const resolveA = resolvePost
|
||||
|
||||
// User logs out, which drops the in-flight reference to A.
|
||||
removeToken()
|
||||
|
||||
// Refresh B starts; it must claim the in-flight slot.
|
||||
const pB = refreshToken(true)
|
||||
expect(postCallCount).toBe(2)
|
||||
const resolveB = resolvePost
|
||||
|
||||
// A settles after B started. Its cleanup must NOT null the in-flight
|
||||
// slot, since that slot now belongs to B. Without the `=== p` guard,
|
||||
// A's .finally would clobber B and let a concurrent caller fire a
|
||||
// second parallel POST.
|
||||
resolveA?.({data: {token: FAKE_TOKEN}})
|
||||
await pA
|
||||
|
||||
// A concurrent caller while B is still in flight must dedup to B —
|
||||
// no third POST.
|
||||
const pB2 = refreshToken(true)
|
||||
expect(postCallCount).toBe(2)
|
||||
|
||||
resolveB?.({data: {token: FAKE_TOKEN}})
|
||||
await Promise.all([pB, pB2])
|
||||
})
|
||||
})
|
||||
|
|
@ -33,53 +33,18 @@ export const removeToken = () => {
|
|||
savedToken = null
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('desktopOAuthRefreshToken')
|
||||
|
||||
// Bump the epoch and drop the in-flight refresh so a refresh that started
|
||||
// before this logout can't re-persist a token after we cleared it.
|
||||
authEpoch++
|
||||
inFlightRefresh = null
|
||||
}
|
||||
|
||||
// Coalesces concurrent same-tab refreshes into one POST. Web Locks (below) is
|
||||
// secure-context-only, so on insecure HTTP there's no cross-tab coordination —
|
||||
// without this guard, refreshes firing close together each spend the single-use
|
||||
// cookie and all but one get a 401.
|
||||
let inFlightRefresh: Promise<void> | null = null
|
||||
|
||||
// Incremented on every removeToken()/logout. A refresh captures the epoch when
|
||||
// it starts and only persists its result if the epoch is unchanged, so a
|
||||
// refresh that resolves after a logout can't undo it.
|
||||
let authEpoch = 0
|
||||
|
||||
/**
|
||||
* Refreshes an auth token while ensuring it is updated everywhere.
|
||||
* The refresh token is sent automatically as an HttpOnly cookie.
|
||||
* The server rotates the cookie on every call.
|
||||
*
|
||||
* Same-tab concurrent calls share one in-flight refresh (always-on dedup); the
|
||||
* Web Locks API inside adds cross-tab coordination only in secure contexts.
|
||||
* Uses the Web Locks API to coordinate across browser tabs. Only one tab
|
||||
* performs the actual refresh; other tabs waiting for the lock detect that
|
||||
* the token in localStorage was already updated and adopt it directly.
|
||||
*/
|
||||
export async function refreshToken(persist: boolean): Promise<void> {
|
||||
if (inFlightRefresh) {
|
||||
return inFlightRefresh
|
||||
}
|
||||
const p = doRefresh(persist)
|
||||
inFlightRefresh = p
|
||||
// Only clear if it still points to this promise — a logout (or a newer
|
||||
// refresh started after it) may have replaced inFlightRefresh meanwhile.
|
||||
p.finally(() => {
|
||||
if (inFlightRefresh === p) {
|
||||
inFlightRefresh = null
|
||||
}
|
||||
})
|
||||
return p
|
||||
}
|
||||
|
||||
async function doRefresh(persist: boolean): Promise<void> {
|
||||
// Snapshot the epoch so we can tell if a logout happened while we awaited.
|
||||
const epochAtStart = authEpoch
|
||||
const loggedOutSinceStart = () => authEpoch !== epochAtStart
|
||||
|
||||
// In desktop mode, refresh via IPC to the Electron main process
|
||||
if (isDesktopApp()) {
|
||||
const storedRefreshToken = localStorage.getItem('desktopOAuthRefreshToken')
|
||||
|
|
@ -88,9 +53,6 @@ async function doRefresh(persist: boolean): Promise<void> {
|
|||
}
|
||||
try {
|
||||
const tokens = await refreshDesktopToken(window.API_URL, storedRefreshToken)
|
||||
if (loggedOutSinceStart()) {
|
||||
return
|
||||
}
|
||||
saveToken(tokens.access_token, persist)
|
||||
localStorage.setItem('desktopOAuthRefreshToken', tokens.refresh_token)
|
||||
} catch (e) {
|
||||
|
|
@ -103,13 +65,7 @@ async function doRefresh(persist: boolean): Promise<void> {
|
|||
// if another tab refreshed while we were queued.
|
||||
const tokenBeforeLock = localStorage.getItem('token')
|
||||
|
||||
const refreshUnderLock = async () => {
|
||||
// A logout may have happened while we waited for the lock — don't
|
||||
// re-adopt or re-fetch a token after the user signed out.
|
||||
if (loggedOutSinceStart()) {
|
||||
return
|
||||
}
|
||||
|
||||
const doRefresh = async () => {
|
||||
// If the token in localStorage changed while waiting for the lock,
|
||||
// another tab already refreshed. Just adopt the new token.
|
||||
const currentToken = localStorage.getItem('token')
|
||||
|
|
@ -122,9 +78,6 @@ async function doRefresh(persist: boolean): Promise<void> {
|
|||
const HTTP = HTTPFactory()
|
||||
try {
|
||||
const response = await HTTP.post('user/token/refresh')
|
||||
if (loggedOutSinceStart()) {
|
||||
return
|
||||
}
|
||||
saveToken(response.data.token, persist)
|
||||
} catch (e) {
|
||||
throw new Error('Error renewing token: ', {cause: e})
|
||||
|
|
@ -132,10 +85,10 @@ async function doRefresh(persist: boolean): Promise<void> {
|
|||
}
|
||||
|
||||
if (navigator.locks) {
|
||||
await navigator.locks.request('vikunja-token-refresh', refreshUnderLock)
|
||||
await navigator.locks.request('vikunja-token-refresh', doRefresh)
|
||||
} else {
|
||||
// Fallback for environments without Web Locks (e.g. insecure HTTP)
|
||||
await refreshUnderLock()
|
||||
await doRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,9 +10,5 @@ export function getProjectTitle(project: IProject) {
|
|||
return i18n.global.t('project.inboxTitle')
|
||||
}
|
||||
|
||||
if (project.title === 'My Open Tasks') {
|
||||
return i18n.global.t('project.myOpenTasksFilterTitle')
|
||||
}
|
||||
|
||||
return project.title
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,17 +2,10 @@ 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 = '', editor?: Editor): Promise<string> {
|
||||
export default function inputPrompt(pos: ClientRect, oldValue: string = ''): 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')
|
||||
|
|
@ -33,7 +26,7 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = '', edit
|
|||
inputElement.value = oldValue
|
||||
wrapperDiv.appendChild(inputElement)
|
||||
popupElement.appendChild(wrapperDiv)
|
||||
container.appendChild(popupElement)
|
||||
document.body.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)
|
||||
|
|
@ -89,41 +82,15 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = '', edit
|
|||
|
||||
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)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
dialog?.removeEventListener('cancel', handleDialogCancel)
|
||||
if (container.contains(popupElement)) {
|
||||
container.removeChild(popupElement)
|
||||
if (document.body.contains(popupElement)) {
|
||||
document.body.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
|
||||
}
|
||||
|
|
@ -138,6 +105,15 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = '', edit
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -24,10 +24,8 @@ export const redirectToProvider = (provider: IProvider) => {
|
|||
window.location.href = `${provider.authUrl}?client_id=${provider.clientId}&redirect_uri=${redirectUrl}&response_type=code&scope=${scope}&state=${state}`
|
||||
}
|
||||
|
||||
export const redirectToProviderOnLogout = (provider: IProvider): boolean => {
|
||||
export const redirectToProviderOnLogout = (provider: IProvider) => {
|
||||
if (provider.logoutUrl.length > 0) {
|
||||
window.location.href = `${provider.logoutUrl}`
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ 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'
|
||||
|
|
@ -50,13 +49,8 @@ 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).from(now.value)
|
||||
? dayjs(date).locale(locale).fromNow()
|
||||
: ''
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
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,7 +30,6 @@ 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',
|
||||
|
|
@ -42,7 +41,6 @@ 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
|
||||
|
|
@ -53,7 +51,7 @@ export const DEFAULT_LANGUAGE: SupportedLocale= 'en'
|
|||
|
||||
export type ISOLanguage = string
|
||||
|
||||
const RTL_LANGUAGES = ['ar-SA', 'he-IL', 'fa-IR'] as const
|
||||
const RTL_LANGUAGES = ['ar-SA', 'he-IL'] as const
|
||||
|
||||
export function isRTLLanguage(locale: SupportedLocale): boolean {
|
||||
return RTL_LANGUAGES.includes(locale as typeof RTL_LANGUAGES[number])
|
||||
|
|
|
|||
|
|
@ -284,7 +284,8 @@
|
|||
"default": "افتراضي",
|
||||
"month": "شهر",
|
||||
"day": "يوم",
|
||||
"hour": "ساعة"
|
||||
"hour": "ساعة",
|
||||
"range": "نطاق التاريخ"
|
||||
},
|
||||
"table": {
|
||||
"title": "جدول",
|
||||
|
|
@ -293,6 +294,7 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "الحد: {limit}",
|
||||
"noLimit": "غير محدد",
|
||||
"doneBucket": "حافظة المهام المكتملة",
|
||||
"doneBucketHint": "سيتم تلقائياً وضع علامة مكتمل على جميع المهام التي تم نقلها إلى هذه الحافظة.",
|
||||
"doneBucketHintExtended": "سيتم وضع علامة مكتمل على جميع المهام التي تم نقلها إلى حافظة المهام المكتملة. كما سيتم نقل جميع المهام المكتملة من أماكن أخرى.",
|
||||
|
|
|
|||
|
|
@ -314,7 +314,8 @@
|
|||
"default": "По подразбиране",
|
||||
"month": "Месец",
|
||||
"day": "Ден",
|
||||
"hour": "Час"
|
||||
"hour": "Час",
|
||||
"range": "Времеви диапазон"
|
||||
},
|
||||
"table": {
|
||||
"title": "Таблица",
|
||||
|
|
@ -323,6 +324,7 @@
|
|||
"kanban": {
|
||||
"title": "Канбан",
|
||||
"limit": "Лимит: {limit}",
|
||||
"noLimit": "Не е зададен",
|
||||
"doneBucket": "Колона за завършени",
|
||||
"doneBucketHint": "Всички задачи, преместени в тази колона, автоматично ще бъдат маркирани като завършени.",
|
||||
"doneBucketHintExtended": "Всички задачи, преместени в колоната за завършени, ще бъдат автоматично маркирани като завършени. Всички задачи, маркирани като завършени от другаде, също ще бъдат преместени тук.",
|
||||
|
|
|
|||
|
|
@ -383,6 +383,7 @@
|
|||
"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.",
|
||||
|
|
@ -411,6 +412,7 @@
|
|||
"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,7 +172,6 @@
|
|||
"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)"
|
||||
|
|
@ -349,7 +348,6 @@
|
|||
"shared": "Geteilte Projekte",
|
||||
"noDescriptionAvailable": "Keine Projektbeschreibung verfügbar.",
|
||||
"inboxTitle": "Eingang",
|
||||
"myOpenTasksFilterTitle": "Meine offenen Aufgaben",
|
||||
"favorite": "Dieses Projekt als Favorit markieren",
|
||||
"unfavorite": "Dieses Projekt von Favoriten entfernen",
|
||||
"openSettingsMenu": "Projekteinstellungen öffnen",
|
||||
|
|
@ -394,7 +392,6 @@
|
|||
"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": {
|
||||
|
|
@ -473,6 +470,7 @@
|
|||
"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.",
|
||||
|
|
@ -501,6 +499,7 @@
|
|||
"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.",
|
||||
|
|
@ -784,10 +783,7 @@
|
|||
"closeDialog": "Dialog schließen",
|
||||
"closeQuickActions": "Schnellaktionen schließen",
|
||||
"skipToContent": "Überspringen und zum Hauptinhalt gehen",
|
||||
"sortBy": "Sortieren nach",
|
||||
"dateRange": "Zeitraum",
|
||||
"notSet": "Nicht festgelegt",
|
||||
"user": "Benutzer:in"
|
||||
"sortBy": "Sortieren nach"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "Projektfarbe",
|
||||
|
|
@ -866,7 +862,6 @@
|
|||
"date": "Datum",
|
||||
"ranges": {
|
||||
"today": "Heute",
|
||||
"tomorrow": "Morgen",
|
||||
"thisWeek": "Diese Woche",
|
||||
"restOfThisWeek": "Der Rest dieser Woche",
|
||||
"nextWeek": "Nächste Woche",
|
||||
|
|
@ -997,7 +992,6 @@
|
|||
"repeatAfter": "Wiederholung setzen",
|
||||
"percentDone": "Fortschritt einstellen",
|
||||
"attachments": "Anhänge hinzufügen",
|
||||
"timeTracking": "Zeit erfassen",
|
||||
"relatedTasks": "Beziehung hinzufügen",
|
||||
"moveProject": "Verschieben",
|
||||
"duplicate": "Duplizieren",
|
||||
|
|
@ -1071,10 +1065,7 @@
|
|||
"addedSuccess": "Der Kommentar wurde erfolgreich hinzugefügt.",
|
||||
"permalink": "Permalink zu diesem Kommentar kopieren",
|
||||
"sortNewestFirst": "Neueste zuerst",
|
||||
"sortOldestFirst": "Älteste zuerst",
|
||||
"reply": "Antworten",
|
||||
"jumpToOriginal": "Zum ursprünglichen Kommentar springen",
|
||||
"deletedComment": "gelöschter Kommentar"
|
||||
"sortOldestFirst": "Älteste zuerst"
|
||||
},
|
||||
"mention": {
|
||||
"noUsersFound": "Keine Nutzer:innen gefunden"
|
||||
|
|
@ -1467,32 +1458,6 @@
|
|||
"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,7 +172,6 @@
|
|||
"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)"
|
||||
|
|
@ -349,7 +348,6 @@
|
|||
"shared": "Geteilte Projekte",
|
||||
"noDescriptionAvailable": "Keine Projektbeschreibung verfügbar.",
|
||||
"inboxTitle": "Eingang",
|
||||
"myOpenTasksFilterTitle": "Meine offenen Aufgaben",
|
||||
"favorite": "Dieses Projekt als Favorit markieren",
|
||||
"unfavorite": "Dieses Projekt von Favoriten entfernen",
|
||||
"openSettingsMenu": "Projekteinstellungen öffnen",
|
||||
|
|
@ -394,7 +392,6 @@
|
|||
"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": {
|
||||
|
|
@ -473,6 +470,7 @@
|
|||
"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.",
|
||||
|
|
@ -501,6 +499,7 @@
|
|||
"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.",
|
||||
|
|
@ -784,10 +783,7 @@
|
|||
"closeDialog": "Dialog schließen",
|
||||
"closeQuickActions": "Schnellaktionen schließen",
|
||||
"skipToContent": "Überspringen und zum Hauptinhalt gehen",
|
||||
"sortBy": "Sortieren nach",
|
||||
"dateRange": "Zeitraum",
|
||||
"notSet": "Nicht festgelegt",
|
||||
"user": "Benutzer:in"
|
||||
"sortBy": "Sortieren nach"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "Projektfarbe",
|
||||
|
|
@ -866,7 +862,6 @@
|
|||
"date": "Datum",
|
||||
"ranges": {
|
||||
"today": "Heute",
|
||||
"tomorrow": "Morgen",
|
||||
"thisWeek": "Diese Woche",
|
||||
"restOfThisWeek": "Der Rest dieser Woche",
|
||||
"nextWeek": "Nächste Woche",
|
||||
|
|
@ -997,7 +992,6 @@
|
|||
"repeatAfter": "Wiederholung setzen",
|
||||
"percentDone": "Fortschritt einstellen",
|
||||
"attachments": "Anhänge hinzufügen",
|
||||
"timeTracking": "Zeit erfassen",
|
||||
"relatedTasks": "Beziehung hinzufügen",
|
||||
"moveProject": "Verschieben",
|
||||
"duplicate": "Duplizieren",
|
||||
|
|
@ -1071,10 +1065,7 @@
|
|||
"addedSuccess": "Din Kommentar isch erfolgriich hinzuegfüegt worde.",
|
||||
"permalink": "Permalink zu diesem Kommentar kopieren",
|
||||
"sortNewestFirst": "Neueste zuerst",
|
||||
"sortOldestFirst": "Älteste zuerst",
|
||||
"reply": "Antworten",
|
||||
"jumpToOriginal": "Zum ursprünglichen Kommentar springen",
|
||||
"deletedComment": "gelöschter Kommentar"
|
||||
"sortOldestFirst": "Älteste zuerst"
|
||||
},
|
||||
"mention": {
|
||||
"noUsersFound": "Keine Nutzer:innen gefunden"
|
||||
|
|
@ -1467,32 +1458,6 @@
|
|||
"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,7 +172,6 @@
|
|||
"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)"
|
||||
|
|
@ -349,7 +348,6 @@
|
|||
"shared": "Shared Projects",
|
||||
"noDescriptionAvailable": "No project description is available.",
|
||||
"inboxTitle": "Inbox",
|
||||
"myOpenTasksFilterTitle": "My Open Tasks",
|
||||
"favorite": "Mark this project as favorite",
|
||||
"unfavorite": "Remove this project from favorites",
|
||||
"openSettingsMenu": "Open project settings menu",
|
||||
|
|
@ -394,7 +392,6 @@
|
|||
"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": {
|
||||
|
|
@ -473,6 +470,7 @@
|
|||
"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.",
|
||||
|
|
@ -501,6 +499,7 @@
|
|||
"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.",
|
||||
|
|
@ -784,10 +783,7 @@
|
|||
"closeDialog": "Close dialog",
|
||||
"closeQuickActions": "Close quick actions",
|
||||
"skipToContent": "Skip to main content",
|
||||
"sortBy": "Sort by",
|
||||
"dateRange": "Date range",
|
||||
"notSet": "Not set",
|
||||
"user": "User"
|
||||
"sortBy": "Sort by"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "Project color",
|
||||
|
|
@ -866,7 +862,6 @@
|
|||
"date": "Date",
|
||||
"ranges": {
|
||||
"today": "Today",
|
||||
"tomorrow": "Tomorrow",
|
||||
"thisWeek": "This Week",
|
||||
"restOfThisWeek": "The Rest of This Week",
|
||||
"nextWeek": "Next Week",
|
||||
|
|
@ -997,7 +992,6 @@
|
|||
"repeatAfter": "Set Repeating Interval",
|
||||
"percentDone": "Set Progress",
|
||||
"attachments": "Add Attachments",
|
||||
"timeTracking": "Track time",
|
||||
"relatedTasks": "Add Relation",
|
||||
"moveProject": "Move",
|
||||
"duplicate": "Duplicate",
|
||||
|
|
@ -1071,10 +1065,7 @@
|
|||
"addedSuccess": "The comment was added successfully.",
|
||||
"permalink": "Copy permalink to this comment",
|
||||
"sortNewestFirst": "Newest first",
|
||||
"sortOldestFirst": "Oldest first",
|
||||
"reply": "Reply",
|
||||
"jumpToOriginal": "Jump to original comment",
|
||||
"deletedComment": "deleted comment"
|
||||
"sortOldestFirst": "Oldest first"
|
||||
},
|
||||
"mention": {
|
||||
"noUsersFound": "No users found"
|
||||
|
|
@ -1467,32 +1458,6 @@
|
|||
"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,7 +251,8 @@
|
|||
"default": "Predeterminado",
|
||||
"month": "Mes",
|
||||
"day": "Día",
|
||||
"hour": "Hora"
|
||||
"hour": "Hora",
|
||||
"range": "Rango de fechas"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tabla",
|
||||
|
|
@ -260,6 +261,7 @@
|
|||
"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,7 +347,8 @@
|
|||
"default": "Oletus",
|
||||
"month": "Kuukausi",
|
||||
"day": "Päivä",
|
||||
"hour": "Tunti"
|
||||
"hour": "Tunti",
|
||||
"range": "Ajanjakso"
|
||||
},
|
||||
"table": {
|
||||
"title": "Taulukko",
|
||||
|
|
@ -356,6 +357,7 @@
|
|||
"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,6 +346,7 @@
|
|||
"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.",
|
||||
|
|
@ -369,6 +370,7 @@
|
|||
"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,7 +318,8 @@
|
|||
"default": "ברירת מחדל",
|
||||
"month": "חודש",
|
||||
"day": "יום",
|
||||
"hour": "שעה"
|
||||
"hour": "שעה",
|
||||
"range": "טווח תאריכים"
|
||||
},
|
||||
"table": {
|
||||
"title": "טבלה",
|
||||
|
|
@ -327,6 +328,7 @@
|
|||
"kanban": {
|
||||
"title": "קאנבאן",
|
||||
"limit": "הגבלה: {limit}",
|
||||
"noLimit": "לא נקבע",
|
||||
"doneBucket": "דלי גמורים",
|
||||
"doneBucketHint": "דלי גמורים נשמר בהצלחה.",
|
||||
"doneBucketHintExtended": "כל המטלות המוכנסות לדלי הגמורים יסומנו אוטומטית כגמורים. כל המטלות המסומנות כגמורים מבחוץ יוזזו גם.",
|
||||
|
|
|
|||
|
|
@ -289,14 +289,16 @@
|
|||
"default": "Zadano",
|
||||
"month": "Mjesec",
|
||||
"day": "Dan",
|
||||
"hour": "Sat"
|
||||
"hour": "Sat",
|
||||
"range": "Raspon datuma"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tablica",
|
||||
"columns": "Stupci"
|
||||
},
|
||||
"kanban": {
|
||||
"title": "Kanban"
|
||||
"title": "Kanban",
|
||||
"noLimit": "Nije postavljeno"
|
||||
},
|
||||
"pseudo": {
|
||||
"favorites": {
|
||||
|
|
|
|||
|
|
@ -290,7 +290,8 @@
|
|||
"default": "Alapértelmezett",
|
||||
"month": "Hónap",
|
||||
"day": "Nap",
|
||||
"hour": "Óra"
|
||||
"hour": "Óra",
|
||||
"range": "Időintervallum"
|
||||
},
|
||||
"table": {
|
||||
"title": "Táblázat",
|
||||
|
|
@ -299,6 +300,7 @@
|
|||
"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.",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue