Compare commits

..

2 Commits

Author SHA1 Message Date
kolaente de5be2a7d3 docs(skills): require v2 tests to be a 1:1 port of v1 (full permission matrix)
v1 routes and their tests will eventually be deleted, so v2 webtests must
independently cover everything v1 covered for the resource — especially the
full permission/sharing matrix — with no representative-subset shortcuts.
2026-06-03 20:35:40 +02:00
kolaente f856bf6318 docs(skills): note v2 query params must be direct fields on the handler input
A shared/embedded query-param helper struct silently fails to bind under Huma
when combined with other query params (found implementing Project's expand);
each query param must be a direct field on the operation's input struct.
2026-06-03 20:05:08 +02:00
457 changed files with 7462 additions and 39727 deletions

View File

@ -72,7 +72,7 @@ Use the package's `Register` wrapper, **not** `huma.Register` directly — it se
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:
- **List** takes `*ListParams` (gives you `page`/`per_page`/`q` for free) 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 {
@ -81,8 +81,8 @@ Every handler: pull auth with `authFromCtx(ctx)`, call the matching `handler.Do*
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).
- **Read** embeds `conditional.Params` in its input, builds an ETag from `id + Updated.UnixNano()`, calls `in.PreconditionFailed(etag, label.Updated)` when `in.HasConditionalParams()`, and returns `*singleReadBody[Model]` with the **quoted** ETag (`"`+etag+`"`).
- **Create / Update** take a `Body Model` input and return `*singleBody[Model]`. Update sets `in.Body.ID = in.ID` (URL wins over body).
- **Delete** returns `*emptyBody`.
### 3. Self-register the resource
@ -170,9 +170,11 @@ Otherwise the same rules apply: register with the `Register` wrapper, pull auth
## 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`:
**The v2 test is a 1:1 port of the v1 test(s) — not a subset.** v1 routes *and their tests* will eventually be deleted, so the v2 webtest must independently prove everything v1 proved for this resource. Find the v1 coverage first — the v1 webtest in `pkg/webtests/<resource>_test.go` and the model tests in `pkg/models/<resource>_test.go` / `<resource>_permissions_test.go` — and port **every scenario**. Especially: the **complete permission/sharing matrix** (owner; team/user/parent-project shares × read/write/admin; member-but-not-admin; non-member; author-vs-writer-non-author), plus `search`/filter, archived-state blocking, validation/too-long, exact result-set cardinality, and all not-found cases. **No representative-subset shortcuts** — a dropped share-kind×level case is a coverage regression that silently disappears the day v1 is removed. (v2 *adds* HTTP-layer assertions v1 lacked — status codes, ETag/304 — but never *drops* a v1 behavior.)
- One `Test<Resource>` covering list/read/create/update/delete, positive + negative (forbidden, nonexistent), mirroring the v1 model test.
Mirror the v1 webtest shape so 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 with the **full** positive+negative permission matrix ported from v1 (not 12 representative cases).
- 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.

3
.envrc Normal file
View File

@ -0,0 +1,3 @@
source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0="
use devenv

View File

@ -79,7 +79,7 @@ runs:
} >> "$GITHUB_ENV"
- name: Download Mage binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
@ -89,7 +89,7 @@ runs:
- name: Download frontend dist (vikunja only)
if: inputs.project == 'vikunja'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: frontend_dist
path: frontend/dist
@ -110,7 +110,7 @@ runs:
sudo mv upx-5.0.0-amd64_linux/upx /usr/local/bin
- name: Setup xgo cache
uses: useblacksmith/cache@c5fe29eb0efdf1cf4186b9f7fcbbcbc0cf025662 # v5.1.0
uses: useblacksmith/cache@71c7c918062ba3861252d84b07fe5ab2a6b467a6 # v5
with:
path: /home/runner/.xgo-cache
key: xgo-${{ inputs.project }}-${{ hashFiles('**/go.sum') }}
@ -133,7 +133,7 @@ runs:
cd build && mage release:build "$PROJECT"
- name: GPG setup
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main
uses: kolaente/action-gpg@main
with:
gpg-passphrase: ${{ inputs.gpg-passphrase }}
gpg-sign-key: ${{ inputs.gpg-sign-key }}
@ -164,7 +164,7 @@ runs:
done
- name: Upload zips to S3
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
uses: kolaente/s3-action@main
with:
s3-access-key-id: ${{ inputs.s3-access-key-id }}
s3-secret-access-key: ${{ inputs.s3-secret-access-key }}
@ -176,14 +176,14 @@ runs:
strip-path-prefix: ${{ env.DIST_PREFIX }}/zip/
- name: Store binaries
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ env.ARTIFACT_BINARIES_NAME }}
path: ./${{ env.DIST_PREFIX }}/binaries/*
- name: Store binary packages
if: github.ref_type == 'tag'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ env.ARTIFACT_ZIPS_NAME }}
path: ./${{ env.DIST_PREFIX }}/zip/*

View File

@ -91,12 +91,12 @@ runs:
echo "S3_TARGET_PATH=/${PROJECT}/${VERSION_OR_UNSTABLE}" >> "$GITHUB_ENV"
- name: Download project binaries
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: ${{ env.BINARIES_ARTIFACT_NAME }}
path: ${{ env.BINARIES_DOWNLOAD_PATH }}
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
@ -123,7 +123,7 @@ runs:
- name: GPG setup for archlinux signing
if: inputs.packager == 'archlinux'
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main
uses: kolaente/action-gpg@main
with:
gpg-passphrase: ${{ inputs.gpg-passphrase }}
gpg-sign-key: ${{ inputs.gpg-sign-key }}
@ -163,7 +163,7 @@ runs:
run: mkdir -p "$PACKAGE_OUTPUT_DIR"
- name: Create package
uses: kolaente/action-gh-nfpm@08460c16ce3baaa48eaf94d51eea0e653b15d955 # master
uses: kolaente/action-gh-nfpm@master
with:
packager: ${{ inputs.packager }}
target: ${{ env.PACKAGE_OUTPUT_DIR }}/${{ env.PACKAGE_FILENAME }}
@ -186,7 +186,7 @@ runs:
"$PACKAGE_OUTPUT_DIR/$PACKAGE_FILENAME"
- name: Upload to S3
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
uses: kolaente/s3-action@main
with:
s3-access-key-id: ${{ inputs.s3-access-key-id }}
s3-secret-access-key: ${{ inputs.s3-secret-access-key }}
@ -198,7 +198,7 @@ runs:
strip-path-prefix: ${{ env.PACKAGE_OUTPUT_DIR }}/
- name: Store OS package
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ env.ARTIFACT_NAME }}
path: ${{ env.PACKAGE_OUTPUT_DIR }}/*

View File

@ -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'

View File

@ -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:

View File

@ -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 }}

View File

@ -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

View File

@ -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: |

View File

@ -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:

View File

@ -8,14 +8,14 @@ jobs:
runs-on: ubuntu-latest
name: prepare-build-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 build mage
id: cache-build-mage
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
with:
key: ${{ runner.os }}-build-mage-build-${{ hashFiles('build/magefile.go') }}
path: |
@ -33,7 +33,7 @@ jobs:
export PATH=$PATH:$GOPATH/bin
mage -compile ./build-mage-static
- name: Store build mage binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: build_mage_bin
path: ./build/build-mage-static
@ -43,14 +43,14 @@ jobs:
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 +58,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 +70,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 +81,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,10 +93,10 @@ 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: proudust/gh-describe@v2
- uses: ./.github/actions/release-binaries
with:
project: vikunja
@ -112,10 +112,10 @@ jobs:
veans-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: proudust/gh-describe@v2
- uses: ./.github/actions/release-binaries
with:
project: veans
@ -147,10 +147,10 @@ jobs:
pkg: armv7
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
- uses: ./.github/actions/release-os-package
with:
project: vikunja
@ -186,10 +186,10 @@ jobs:
pkg: armv7
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
- uses: ./.github/actions/release-os-package
with:
project: veans
@ -235,19 +235,19 @@ 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
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: build_mage_bin
path: build
- 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
@ -257,14 +257,14 @@ jobs:
# 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
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: veans_os_package_*
merge-multiple: true
path: dist/repo-work/incoming
- name: Download desktop packages (Linux)
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_desktop_packages_ubuntu-latest
path: dist/repo-work/incoming-desktop
@ -309,7 +309,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 }}"
@ -384,7 +384,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 +398,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 +411,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 +431,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 +451,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 +461,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 +473,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 +486,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 +520,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 }}
@ -539,44 +539,44 @@ 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
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: veans_bin_packages
- name: Download Veans OS Packages
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: veans_os_package_*
merge-multiple: true
- name: Download Desktop Package Linux
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.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

View File

@ -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

View File

@ -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,19 +74,19 @@ 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
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: golangci-lint
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
with:
version: v2.10.1
working-directory: veans
@ -94,8 +94,8 @@ jobs:
veans-test:
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: Install mage
@ -115,9 +115,9 @@ 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: Check
@ -152,7 +152,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 +164,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 +254,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 +300,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 +321,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 +351,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 +382,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 +391,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 +400,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 +410,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 +419,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,7 +432,7 @@ 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
@ -442,13 +442,13 @@ jobs:
needs:
- api-build
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
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: Install mage
@ -501,7 +501,7 @@ jobs:
(cd veans && mage test:e2e)
- name: Upload API log on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: veans-e2e-vikunja-log
path: /tmp/vikunja.log
@ -523,19 +523,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 +570,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 }}

1
.gitignore vendored
View File

@ -26,7 +26,6 @@ docs/resources/
pkg/static/templates_vfsdata.go
files/
!pkg/files/
!pkg/web/files/
vikunja-dump*
vendor/
os-packages/

View File

@ -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'

View File

@ -262,8 +262,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,7 +273,6 @@ 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:**

1
CRUSH.md Symbolic link
View File

@ -0,0 +1 @@
AGENTS.md

View File

@ -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

View File

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

View File

@ -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": [

View File

@ -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'),
@ -553,14 +543,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()
})
}

View File

@ -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.2",
"electron-builder": "26.8.1",
"unzipper": "0.12.3"
},
"dependencies": {
"express": "5.2.1"
@ -73,16 +73,12 @@
"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",
"tmp": ">=0.2.6",
"ip-address": ">=10.1.1"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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
}
}

View File

@ -1 +1 @@
24.18.0
24.13.0

View File

@ -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,111 +51,111 @@
"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-blockquote": "3.17.0",
"@tiptap/extension-code-block-lowlight": "3.17.0",
"@tiptap/extension-hard-break": "3.17.0",
"@tiptap/extension-image": "3.17.0",
"@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.16.0",
"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.60.1",
"@typescript-eslint/parser": "8.60.1",
"@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",
"happy-dom": "20.9.0",
"histoire": "1.0.0-beta.1",
"otplib": "12.0.1",
"postcss": "8.5.15",
"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.61.0",
"rollup-plugin-visualizer": "6.0.11",
"sass-embedded": "1.100.0",
"stylelint": "17.13.0",
"stylelint": "17.12.0",
"stylelint-config-property-sort-order-smacss": "10.0.0",
"stylelint-config-recommended-vue": "1.6.1",
"stylelint-config-standard-scss": "17.0.0",
"stylelint-use-logical": "2.1.3",
"tailwindcss": "4.3.1",
"tailwindcss": "4.3.0",
"typescript": "5.9.3",
"unplugin-inject-preload": "3.0.0",
"vite": "7.3.6",
"vite": "7.3.5",
"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.8",
"vue-tsc": "3.3.3",
"wait-on": "9.0.10",
"workbox-cli": "7.4.1",
"ws": "8.21.0"
@ -169,20 +169,14 @@
"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",
"ip-address": ">=10.1.1",
"postcss": ">=8.5.10",
"tmp": ">=0.2.6"
}
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -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" />

View File

@ -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('')
})
})

View File

@ -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>

View File

@ -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)
}

View File

@ -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>

View File

@ -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()

View File

@ -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<{

View File

@ -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],

View File

@ -722,7 +722,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()

View File

@ -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()

View File

@ -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 === '') {

View File

@ -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)

View File

@ -68,7 +68,7 @@ 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,
@ -211,13 +211,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 +221,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 +236,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 +261,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 +277,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 +289,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;
@ -436,7 +403,6 @@ $modal-width: 1024px;
block-size: auto;
max-inline-size: none;
max-block-size: none;
background: transparent;
&::backdrop {
display: none;

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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,
})

View File

@ -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 {

View File

@ -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"

View File

@ -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
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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"
> &gt; </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>

View File

@ -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>

View File

@ -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,

View File

@ -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({})
})
})

View File

@ -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}

View File

@ -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))
})

View File

@ -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]

View File

@ -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='

View File

@ -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])
})
})

View File

@ -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()
}
}

View File

@ -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
}

View File

@ -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)

View File

@ -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
}

View File

@ -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()
: ''
}

View File

@ -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)
})
})

View File

@ -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)
}

View File

@ -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',
@ -53,7 +52,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])

View File

@ -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": "سيتم وضع علامة مكتمل على جميع المهام التي تم نقلها إلى حافظة المهام المكتملة. كما سيتم نقل جميع المهام المكتملة من أماكن أخرى.",

View File

@ -314,7 +314,8 @@
"default": "По подразбиране",
"month": "Месец",
"day": "Ден",
"hour": "Час"
"hour": "Час",
"range": "Времеви диапазон"
},
"table": {
"title": "Таблица",
@ -323,6 +324,7 @@
"kanban": {
"title": "Канбан",
"limit": "Лимит: {limit}",
"noLimit": "Не е зададен",
"doneBucket": "Колона за завършени",
"doneBucketHint": "Всички задачи, преместени в тази колона, автоматично ще бъдат маркирани като завършени.",
"doneBucketHintExtended": "Всички задачи, преместени в колоната за завършени, ще бъдат автоматично маркирани като завършени. Всички задачи, маркирани като завършени от другаде, също ще бъдат преместени тук.",

View File

@ -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é.",

View File

@ -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",
@ -997,7 +993,6 @@
"repeatAfter": "Wiederholung setzen",
"percentDone": "Fortschritt einstellen",
"attachments": "Anhänge hinzufügen",
"timeTracking": "Zeit erfassen",
"relatedTasks": "Beziehung hinzufügen",
"moveProject": "Verschieben",
"duplicate": "Duplizieren",
@ -1467,32 +1462,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",

View File

@ -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",
@ -997,7 +993,6 @@
"repeatAfter": "Wiederholung setzen",
"percentDone": "Fortschritt einstellen",
"attachments": "Anhänge hinzufügen",
"timeTracking": "Zeit erfassen",
"relatedTasks": "Beziehung hinzufügen",
"moveProject": "Verschieben",
"duplicate": "Duplizieren",
@ -1467,32 +1462,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",

View File

@ -172,7 +172,6 @@
"yyyy/mm/dd": "ΕΕΕΕ/ΜΜ/ΗΗ"
},
"timeFormat": "Μορφή ώρας",
"timeTrackingDefaultStart": "Ώρα έναρξης παρακολούθησης χρόνου με έξυπνο-γέμισμα",
"timeFormatOptions": {
"12h": "12 ώρες (ΠΜ/ΜΜ)",
"24h": "24 ώρες (ΩΩ:ΛΛ)"
@ -393,7 +392,6 @@
"title": "Αντιγραφή του έργου",
"label": "Αντιγραφή",
"text": "Επιλέξτε ένα γονικό έργο που θα περιλαμβάνει το αντίγραφο του έργου:",
"shares": "Αντιγραφή διαμοιρασμών (χρήστες, ομάδες και σύνδεσμοι διαμοιρασμού) στο αντίγραφο",
"success": "Το έργο αντιγράφηκε με επιτυχία."
},
"edit": {
@ -472,6 +470,7 @@
"month": "Μήνας",
"day": "Ημέρα",
"hour": "Ώρα",
"range": "Εύρος Ημερομηνιών",
"chartLabel": "Γράφημα Gantt Έργου",
"taskBarsForRow": "Μπάρες εργασίας για τη γραμμή {rowId}",
"taskBarLabel": "Εργασία: {task}. Από {startDate} έως {endDate}. {dateType}. Κάντε κλικ για επεξεργασία, σύρετε για μετακίνηση.",
@ -500,6 +499,7 @@
"kanban": {
"title": "Kanban",
"limit": "Όριο: {limit}",
"noLimit": "Δεν έχει οριστεί",
"doneBucket": "Κάδος για ολοκληρωμένα",
"doneBucketHint": "Όλες οι εργασίες που μετακινούνται σε αυτόν τον κάδο θα σημειώνονται αυτόματα ως ολοκληρωμένες.",
"doneBucketHintExtended": "Όλες οι εργασίες που μετακινούνται στον κάδο των ολοκληρωμένων θα σημειώνονται αυτόματα ως ολοκληρωμένες. Όλες οι εργασίες που σημειώνονται ως ολοκληρωμένες από αλλού επίσης θα μετακινηθούν.",
@ -783,10 +783,7 @@
"closeDialog": "Κλείσμο του διαλόγου",
"closeQuickActions": "Κλείσιμο των γρήγορων ενεργειών",
"skipToContent": "Μετάβαση στο κύριο περιεχόμενο",
"sortBy": "Ταξινόμηση ανά",
"dateRange": "Εύρος ημερομηνιών",
"notSet": "Μη ορισμένο",
"user": "Χρήστης"
"sortBy": "Ταξινόμηση ανά"
},
"input": {
"projectColor": "Χρώμα έργου",
@ -996,7 +993,6 @@
"repeatAfter": "Ορισμός Επαναλαμβανόμενου Διαστήματος",
"percentDone": "Ορισμός Προόδου",
"attachments": "Προσθήκη Συνημμένων",
"timeTracking": "Χρόνος ίχνους",
"relatedTasks": "Προσθήκη Συσχέτισης",
"moveProject": "Μετακίνηση",
"duplicate": "Αντιγραφή",
@ -1466,32 +1462,6 @@
"frontendVersion": "Έκδοση frontend: {version}",
"apiVersion": "Έκδοση API: {version}"
},
"timeTracking": {
"title": "Ιχνηλάτηση χρόνου",
"stop": "Διακοπή χρονομέτρου",
"logTime": "Καταγραφή χρόνου",
"editEntry": "Επεξεργασία εγγραφής",
"form": {
"task": "Εργασία",
"taskSearch": "Αναζήτηση για μια εργασία…",
"commentPlaceholder": "Σε τι δουλέψατε;",
"save": "Αποθήκευση εγγραφής",
"startTimer": "Έναρξη χρονοµέτρου",
"update": "Ενημέρωση εγγραφής",
"smartFill": "Συμπλήρωση από την τελευταία καταχώριση"
},
"list": {
"emptyTask": "Δεν καταγράφηκε ακόμη χρόνος για αυτήν την εργασία.",
"emptyFiltered": "Δεν καταγράφηκε χρόνος με βάση τα επιλεγμένα φίλτρα.",
"total": "Σύνολο",
"time": "Ώρα",
"duration": "Διάρκεια"
},
"browse": {
"selectRange": "Επιλέξτε ένα εύρος",
"userSearch": "Αναζήτηση για ένα χρήστη…"
}
},
"time": {
"units": {
"seconds": "δευτερόλεπτο|δευτερόλεπτα",

View File

@ -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",
@ -997,7 +993,6 @@
"repeatAfter": "Set Repeating Interval",
"percentDone": "Set Progress",
"attachments": "Add Attachments",
"timeTracking": "Track time",
"relatedTasks": "Add Relation",
"moveProject": "Move",
"duplicate": "Duplicate",
@ -1467,32 +1462,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",

View File

@ -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

View File

@ -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.",

View File

@ -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.",

View File

@ -318,7 +318,8 @@
"default": "ברירת מחדל",
"month": "חודש",
"day": "יום",
"hour": "שעה"
"hour": "שעה",
"range": "טווח תאריכים"
},
"table": {
"title": "טבלה",
@ -327,6 +328,7 @@
"kanban": {
"title": "קאנבאן",
"limit": "הגבלה: {limit}",
"noLimit": "לא נקבע",
"doneBucket": "דלי גמורים",
"doneBucketHint": "דלי גמורים נשמר בהצלחה.",
"doneBucketHintExtended": "כל המטלות המוכנסות לדלי הגמורים יסומנו אוטומטית כגמורים. כל המטלות המסומנות כגמורים מבחוץ יוזזו גם.",

View File

@ -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": {

View File

@ -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.",

View File

@ -362,6 +362,7 @@
"month": "Mese",
"day": "Giorno",
"hour": "Ora",
"range": "Intervallo di date",
"chartLabel": "Progetto diagramma di Gantt",
"taskBarsForRow": "Barre delle attività per riga {rowId}",
"taskBarLabel": "Attività: {task}. Da {startDate} a {endDate}. {dateType}. Clicca per modificare, trascina per spostare.",
@ -385,6 +386,7 @@
"kanban": {
"title": "Kanban",
"limit": "Limite: {limit}",
"noLimit": "Non Impostato",
"doneBucket": "Colonna attività completate",
"doneBucketHint": "Tutte le attività spostate in questa colonna verranno automaticamente contrassegnate come completate.",
"doneBucketHintExtended": "Tutte le attività spostate nella colonna attività completate saranno contrassegnate automaticamente come completate. Anche tutte le attività contrassegnate come completate altrove verranno spostate.",

View File

@ -470,6 +470,7 @@
"month": "月",
"day": "日",
"hour": "時間",
"range": "期間",
"chartLabel": "プロジェクトガントチャート",
"taskBarsForRow": "行 {rowId} のタスクバー",
"taskBarLabel": "タスク: {task}。{startDate} から {endDate} まで。{dateType}。クリックして編集、ドラッグして移動。",
@ -498,6 +499,7 @@
"kanban": {
"title": "カンバン",
"limit": "上限: {limit}",
"noLimit": "未設定",
"doneBucket": "バケットを完了",
"doneBucketHint": "このバケットに移動されたすべてのタスクは自動的に完了としてマークされます。",
"doneBucketHintExtended": "完了バケットに移動されたすべてのタスクは自動的に完了としてマークされます。他の場所にあるタスクも完了としてマークされるとこのバケットに移動されます。",

View File

@ -323,7 +323,8 @@
"default": "기본값",
"month": "월",
"day": "일",
"hour": "시"
"hour": "시",
"range": "날짜 범위"
},
"table": {
"title": "테이블",
@ -332,6 +333,7 @@
"kanban": {
"title": "칸반",
"limit": "제한: {limit}",
"noLimit": "설정 안함",
"doneBucket": "완료 버킷",
"doneBucketHint": "이 버킷으로 이동한 모든 할 일은 자동으로 완료로 표시됩니다.",
"doneBucketHintExtended": "완료 버킷으로 이동된 모든 할 일은 자동으로 완료로 표시됩니다. 다른 곳에서 완료로 표시된 모든 할 일도 함께 이동됩니다.",

View File

@ -320,7 +320,8 @@
"default": "Numatytasis",
"month": "Mėnuo",
"day": "Diena",
"hour": "Valanda"
"hour": "Valanda",
"range": "Datos intervalas"
},
"table": {
"title": "Lentelė",
@ -329,6 +330,7 @@
"kanban": {
"title": "Kanbanas",
"limit": "Limitas: {limit}",
"noLimit": "Nenustatytas",
"doneBucket": "Atliktųjų telkinys",
"doneBucketHint": "Visos užduotys nukreiptos į šį telkinį bus automatiškai pažymėtos kaip atliktos.",
"doneBucketHintExtended": "Visos užduotys perkeltos į atliktą telkiny bus automatiškai pažymėtos kaip atliktos. Visos užduotys pažymėtos kaip atliktos iš kitur bus perkeltos taip pat.",

View File

@ -470,6 +470,7 @@
"month": "Maand",
"day": "Dag",
"hour": "Uur",
"range": "Datumbereik",
"chartLabel": "Project Gantt-diagram",
"taskBarsForRow": "Taakbalken voor rij {rowId}",
"taskBarLabel": "Taak: {task}. Van {startDate} tot {endDate}. {dateType}. Klik om te bewerken, sleep om te verplaatsen.",
@ -498,6 +499,7 @@
"kanban": {
"title": "Kanban",
"limit": "Limiet: {limit}",
"noLimit": "Niet ingesteld",
"doneBucket": "Categorie 'voltooid'",
"doneBucketHint": "Alle taken die je naar deze categorie verplaatst worden automatisch als 'voltooid' gemarkeerd.",
"doneBucketHintExtended": "Taken die je verplaatst naar de categorie 'voltooid' worden automatisch gemarkeerd als voltooid. Ook taken die je elders als voltooid aanmerkt, worden hierheen verplaatst.",

View File

@ -353,6 +353,7 @@
"month": "Måned",
"day": "Dag",
"hour": "Time",
"range": "Datointervall",
"chartLabel": "Gantt-kart for prosjekt",
"taskBarsForRow": "Oppgavelinjer for rad {rowId}",
"taskBarLabel": "Oppgave: {task}. Fra {startDate} til {endDate}. {dateType}. Klikk for å redigere, dra for å flytte.",
@ -376,6 +377,7 @@
"kanban": {
"title": "Kanban",
"limit": "Begrens: {limit}",
"noLimit": "Ikke angitt",
"doneBucket": "Ferdigkurv",
"doneBucketHint": "Alle oppgaver som flyttes til denne kurven vil automatisk bli markert som ferdige.",
"doneBucketHintExtended": "Alle oppgaver som er flyttet til ferdigkurven, vil automatisk bli markert som ferdige. Alle oppgaver markert som ferdige fra andre steder vil også bli flyttet.",

View File

@ -300,7 +300,8 @@
"default": "Domyślnie",
"month": "Miesiąc",
"day": "Dzień",
"hour": "Godzina"
"hour": "Godzina",
"range": "Zakres dat"
},
"table": {
"title": "Tabela",
@ -309,6 +310,7 @@
"kanban": {
"title": "Kanban",
"limit": "Limit: {limit}",
"noLimit": "Nie ustawiony",
"doneBucket": "Zakończone zadania",
"doneBucketHint": "Wszystkie zadania przeniesione do tej kolumny zostaną automatycznie oznaczone jako zakończone.",
"doneBucketHintExtended": "Wszystkie zadania przeniesione do kolumny zakończonych zostaną automatycznie oznaczone jako zakończone. Wszystkie zadania oznaczone jako zakończone z innych miejsc zostaną przeniesione.",

View File

@ -286,7 +286,8 @@
"default": "Padrão",
"month": "Mês",
"day": "Dia",
"hour": "Hora"
"hour": "Hora",
"range": "Período"
},
"table": {
"title": "Tabela",
@ -295,6 +296,7 @@
"kanban": {
"title": "Kanban",
"limit": "Limite: {limit}",
"noLimit": "Não definido",
"doneBucket": "Bucket concluído",
"doneBucketHint": "Todas as tarefas movidas para este bucket serão marcadas automaticamente como concluídas.",
"doneBucketHintExtended": "Todas as tarefas movidas para o bucket concluído serão automaticamente concluídas também. Todas as tarefas concluídas de outro lugar serão movidas também.",

View File

@ -362,6 +362,7 @@
"month": "Mês",
"day": "Dia",
"hour": "Hora",
"range": "Intervalo de Datas",
"chartLabel": "Gráfico de Gantt do projeto",
"taskBarsForRow": "Barras de tarefas para a linha {rowId}",
"taskBarLabel": "Tarefa: {task}. De {startDate} a {endDate}. {dateType}. Clica para editar, arrasta para mover.",
@ -385,6 +386,7 @@
"kanban": {
"title": "Kanban",
"limit": "Limite: {limit}",
"noLimit": "Não Definido",
"doneBucket": "Conjunto concluído",
"doneBucketHint": "Todas as tarefas movidas para este conjunto serão automaticamente marcadas como concluídas.",
"doneBucketHintExtended": "Todas as tarefas movidas para o conjunto concluído serão marcadas automaticamente como concluídas. Todas as tarefas marcadas como concluídas em outro lugar também serão movidas.",

View File

@ -5,32 +5,9 @@
},
"home": {
"welcomeNight": "Доброй ночи, {username}!",
"welcomeNightOwl": "Привет, ночная сова {username}",
"welcomeNightBurning": "Работаешь допоздна, {username}?",
"welcomeNightQuiet": "Тихие часы, {username}",
"welcomeNightLate": "Поздно, {username}",
"welcomeMorning": "Доброе утро, {username}!",
"welcomeMorningHey": "Привет, {username}, готов?",
"welcomeMorningFresh": "Свежий старт, {username}",
"welcomeMorningCoffee": "Кофе и задачи, {username}?",
"welcomeMorningRise": "Проснись и планируй, {username}",
"welcomeMorningBack": "С возвращением, {username}",
"welcomeMondayFresh": "Свежая неделя, {username}",
"welcomeTuesday": "Счастливого вторника, {username}",
"welcomeWednesdayMid": "Уже середина недели, {username}",
"welcomeThursday": "Почти готово, {username}",
"welcomeFridayPush": "Пятница, {username}?",
"welcomeSaturday": "Режим выходных, {username}",
"welcomeSundaySession": "Воскресный сеанс, {username}?",
"welcomeDay": "Привет, {username}!",
"welcomeDayFocus": "Давайте сосредоточимся, {username}",
"welcomeDayKeepGoing": "Так держать, {username}",
"welcomeDayWhatsNext": "Что дальше, {username}?",
"welcomeDayGood": "Добрый день, {username}",
"welcomeEvening": "Добрый вечер, {username}!",
"welcomeEveningWind": "Заканчиваешь, {username}?",
"welcomeEveningReturns": "{username} возвращается",
"welcomeEveningOneMore": "Ещё одна вещь, {username}?",
"lastViewed": "Последние просмотренные",
"addToHomeScreen": "Добавьте это приложение на домашний экран для быстрого доступа и удобной работы.",
"goToOverview": "Перейти к обзору",
@ -80,11 +57,6 @@
"openIdTotpSubmit": "Продолжить",
"oauthMissingParams": "Отсутствуют необходимые параметры OAuth: {params}",
"oauthRedirectedToApp": "Вы были перенаправлены в приложение. Теперь вы можете закрыть эту вкладку.",
"desktopTryDemo": "Попробовать демо-версию",
"desktopCustomServer": "Пользовательский URL сервера",
"desktopCustomServerDescription": "Введите URL сервера Vikunja, чтобы начать.",
"desktopWaitingForAuth": "Ожидание аутентификации…",
"desktopOAuthError": "Ошибка аутентификации: {error}",
"logout": "Выйти",
"emailInvalid": "Введите корректный email адрес.",
"usernameRequired": "Введите имя пользователя.",
@ -103,19 +75,6 @@
"registrationFailed": "Произошла ошибка при регистрации. Проверьте введённые данные и повторите попытку."
},
"settings": {
"bots": {
"title": "Боты",
"description": "Боты — это пользователи, которые принадлежат вам и которые имеют доступ только к API. Их можно добавить в проекты, назначить задачи, и аутентификация выполняется с помощью токенов API. Боты не могут использовать обычный интерфейс.",
"namePlaceholder": "Мой помощник",
"create": "Создать бота",
"enable": "Включить",
"badge": "Бот",
"delete": {
"header": "Удалить бота",
"text1": "Удалить бота «{username}»?",
"text2": "Это необратимо. Любые токены API, принадлежащие этому боту, будут аннулированы."
}
},
"title": "Настройки",
"newPasswordTitle": "Изменить пароль",
"newPassword": "Новый пароль",
@ -141,11 +100,6 @@
"weekStart": "Первый день недели",
"weekStartSunday": "Воскресенье",
"weekStartMonday": "Понедельник",
"weekStartTuesday": "Вторник",
"weekStartWednesday": "Среда",
"weekStartThursday": "Четверг",
"weekStartFriday": "Пятница",
"weekStartSaturday": "Суббота",
"language": "Язык",
"defaultProject": "Проект по умолчанию",
"defaultView": "Представление по умолчанию",
@ -179,13 +133,7 @@
"taskAndNotifications": "Проекты и задачи",
"privacy": "Конфиденциальность",
"localization": "Локализация",
"appearance": "Внешний вид и поведение",
"desktop": "Настольное приложение"
},
"desktop": {
"quickEntryShortcut": "Ярлык быстрого входа",
"shortcutRecorderPlaceholder": "Нажмите, чтобы задать ярлык",
"shortcutRecorderRecording": "Нажмите комбинацию клавиш…"
"appearance": "Внешний вид и поведение"
},
"totp": {
"title": "Двухфакторная аутентификация",
@ -215,13 +163,6 @@
"usernameIs": "Имя пользователя для CalDAV: {0}",
"apiTokenHint": "Вы также можете использовать токен API с разрешением CalDAV. Создайте его в {link}."
},
"feeds": {
"title": "Atom-лента",
"howTo": "Вы можете подписаться на уведомления Vikunja в любом приложении для чтения новостей, поддерживающем Atom-ленты. Используйте следующий URL:",
"usernameIs": "Имя пользователя для доступа к ленте: {0}",
"apiTokenHint": "Для аутентификации используйте токен API с разрешением {scope}. Создайте его на странице {link}.",
"tokenTitle": "Atom-лента"
},
"avatar": {
"title": "Аватар",
"initials": "Инициалы",
@ -344,7 +285,6 @@
"shared": "Общие проекты",
"noDescriptionAvailable": "Описание проекта отсутствует.",
"inboxTitle": "Входящие",
"myOpenTasksFilterTitle": "Мои открытые задачи",
"favorite": "Отметить проект как избранный",
"unfavorite": "Удалить проект из избранного",
"openSettingsMenu": "Открыть настройки проекта",
@ -389,7 +329,6 @@
"title": "Создание копии проекта",
"label": "Создать копию",
"text": "Выберите родительский проект, в который поместить копию проекта:",
"shares": "Скопировать настройки доступа (пользователей, групп и ссылок для обмена)",
"success": "Копия проекта создана."
},
"edit": {
@ -468,6 +407,7 @@
"month": "Месяц",
"day": "День",
"hour": "Час",
"range": "Диапазон",
"chartLabel": "Диаграмма Ганта",
"taskBarsForRow": "Задачи в строке {rowId}",
"taskBarLabel": "Задача: {task}. С {startDate} по {endDate}. {dateType}. Нажмите для изменения, потяните для перемещения.",
@ -486,8 +426,7 @@
"partialDatesStart": "Только дата начала (без окончания)",
"partialDatesEnd": "Только дата окончания (без начала)",
"expandGroup": "Развернуть группу: {task}",
"collapseGroup": "Свернуть группу: {task}",
"toggleRelationArrows": "Переключить стрелки связи"
"collapseGroup": "Свернуть группу: {task}"
},
"table": {
"title": "Таблица",
@ -496,6 +435,7 @@
"kanban": {
"title": "Канбан",
"limit": "Лимит: {limit}",
"noLimit": "не установлен",
"doneBucket": "Колонка завершённых",
"doneBucketHint": "Все задачи, помещённые в эту колонку, автоматически отмечаются как завершённые.",
"doneBucketHintExtended": "Все задачи, перенесённые в колонку завершённых, будут помечены как завершённые. Все задачи, помеченные как завершённые, также будут перемещены в эту колонку.",
@ -516,8 +456,7 @@
"bucketTitleSavedSuccess": "Название колонки сохранено.",
"bucketLimitSavedSuccess": "Лимит колонки сохранён.",
"collapse": "Свернуть эту колонку",
"bucketLimitReached": "Вы достигли лимита колонки. Удалите какие-нибудь задачи или увеличьте лимит, чтобы добавить новые задачи.",
"bucketOptions": "Настройки колонки"
"bucketLimitReached": "Вы достигли лимита колонки. Удалите какие-нибудь задачи или увеличьте лимит, чтобы добавить новые задачи."
},
"pseudo": {
"favorites": {
@ -740,9 +679,7 @@
"upcoming": "Предстоящие задачи",
"settings": "Настройки",
"imprint": "Отпечаток",
"privacy": "Политика конфиденциальности",
"closeSidebar": "Закрыть боковую панель",
"home": "Главная страница Vikunja"
"privacy": "Политика конфиденциальности"
},
"misc": {
"loading": "Загрузка…",
@ -774,17 +711,9 @@
"createdBy": "Создатель {0}",
"actions": "Действия",
"cannotBeUndone": "Это действие отменить нельзя!",
"avatarOfUser": "Изображение профиля {user}",
"closeBanner": "Закрыть баннер",
"closeDialog": "Закрыть диалог",
"closeQuickActions": "Закрыть быстрые действия",
"skipToContent": "Перейти к основному содержимому",
"dateRange": "Диапазон",
"notSet": "Не задано",
"user": "Пользователь"
"avatarOfUser": "Изображение профиля {user}"
},
"input": {
"projectColor": "Цвет проекта",
"resetColor": "Сбросить цвет",
"datepicker": {
"today": "Сегодня",
@ -857,7 +786,6 @@
"date": "Дата",
"ranges": {
"today": "Сегодня",
"tomorrow": "Завтра",
"thisWeek": "Эта неделя",
"restOfThisWeek": "Остаток этой недели",
"nextWeek": "Следующая неделя",
@ -965,8 +893,6 @@
"belongsToProject": "Задача принадлежит проекту «{project}»",
"back": "Вернуться к проекту",
"due": "Истекает {at}",
"closeTaskDetail": "Закрыть детали задачи",
"title": "Детали задачи",
"scrollToBottom": "Прокрутить до конца страницы",
"organization": "Организация",
"management": "Управление",
@ -1060,10 +986,7 @@
"addedSuccess": "Комментарий добавлен.",
"permalink": "Скопировать постоянную ссылку на комментарий",
"sortNewestFirst": "Сначала новые",
"sortOldestFirst": "Сначала старые",
"reply": "Ответить",
"jumpToOriginal": "Перейти к исходному комментарию",
"deletedComment": "удалённый комментарий"
"sortOldestFirst": "Сначала старые"
},
"mention": {
"noUsersFound": "Пользователи не найдены"
@ -1327,11 +1250,9 @@
"none": "Уведомлений нет. Хорошего дня!",
"explainer": "Здесь появятся уведомления, когда что-нибудь произойдёт с проектами или задачами, на которые вы подписаны.",
"markAllRead": "Отметить всё как прочитанное",
"markAllReadSuccess": "Все уведомления отмечены как прочитанные.",
"subscribeFeed": "Подписаться на уведомления через Atom-ленту"
"markAllReadSuccess": "Все уведомления отмечены как прочитанные."
},
"quickActions": {
"notLoggedIn": "Сначала войдите в главное окно Vikunja.",
"commands": "Команды",
"placeholder": "Введите команду или поисковый запрос…",
"hint": "Используйте {project}, чтобы ограничить поиск проектом. Комбинируйте {project} и {label} (метки) с поисковым запросом для поиска задачи с этими метками или на этом проекте. Используйте {assignee} для поиска команд.",
@ -1458,66 +1379,5 @@
"weeks": "неделя|недели|недель",
"years": "год|года|лет"
}
},
"admin": {
"title": "Администрирование",
"labels": {
"users": "Пользователи",
"tasks": "Задачи"
},
"overview": {
"shares": "Общий доступ",
"linkSharesShort": "ссылка",
"teamSharesShort": "группа",
"userSharesShort": "пользователь",
"version": "Версия",
"license": "Лицензия",
"licenseValidUntil": "Истекает",
"licenseExpiresIn": "через {days} дней",
"licenseLastVerified": "Последняя проверка",
"licenseNever": "никогда",
"licenseLastCheckFailed": "последняя проверка не удалась",
"licenseFeatures": "Возможности",
"licenseInstance": "ID экземпляра",
"licenseManage": "Управление"
},
"searchUsersPlaceholder": "Поиск по имени пользователя или электронной почте…",
"users": {
"status": "Статус",
"details": "Детали",
"detailsTitle": "Пользователь: {username}",
"issuer": "Издатель",
"issuerLocal": "Локальный",
"issuerUrl": "URL издателя",
"subject": "Тема",
"statusActive": "Активен",
"statusEmailConfirmation": "Нужно подтвердить почту",
"statusDisabled": "Отключен",
"statusLocked": "Заблокирован",
"isAdminLabel": "Администратор",
"addUser": "Добавить пользователя",
"createTitle": "Создать пользователя",
"nameLabel": "Имя",
"skipEmailConfirm": "Пропустить подтверждение по электронной почте",
"createSubmit": "Создать пользователя",
"saveButton": "Сохранить изменения",
"createdSuccess": "Пользователь {username} создан.",
"updatedSuccess": "Пользователь {username} обновлён.",
"deletedSuccess": "Пользователь {username} удалён.",
"deleteScheduledSuccess": "Пользователь {username} получит подтверждение по электронной почте для запланированного удаления.",
"confirmDeleteTitle": "Удалить пользователя?",
"confirmDeleteIntro": "Как следует удалить пользователя {username}?",
"deleteModeScheduled": "Запланировать удаление",
"deleteModeScheduledHelp": "Запланированное удаление отправляет пользователю письмо с подтверждением, как если бы пользователь сам запросил удаление аккаунта.",
"deleteModeNow": "Удалить сейчас",
"deleteModeNowHelp": "Удаление сейчас удаляет пользователя и все его данные сразу. Это не может быть отменено."
},
"projects": {
"ownerLabel": "Владелец",
"reassignOwner": "Переназначить владельца",
"reassignTitle": "Переназначить {title}",
"reassignedSuccess": "Владелец проекта переназначен.",
"newOwnerLabel": "Новый владелец"
}
}
}

View File

@ -314,7 +314,8 @@
"default": "Privzeto",
"month": "Mesec",
"day": "Dan",
"hour": "Ura"
"hour": "Ura",
"range": "Datumski obseg"
},
"table": {
"title": "Tabela",
@ -323,6 +324,7 @@
"kanban": {
"title": "Kanban",
"limit": "Omejitev: {limit}",
"noLimit": "Ni nastavljeno",
"doneBucket": "Vedro končanih nalog",
"doneBucketHint": "Vse naloge, premaknjene v to vedro, bodo samodejno označene kot opravljene.",
"doneBucketHintExtended": "Vse naloge, premaknjene v vedro končanih nalog, bodo samodejno označene kot opravljene. Premaknjene bodo tudi vse naloge, ki so od drugje označena kot opravljene.",

View File

@ -362,6 +362,7 @@
"month": "Månad",
"day": "Dag",
"hour": "Timme",
"range": "Datumintervall",
"chartLabel": "Projektets Gantt-schema",
"taskBarsForRow": "Uppgiftsstaplar för rad {rowId}",
"taskBarLabel": "Uppgift: {task}. Från {startDate} till {endDate}. {dateType}. Klicka för att redigera, dra för att flytta.",
@ -385,6 +386,7 @@
"kanban": {
"title": "Kanban",
"limit": "Gräns: {limit}",
"noLimit": "Ej inställt",
"doneBucket": "Färdigkolumn",
"doneBucketHint": "Alla uppgifter som flyttas till den här kolumnen markeras automatiskt som klara.",
"doneBucketHintExtended": "Alla uppgifter som flyttas till färdigkolumnen markeras som färdiga automatiskt. Alla uppgifter som markerats som färdiga från andra håll kommer också att flyttas.",

View File

@ -362,6 +362,7 @@
"month": "Ay",
"day": "Gün",
"hour": "Saat",
"range": "Tarih Aralığı",
"chartLabel": "Proje Gantt Şeması",
"taskBarsForRow": "{rowId} satırı için görev çubukları",
"taskBarLabel": "Görev: {task}. {startDate} tarihinden {endDate} tarihine. {dateType}. Düzenlemek için tıklayın, taşımak için sürükleyin.",
@ -385,6 +386,7 @@
"kanban": {
"title": "Kanban",
"limit": "Sınır: {limit}",
"noLimit": "Belirlenmedi",
"doneBucket": "Tamamlananlar kutusu",
"doneBucketHint": "Bu kutuya taşınan tüm görevler otomatik olarak tamamlandı olarak işaretlenir.",
"doneBucketHintExtended": "Tamamlananlar kutusuna taşınan tüm görevler otomatik olarak tamamlandı olarak işaretlenir. Başka bir yerden tamamlandı olarak işaretlenen görevler de buraya taşınır.",

View File

@ -172,7 +172,6 @@
"yyyy/mm/dd": "YYYY/MM/DD"
},
"timeFormat": "Формат часу",
"timeTrackingDefaultStart": "Початковий час для автоматичного заповнення обліку часу",
"timeFormatOptions": {
"12h": "12-годинний (AM/PM)",
"24h": "24-годинний (HH:mm)"
@ -393,7 +392,6 @@
"title": "Дублювати цей проєкт",
"label": "Дублювати",
"text": "Оберіть батьківський проєкт, який повинен складатися з дубльованих проєктів:",
"shares": "Скопіювати налаштування спільного доступу (користувачів, команди та посилання) до копії проєкту",
"success": "Проєкт дубльовано."
},
"edit": {
@ -472,6 +470,7 @@
"month": "Місяць",
"day": "День",
"hour": "Година",
"range": "Проміжок днів",
"chartLabel": "Діаграма Ганта",
"taskBarsForRow": "Смуги завдань для рядка {rowId}",
"taskBarLabel": "Завдання: {task}. З {startDate} по {endDate}. {dateType}. Натисніть, щоб редагувати, перетягніть, щоб перемістити.",
@ -500,6 +499,7 @@
"kanban": {
"title": "Дошка",
"limit": "Межа: {limit}",
"noLimit": "Немає",
"doneBucket": "Колонка «Виконано»",
"doneBucketHint": "Всі завдання, переміщені в цю колонку, будуть автоматично позначені як виконані.",
"doneBucketHintExtended": "Всі завдання, що потрапляють у колонку «Виконано», автоматично відмічаються як виконані. Завдання, позначені як виконані в інших місцях, також перемістяться сюди.",
@ -783,10 +783,7 @@
"closeDialog": "Закрити діалог",
"closeQuickActions": "Закрити швидкі дії",
"skipToContent": "Перейти до основного вмісту",
"sortBy": "Сортувати за",
"dateRange": "Діапазон дат",
"notSet": "Не встановлено",
"user": "Користувач"
"sortBy": "Сортувати за"
},
"input": {
"projectColor": "Колір проєкту",
@ -989,14 +986,13 @@
"assign": "Доручити",
"label": "Позначки",
"priority": "Встановити пріоритет",
"dueDate": "Встановити термін виконання",
"dueDate": "Встановити термін",
"startDate": "Почати",
"endDate": "Встановити дату завершення",
"reminders": "Нагадування",
"repeatAfter": "Повторювати",
"percentDone": "Встановити прогрес",
"attachments": "Вкласти",
"timeTracking": "Відстежити час",
"relatedTasks": "Пов'язати",
"moveProject": "Перемістити",
"duplicate": "Дублювати",
@ -1063,7 +1059,7 @@
"edited": "змінено: {date}",
"creating": "Створюю коментар…",
"placeholder": "Введіть коментар, натисніть '/' для додаткових опцій…",
"comment": "Зберегти коментар",
"comment": "Залишити",
"delete": "Видалити коментар",
"deleteText1": "Справді впровадити?",
"deleteSuccess": "Коментар успішно видалено.",
@ -1152,11 +1148,11 @@
"repeat": {
"everyDay": "Щодня",
"everyWeek": "Щотижня",
"every30d": "Кожні 30 днів",
"every30d": "Щомісяця",
"mode": "Спосіб",
"monthly": "Щомісяця",
"fromCurrentDate": "З дня закінчення",
"each": "Кожен",
"fromCurrentDate": "Щодень закінчення",
"each": "Що",
"specifyAmount": "Вкажіть величину…",
"hours": "Години",
"days": "День",
@ -1222,8 +1218,8 @@
"success": "Вживача успішно видалено зі спільноти."
},
"leave": {
"title": "Залишити спільноту",
"text1": "Ви впевнені, що хочете залишити цю спільноту?",
"title": "Покинути спільноту",
"text1": "Справді покинути?",
"text2": "Ви втратите доступ до всіх проєктів, до яких має доступ ця команда. Якщо передумаєте, вам знадобиться адміністратор команди, щоб додати вас знову.",
"success": "Ви покинули спільноту."
}
@ -1466,32 +1462,6 @@
"frontendVersion": "Версія інтерфейсу: {version}",
"apiVersion": "API версія: {version}"
},
"timeTracking": {
"title": "Відстеження часу",
"stop": "Зупинити таймер",
"logTime": "Записати час",
"editEntry": "Редагувати запис",
"form": {
"task": "Завдання",
"taskSearch": "Знайти завдання…",
"commentPlaceholder": "Над чим ви працювали?",
"save": "Зберегти запис",
"startTimer": "Запустити таймер",
"update": "Оновити запис",
"smartFill": "Заповнити з останнього запису"
},
"list": {
"emptyTask": "Для цього завдання ще немає записів обліку часу.",
"emptyFiltered": "Немає записів обліку часу для вибраних фільтрів.",
"total": "Загалом",
"time": "Час",
"duration": "Тривалість"
},
"browse": {
"selectRange": "Обрати діапазон",
"userSearch": "Знайти користувача…"
}
},
"time": {
"units": {
"seconds": "секунда|секунд(и)",

View File

@ -319,7 +319,8 @@
"default": "Mặc định",
"month": "Tháng",
"day": "Ngày",
"hour": "Giờ"
"hour": "Giờ",
"range": "Khoảng thời gian"
},
"table": {
"title": "Bảng",
@ -328,6 +329,7 @@
"kanban": {
"title": "Kanban",
"limit": "Giới hạn: {limit}",
"noLimit": "Không giới hạn",
"doneBucket": "Cột hoàn thành",
"doneBucketHint": "Tất cả các tác vụ được chuyển đến cột này sẽ được tự động đánh dấu là hoàn thành.",
"doneBucketHintExtended": "Tất cả các tác vụ được đưa đến cột hoàn thành sẽ được tự động đánh dấu là hoàn thành. Tất cả các tác vụ hoàn thành từ các phần khác cũng sẽ được chuyển tới đây.",

View File

@ -338,6 +338,7 @@
"month": "月",
"day": "日",
"hour": "时",
"range": "日期范围",
"chartLabel": "项目甘特图",
"scheduledDates": "预定日期",
"estimatedDates": "估计日期"
@ -349,6 +350,7 @@
"kanban": {
"title": "看板",
"limit": "限制: {limit}",
"noLimit": "未设置",
"doneBucket": "已完成的桶数",
"doneBucketHint": "移入此存储桶的所有任务将自动标记为已完成。",
"doneBucketHintExtended": "所有移入已完成存储桶的任务都将自动标记为已完成。 从其他位置标记为已完成的任务也将被移动。",

View File

@ -362,6 +362,7 @@
"month": "月",
"day": "日",
"hour": "時",
"range": "日期範圍",
"chartLabel": "專案甘特圖",
"taskBarsForRow": "第 {rowId} 列的任務列",
"taskBarLabel": "任務:{task}。從 {startDate} 到 {endDate}。{dateType}。點擊編輯,拖曳移動。",
@ -385,6 +386,7 @@
"kanban": {
"title": "看板",
"limit": "限制: {limit}",
"noLimit": "未設定",
"doneBucket": "已完成類別",
"doneBucketHint": "移入此類別的任務將自動標記為已完成。",
"doneBucketHintExtended": "所有移入已完成類別的任務將自動標記為已完成。 其他位置標記為已完成的任務也將被移動。",

View File

@ -22,7 +22,6 @@ export const DAYJS_LOCALE_MAPPING = {
'ja-jp': 'ja',
'hu-hu': 'hu',
'ar-sa': 'ar-sa',
'fa-ir': 'fa',
'sl-si': 'sl',
'pt-br': 'pt',
'hr-hr': 'hr',
@ -56,7 +55,6 @@ export const DAYJS_LANGUAGE_IMPORTS = {
'ja-jp': () => import('dayjs/locale/ja'),
'hu-hu': () => import('dayjs/locale/hu'),
'ar-sa': () => import('dayjs/locale/ar-sa'),
'fa-ir': () => import('dayjs/locale/fa'),
'sl-si': () => import('dayjs/locale/sl'),
'pt-br': () => import('dayjs/locale/pt-br'),
'hr-hr': () => import('dayjs/locale/hr'),

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