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`). 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 ```go
items, ok := result.([]*models.Foo) items, ok := result.([]*models.Foo)
if !ok { 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 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. - **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.) - **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** 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). - **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`. - **Delete** returns `*emptyBody`.
### 3. Self-register the resource ### 3. Self-register the resource
@ -170,9 +170,11 @@ Otherwise the same rules apply: register with the `Register` wrapper, pull auth
## Tests (mandatory) ## 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`. - 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. - 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" } >> "$GITHUB_ENV"
- name: Download Mage binary - name: Download Mage binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: mage_bin name: mage_bin
@ -89,7 +89,7 @@ runs:
- name: Download frontend dist (vikunja only) - name: Download frontend dist (vikunja only)
if: inputs.project == 'vikunja' if: inputs.project == 'vikunja'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: frontend_dist name: frontend_dist
path: frontend/dist path: frontend/dist
@ -110,7 +110,7 @@ runs:
sudo mv upx-5.0.0-amd64_linux/upx /usr/local/bin sudo mv upx-5.0.0-amd64_linux/upx /usr/local/bin
- name: Setup xgo cache - name: Setup xgo cache
uses: useblacksmith/cache@c5fe29eb0efdf1cf4186b9f7fcbbcbc0cf025662 # v5.1.0 uses: useblacksmith/cache@71c7c918062ba3861252d84b07fe5ab2a6b467a6 # v5
with: with:
path: /home/runner/.xgo-cache path: /home/runner/.xgo-cache
key: xgo-${{ inputs.project }}-${{ hashFiles('**/go.sum') }} key: xgo-${{ inputs.project }}-${{ hashFiles('**/go.sum') }}
@ -133,7 +133,7 @@ runs:
cd build && mage release:build "$PROJECT" cd build && mage release:build "$PROJECT"
- name: GPG setup - name: GPG setup
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main uses: kolaente/action-gpg@main
with: with:
gpg-passphrase: ${{ inputs.gpg-passphrase }} gpg-passphrase: ${{ inputs.gpg-passphrase }}
gpg-sign-key: ${{ inputs.gpg-sign-key }} gpg-sign-key: ${{ inputs.gpg-sign-key }}
@ -164,7 +164,7 @@ runs:
done done
- name: Upload zips to S3 - name: Upload zips to S3
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main uses: kolaente/s3-action@main
with: with:
s3-access-key-id: ${{ inputs.s3-access-key-id }} s3-access-key-id: ${{ inputs.s3-access-key-id }}
s3-secret-access-key: ${{ inputs.s3-secret-access-key }} s3-secret-access-key: ${{ inputs.s3-secret-access-key }}
@ -176,14 +176,14 @@ runs:
strip-path-prefix: ${{ env.DIST_PREFIX }}/zip/ strip-path-prefix: ${{ env.DIST_PREFIX }}/zip/
- name: Store binaries - name: Store binaries
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: ${{ env.ARTIFACT_BINARIES_NAME }} name: ${{ env.ARTIFACT_BINARIES_NAME }}
path: ./${{ env.DIST_PREFIX }}/binaries/* path: ./${{ env.DIST_PREFIX }}/binaries/*
- name: Store binary packages - name: Store binary packages
if: github.ref_type == 'tag' if: github.ref_type == 'tag'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: ${{ env.ARTIFACT_ZIPS_NAME }} name: ${{ env.ARTIFACT_ZIPS_NAME }}
path: ./${{ env.DIST_PREFIX }}/zip/* path: ./${{ env.DIST_PREFIX }}/zip/*

View File

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

View File

@ -16,11 +16,11 @@ runs:
echo "CYPRESS_INSTALL_BINARY=0" >> $GITHUB_ENV echo "CYPRESS_INSTALL_BINARY=0" >> $GITHUB_ENV
echo "PUPPETEER_SKIP_DOWNLOAD=true" >> $GITHUB_ENV echo "PUPPETEER_SKIP_DOWNLOAD=true" >> $GITHUB_ENV
echo "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1" >> $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: with:
run_install: false run_install: false
package_json_file: frontend/package.json package_json_file: frontend/package.json
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with: with:
node-version-file: frontend/.nvmrc node-version-file: frontend/.nvmrc
cache: 'pnpm' cache: 'pnpm'

View File

@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout (for prompt template) - name: Checkout (for prompt template)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
sparse-checkout: | sparse-checkout: |
.github/workflows/auto-label.prompt.md .github/workflows/auto-label.prompt.md
@ -29,7 +29,7 @@ jobs:
- name: Render system prompt from live labels - name: Render system prompt from live labels
id: render id: render
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env: env:
PROMPT_TEMPLATE_PATH: .github/workflows/auto-label.prompt.md PROMPT_TEMPLATE_PATH: .github/workflows/auto-label.prompt.md
with: with:
@ -122,7 +122,7 @@ jobs:
- name: Classify with AI - name: Classify with AI
id: classify id: classify
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1 uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
with: with:
model: openai/gpt-4.1-mini model: openai/gpt-4.1-mini
# GPT-5 is a reasoning model: output tokens include reasoning, so budget generously. # 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 }} prompt-file: ${{ steps.prep.outputs.prompt_path }}
- name: Apply labels - name: Apply labels
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env: env:
AI_RESPONSE: ${{ steps.classify.outputs.response }} AI_RESPONSE: ${{ steps.classify.outputs.response }}
with: with:

View File

@ -9,19 +9,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with: with:
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }} ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
persist-credentials: true persist-credentials: true
- name: push source files - name: push source files
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3 uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2
with: with:
command: 'push' command: 'push'
env: env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: pull translations - name: pull translations
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3 uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2
with: with:
command: 'download' command: 'download'
command_args: '--export-only-approved --skip-untranslated-strings' command_args: '--export-only-approved --skip-untranslated-strings'
@ -29,7 +29,7 @@ jobs:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with: with:
node-version-file: frontend/.nvmrc node-version-file: frontend/.nvmrc
- name: Ensure file permissions - name: Ensure file permissions
@ -55,7 +55,7 @@ jobs:
git commit -m "chore(i18n): update translations via Crowdin" git commit -m "chore(i18n): update translations via Crowdin"
- name: Push changes - name: Push changes
if: steps.check_changes.outputs.changes_exist != '0' if: steps.check_changes.outputs.changes_exist != '0'
uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # master uses: ad-m/github-push-action@master
with: with:
ssh: true ssh: true
branch: ${{ github.ref }} branch: ${{ github.ref }}

View File

@ -18,11 +18,11 @@ jobs:
directory: [frontend, desktop] directory: [frontend, desktop]
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Create Diff - name: Create Diff
uses: e18e/action-dependency-diff@8e9b8c1957ab066d36235a43f4c1ff1522e1bdbc # v1.6.1 uses: e18e/action-dependency-diff@v1
with: with:
working-directory: ${{ matrix.directory }} working-directory: ${{ matrix.directory }}
@ -33,11 +33,11 @@ jobs:
directory: [frontend, desktop] directory: [frontend, desktop]
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Check provenance downgrades - name: Check provenance downgrades
uses: danielroe/provenance-action@81568f71211c1839d6d3583c6a93037f5348c816 # main uses: danielroe/provenance-action@main
with: with:
workspace-path: ${{ matrix.directory }} workspace-path: ${{ matrix.directory }}
fail-on-provenance-change: true fail-on-provenance-change: true

View File

@ -10,14 +10,14 @@ jobs:
steps: steps:
- name: Generate GitHub App token - name: Generate GitHub App token
id: generate-token id: generate-token
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with: with:
app-id: ${{ secrets.BOT_APP_ID }} app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
- name: Find closing PR or commit - name: Find closing PR or commit
id: find-closer id: find-closer
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with: with:
github-token: ${{ steps.generate-token.outputs.token }} github-token: ${{ steps.generate-token.outputs.token }}
script: | script: |
@ -82,7 +82,7 @@ jobs:
- name: Comment on issue - name: Comment on issue
if: steps.find-closer.outputs.closed_by_code == 'true' if: steps.find-closer.outputs.closed_by_code == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with: with:
github-token: ${{ steps.generate-token.outputs.token }} github-token: ${{ steps.generate-token.outputs.token }}
script: | script: |

View File

@ -25,7 +25,7 @@ jobs:
docker-images: false docker-images: false
swap-storage: false swap-storage: false
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with: with:
# For pull_request_target, we need to explicitly fetch the PR ref from forks # 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. # 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 ref: refs/pull/${{ github.event.pull_request.number }}/head
- name: Git describe - name: Git describe
id: ghd id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0 uses: proudust/gh-describe@v2
- name: Login to GHCR - name: Login to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
with: with:
version: latest version: latest
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with: with:
images: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} images: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
tags: | tags: |
type=ref,event=pr type=ref,event=pr
type=sha,format=long type=sha,format=long
- name: Build and push PR image - name: Build and push PR image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with: with:
context: . context: .
platforms: linux/amd64 platforms: linux/amd64
@ -66,7 +66,7 @@ jobs:
build-args: | build-args: |
RELEASE_VERSION=${{ steps.ghd.outputs.describe }} RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
- name: Comment on PR - name: Comment on PR
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env: env:
DOCKER_META_TAGS: ${{ steps.meta.outputs.tags }} DOCKER_META_TAGS: ${{ steps.meta.outputs.tags }}
with: with:

View File

@ -8,14 +8,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: prepare-build-mage name: prepare-build-mage
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: Cache build mage - name: Cache build mage
id: cache-build-mage id: cache-build-mage
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
with: with:
key: ${{ runner.os }}-build-mage-build-${{ hashFiles('build/magefile.go') }} key: ${{ runner.os }}-build-mage-build-${{ hashFiles('build/magefile.go') }}
path: | path: |
@ -33,7 +33,7 @@ jobs:
export PATH=$PATH:$GOPATH/bin export PATH=$PATH:$GOPATH/bin
mage -compile ./build-mage-static mage -compile ./build-mage-static
- name: Store build mage binary - name: Store build mage binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: build_mage_bin name: build_mage_bin
path: ./build/build-mage-static path: ./build/build-mage-static
@ -43,14 +43,14 @@ jobs:
steps: steps:
- name: Git describe - name: Git describe
id: ghd id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0 uses: proudust/gh-describe@v2
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with: with:
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GHCR - name: Login to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@ -58,7 +58,7 @@ jobs:
- name: Docker meta version - name: Docker meta version
if: ${{ github.ref_type == 'tag' }} if: ${{ github.ref_type == 'tag' }}
id: meta id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with: with:
images: | images: |
vikunja/vikunja vikunja/vikunja
@ -70,7 +70,7 @@ jobs:
type=raw,value=latest type=raw,value=latest
- name: Build and push unstable - name: Build and push unstable
if: ${{ github.ref_type != 'tag' }} if: ${{ github.ref_type != 'tag' }}
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with: with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8 platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
push: true push: true
@ -81,7 +81,7 @@ jobs:
RELEASE_VERSION=${{ steps.ghd.outputs.describe }} RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
- name: Build and push version - name: Build and push version
if: ${{ github.ref_type == 'tag' }} if: ${{ github.ref_type == 'tag' }}
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with: with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8 platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
push: true push: true
@ -93,10 +93,10 @@ jobs:
binaries: binaries:
runs-on: blacksmith-8vcpu-ubuntu-2204 runs-on: blacksmith-8vcpu-ubuntu-2204
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Git describe - name: Git describe
id: ghd id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0 uses: proudust/gh-describe@v2
- uses: ./.github/actions/release-binaries - uses: ./.github/actions/release-binaries
with: with:
project: vikunja project: vikunja
@ -112,10 +112,10 @@ jobs:
veans-binaries: veans-binaries:
runs-on: blacksmith-8vcpu-ubuntu-2204 runs-on: blacksmith-8vcpu-ubuntu-2204
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Git describe - name: Git describe
id: ghd id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0 uses: proudust/gh-describe@v2
- uses: ./.github/actions/release-binaries - uses: ./.github/actions/release-binaries
with: with:
project: veans project: veans
@ -147,10 +147,10 @@ jobs:
pkg: armv7 pkg: armv7
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Git describe - name: Git describe
id: ghd id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0 uses: proudust/gh-describe@v2
- uses: ./.github/actions/release-os-package - uses: ./.github/actions/release-os-package
with: with:
project: vikunja project: vikunja
@ -186,10 +186,10 @@ jobs:
pkg: armv7 pkg: armv7
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Git describe - name: Git describe
id: ghd id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0 uses: proudust/gh-describe@v2
- uses: ./.github/actions/release-os-package - uses: ./.github/actions/release-os-package
with: with:
project: veans project: veans
@ -235,19 +235,19 @@ jobs:
REPO_SUITE: ${{ github.ref_type == 'tag' && 'stable' || 'unstable' }} REPO_SUITE: ${{ github.ref_type == 'tag' && 'stable' || 'unstable' }}
RELEASE_VERSION: unstable RELEASE_VERSION: unstable
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download build mage binary - name: Download build mage binary
# Statically compiled in test.yml's build-mage job so it runs inside # Statically compiled in test.yml's build-mage job so it runs inside
# ubuntu/fedora/archlinux containers without a Go toolchain. # ubuntu/fedora/archlinux containers without a Go toolchain.
if: matrix.format != 'apk' if: matrix.format != 'apk'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: build_mage_bin name: build_mage_bin
path: build path: build
- name: Download all server OS packages - name: Download all server OS packages
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
pattern: vikunja_os_package_* pattern: vikunja_os_package_*
merge-multiple: true merge-multiple: true
@ -257,14 +257,14 @@ jobs:
# Merged into the same incoming dir so reprepro / createrepo_c / # Merged into the same incoming dir so reprepro / createrepo_c /
# repo-add / the apk loop pick them up alongside vikunja's packages # repo-add / the apk loop pick them up alongside vikunja's packages
# — same suite, same arch fan-out, no extra source entry for users. # — 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: with:
pattern: veans_os_package_* pattern: veans_os_package_*
merge-multiple: true merge-multiple: true
path: dist/repo-work/incoming path: dist/repo-work/incoming
- name: Download desktop packages (Linux) - name: Download desktop packages (Linux)
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: vikunja_desktop_packages_ubuntu-latest name: vikunja_desktop_packages_ubuntu-latest
path: dist/repo-work/incoming-desktop path: dist/repo-work/incoming-desktop
@ -309,7 +309,7 @@ jobs:
- name: GPG setup - name: GPG setup
if: matrix.format != 'apk' if: matrix.format != 'apk'
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main uses: kolaente/action-gpg@main
with: with:
gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}" gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}"
gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}" 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 find dist/repo-output -type d -empty -delete 2>/dev/null || true
- name: Upload to R2 - name: Upload to R2
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main uses: kolaente/s3-action@main
with: with:
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }} s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }} s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
@ -398,12 +398,12 @@ jobs:
config-yaml: config-yaml:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Git describe - name: Git describe
id: ghd id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0 uses: proudust/gh-describe@v2
- name: Download Mage Binary - name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: mage_bin name: mage_bin
- name: generate - name: generate
@ -411,7 +411,7 @@ jobs:
chmod +x ./mage-static chmod +x ./mage-static
./mage-static generate:config-yaml 1 ./mage-static generate:config-yaml 1
- name: Upload to S3 - name: Upload to S3
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main uses: kolaente/s3-action@main
with: with:
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }} s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }} s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
@ -431,16 +431,16 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Git describe - name: Git describe
id: ghd id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0 uses: proudust/gh-describe@v2
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
with: with:
package_json_file: desktop/package.json package_json_file: desktop/package.json
- name: Setup Node - name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with: with:
node-version-file: frontend/.nvmrc node-version-file: frontend/.nvmrc
cache: pnpm cache: pnpm
@ -451,7 +451,7 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install --no-install-recommends -y libopenjp2-tools rpm libarchive-tools sudo apt-get install --no-install-recommends -y libopenjp2-tools rpm libarchive-tools
- name: get frontend - name: get frontend
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: frontend_dist name: frontend_dist
path: frontend/dist path: frontend/dist
@ -461,7 +461,7 @@ jobs:
pnpm install --frozen-lockfile --prefer-offline --fetch-timeout 100000 pnpm install --frozen-lockfile --prefer-offline --fetch-timeout 100000
node build.js "${{ steps.ghd.outputs.describe }}" ${{ github.ref_type == 'tag' }} node build.js "${{ steps.ghd.outputs.describe }}" ${{ github.ref_type == 'tag' }}
- name: Upload to S3 - name: Upload to S3
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main uses: kolaente/s3-action@main
with: with:
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }} s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }} s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
@ -473,7 +473,7 @@ jobs:
strip-path-prefix: desktop/dist/ strip-path-prefix: desktop/dist/
exclude: "desktop/dist/*.blockmap" exclude: "desktop/dist/*.blockmap"
- name: Store Desktop Package - name: Store Desktop Package
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: vikunja_desktop_packages_${{ matrix.os }} name: vikunja_desktop_packages_${{ matrix.os }}
path: | path: |
@ -486,16 +486,16 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with: with:
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }} ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
persist-credentials: true persist-credentials: true
- name: Download Mage Binary - name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: mage_bin name: mage_bin
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: generate - name: generate
@ -520,7 +520,7 @@ jobs:
git commit -am "[skip ci] Updated swagger docs" git commit -am "[skip ci] Updated swagger docs"
- name: Push changes - name: Push changes
if: steps.check_changes.outputs.changes_exist != '0' if: steps.check_changes.outputs.changes_exist != '0'
uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # master uses: ad-m/github-push-action@master
with: with:
ssh: true ssh: true
branch: ${{ github.ref }} branch: ${{ github.ref }}
@ -539,44 +539,44 @@ jobs:
contents: write contents: write
steps: steps:
- name: Download Binaries - name: Download Binaries
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: vikunja_bin_packages name: vikunja_bin_packages
- name: Download OS Packages - name: Download OS Packages
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
pattern: vikunja_os_package_* pattern: vikunja_os_package_*
merge-multiple: true merge-multiple: true
- name: Download Veans Binaries - name: Download Veans Binaries
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: veans_bin_packages name: veans_bin_packages
- name: Download Veans OS Packages - name: Download Veans OS Packages
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
pattern: veans_os_package_* pattern: veans_os_package_*
merge-multiple: true merge-multiple: true
- name: Download Desktop Package Linux - name: Download Desktop Package Linux
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: vikunja_desktop_packages_ubuntu-latest name: vikunja_desktop_packages_ubuntu-latest
- name: Download Desktop Package MacOS - name: Download Desktop Package MacOS
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: vikunja_desktop_packages_macos-latest name: vikunja_desktop_packages_macos-latest
- name: Download Desktop Package Windows - name: Download Desktop Package Windows
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: vikunja_desktop_packages_windows-latest name: vikunja_desktop_packages_windows-latest
- name: Release - name: Release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
if: github.ref_type == 'tag' if: github.ref_type == 'tag'
with: with:
draft: true draft: true

View File

@ -12,7 +12,7 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 - uses: actions/stale@v9
with: with:
only-labels: 'waiting for reply' only-labels: 'waiting for reply'
days-before-issue-stale: 30 days-before-issue-stale: 30
@ -24,7 +24,6 @@ jobs:
questions. If you're still seeing this on a recent version, just questions. If you're still seeing this on a recent version, just
drop a comment with the requested info and we'll reopen. Thanks drop a comment with the requested info and we'll reopen. Thanks
for the report! for the report!
stale-pr-label: 'waiting for reply' days-before-pr-stale: -1
days-before-pr-stale: 30
days-before-pr-close: -1 days-before-pr-close: -1
operations-per-run: 100 operations-per-run: 100

View File

@ -8,26 +8,26 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: prepare-mage name: prepare-mage
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: Cache Mage - name: Cache Mage
id: cache-mage id: cache-mage
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
with: with:
key: ${{ runner.os }}-build-mage-${{ hashFiles('magefile.go') }} key: ${{ runner.os }}-build-mage-${{ hashFiles('magefile.go') }}
path: | path: |
./mage-static ./mage-static
- name: Compile Mage - name: Compile Mage
if: ${{ steps.cache-mage.outputs.cache-hit != 'true' }} if: ${{ steps.cache-mage.outputs.cache-hit != 'true' }}
uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b # v3.1.0 uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b # v3
with: with:
version: latest version: latest
args: -compile ./mage-static args: -compile ./mage-static
- name: Store Mage Binary - name: Store Mage Binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: mage_bin name: mage_bin
path: ./mage-static path: ./mage-static
@ -36,16 +36,16 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: mage needs: mage
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary - name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: mage_bin name: mage_bin
- name: Git describe - name: Git describe
id: ghd id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0 uses: proudust/gh-describe@v2
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: Build - name: Build
@ -57,7 +57,7 @@ jobs:
chmod +x ./mage-static chmod +x ./mage-static
./mage-static build ./mage-static build
- name: Store Vikunja Binary - name: Store Vikunja Binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: vikunja_bin name: vikunja_bin
path: ./vikunja path: ./vikunja
@ -65,8 +65,8 @@ jobs:
api-lint: api-lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: prepare frontend files - name: prepare frontend files
@ -74,19 +74,19 @@ jobs:
mkdir -p frontend/dist mkdir -p frontend/dist
touch frontend/dist/index.html touch frontend/dist/index.html
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
with: with:
version: v2.10.1 version: v2.10.1
veans-lint: veans-lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
with: with:
version: v2.10.1 version: v2.10.1
working-directory: veans working-directory: veans
@ -94,8 +94,8 @@ jobs:
veans-test: veans-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: Install mage - name: Install mage
@ -115,9 +115,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: mage needs: mage
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary - name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: mage_bin name: mage_bin
- name: Check - name: Check
@ -152,7 +152,7 @@ jobs:
ports: ports:
- 3306:3306 - 3306:3306
migration-smoke-db-postgres: migration-smoke-db-postgres:
image: postgres:18@sha256:4aabea78cf39b90e834caf3af7d602a18565f6fe2508705c8d01aa63245c2e20 image: postgres:18@sha256:5773fe724c49c42a7a9ca70202e11e1dff21fb7235b335a73f39297d200b73a2
env: env:
POSTGRES_PASSWORD: vikunjatest POSTGRES_PASSWORD: vikunjatest
POSTGRES_DB: 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 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 unzip vikunja-latest.zip vikunja-unstable-linux-amd64
- name: Download Vikunja Binary - name: Download Vikunja Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: vikunja_bin name: vikunja_bin
- name: run migration - name: run migration
@ -254,13 +254,13 @@ jobs:
ports: ports:
- 389:389 - 389:389
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary - name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: mage_bin name: mage_bin
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: Configure Postgres for faster tests - name: Configure Postgres for faster tests
@ -300,13 +300,13 @@ jobs:
needs: needs:
- mage - mage
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary - name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: mage_bin name: mage_bin
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: test - name: test
@ -321,13 +321,13 @@ jobs:
needs: needs:
- mage - mage
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary - name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: mage_bin name: mage_bin
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: test - name: test
@ -351,13 +351,13 @@ jobs:
ports: ports:
- 9000:9000 - 9000:9000
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary - name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: mage_bin name: mage_bin
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: test S3 file storage integration - name: test S3 file storage integration
@ -382,7 +382,7 @@ jobs:
frontend-lint: frontend-lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: ./.github/actions/setup-frontend - uses: ./.github/actions/setup-frontend
- name: Lint - name: Lint
working-directory: frontend working-directory: frontend
@ -391,7 +391,7 @@ jobs:
frontend-stylelint: frontend-stylelint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: ./.github/actions/setup-frontend - uses: ./.github/actions/setup-frontend
- name: Lint styles - name: Lint styles
working-directory: frontend working-directory: frontend
@ -400,7 +400,7 @@ jobs:
frontend-typecheck: frontend-typecheck:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: ./.github/actions/setup-frontend - uses: ./.github/actions/setup-frontend
- name: Typecheck - name: Typecheck
continue-on-error: true continue-on-error: true
@ -410,7 +410,7 @@ jobs:
test-frontend-unit: test-frontend-unit:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: ./.github/actions/setup-frontend - uses: ./.github/actions/setup-frontend
- name: Run unit tests - name: Run unit tests
working-directory: frontend working-directory: frontend
@ -419,11 +419,11 @@ jobs:
frontend-build: frontend-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: ./.github/actions/setup-frontend - uses: ./.github/actions/setup-frontend
- name: Git describe - name: Git describe
id: ghd id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0 uses: proudust/gh-describe@v2
- name: Inject frontend version - name: Inject frontend version
working-directory: frontend working-directory: frontend
run: | run: |
@ -432,7 +432,7 @@ jobs:
working-directory: frontend working-directory: frontend
run: pnpm build run: pnpm build
- name: Store Frontend - name: Store Frontend
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: frontend_dist name: frontend_dist
path: ./frontend/dist path: ./frontend/dist
@ -442,13 +442,13 @@ jobs:
needs: needs:
- api-build - api-build
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Vikunja Binary - name: Download Vikunja Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: vikunja_bin name: vikunja_bin
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: Install mage - name: Install mage
@ -501,7 +501,7 @@ jobs:
(cd veans && mage test:e2e) (cd veans && mage test:e2e)
- name: Upload API log on failure - name: Upload API log on failure
if: failure() if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: veans-e2e-vikunja-log name: veans-e2e-vikunja-log
path: /tmp/vikunja.log path: /tmp/vikunja.log
@ -523,19 +523,19 @@ jobs:
ports: ports:
- 5556:5556 - 5556:5556
container: 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 options: --user 1001
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Vikunja Binary - name: Download Vikunja Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: vikunja_bin name: vikunja_bin
- uses: ./.github/actions/setup-frontend - uses: ./.github/actions/setup-frontend
with: with:
install-e2e-binaries: false # Playwright browsers already in container install-e2e-binaries: false # Playwright browsers already in container
- name: Download Frontend - name: Download Frontend
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: frontend_dist name: frontend_dist
path: ./frontend/dist path: ./frontend/dist
@ -570,14 +570,14 @@ jobs:
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTID: vikunja VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTID: vikunja
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTSECRET: secret VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTSECRET: secret
- name: Upload Playwright Report - name: Upload Playwright Report
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
if: always() if: always()
with: with:
name: playwright-report-${{ matrix.shard }} name: playwright-report-${{ matrix.shard }}
path: frontend/playwright-report/ path: frontend/playwright-report/
retention-days: 30 retention-days: 30
- name: Upload Test Results - name: Upload Test Results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
if: always() if: always()
with: with:
name: playwright-test-results-${{ matrix.shard }} name: playwright-test-results-${{ matrix.shard }}

1
.gitignore vendored
View File

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

View File

@ -145,13 +145,6 @@ linters:
- revive - revive
path: pkg/utils/* path: pkg/utils/*
text: 'var-naming: avoid meaningless package names' 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: - linters:
- revive - revive
text: 'var-naming: avoid package names that conflict with Go standard library package names' 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. 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. 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 ## Key Files and Conventions
**Configuration:** **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/*` - 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 - 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 - 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. - 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:** **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 # syntax=docker/dockerfile:1@sha256:b6afd42430b15f2d2a4c5a02b919e98a525b785b1aaff16747d2f623364e39b6
FROM --platform=$BUILDPLATFORM node:24.18.0-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS frontendbuilder FROM --platform=$BUILDPLATFORM node:24.13.0-alpine@sha256:931d7d57f8c1fd0e2179dbff7cc7da4c9dd100998bc2b32afc85142d8efbc213 AS frontendbuilder
WORKDIR /build WORKDIR /build
@ -14,7 +14,7 @@ COPY frontend/ ./
ARG RELEASE_VERSION=dev ARG RELEASE_VERSION=dev
RUN echo "{\"VERSION\": \"${RELEASE_VERSION/-g/-}\"}" > src/version.json && pnpm run build 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 && \ RUN go install github.com/magefile/mage@latest && \
mv /go/bin/mage /usr/local/go/bin mv /go/bin/mage /usr/local/go/bin

View File

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

View File

@ -849,11 +849,6 @@
"default_value": "(&(objectclass=*)(|(objectclass=group)(objectclass=groupOfNames)))", "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`." "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", "key": "avatarsyncattribute",
"default_value": "", "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", "key": "outgoingrequests",
"children": [ "children": [

View File

@ -100,15 +100,10 @@ app.on('second-instance', (_event, argv) => {
return return
} }
// Reveal the main window. It may be hidden in the tray (not just minimized), // Focus the main window
// 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.
if (mainWindow) { if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore() if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.show()
mainWindow.focus() mainWindow.focus()
} else if (serverPort) {
createMainWindow()
} }
// Find the deep link URL in argv // Find the deep link URL in argv
@ -241,11 +236,6 @@ function createMainWindow() {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1680, width: 1680,
height: 960, 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: { webPreferences: {
...BASE_WEB_PREFERENCES, ...BASE_WEB_PREFERENCES,
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
@ -553,14 +543,3 @@ app.on('window-all-closed', () => {
app.quit() 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", "main": "main.js",
"repository": "https://code.vikunja.io/desktop", "repository": "https://code.vikunja.io/desktop",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"packageManager": "pnpm@10.34.4", "packageManager": "pnpm@10.28.1",
"author": { "author": {
"email": "maintainers@vikunja.io", "email": "maintainers@vikunja.io",
"name": "Vikunja Team" "name": "Vikunja Team"
@ -61,9 +61,9 @@
} }
}, },
"devDependencies": { "devDependencies": {
"electron": "40.10.5", "electron": "40.10.2",
"electron-builder": "26.15.3", "electron-builder": "26.8.1",
"unzipper": "0.12.5" "unzipper": "0.12.3"
}, },
"dependencies": { "dependencies": {
"express": "5.2.1" "express": "5.2.1"
@ -73,16 +73,12 @@
"electron" "electron"
], ],
"overrides": { "overrides": {
"minimatch": "10.2.5", "minimatch": "^10.2.3",
"tar": "7.5.17", "tar": "^7.5.11",
"@tootallnate/once": "3.0.1", "@tootallnate/once": "^3.0.1",
"picomatch": "4.0.4", "picomatch": ">=4.0.4",
"tmp": "0.2.7", "tmp": ">=0.2.6",
"ip-address": "10.2.0", "ip-address": ">=10.1.1"
"form-data": "4.0.6",
"js-yaml": "5.2.0",
"undici@6": "6.27.0",
"undici@7": "7.28.0"
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -3,11 +3,10 @@
"devenv": { "devenv": {
"locked": { "locked": {
"dir": "src/modules", "dir": "src/modules",
"lastModified": 1782492839, "lastModified": 1773012232,
"narHash": "sha256-j9wrcB4al5QhMelEghJ0Qs+RQPT+wyCcI4070NEgPLQ=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv", "repo": "devenv",
"rev": "3d39d0817d62069f7b18821c34a617b5141cb278", "rev": "46a4bd0299a26ad948b71d3053174ba7b90522f7",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -17,16 +16,71 @@
"type": "github" "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": { "nixpkgs": {
"inputs": { "inputs": {
"nixpkgs-src": "nixpkgs-src" "nixpkgs-src": "nixpkgs-src"
}, },
"locked": { "locked": {
"lastModified": 1782132010, "lastModified": 1772749504,
"narHash": "sha256-ZnAVHdVrotp80iIMm5CSR1fdxPlw7Uwmwxb+O/wsgZ8=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv-nixpkgs", "repo": "devenv-nixpkgs",
"rev": "12866ae2dddbc0ab8b329915f8072bb9c75bde89", "rev": "08543693199362c1fddb8f52126030d0d374ba2e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -39,11 +93,11 @@
"nixpkgs-src": { "nixpkgs-src": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1781607440, "lastModified": 1769922788,
"narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=", "narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "3e41b24abd260e8f71dbe2f5737d24122f972158", "rev": "207d15f1a6603226e1e223dc79ac29c7846da32e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -55,11 +109,10 @@
}, },
"nixpkgs-unstable": { "nixpkgs-unstable": {
"locked": { "locked": {
"lastModified": 1782467914, "lastModified": 1772773019,
"narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "e73de5be04e0eff4190a1432b946d469c794e7b4", "rev": "aca4d95fce4914b3892661bcb80b8087293536c6",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -72,11 +125,15 @@
"root": { "root": {
"inputs": { "inputs": {
"devenv": "devenv", "devenv": "devenv",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"nixpkgs-unstable": "nixpkgs-unstable" "nixpkgs-unstable": "nixpkgs-unstable",
"pre-commit-hooks": [
"git-hooks"
]
} }
} }
}, },
"root": "root", "root": "root",
"version": 7 "version": 7
} }

View File

@ -1 +1 @@
24.18.0 24.13.0

View File

@ -13,7 +13,7 @@
}, },
"homepage": "https://vikunja.io/", "homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja", "funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@10.34.4", "packageManager": "pnpm@10.28.1",
"engines": { "engines": {
"node": ">=24.0.0" "node": ">=24.0.0"
}, },
@ -51,111 +51,111 @@
"story:preview": "histoire preview" "story:preview": "histoire preview"
}, },
"dependencies": { "dependencies": {
"@floating-ui/dom": "1.7.6", "@floating-ui/dom": "1.7.4",
"@fortawesome/fontawesome-svg-core": "7.3.0", "@fortawesome/fontawesome-svg-core": "7.1.0",
"@fortawesome/free-regular-svg-icons": "7.3.0", "@fortawesome/free-regular-svg-icons": "7.1.0",
"@fortawesome/free-solid-svg-icons": "7.3.0", "@fortawesome/free-solid-svg-icons": "7.1.0",
"@fortawesome/vue-fontawesome": "3.3.0", "@fortawesome/vue-fontawesome": "3.1.3",
"@intlify/unplugin-vue-i18n": "11.2.4", "@intlify/unplugin-vue-i18n": "11.0.3",
"@kyvg/vue3-notification": "3.4.2", "@kyvg/vue3-notification": "3.4.2",
"@sentry/vue": "10.62.0", "@sentry/vue": "10.36.0",
"@tiptap/core": "3.27.1", "@tiptap/core": "3.17.0",
"@tiptap/extension-blockquote": "3.27.1", "@tiptap/extension-blockquote": "3.17.0",
"@tiptap/extension-code-block-lowlight": "3.27.1", "@tiptap/extension-code-block-lowlight": "3.17.0",
"@tiptap/extension-hard-break": "3.27.1", "@tiptap/extension-hard-break": "3.17.0",
"@tiptap/extension-image": "3.27.1", "@tiptap/extension-image": "3.17.0",
"@tiptap/extension-link": "3.27.1", "@tiptap/extension-link": "3.17.0",
"@tiptap/extension-list": "3.27.1", "@tiptap/extension-list": "3.17.0",
"@tiptap/extension-mention": "3.27.1", "@tiptap/extension-mention": "3.17.0",
"@tiptap/extension-table": "3.27.1", "@tiptap/extension-table": "3.17.0",
"@tiptap/extension-typography": "3.27.1", "@tiptap/extension-typography": "3.17.0",
"@tiptap/extension-underline": "3.27.1", "@tiptap/extension-underline": "3.17.0",
"@tiptap/extensions": "3.27.1", "@tiptap/extensions": "3.17.0",
"@tiptap/pm": "3.27.1", "@tiptap/pm": "3.17.0",
"@tiptap/starter-kit": "3.27.1", "@tiptap/starter-kit": "3.17.0",
"@tiptap/suggestion": "3.27.1", "@tiptap/suggestion": "3.17.0",
"@tiptap/vue-3": "3.27.1", "@tiptap/vue-3": "3.17.0",
"@vueuse/core": "14.3.0", "@vueuse/core": "14.1.0",
"@vueuse/router": "14.3.0", "@vueuse/router": "14.1.0",
"axios": "1.18.1", "axios": "1.16.0",
"blurhash": "2.0.5", "blurhash": "2.0.5",
"bulma-css-variables": "0.9.33", "bulma-css-variables": "0.9.33",
"change-case": "5.4.4", "change-case": "5.4.4",
"dayjs": "1.11.21", "dayjs": "1.11.19",
"dompurify": "3.4.11", "dompurify": "3.4.0",
"fast-deep-equal": "3.1.3", "fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13", "flatpickr": "4.6.13",
"floating-vue": "5.2.2", "floating-vue": "5.2.2",
"is-touch-device": "1.0.1", "is-touch-device": "1.0.1",
"klona": "2.0.6", "klona": "2.0.6",
"lowlight": "3.3.0", "lowlight": "3.3.0",
"marked": "17.0.6", "marked": "17.0.1",
"nanoid": "5.1.16", "nanoid": "5.1.6",
"pinia": "3.0.4", "pinia": "3.0.4",
"register-service-worker": "1.7.2", "register-service-worker": "1.7.2",
"sortablejs": "1.15.7", "sortablejs": "1.15.6",
"ufo": "1.6.4", "ufo": "1.6.3",
"vue": "3.5.39", "vue": "3.5.27",
"vue-advanced-cropper": "2.8.9", "vue-advanced-cropper": "2.8.9",
"vue-flatpickr-component": "11.0.5", "vue-flatpickr-component": "11.0.5",
"vue-i18n": "11.4.6", "vue-i18n": "11.2.8",
"vue-router": "4.6.4", "vue-router": "4.6.4",
"vuemoji-picker": "0.3.2", "vuemoji-picker": "0.3.2",
"workbox-precaching": "7.4.1", "workbox-precaching": "7.4.1",
"zhyswan-vuedraggable": "4.1.3" "zhyswan-vuedraggable": "4.1.3"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "10.5.0", "@faker-js/faker": "10.4.0",
"@histoire/plugin-screenshot": "1.0.0-beta.1", "@histoire/plugin-screenshot": "1.0.0-beta.1",
"@histoire/plugin-vue": "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", "@sentry/vite-plugin": "3.6.1",
"@tailwindcss/vite": "4.3.1", "@tailwindcss/vite": "4.3.0",
"@tsconfig/node24": "24.0.4", "@tsconfig/node24": "24.0.4",
"@types/codemirror": "5.60.17", "@types/codemirror": "5.60.17",
"@types/is-touch-device": "1.0.3", "@types/is-touch-device": "1.0.3",
"@types/node": "24.13.2", "@types/node": "24.12.4",
"@types/sortablejs": "1.15.9", "@types/sortablejs": "1.15.9",
"@types/ws": "8.18.1", "@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.62.0", "@typescript-eslint/eslint-plugin": "8.60.1",
"@typescript-eslint/parser": "8.62.0", "@typescript-eslint/parser": "8.60.1",
"@vitejs/plugin-vue": "6.0.7", "@vitejs/plugin-vue": "6.0.7",
"@vue/eslint-config-typescript": "14.9.0", "@vue/eslint-config-typescript": "14.7.0",
"@vue/test-utils": "2.4.11", "@vue/test-utils": "2.4.10",
"@vue/tsconfig": "0.9.1", "@vue/tsconfig": "0.9.1",
"@vueuse/shared": "14.3.0", "@vueuse/shared": "14.3.0",
"autoprefixer": "10.5.2", "autoprefixer": "10.5.0",
"browserslist": "4.28.4", "browserslist": "4.28.2",
"caniuse-lite": "1.0.30001799", "caniuse-lite": "1.0.30001793",
"csstype": "3.2.3", "csstype": "3.2.3",
"esbuild": "0.28.1", "esbuild": "0.28.0",
"eslint": "9.39.4", "eslint": "9.39.4",
"eslint-plugin-depend": "1.5.0", "eslint-plugin-depend": "1.5.0",
"eslint-plugin-vue": "10.9.2", "eslint-plugin-vue": "10.9.2",
"happy-dom": "20.10.6", "happy-dom": "20.9.0",
"histoire": "1.0.0-beta.1", "histoire": "1.0.0-beta.1",
"otplib": "12.0.1", "otplib": "12.0.1",
"postcss": "8.5.15", "postcss": "8.5.15",
"postcss-easing-gradients": "3.0.1", "postcss-easing-gradients": "3.0.1",
"postcss-html": "1.8.1", "postcss-html": "1.8.1",
"postcss-preset-env": "11.3.1", "postcss-preset-env": "11.3.0",
"rollup": "4.62.2", "rollup": "4.61.0",
"rollup-plugin-visualizer": "6.0.11", "rollup-plugin-visualizer": "6.0.11",
"sass-embedded": "1.100.0", "sass-embedded": "1.100.0",
"stylelint": "17.13.0", "stylelint": "17.12.0",
"stylelint-config-property-sort-order-smacss": "10.0.0", "stylelint-config-property-sort-order-smacss": "10.0.0",
"stylelint-config-recommended-vue": "1.6.1", "stylelint-config-recommended-vue": "1.6.1",
"stylelint-config-standard-scss": "17.0.0", "stylelint-config-standard-scss": "17.0.0",
"stylelint-use-logical": "2.1.3", "stylelint-use-logical": "2.1.3",
"tailwindcss": "4.3.1", "tailwindcss": "4.3.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"unplugin-inject-preload": "3.0.0", "unplugin-inject-preload": "3.0.0",
"vite": "7.3.6", "vite": "7.3.5",
"vite-plugin-pwa": "1.3.0", "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", "vite-svg-loader": "5.1.1",
"vitest": "4.1.9", "vitest": "4.1.8",
"vue-tsc": "3.3.5", "vue-tsc": "3.3.3",
"wait-on": "9.0.10", "wait-on": "9.0.10",
"workbox-cli": "7.4.1", "workbox-cli": "7.4.1",
"ws": "8.21.0" "ws": "8.21.0"
@ -169,20 +169,14 @@
"vue-demi" "vue-demi"
], ],
"overrides": { "overrides": {
"minimatch": "10.2.5", "minimatch": "^10.2.3",
"rollup": "$rollup", "rollup": "$rollup",
"basic-ftp": "6.0.1", "basic-ftp": ">=5.2.2",
"serialize-javascript": "7.0.6", "serialize-javascript": "^7.0.5",
"flatted": "3.4.2", "flatted": "^3.4.1",
"ip-address": "10.2.0", "ip-address": ">=10.1.1",
"postcss": "8.5.15", "postcss": ">=8.5.10",
"tmp": "0.2.7", "tmp": ">=0.2.6"
"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"
} }
} }
} }

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 {useBaseStore} from '@/stores/base'
import {useColorScheme} from '@/composables/useColorScheme' import {useColorScheme} from '@/composables/useColorScheme'
import {useTimeTrackingFavicon} from '@/composables/useTimeTrackingFavicon'
import {useBodyClass} from '@/composables/useBodyClass' import {useBodyClass} from '@/composables/useBodyClass'
import QuickAddOverlay from '@/components/quick-actions/QuickAddOverlay.vue' import QuickAddOverlay from '@/components/quick-actions/QuickAddOverlay.vue'
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue' import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
@ -108,7 +107,6 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
setLanguage(authStore.settings.language ?? DEFAULT_LANGUAGE) setLanguage(authStore.settings.language ?? DEFAULT_LANGUAGE)
useColorScheme() useColorScheme()
useTimeTrackingFavicon()
</script> </script>
<style src="@/styles/tailwind.css" /> <style src="@/styles/tailwind.css" />

View File

@ -36,18 +36,4 @@ describe('DatepickerWithRange predefined ranges', () => {
const last = wrapper.emitted('update:modelValue')?.pop()?.[0] const last = wrapper.emitted('update:modelValue')?.pop()?.[0]
expect(last).toEqual({dateFrom: 'now/M-1M', dateTo: 'now/M'}) 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' import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
const props = defineProps<{ const props = defineProps<{
// null for a side that's been cleared (the Custom option) emitted, so accepted too.
modelValue: { modelValue: {
dateFrom: Date | string | null, dateFrom: Date | string,
dateTo: Date | string | null, dateTo: Date | string,
}, },
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: { 'update:modelValue': [value: {
dateFrom: Date | string | null, dateFrom: Date | string,
dateTo: Date | string | null dateTo: Date | string
}] }]
}>() }>()
@ -150,8 +149,8 @@ const to = ref('')
watch( watch(
() => props.modelValue, () => props.modelValue,
newValue => { newValue => {
from.value = typeof newValue.dateFrom === 'string' ? newValue.dateFrom : (newValue.dateFrom?.toISOString() ?? '') from.value = typeof newValue.dateFrom === 'string' ? newValue.dateFrom : newValue.dateFrom.toISOString()
to.value = typeof newValue.dateTo === 'string' ? newValue.dateTo : (newValue.dateTo?.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. // 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. // Otherwise flatpickr runs in an endless loop and slows down the browser.
const dateFrom = parseDateOrString(from.value, false) const dateFrom = parseDateOrString(from.value, false)
@ -209,22 +208,14 @@ const customRangeActive = computed<boolean>(() => {
}) })
const buttonText = computed<string>(() => { const buttonText = computed<string>(() => {
if (from.value === '' || to.value === '') { if (from.value !== '' && to.value !== '') {
return t('task.show.select') 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. return t('task.show.select')
const preset = Object.entries(DATE_RANGES).find(
([, range]) => from.value === range[0] && to.value === range[1],
)
if (preset) {
return t(`input.datepickerRange.ranges.${preset[0]}`)
}
return t('input.datepickerRange.fromto', {
from: from.value,
to: to.value,
})
}) })
</script> </script>

View File

@ -13,14 +13,14 @@
<div class="gantt-chart-wrapper"> <div class="gantt-chart-wrapper">
<GanttTimelineHeader <GanttTimelineHeader
:timeline-data="timelineData" :timeline-data="timelineData"
:day-width-pixels="dayWidthPixels" :day-width-pixels="DAY_WIDTH_PIXELS"
/> />
<GanttVerticalGridLines <GanttVerticalGridLines
:timeline-data="timelineData" :timeline-data="timelineData"
:total-width="totalWidth" :total-width="totalWidth"
:height="ganttRows.length * 40" :height="ganttRows.length * 40"
:day-width-pixels="dayWidthPixels" :day-width-pixels="DAY_WIDTH_PIXELS"
/> />
<GanttChartBody <GanttChartBody
@ -57,7 +57,7 @@
:total-width="totalWidth" :total-width="totalWidth"
:date-from-date="dateFromDate" :date-from-date="dateFromDate"
:date-to-date="dateToDate" :date-to-date="dateToDate"
:day-width-pixels="dayWidthPixels" :day-width-pixels="DAY_WIDTH_PIXELS"
:is-dragging="isDragging" :is-dragging="isDragging"
:is-resizing="isResizing" :is-resizing="isResizing"
:drag-state="dragState" :drag-state="dragState"
@ -89,7 +89,7 @@
</template> </template>
<script setup lang="ts"> <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 {useRouter} from 'vue-router'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync' import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync'
@ -126,9 +126,7 @@ const emit = defineEmits<{
(e: 'update:task', task: ITaskPartialWithId): void (e: 'update:task', task: ITaskPartialWithId): void
}>() }>()
const DAY_WIDTH_PIXELS_MIN = 30 const DAY_WIDTH_PIXELS = 30
const dayWidthPixels = ref(0)
let resizeObserver: ResizeObserver
const {tasks, filters} = toRefs(props) const {tasks, filters} = toRefs(props)
@ -160,7 +158,7 @@ const dateToDate = computed(() => dayjs(filters.value.dateTo).endOf('day').toDat
const totalWidth = computed(() => { const totalWidth = computed(() => {
const dateDiff = Math.ceil((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY) 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(() => { 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 // Build the task tree when tasks change
watch( watch(
[tasks, filters], [tasks, filters],
@ -402,7 +351,7 @@ const ROW_HEIGHT = 40
const barPositions = computed(() => { const barPositions = computed(() => {
const positions = new Map<number, GanttBarPosition>() const positions = new Map<number, GanttBarPosition>()
const ds = dragState.value 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) => { ganttBars.value.forEach((rowBars, rowIndex) => {
for (const bar of rowBars) { for (const bar of rowBars) {
@ -437,7 +386,7 @@ function computeBarX(date: Date): number {
(roundToNaturalDayBoundary(date, true).getTime() - dateFromDate.value.getTime()) / (roundToNaturalDayBoundary(date, true).getTime() - dateFromDate.value.getTime()) /
MILLISECONDS_A_DAY, MILLISECONDS_A_DAY,
) )
return diff * dayWidthPixels.value return diff * DAY_WIDTH_PIXELS
} }
function computeBarWidth(bar: GanttBarModel): number { function computeBarWidth(bar: GanttBarModel): number {
@ -445,7 +394,7 @@ function computeBarWidth(bar: GanttBarModel): number {
(roundToNaturalDayBoundary(bar.end).getTime() - roundToNaturalDayBoundary(bar.start, true).getTime()) / (roundToNaturalDayBoundary(bar.end).getTime() - roundToNaturalDayBoundary(bar.start, true).getTime()) /
MILLISECONDS_A_DAY, MILLISECONDS_A_DAY,
) )
return diff * dayWidthPixels.value return diff * DAY_WIDTH_PIXELS
} }
// Compute relation arrows // Compute relation arrows
@ -641,7 +590,7 @@ function startDrag(bar: GanttBarModel, event: PointerEvent) {
if (!dragState.value || !isDragging.value) return if (!dragState.value || !isDragging.value) return
const diff = e.clientX - dragState.value.startX 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) { if (days !== dragState.value.currentDays) {
dragState.value.currentDays = days dragState.value.currentDays = days
@ -703,7 +652,7 @@ function startResize(bar: GanttBarModel, edge: 'start' | 'end', event: PointerEv
if (!dragState.value || !isResizing.value) return if (!dragState.value || !isResizing.value) return
const diff = e.clientX - dragState.value.startX 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') { if (edge === 'start') {
const newStart = new Date(dragState.value.originalStart) const newStart = new Date(dragState.value.originalStart)
@ -781,7 +730,7 @@ function focusTaskBar(rowId: string) {
setTimeout(() => { setTimeout(() => {
const taskBarElement = document.querySelector(`[data-row-id="${rowId}"] [role="slider"]`) as HTMLElement const taskBarElement = document.querySelector(`[data-row-id="${rowId}"] [role="slider"]`) as HTMLElement
if (taskBarElement) { if (taskBarElement) {
taskBarElement.focus({preventScroll: true}) taskBarElement.focus()
} }
}, 0) }, 0)
} }

View File

@ -54,15 +54,7 @@
</ProjectSettingsDropdown> </ProjectSettingsDropdown>
</div> </div>
<div
v-else-if="pageTitle"
class="project-title-wrapper"
>
<span class="project-title">{{ pageTitle }}</span>
</div>
<div class="navbar-end"> <div class="navbar-end">
<TimerBadge />
<OpenQuickActions /> <OpenQuickActions />
<Notifications /> <Notifications />
<Dropdown> <Dropdown>
@ -129,17 +121,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { PERMISSIONS as Permissions } from '@/constants/permissions' import { PERMISSIONS as Permissions } from '@/constants/permissions'
import { PRO_FEATURE } from '@/constants/proFeatures'
import ProjectSettingsDropdown from '@/components/project/ProjectSettingsDropdown.vue' import ProjectSettingsDropdown from '@/components/project/ProjectSettingsDropdown.vue'
import Dropdown from '@/components/misc/Dropdown.vue' import Dropdown from '@/components/misc/Dropdown.vue'
import DropdownItem from '@/components/misc/DropdownItem.vue' import DropdownItem from '@/components/misc/DropdownItem.vue'
import Notifications from '@/components/notifications/Notifications.vue' import Notifications from '@/components/notifications/Notifications.vue'
import TimerBadge from '@/components/time-tracking/TimerBadge.vue'
import Logo from '@/components/home/Logo.vue' import Logo from '@/components/home/Logo.vue'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import MenuButton from '@/components/home/MenuButton.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 canWriteCurrentProject = computed(() => baseStore.currentProject?.maxPermission !== null && baseStore.currentProject?.maxPermission !== undefined && baseStore.currentProject.maxPermission > Permissions.READ)
const menuActive = computed(() => baseStore.menuActive) 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 authStore = useAuthStore()
const configStore = useConfigStore() const configStore = useConfigStore()
const imprintUrl = computed(() => configStore.legal.imprintUrl) const imprintUrl = computed(() => configStore.legal.imprintUrl)
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl) const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
const adminPanelEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.ADMIN_PANEL)) const adminPanelEnabled = computed(() => configStore.isProFeatureEnabled('admin_panel'))
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -71,14 +71,6 @@
{{ $t('team.title') }} {{ $t('team.title') }}
</RouterLink> </RouterLink>
</li> </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> </menu>
</nav> </nav>
@ -141,17 +133,12 @@ import Loading from '@/components/misc/Loading.vue'
import {useBaseStore} from '@/stores/base' import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects' import {useProjectStore} from '@/stores/projects'
import {useConfigStore} from '@/stores/config'
import {PRO_FEATURE} from '@/constants/proFeatures'
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue' import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
import type {IProject} from '@/modelTypes/IProject' import type {IProject} from '@/modelTypes/IProject'
import {useSidebarResize} from '@/composables/useSidebarResize' import {useSidebarResize} from '@/composables/useSidebarResize'
const baseStore = useBaseStore() const baseStore = useBaseStore()
const projectStore = useProjectStore() const projectStore = useProjectStore()
const configStore = useConfigStore()
const timeTrackingEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING))
const {sidebarWidth, isResizing, startResize, isMobile} = useSidebarResize() const {sidebarWidth, isResizing, startResize, isMobile} = useSidebarResize()

View File

@ -5,10 +5,7 @@
:disabled="disabled || undefined" :disabled="disabled || undefined"
@click.stop="toggleDatePopup" @click.stop="toggleDatePopup"
> >
<i v-if="date === null && emptyLabel !== ''">{{ emptyLabel }}</i> {{ date === null ? chooseDateLabel : formatDisplayDate(date) }}
<template v-else>
{{ date === null ? chooseDateLabel : formatDisplayDate(date) }}
</template>
</SimpleButton> </SimpleButton>
<CustomTransition name="fade"> <CustomTransition name="fade">
@ -19,7 +16,6 @@
> >
<DatepickerInline <DatepickerInline
v-model="date" v-model="date"
:show-shortcuts="showShortcuts"
@update:modelValue="updateData" @update:modelValue="updateData"
/> />
@ -52,17 +48,12 @@ const props = withDefaults(defineProps<{
modelValue: Date | null | string, modelValue: Date | null | string,
chooseDateLabel?: string, chooseDateLabel?: string,
disabled?: boolean, disabled?: boolean,
showShortcuts?: boolean,
// When the value is null, show this (italic) instead of chooseDateLabel.
emptyLabel?: string,
}>(), { }>(), {
chooseDateLabel: () => { chooseDateLabel: () => {
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
return t('input.datepicker.chooseDate') return t('input.datepicker.chooseDate')
}, },
disabled: false, disabled: false,
showShortcuts: true,
emptyLabel: '',
}) })
const emit = defineEmits<{ const emit = defineEmits<{

View File

@ -1,68 +1,66 @@
<template> <template>
<template v-if="showShortcuts"> <BaseButton
<BaseButton v-if="(new Date()).getHours() < 21"
v-if="(new Date()).getHours() < 21" class="datepicker__quick-select-date"
class="datepicker__quick-select-date" @click.stop="setDate('today')"
@click.stop="setDate('today')" >
> <span class="icon"><Icon :icon="['far', 'calendar-alt']" /></span>
<span class="icon"><Icon :icon="['far', 'calendar-alt']" /></span> <span class="text">
<span class="text"> <span>{{ $t('input.datepicker.today') }}</span>
<span>{{ $t('input.datepicker.today') }}</span> <span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span> </span>
</span> </BaseButton>
</BaseButton> <BaseButton
<BaseButton class="datepicker__quick-select-date"
class="datepicker__quick-select-date" @click.stop="setDate('tomorrow')"
@click.stop="setDate('tomorrow')" >
> <span class="icon"><Icon :icon="['far', 'sun']" /></span>
<span class="icon"><Icon :icon="['far', 'sun']" /></span> <span class="text">
<span class="text"> <span>{{ $t('input.datepicker.tomorrow') }}</span>
<span>{{ $t('input.datepicker.tomorrow') }}</span> <span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span> </span>
</span> </BaseButton>
</BaseButton> <BaseButton
<BaseButton class="datepicker__quick-select-date"
class="datepicker__quick-select-date" @click.stop="setDate('nextMonday')"
@click.stop="setDate('nextMonday')" >
> <span class="icon"><Icon icon="coffee" /></span>
<span class="icon"><Icon icon="coffee" /></span> <span class="text">
<span class="text"> <span>{{ $t('input.datepicker.nextMonday') }}</span>
<span>{{ $t('input.datepicker.nextMonday') }}</span> <span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span> </span>
</span> </BaseButton>
</BaseButton> <BaseButton
<BaseButton v-if="!((new Date()).getDay() === 0 && (new Date()).getHours() >= 21)"
v-if="!((new Date()).getDay() === 0 && (new Date()).getHours() >= 21)" class="datepicker__quick-select-date"
class="datepicker__quick-select-date" @click.stop="setDate('thisWeekend')"
@click.stop="setDate('thisWeekend')" >
> <span class="icon"><Icon icon="cocktail" /></span>
<span class="icon"><Icon icon="cocktail" /></span> <span class="text">
<span class="text"> <span>{{ $t('input.datepicker.thisWeekend') }}</span>
<span>{{ $t('input.datepicker.thisWeekend') }}</span> <span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span> </span>
</span> </BaseButton>
</BaseButton> <BaseButton
<BaseButton class="datepicker__quick-select-date"
class="datepicker__quick-select-date" @click.stop="setDate('laterThisWeek')"
@click.stop="setDate('laterThisWeek')" >
> <span class="icon"><Icon icon="chess-knight" /></span>
<span class="icon"><Icon icon="chess-knight" /></span> <span class="text">
<span class="text"> <span>{{ $t('input.datepicker.laterThisWeek') }}</span>
<span>{{ $t('input.datepicker.laterThisWeek') }}</span> <span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span> </span>
</span> </BaseButton>
</BaseButton> <BaseButton
<BaseButton class="datepicker__quick-select-date"
class="datepicker__quick-select-date" @click.stop="setDate('nextWeek')"
@click.stop="setDate('nextWeek')" >
> <span class="icon"><Icon icon="forward" /></span>
<span class="icon"><Icon icon="forward" /></span> <span class="text">
<span class="text"> <span>{{ $t('input.datepicker.nextWeek') }}</span>
<span>{{ $t('input.datepicker.nextWeek') }}</span> <span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span> </span>
</span> </BaseButton>
</BaseButton>
</template>
<div class="flatpickr-container"> <div class="flatpickr-container">
<flat-pickr <flat-pickr
@ -89,12 +87,9 @@ import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
import {useTimeFormat} from '@/composables/useTimeFormat' import {useTimeFormat} from '@/composables/useTimeFormat'
import {TIME_FORMAT} from '@/constants/timeFormat' import {TIME_FORMAT} from '@/constants/timeFormat'
const props = withDefaults(defineProps<{ const props = defineProps<{
modelValue: Date | null | string modelValue: Date | null | string
showShortcuts?: boolean }>()
}>(), {
showShortcuts: true,
})
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [Date | null], 'update:modelValue': [Date | null],

View File

@ -722,7 +722,7 @@ async function addImage(event: Event) {
return return
} }
const url = await inputPrompt(event.target.getBoundingClientRect(), '', editor.value) const url = await inputPrompt(event.target.getBoundingClientRect())
if (url) { if (url) {
editor.value?.chain().focus().setImage({src: url}).run() 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 EmojiList from './EmojiList.vue'
import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData' import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData'
import {getPopupContainer} from '../popupContainer'
export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion') export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion')
@ -79,7 +78,7 @@ export default function emojiSuggestionSetup() {
popupElement.style.left = '0' popupElement.style.left = '0'
popupElement.style.zIndex = '4700' popupElement.style.zIndex = '4700'
popupElement.appendChild(component.element!) popupElement.appendChild(component.element!)
getPopupContainer(props.editor).appendChild(popupElement) document.body.appendChild(popupElement)
const rect = props.clientRect() const rect = props.clientRect()
if (!rect) { if (!rect) {
@ -109,7 +108,7 @@ export default function emojiSuggestionSetup() {
cleanupFloating = null cleanupFloating = null
} }
if (popupElement) { if (popupElement) {
popupElement.remove() document.body.removeChild(popupElement)
popupElement = null popupElement = null
} }
component?.destroy() component?.destroy()

View File

@ -3,7 +3,7 @@ import inputPrompt from '@/helpers/inputPrompt'
export async function setLinkInEditor(pos: DOMRect, editor: Editor | null | undefined) { export async function setLinkInEditor(pos: DOMRect, editor: Editor | null | undefined) {
const previousUrl = editor?.getAttributes('link').href || '' const previousUrl = editor?.getAttributes('link').href || ''
const url = await inputPrompt(pos, previousUrl, editor ?? undefined) const url = await inputPrompt(pos, previousUrl)
// empty // empty
if (url === '') { if (url === '') {

View File

@ -1,7 +1,6 @@
import {library} from '@fortawesome/fontawesome-svg-core' import {library} from '@fortawesome/fontawesome-svg-core'
import { import {
faAlignLeft, faAlignLeft,
faAngleLeft,
faAngleRight, faAngleRight,
faAnglesUp, faAnglesUp,
faArchive, faArchive,
@ -122,7 +121,6 @@ library.add(faCode)
library.add(faQuoteRight) library.add(faQuoteRight)
library.add(faListUl) library.add(faListUl)
library.add(faAlignLeft) library.add(faAlignLeft)
library.add(faAngleLeft)
library.add(faAngleRight) library.add(faAngleRight)
library.add(faArchive) library.add(faArchive)
library.add(faArrowLeft) library.add(faArrowLeft)

View File

@ -68,7 +68,7 @@ const props = withDefaults(defineProps<{
enabled?: boolean, enabled?: boolean,
overflow?: boolean, overflow?: boolean,
wide?: boolean, wide?: boolean,
variant?: 'default' | 'hint-modal' | 'scrolling' | 'top', variant?: 'default' | 'hint-modal' | 'scrolling',
}>(), { }>(), {
enabled: true, enabled: true,
overflow: false, overflow: false,
@ -211,13 +211,7 @@ $modal-width: 1024px;
// Reset UA dialog styles // Reset UA dialog styles
padding: 0; padding: 0;
border: none; border: none;
// The scrim lives on the dialog element, not on ::backdrop: Chromium background: transparent;
// intermittently stops painting a styled ::backdrop (e.g. after the
// dialog's subtree re-renders, or while display is transitioned) even
// though getComputedStyle still reports the color. The dialog fills the
// viewport anyway, and its opacity transition fades the scrim with it
// same as the old div-based .modal-mask.
background: rgba(0, 0, 0, .8);
color: #ffffff; color: #ffffff;
// Fill viewport // Fill viewport
position: fixed; position: fixed;
@ -227,12 +221,10 @@ $modal-width: 1024px;
max-inline-size: 100%; max-inline-size: 100%;
max-block-size: 100%; max-block-size: 100%;
// Transitions. No display/allow-discrete transition needed: the close // Transitions
// fade runs while the dialog is still [open] (data-closing + timer in
// closeDialog), and transitioning display triggers the Chromium paint
// bug above.
opacity: 0; opacity: 0;
transition: opacity 150ms ease; transition: opacity 150ms ease,
display 150ms ease allow-discrete;
&[open]:not([data-closing]) { &[open]:not([data-closing]) {
opacity: 1; opacity: 1;
@ -244,11 +236,16 @@ $modal-width: 1024px;
&::backdrop { &::backdrop {
background-color: rgba(0, 0, 0, 0); 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 &[open]:not([data-closing])::backdrop {
&:has(.is-quick-add-mode) { background-color: rgba(0, 0, 0, .8);
background: transparent;
@starting-style {
background-color: rgba(0, 0, 0, 0);
}
} }
} }
@ -264,20 +261,13 @@ $modal-width: 1024px;
} }
.default .modal-content, .default .modal-content,
.hint-modal .modal-content, .hint-modal .modal-content {
.top .modal-content {
text-align: center; text-align: center;
position: absolute; position: absolute;
// fine to use top/left since we're only using this to position it centered // fine to use top/left since we're only using this to position it centered
inset-block-start: 50%; inset-block-start: 50%;
inset-inline-start: 50%; inset-inline-start: 50%;
transform: translate(-50%, -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"] & { [dir="rtl"] & {
transform: translate(50%, -50%); transform: translate(50%, -50%);
@ -287,9 +277,6 @@ $modal-width: 1024px;
margin: 0; margin: 0;
position: static; position: static;
transform: none; transform: none;
// the fullscreen mobile layout flows and scrolls in .modal-container
max-block-size: none;
overflow: visible;
} }
.modal-header { .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 // 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 // `wide` prop can still expand the modal (the .is-wide rule below would
// otherwise be outranked by .default .modal-content's specificity). // otherwise be outranked by .default .modal-content's specificity).
.default .modal-content:not(.is-wide), .default .modal-content:not(.is-wide),
.hint-modal .modal-content:not(.is-wide), .hint-modal .modal-content:not(.is-wide) {
.top .modal-content:not(.is-wide) {
inline-size: calc(100% - 2rem); inline-size: calc(100% - 2rem);
max-inline-size: 640px; max-inline-size: 640px;
@ -436,7 +403,6 @@ $modal-width: 1024px;
block-size: auto; block-size: auto;
max-inline-size: none; max-inline-size: none;
max-block-size: none; max-block-size: none;
background: transparent;
&::backdrop { &::backdrop {
display: none; display: none;

View File

@ -8,14 +8,14 @@
<template #default> <template #default>
<Card :has-content="false"> <Card :has-content="false">
<div class="gantt-options"> <div class="gantt-options">
<FormField :label="$t('misc.dateRange')"> <FormField :label="$t('project.gantt.range')">
<Foo <Foo
id="range" id="range"
ref="flatPickerEl" ref="flatPickerEl"
v-model="flatPickerDateRange" v-model="flatPickerDateRange"
:config="flatPickerConfig" :config="flatPickerConfig"
class="input" class="input"
:placeholder="$t('misc.dateRange')" :placeholder="$t('project.gantt.range')"
/> />
</FormField> </FormField>
<div <div

View File

@ -109,7 +109,7 @@
@click.stop="showSetLimitInput = true" @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>
<DropdownItem <DropdownItem

View File

@ -2,7 +2,6 @@
<Modal <Modal
:enabled="active" :enabled="active"
:overflow="isNewTaskCommand" :overflow="isNewTaskCommand"
variant="top"
@close="closeQuickActions" @close="closeQuickActions"
> >
<div <div
@ -705,16 +704,15 @@ function reset() {
<style lang="scss" scoped> <style lang="scss" scoped>
.quick-actions { .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; overflow: hidden;
justify-content: flex-start !important; 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 { &.is-quick-add-mode {
padding: 0; padding: 0;
margin: 0; margin: 0;

View File

@ -25,7 +25,6 @@
rows="1" rows="1"
@keydown="resetEmptyTitleError" @keydown="resetEmptyTitleError"
@keydown.enter="handleEnter" @keydown.enter="handleEnter"
@keydown.esc="blurTaskInput"
/> />
<QuickAddMagic <QuickAddMagic
:highlight-hint-icon="taskAddHovered" :highlight-hint-icon="taskAddHovered"
@ -283,10 +282,6 @@ function focusTaskInput() {
newTaskInput.value?.focus() newTaskInput.value?.focus()
} }
function blurTaskInput() {
newTaskInput.value?.blur()
}
defineExpose({ defineExpose({
focusTaskInput, focusTaskInput,
}) })

View File

@ -123,7 +123,7 @@
</XButton> </XButton>
<!-- Dropzone --> <!-- Dropzone -->
<Teleport :to="dropzoneTeleportTarget"> <Teleport to="body">
<div <div
v-if="editEnabled" v-if="editEnabled"
:class="{hidden: !showDropzone}" :class="{hidden: !showDropzone}"
@ -185,7 +185,7 @@
</template> </template>
<script setup lang="ts"> <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 {useDropZone} from '@vueuse/core'
import User from '@/components/misc/User.vue' import User from '@/components/misc/User.vue'
@ -322,34 +322,6 @@ const showDropzone = computed(() =>
props.editEnabled && isDraggingFiles.value && !isDragOverEditor.value, 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 => { watch(() => props.editEnabled, enabled => {
if (!enabled) { if (!enabled) {
resetDragState() resetDragState()
@ -506,7 +478,7 @@ defineExpose({
inset-inline-start: 0; inset-inline-start: 0;
inset-block-end: 0; inset-block-end: 0;
inset-inline-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; text-align: center;
&.hidden { &.hidden {

View File

@ -4,7 +4,7 @@
v-if="editEnabled && Object.keys(relatedTasks).length > 0" v-if="editEnabled && Object.keys(relatedTasks).length > 0"
id="showRelatedTasksFormButton" id="showRelatedTasksFormButton"
v-tooltip="$t('task.relation.add')" 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}" :class="{'is-active': showNewRelationForm}"
variant="secondary" variant="secondary"
icon="plus" icon="plus"

View File

@ -326,17 +326,9 @@ const isOverdue = computed(() => (
let oldTask let oldTask
async function markAsDone(checked: boolean, wasReverted: boolean = false) { async function markAsDone(checked: boolean, wasReverted: boolean = false) {
oldTask = {...task.value} const updateFunc = async () => {
oldTask = {...task.value}
// Fire the request immediately and with the intended done value snapshotted, so a re-render or const newTask = await taskStore.update(task.value)
// 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
task.value = newTask task.value = newTask
updateDueDate() updateDueDate()
@ -362,9 +354,9 @@ async function markAsDone(checked: boolean, wasReverted: boolean = false) {
} }
if (checked) { 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 { } 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 { createGlobalState, useIntervalFn } from '@vueuse/core'
import { onBeforeRouteUpdate } from 'vue-router' import { onBeforeRouteUpdate } from 'vue-router'
@ -18,14 +18,10 @@ export const useGlobalNow = createGlobalState(() => {
useIntervalFn(update, GLOBAL_NOW_INTERVAL, { immediate: true }) useIntervalFn(update, GLOBAL_NOW_INTERVAL, { immediate: true })
// Now that this state can be initialised from a plain helper (formatDateSince), the // ensure the now value is refreshed when the route changes
// first caller is not guaranteed to be a component — guard the route hook accordingly. onBeforeRouteUpdate(() => {
if (getCurrentInstance()) { update()
// ensure the now value is refreshed when the route changes })
onBeforeRouteUpdate(() => {
update()
})
}
return { return {
now, 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 {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 {useRouteQuery} from '@vueuse/router'
import TaskCollectionService, { import TaskCollectionService, {
@ -12,7 +10,6 @@ import type {ITask} from '@/modelTypes/ITask'
import {error} from '@/message' import {error} from '@/message'
import type {IProject} from '@/modelTypes/IProject' import type {IProject} from '@/modelTypes/IProject'
import {useAuthStore} from '@/stores/auth' import {useAuthStore} from '@/stores/auth'
import {useViewFiltersStore} from '@/stores/viewFilters'
import type {IProjectView} from '@/modelTypes/IProjectView' import type {IProjectView} from '@/modelTypes/IProjectView'
export type Order = 'asc' | 'desc' | 'none' export type Order = 'asc' | 'desc' | 'none'
@ -62,22 +59,6 @@ const SORT_BY_DEFAULT: SortBy = {
id: 'desc', 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. // 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 // 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. // precedence over everything else, making any other sort columns pretty useless.
@ -113,9 +94,6 @@ export function useTaskList(
const projectId = computed(() => projectIdGetter()) const projectId = computed(() => projectIdGetter())
const projectViewId = computed(() => projectViewIdGetter()) const projectViewId = computed(() => projectViewIdGetter())
const router = useRouter()
const viewFiltersStore = useViewFiltersStore()
const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()}) const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()})
const page = useRouteQuery('page', '1', { transform: Number }) 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 allParams = computed(() => {
const loadParams = {...params.value} 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 savedToken = null
localStorage.removeItem('token') localStorage.removeItem('token')
localStorage.removeItem('desktopOAuthRefreshToken') 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. * Refreshes an auth token while ensuring it is updated everywhere.
* The refresh token is sent automatically as an HttpOnly cookie. * The refresh token is sent automatically as an HttpOnly cookie.
* The server rotates the cookie on every call. * The server rotates the cookie on every call.
* *
* Same-tab concurrent calls share one in-flight refresh (always-on dedup); the * Uses the Web Locks API to coordinate across browser tabs. Only one tab
* Web Locks API inside adds cross-tab coordination only in secure contexts. * 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> { 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 // In desktop mode, refresh via IPC to the Electron main process
if (isDesktopApp()) { if (isDesktopApp()) {
const storedRefreshToken = localStorage.getItem('desktopOAuthRefreshToken') const storedRefreshToken = localStorage.getItem('desktopOAuthRefreshToken')
@ -88,9 +53,6 @@ async function doRefresh(persist: boolean): Promise<void> {
} }
try { try {
const tokens = await refreshDesktopToken(window.API_URL, storedRefreshToken) const tokens = await refreshDesktopToken(window.API_URL, storedRefreshToken)
if (loggedOutSinceStart()) {
return
}
saveToken(tokens.access_token, persist) saveToken(tokens.access_token, persist)
localStorage.setItem('desktopOAuthRefreshToken', tokens.refresh_token) localStorage.setItem('desktopOAuthRefreshToken', tokens.refresh_token)
} catch (e) { } catch (e) {
@ -103,13 +65,7 @@ async function doRefresh(persist: boolean): Promise<void> {
// if another tab refreshed while we were queued. // if another tab refreshed while we were queued.
const tokenBeforeLock = localStorage.getItem('token') const tokenBeforeLock = localStorage.getItem('token')
const refreshUnderLock = async () => { const doRefresh = 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
}
// If the token in localStorage changed while waiting for the lock, // If the token in localStorage changed while waiting for the lock,
// another tab already refreshed. Just adopt the new token. // another tab already refreshed. Just adopt the new token.
const currentToken = localStorage.getItem('token') const currentToken = localStorage.getItem('token')
@ -122,9 +78,6 @@ async function doRefresh(persist: boolean): Promise<void> {
const HTTP = HTTPFactory() const HTTP = HTTPFactory()
try { try {
const response = await HTTP.post('user/token/refresh') const response = await HTTP.post('user/token/refresh')
if (loggedOutSinceStart()) {
return
}
saveToken(response.data.token, persist) saveToken(response.data.token, persist)
} catch (e) { } catch (e) {
throw new Error('Error renewing token: ', {cause: e}) throw new Error('Error renewing token: ', {cause: e})
@ -132,10 +85,10 @@ async function doRefresh(persist: boolean): Promise<void> {
} }
if (navigator.locks) { if (navigator.locks) {
await navigator.locks.request('vikunja-token-refresh', refreshUnderLock) await navigator.locks.request('vikunja-token-refresh', doRefresh)
} else { } else {
// Fallback for environments without Web Locks (e.g. insecure HTTP) // 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') return i18n.global.t('project.inboxTitle')
} }
if (project.title === 'My Open Tasks') {
return i18n.global.t('project.myOpenTasksFilterTitle')
}
return project.title return project.title
} }

View File

@ -2,17 +2,10 @@ import {createRandomID} from '@/helpers/randomId'
import {computePosition, flip, shift, offset} from '@floating-ui/dom' import {computePosition, flip, shift, offset} from '@floating-ui/dom'
import {nextTick} from 'vue' import {nextTick} from 'vue'
import {eventToShortcutString} from '@/helpers/shortcut' 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) => { return new Promise((resolve) => {
const id = 'link-input-' + createRandomID() 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 // Create popup element
const popupElement = document.createElement('div') const popupElement = document.createElement('div')
@ -33,7 +26,7 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = '', edit
inputElement.value = oldValue inputElement.value = oldValue
wrapperDiv.appendChild(inputElement) wrapperDiv.appendChild(inputElement)
popupElement.appendChild(wrapperDiv) popupElement.appendChild(wrapperDiv)
container.appendChild(popupElement) document.body.appendChild(popupElement)
// Create a local mutable copy of the position for scroll tracking // Create a local mutable copy of the position for scroll tracking
let currentRect = new DOMRect(pos.left, pos.top, pos.width, pos.height) 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()) 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 = () => { const cleanup = () => {
window.removeEventListener('scroll', handleScroll, true) window.removeEventListener('scroll', handleScroll, true)
document.removeEventListener('click', handleClickOutside) if (document.body.contains(popupElement)) {
dialog?.removeEventListener('cancel', handleDialogCancel) document.body.removeChild(popupElement)
if (container.contains(popupElement)) {
container.removeChild(popupElement)
} }
} }
document.getElementById(id)?.addEventListener('keydown', event => { document.getElementById(id)?.addEventListener('keydown', event => {
const shortcutString = eventToShortcutString(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') { if (shortcutString !== 'Enter') {
return return
} }
@ -138,6 +105,15 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = '', edit
cleanup() 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 // Add slight delay to prevent immediate closing
setTimeout(() => { setTimeout(() => {
document.addEventListener('click', handleClickOutside) 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}` 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) { if (provider.logoutUrl.length > 0) {
window.location.href = `${provider.logoutUrl}` 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 {createSharedComposable} from '@vueuse/core'
import {computed, toValue, type MaybeRefOrGetter} from 'vue' import {computed, toValue, type MaybeRefOrGetter} from 'vue'
import {useDateDisplay} from '@/composables/useDateDisplay' import {useDateDisplay} from '@/composables/useDateDisplay'
import {useGlobalNow} from '@/composables/useGlobalNow'
import {useTimeFormat} from '@/composables/useTimeFormat' import {useTimeFormat} from '@/composables/useTimeFormat'
import {DATE_DISPLAY, type DateDisplay} from '@/constants/dateDisplay' import {DATE_DISPLAY, type DateDisplay} from '@/constants/dateDisplay'
import {TIME_FORMAT, type TimeFormat} from '@/constants/timeFormat' 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' 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 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': '日本語', 'ja-JP': '日本語',
'hu-HU': 'Magyar', 'hu-HU': 'Magyar',
'ar-SA': 'اَلْعَرَبِيَّةُ', 'ar-SA': 'اَلْعَرَبِيَّةُ',
'fa-IR': 'فارسی',
'sl-SI': 'Slovenščina', 'sl-SI': 'Slovenščina',
'pt-BR': 'Português Brasileiro', 'pt-BR': 'Português Brasileiro',
'hr-HR': 'Hrvatski', 'hr-HR': 'Hrvatski',
@ -53,7 +52,7 @@ export const DEFAULT_LANGUAGE: SupportedLocale= 'en'
export type ISOLanguage = string 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 { export function isRTLLanguage(locale: SupportedLocale): boolean {
return RTL_LANGUAGES.includes(locale as typeof RTL_LANGUAGES[number]) return RTL_LANGUAGES.includes(locale as typeof RTL_LANGUAGES[number])

View File

@ -284,7 +284,8 @@
"default": "افتراضي", "default": "افتراضي",
"month": "شهر", "month": "شهر",
"day": "يوم", "day": "يوم",
"hour": "ساعة" "hour": "ساعة",
"range": "نطاق التاريخ"
}, },
"table": { "table": {
"title": "جدول", "title": "جدول",
@ -293,6 +294,7 @@
"kanban": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "الحد: {limit}", "limit": "الحد: {limit}",
"noLimit": "غير محدد",
"doneBucket": "حافظة المهام المكتملة", "doneBucket": "حافظة المهام المكتملة",
"doneBucketHint": "سيتم تلقائياً وضع علامة مكتمل على جميع المهام التي تم نقلها إلى هذه الحافظة.", "doneBucketHint": "سيتم تلقائياً وضع علامة مكتمل على جميع المهام التي تم نقلها إلى هذه الحافظة.",
"doneBucketHintExtended": "سيتم وضع علامة مكتمل على جميع المهام التي تم نقلها إلى حافظة المهام المكتملة. كما سيتم نقل جميع المهام المكتملة من أماكن أخرى.", "doneBucketHintExtended": "سيتم وضع علامة مكتمل على جميع المهام التي تم نقلها إلى حافظة المهام المكتملة. كما سيتم نقل جميع المهام المكتملة من أماكن أخرى.",

View File

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

View File

@ -383,6 +383,7 @@
"month": "Měsíc", "month": "Měsíc",
"day": "Den", "day": "Den",
"hour": "Hodina", "hour": "Hodina",
"range": "Časové období",
"chartLabel": "Projektový Ganttův diagram", "chartLabel": "Projektový Ganttův diagram",
"taskBarsForRow": "Chlívky pro řádek {rowId}", "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.", "taskBarLabel": "Úkol: {task}. Od {startDate} do {endDate}. {dateType}. Klikněte pro úpravu, přetáhněte pro přesun.",
@ -411,6 +412,7 @@
"kanban": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "Limit: {limit}", "limit": "Limit: {limit}",
"noLimit": "Nenastaveno",
"doneBucket": "Sloupec \"Hotovo\"", "doneBucket": "Sloupec \"Hotovo\"",
"doneBucketHint": "Všechny úkoly přesunuté do tohoto sloupce budou automaticky označeny jako dokončené.", "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é.", "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" "yyyy/mm/dd": "JJJJ/MM/TT"
}, },
"timeFormat": "Zeitformat", "timeFormat": "Zeitformat",
"timeTrackingDefaultStart": "Startzeit für die Zeiterfassung",
"timeFormatOptions": { "timeFormatOptions": {
"12h": "12 Stunden (AM/PM)", "12h": "12 Stunden (AM/PM)",
"24h": "24 Stunden (HH:mm)" "24h": "24 Stunden (HH:mm)"
@ -349,7 +348,6 @@
"shared": "Geteilte Projekte", "shared": "Geteilte Projekte",
"noDescriptionAvailable": "Keine Projektbeschreibung verfügbar.", "noDescriptionAvailable": "Keine Projektbeschreibung verfügbar.",
"inboxTitle": "Eingang", "inboxTitle": "Eingang",
"myOpenTasksFilterTitle": "Meine offenen Aufgaben",
"favorite": "Dieses Projekt als Favorit markieren", "favorite": "Dieses Projekt als Favorit markieren",
"unfavorite": "Dieses Projekt von Favoriten entfernen", "unfavorite": "Dieses Projekt von Favoriten entfernen",
"openSettingsMenu": "Projekteinstellungen öffnen", "openSettingsMenu": "Projekteinstellungen öffnen",
@ -394,7 +392,6 @@
"title": "Dupliziere dieses Projekt", "title": "Dupliziere dieses Projekt",
"label": "Duplizieren", "label": "Duplizieren",
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:", "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." "success": "Das Projekt wurde erfolgreich dupliziert."
}, },
"edit": { "edit": {
@ -473,6 +470,7 @@
"month": "Monat", "month": "Monat",
"day": "Tag", "day": "Tag",
"hour": "Stunde", "hour": "Stunde",
"range": "Zeitraum",
"chartLabel": "Projekt Gantt-Diagramm", "chartLabel": "Projekt Gantt-Diagramm",
"taskBarsForRow": "Aufgabenleisten für Zeile {rowId}", "taskBarsForRow": "Aufgabenleisten für Zeile {rowId}",
"taskBarLabel": "Aufgabe: {task}. Von {startDate} bis {endDate}. {dateType}. Klicke zum Bearbeiten, ziehe zum Verschieben.", "taskBarLabel": "Aufgabe: {task}. Von {startDate} bis {endDate}. {dateType}. Klicke zum Bearbeiten, ziehe zum Verschieben.",
@ -501,6 +499,7 @@
"kanban": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "Limit: {limit}", "limit": "Limit: {limit}",
"noLimit": "Nicht gesetzt",
"doneBucket": "Erledigt Spalte", "doneBucket": "Erledigt Spalte",
"doneBucketHint": "Alle Aufgaben, die in diese Spalte verschoben werden, werden automatisch als erledigt markiert.", "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.", "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", "closeDialog": "Dialog schließen",
"closeQuickActions": "Schnellaktionen schließen", "closeQuickActions": "Schnellaktionen schließen",
"skipToContent": "Überspringen und zum Hauptinhalt gehen", "skipToContent": "Überspringen und zum Hauptinhalt gehen",
"sortBy": "Sortieren nach", "sortBy": "Sortieren nach"
"dateRange": "Zeitraum",
"notSet": "Nicht festgelegt",
"user": "Benutzer:in"
}, },
"input": { "input": {
"projectColor": "Projektfarbe", "projectColor": "Projektfarbe",
@ -997,7 +993,6 @@
"repeatAfter": "Wiederholung setzen", "repeatAfter": "Wiederholung setzen",
"percentDone": "Fortschritt einstellen", "percentDone": "Fortschritt einstellen",
"attachments": "Anhänge hinzufügen", "attachments": "Anhänge hinzufügen",
"timeTracking": "Zeit erfassen",
"relatedTasks": "Beziehung hinzufügen", "relatedTasks": "Beziehung hinzufügen",
"moveProject": "Verschieben", "moveProject": "Verschieben",
"duplicate": "Duplizieren", "duplicate": "Duplizieren",
@ -1467,32 +1462,6 @@
"frontendVersion": "Frontend-Version: {version}", "frontendVersion": "Frontend-Version: {version}",
"apiVersion": "API-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": { "time": {
"units": { "units": {
"seconds": "Sekunde|Sekunden", "seconds": "Sekunde|Sekunden",

View File

@ -172,7 +172,6 @@
"yyyy/mm/dd": "JJJJ/MM/TT" "yyyy/mm/dd": "JJJJ/MM/TT"
}, },
"timeFormat": "Zeitformat", "timeFormat": "Zeitformat",
"timeTrackingDefaultStart": "Startzeit für die Zeiterfassung",
"timeFormatOptions": { "timeFormatOptions": {
"12h": "12 Stunden (AM/PM)", "12h": "12 Stunden (AM/PM)",
"24h": "24 Stunden (HH:mm)" "24h": "24 Stunden (HH:mm)"
@ -349,7 +348,6 @@
"shared": "Geteilte Projekte", "shared": "Geteilte Projekte",
"noDescriptionAvailable": "Keine Projektbeschreibung verfügbar.", "noDescriptionAvailable": "Keine Projektbeschreibung verfügbar.",
"inboxTitle": "Eingang", "inboxTitle": "Eingang",
"myOpenTasksFilterTitle": "Meine offenen Aufgaben",
"favorite": "Dieses Projekt als Favorit markieren", "favorite": "Dieses Projekt als Favorit markieren",
"unfavorite": "Dieses Projekt von Favoriten entfernen", "unfavorite": "Dieses Projekt von Favoriten entfernen",
"openSettingsMenu": "Projekteinstellungen öffnen", "openSettingsMenu": "Projekteinstellungen öffnen",
@ -394,7 +392,6 @@
"title": "Dupliziere dieses Projekt", "title": "Dupliziere dieses Projekt",
"label": "Duplizieren", "label": "Duplizieren",
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:", "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." "success": "Das Projekt wurde erfolgreich dupliziert."
}, },
"edit": { "edit": {
@ -473,6 +470,7 @@
"month": "Monat", "month": "Monat",
"day": "Tag", "day": "Tag",
"hour": "Stunde", "hour": "Stunde",
"range": "Zeitraum",
"chartLabel": "Projekt Gantt-Diagramm", "chartLabel": "Projekt Gantt-Diagramm",
"taskBarsForRow": "Aufgabenleisten für Zeile {rowId}", "taskBarsForRow": "Aufgabenleisten für Zeile {rowId}",
"taskBarLabel": "Aufgabe: {task}. Von {startDate} bis {endDate}. {dateType}. Klicke zum Bearbeiten, ziehe zum Verschieben.", "taskBarLabel": "Aufgabe: {task}. Von {startDate} bis {endDate}. {dateType}. Klicke zum Bearbeiten, ziehe zum Verschieben.",
@ -501,6 +499,7 @@
"kanban": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "Limit: {limit}", "limit": "Limit: {limit}",
"noLimit": "Nicht gesetzt",
"doneBucket": "Erledigt Spalte", "doneBucket": "Erledigt Spalte",
"doneBucketHint": "Alle Aufgaben, die in diese Spalte verschoben werden, werden automatisch als erledigt markiert.", "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.", "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", "closeDialog": "Dialog schließen",
"closeQuickActions": "Schnellaktionen schließen", "closeQuickActions": "Schnellaktionen schließen",
"skipToContent": "Überspringen und zum Hauptinhalt gehen", "skipToContent": "Überspringen und zum Hauptinhalt gehen",
"sortBy": "Sortieren nach", "sortBy": "Sortieren nach"
"dateRange": "Zeitraum",
"notSet": "Nicht festgelegt",
"user": "Benutzer:in"
}, },
"input": { "input": {
"projectColor": "Projektfarbe", "projectColor": "Projektfarbe",
@ -997,7 +993,6 @@
"repeatAfter": "Wiederholung setzen", "repeatAfter": "Wiederholung setzen",
"percentDone": "Fortschritt einstellen", "percentDone": "Fortschritt einstellen",
"attachments": "Anhänge hinzufügen", "attachments": "Anhänge hinzufügen",
"timeTracking": "Zeit erfassen",
"relatedTasks": "Beziehung hinzufügen", "relatedTasks": "Beziehung hinzufügen",
"moveProject": "Verschieben", "moveProject": "Verschieben",
"duplicate": "Duplizieren", "duplicate": "Duplizieren",
@ -1467,32 +1462,6 @@
"frontendVersion": "Frontend-Version: {version}", "frontendVersion": "Frontend-Version: {version}",
"apiVersion": "API-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": { "time": {
"units": { "units": {
"seconds": "Sekunde|Sekunden", "seconds": "Sekunde|Sekunden",

View File

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

View File

@ -172,7 +172,6 @@
"yyyy\/mm\/dd": "YYYY\/MM\/DD" "yyyy\/mm\/dd": "YYYY\/MM\/DD"
}, },
"timeFormat": "Time format", "timeFormat": "Time format",
"timeTrackingDefaultStart": "Time tracking smart-fill start time",
"timeFormatOptions": { "timeFormatOptions": {
"12h": "12-hour (AM/PM)", "12h": "12-hour (AM/PM)",
"24h": "24-hour (HH:mm)" "24h": "24-hour (HH:mm)"
@ -349,7 +348,6 @@
"shared": "Shared Projects", "shared": "Shared Projects",
"noDescriptionAvailable": "No project description is available.", "noDescriptionAvailable": "No project description is available.",
"inboxTitle": "Inbox", "inboxTitle": "Inbox",
"myOpenTasksFilterTitle": "My Open Tasks",
"favorite": "Mark this project as favorite", "favorite": "Mark this project as favorite",
"unfavorite": "Remove this project from favorites", "unfavorite": "Remove this project from favorites",
"openSettingsMenu": "Open project settings menu", "openSettingsMenu": "Open project settings menu",
@ -394,7 +392,6 @@
"title": "Duplicate this project", "title": "Duplicate this project",
"label": "Duplicate", "label": "Duplicate",
"text": "Select a parent project which should hold the duplicated project:", "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." "success": "The project was successfully duplicated."
}, },
"edit": { "edit": {
@ -473,6 +470,7 @@
"month": "Month", "month": "Month",
"day": "Day", "day": "Day",
"hour": "Hour", "hour": "Hour",
"range": "Date Range",
"chartLabel": "Project Gantt Chart", "chartLabel": "Project Gantt Chart",
"taskBarsForRow": "Task bars for row {rowId}", "taskBarsForRow": "Task bars for row {rowId}",
"taskBarLabel": "Task: {task}. From {startDate} to {endDate}. {dateType}. Click to edit, drag to move.", "taskBarLabel": "Task: {task}. From {startDate} to {endDate}. {dateType}. Click to edit, drag to move.",
@ -501,6 +499,7 @@
"kanban": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "Limit: {limit}", "limit": "Limit: {limit}",
"noLimit": "Not Set",
"doneBucket": "Done bucket", "doneBucket": "Done bucket",
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.", "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.", "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", "closeDialog": "Close dialog",
"closeQuickActions": "Close quick actions", "closeQuickActions": "Close quick actions",
"skipToContent": "Skip to main content", "skipToContent": "Skip to main content",
"sortBy": "Sort by", "sortBy": "Sort by"
"dateRange": "Date range",
"notSet": "Not set",
"user": "User"
}, },
"input": { "input": {
"projectColor": "Project color", "projectColor": "Project color",
@ -997,7 +993,6 @@
"repeatAfter": "Set Repeating Interval", "repeatAfter": "Set Repeating Interval",
"percentDone": "Set Progress", "percentDone": "Set Progress",
"attachments": "Add Attachments", "attachments": "Add Attachments",
"timeTracking": "Track time",
"relatedTasks": "Add Relation", "relatedTasks": "Add Relation",
"moveProject": "Move", "moveProject": "Move",
"duplicate": "Duplicate", "duplicate": "Duplicate",
@ -1467,32 +1462,6 @@
"frontendVersion": "Frontend version: {version}", "frontendVersion": "Frontend version: {version}",
"apiVersion": "API 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": { "time": {
"units": { "units": {
"seconds": "second|seconds", "seconds": "second|seconds",

View File

@ -251,7 +251,8 @@
"default": "Predeterminado", "default": "Predeterminado",
"month": "Mes", "month": "Mes",
"day": "Día", "day": "Día",
"hour": "Hora" "hour": "Hora",
"range": "Rango de fechas"
}, },
"table": { "table": {
"title": "Tabla", "title": "Tabla",
@ -260,6 +261,7 @@
"kanban": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "Límite: {limit}", "limit": "Límite: {limit}",
"noLimit": "No Establecido",
"doneBucket": "Contenedor completado", "doneBucket": "Contenedor completado",
"doneBucketHint": "Todas las tareas movidas a este contenedor se marcarán automáticamente como finalizadas.", "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.", "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", "default": "Oletus",
"month": "Kuukausi", "month": "Kuukausi",
"day": "Päivä", "day": "Päivä",
"hour": "Tunti" "hour": "Tunti",
"range": "Ajanjakso"
}, },
"table": { "table": {
"title": "Taulukko", "title": "Taulukko",
@ -356,6 +357,7 @@
"kanban": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "Raja: {limit}", "limit": "Raja: {limit}",
"noLimit": "Ei Asetettu",
"doneBucket": "Valmiit sarake", "doneBucket": "Valmiit sarake",
"doneBucketHint": "Kaikki tähän sarakkeeseen lisätyt tehtävät merkitään automaattisesti valmiiksi.", "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.", "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", "month": "Mois",
"day": "Jour", "day": "Jour",
"hour": "Heure", "hour": "Heure",
"range": "Intervalle",
"chartLabel": "Diagramme de Gantt du projet", "chartLabel": "Diagramme de Gantt du projet",
"taskBarsForRow": "Barres de tâches pour la ligne {rowId}", "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.", "taskBarLabel": "Tâche : {task}. De {startDate} à {endDate}. {dateType}. Cliquez pour modifier, faites glisser pour déplacer.",
@ -369,6 +370,7 @@
"kanban": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "Limite : {limit}", "limit": "Limite : {limit}",
"noLimit": "Non défini",
"doneBucket": "Colonne des tâches terminées", "doneBucket": "Colonne des tâches terminées",
"doneBucketHint": "Toute tâche déplacée dans cette colonne sera automatiquement marquée comme terminée.", "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.", "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": "ברירת מחדל", "default": "ברירת מחדל",
"month": "חודש", "month": "חודש",
"day": "יום", "day": "יום",
"hour": "שעה" "hour": "שעה",
"range": "טווח תאריכים"
}, },
"table": { "table": {
"title": "טבלה", "title": "טבלה",
@ -327,6 +328,7 @@
"kanban": { "kanban": {
"title": "קאנבאן", "title": "קאנבאן",
"limit": "הגבלה: {limit}", "limit": "הגבלה: {limit}",
"noLimit": "לא נקבע",
"doneBucket": "דלי גמורים", "doneBucket": "דלי גמורים",
"doneBucketHint": "דלי גמורים נשמר בהצלחה.", "doneBucketHint": "דלי גמורים נשמר בהצלחה.",
"doneBucketHintExtended": "כל המטלות המוכנסות לדלי הגמורים יסומנו אוטומטית כגמורים. כל המטלות המסומנות כגמורים מבחוץ יוזזו גם.", "doneBucketHintExtended": "כל המטלות המוכנסות לדלי הגמורים יסומנו אוטומטית כגמורים. כל המטלות המסומנות כגמורים מבחוץ יוזזו גם.",

View File

@ -289,14 +289,16 @@
"default": "Zadano", "default": "Zadano",
"month": "Mjesec", "month": "Mjesec",
"day": "Dan", "day": "Dan",
"hour": "Sat" "hour": "Sat",
"range": "Raspon datuma"
}, },
"table": { "table": {
"title": "Tablica", "title": "Tablica",
"columns": "Stupci" "columns": "Stupci"
}, },
"kanban": { "kanban": {
"title": "Kanban" "title": "Kanban",
"noLimit": "Nije postavljeno"
}, },
"pseudo": { "pseudo": {
"favorites": { "favorites": {

View File

@ -290,7 +290,8 @@
"default": "Alapértelmezett", "default": "Alapértelmezett",
"month": "Hónap", "month": "Hónap",
"day": "Nap", "day": "Nap",
"hour": "Óra" "hour": "Óra",
"range": "Időintervallum"
}, },
"table": { "table": {
"title": "Táblázat", "title": "Táblázat",
@ -299,6 +300,7 @@
"kanban": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "Korlát: {limit}", "limit": "Korlát: {limit}",
"noLimit": "Nincs beállítva",
"doneBucket": "Kész vödör", "doneBucket": "Kész vödör",
"doneBucketHint": "Az ebbe a csoportba helyezett összes feladat automatikusan készként lesz megjelölve.", "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.", "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", "month": "Mese",
"day": "Giorno", "day": "Giorno",
"hour": "Ora", "hour": "Ora",
"range": "Intervallo di date",
"chartLabel": "Progetto diagramma di Gantt", "chartLabel": "Progetto diagramma di Gantt",
"taskBarsForRow": "Barre delle attività per riga {rowId}", "taskBarsForRow": "Barre delle attività per riga {rowId}",
"taskBarLabel": "Attività: {task}. Da {startDate} a {endDate}. {dateType}. Clicca per modificare, trascina per spostare.", "taskBarLabel": "Attività: {task}. Da {startDate} a {endDate}. {dateType}. Clicca per modificare, trascina per spostare.",
@ -385,6 +386,7 @@
"kanban": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "Limite: {limit}", "limit": "Limite: {limit}",
"noLimit": "Non Impostato",
"doneBucket": "Colonna attività completate", "doneBucket": "Colonna attività completate",
"doneBucketHint": "Tutte le attività spostate in questa colonna verranno automaticamente contrassegnate come 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.", "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": "月", "month": "月",
"day": "日", "day": "日",
"hour": "時間", "hour": "時間",
"range": "期間",
"chartLabel": "プロジェクトガントチャート", "chartLabel": "プロジェクトガントチャート",
"taskBarsForRow": "行 {rowId} のタスクバー", "taskBarsForRow": "行 {rowId} のタスクバー",
"taskBarLabel": "タスク: {task}。{startDate} から {endDate} まで。{dateType}。クリックして編集、ドラッグして移動。", "taskBarLabel": "タスク: {task}。{startDate} から {endDate} まで。{dateType}。クリックして編集、ドラッグして移動。",
@ -498,6 +499,7 @@
"kanban": { "kanban": {
"title": "カンバン", "title": "カンバン",
"limit": "上限: {limit}", "limit": "上限: {limit}",
"noLimit": "未設定",
"doneBucket": "バケットを完了", "doneBucket": "バケットを完了",
"doneBucketHint": "このバケットに移動されたすべてのタスクは自動的に完了としてマークされます。", "doneBucketHint": "このバケットに移動されたすべてのタスクは自動的に完了としてマークされます。",
"doneBucketHintExtended": "完了バケットに移動されたすべてのタスクは自動的に完了としてマークされます。他の場所にあるタスクも完了としてマークされるとこのバケットに移動されます。", "doneBucketHintExtended": "完了バケットに移動されたすべてのタスクは自動的に完了としてマークされます。他の場所にあるタスクも完了としてマークされるとこのバケットに移動されます。",

View File

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

View File

@ -320,7 +320,8 @@
"default": "Numatytasis", "default": "Numatytasis",
"month": "Mėnuo", "month": "Mėnuo",
"day": "Diena", "day": "Diena",
"hour": "Valanda" "hour": "Valanda",
"range": "Datos intervalas"
}, },
"table": { "table": {
"title": "Lentelė", "title": "Lentelė",
@ -329,6 +330,7 @@
"kanban": { "kanban": {
"title": "Kanbanas", "title": "Kanbanas",
"limit": "Limitas: {limit}", "limit": "Limitas: {limit}",
"noLimit": "Nenustatytas",
"doneBucket": "Atliktųjų telkinys", "doneBucket": "Atliktųjų telkinys",
"doneBucketHint": "Visos užduotys nukreiptos į šį telkinį bus automatiškai pažymėtos kaip atliktos.", "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.", "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", "month": "Maand",
"day": "Dag", "day": "Dag",
"hour": "Uur", "hour": "Uur",
"range": "Datumbereik",
"chartLabel": "Project Gantt-diagram", "chartLabel": "Project Gantt-diagram",
"taskBarsForRow": "Taakbalken voor rij {rowId}", "taskBarsForRow": "Taakbalken voor rij {rowId}",
"taskBarLabel": "Taak: {task}. Van {startDate} tot {endDate}. {dateType}. Klik om te bewerken, sleep om te verplaatsen.", "taskBarLabel": "Taak: {task}. Van {startDate} tot {endDate}. {dateType}. Klik om te bewerken, sleep om te verplaatsen.",
@ -498,6 +499,7 @@
"kanban": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "Limiet: {limit}", "limit": "Limiet: {limit}",
"noLimit": "Niet ingesteld",
"doneBucket": "Categorie 'voltooid'", "doneBucket": "Categorie 'voltooid'",
"doneBucketHint": "Alle taken die je naar deze categorie verplaatst worden automatisch als 'voltooid' gemarkeerd.", "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.", "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", "month": "Måned",
"day": "Dag", "day": "Dag",
"hour": "Time", "hour": "Time",
"range": "Datointervall",
"chartLabel": "Gantt-kart for prosjekt", "chartLabel": "Gantt-kart for prosjekt",
"taskBarsForRow": "Oppgavelinjer for rad {rowId}", "taskBarsForRow": "Oppgavelinjer for rad {rowId}",
"taskBarLabel": "Oppgave: {task}. Fra {startDate} til {endDate}. {dateType}. Klikk for å redigere, dra for å flytte.", "taskBarLabel": "Oppgave: {task}. Fra {startDate} til {endDate}. {dateType}. Klikk for å redigere, dra for å flytte.",
@ -376,6 +377,7 @@
"kanban": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "Begrens: {limit}", "limit": "Begrens: {limit}",
"noLimit": "Ikke angitt",
"doneBucket": "Ferdigkurv", "doneBucket": "Ferdigkurv",
"doneBucketHint": "Alle oppgaver som flyttes til denne kurven vil automatisk bli markert som ferdige.", "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.", "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", "default": "Domyślnie",
"month": "Miesiąc", "month": "Miesiąc",
"day": "Dzień", "day": "Dzień",
"hour": "Godzina" "hour": "Godzina",
"range": "Zakres dat"
}, },
"table": { "table": {
"title": "Tabela", "title": "Tabela",
@ -309,6 +310,7 @@
"kanban": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "Limit: {limit}", "limit": "Limit: {limit}",
"noLimit": "Nie ustawiony",
"doneBucket": "Zakończone zadania", "doneBucket": "Zakończone zadania",
"doneBucketHint": "Wszystkie zadania przeniesione do tej kolumny zostaną automatycznie oznaczone jako zakończone.", "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.", "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", "default": "Padrão",
"month": "Mês", "month": "Mês",
"day": "Dia", "day": "Dia",
"hour": "Hora" "hour": "Hora",
"range": "Período"
}, },
"table": { "table": {
"title": "Tabela", "title": "Tabela",
@ -295,6 +296,7 @@
"kanban": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "Limite: {limit}", "limit": "Limite: {limit}",
"noLimit": "Não definido",
"doneBucket": "Bucket concluído", "doneBucket": "Bucket concluído",
"doneBucketHint": "Todas as tarefas movidas para este bucket serão marcadas automaticamente como concluídas.", "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.", "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", "month": "Mês",
"day": "Dia", "day": "Dia",
"hour": "Hora", "hour": "Hora",
"range": "Intervalo de Datas",
"chartLabel": "Gráfico de Gantt do projeto", "chartLabel": "Gráfico de Gantt do projeto",
"taskBarsForRow": "Barras de tarefas para a linha {rowId}", "taskBarsForRow": "Barras de tarefas para a linha {rowId}",
"taskBarLabel": "Tarefa: {task}. De {startDate} a {endDate}. {dateType}. Clica para editar, arrasta para mover.", "taskBarLabel": "Tarefa: {task}. De {startDate} a {endDate}. {dateType}. Clica para editar, arrasta para mover.",
@ -385,6 +386,7 @@
"kanban": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "Limite: {limit}", "limit": "Limite: {limit}",
"noLimit": "Não Definido",
"doneBucket": "Conjunto concluído", "doneBucket": "Conjunto concluído",
"doneBucketHint": "Todas as tarefas movidas para este conjunto serão automaticamente marcadas como concluídas.", "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.", "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": { "home": {
"welcomeNight": "Доброй ночи, {username}!", "welcomeNight": "Доброй ночи, {username}!",
"welcomeNightOwl": "Привет, ночная сова {username}",
"welcomeNightBurning": "Работаешь допоздна, {username}?",
"welcomeNightQuiet": "Тихие часы, {username}",
"welcomeNightLate": "Поздно, {username}",
"welcomeMorning": "Доброе утро, {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}!", "welcomeDay": "Привет, {username}!",
"welcomeDayFocus": "Давайте сосредоточимся, {username}",
"welcomeDayKeepGoing": "Так держать, {username}",
"welcomeDayWhatsNext": "Что дальше, {username}?",
"welcomeDayGood": "Добрый день, {username}",
"welcomeEvening": "Добрый вечер, {username}!", "welcomeEvening": "Добрый вечер, {username}!",
"welcomeEveningWind": "Заканчиваешь, {username}?",
"welcomeEveningReturns": "{username} возвращается",
"welcomeEveningOneMore": "Ещё одна вещь, {username}?",
"lastViewed": "Последние просмотренные", "lastViewed": "Последние просмотренные",
"addToHomeScreen": "Добавьте это приложение на домашний экран для быстрого доступа и удобной работы.", "addToHomeScreen": "Добавьте это приложение на домашний экран для быстрого доступа и удобной работы.",
"goToOverview": "Перейти к обзору", "goToOverview": "Перейти к обзору",
@ -80,11 +57,6 @@
"openIdTotpSubmit": "Продолжить", "openIdTotpSubmit": "Продолжить",
"oauthMissingParams": "Отсутствуют необходимые параметры OAuth: {params}", "oauthMissingParams": "Отсутствуют необходимые параметры OAuth: {params}",
"oauthRedirectedToApp": "Вы были перенаправлены в приложение. Теперь вы можете закрыть эту вкладку.", "oauthRedirectedToApp": "Вы были перенаправлены в приложение. Теперь вы можете закрыть эту вкладку.",
"desktopTryDemo": "Попробовать демо-версию",
"desktopCustomServer": "Пользовательский URL сервера",
"desktopCustomServerDescription": "Введите URL сервера Vikunja, чтобы начать.",
"desktopWaitingForAuth": "Ожидание аутентификации…",
"desktopOAuthError": "Ошибка аутентификации: {error}",
"logout": "Выйти", "logout": "Выйти",
"emailInvalid": "Введите корректный email адрес.", "emailInvalid": "Введите корректный email адрес.",
"usernameRequired": "Введите имя пользователя.", "usernameRequired": "Введите имя пользователя.",
@ -103,19 +75,6 @@
"registrationFailed": "Произошла ошибка при регистрации. Проверьте введённые данные и повторите попытку." "registrationFailed": "Произошла ошибка при регистрации. Проверьте введённые данные и повторите попытку."
}, },
"settings": { "settings": {
"bots": {
"title": "Боты",
"description": "Боты — это пользователи, которые принадлежат вам и которые имеют доступ только к API. Их можно добавить в проекты, назначить задачи, и аутентификация выполняется с помощью токенов API. Боты не могут использовать обычный интерфейс.",
"namePlaceholder": "Мой помощник",
"create": "Создать бота",
"enable": "Включить",
"badge": "Бот",
"delete": {
"header": "Удалить бота",
"text1": "Удалить бота «{username}»?",
"text2": "Это необратимо. Любые токены API, принадлежащие этому боту, будут аннулированы."
}
},
"title": "Настройки", "title": "Настройки",
"newPasswordTitle": "Изменить пароль", "newPasswordTitle": "Изменить пароль",
"newPassword": "Новый пароль", "newPassword": "Новый пароль",
@ -141,11 +100,6 @@
"weekStart": "Первый день недели", "weekStart": "Первый день недели",
"weekStartSunday": "Воскресенье", "weekStartSunday": "Воскресенье",
"weekStartMonday": "Понедельник", "weekStartMonday": "Понедельник",
"weekStartTuesday": "Вторник",
"weekStartWednesday": "Среда",
"weekStartThursday": "Четверг",
"weekStartFriday": "Пятница",
"weekStartSaturday": "Суббота",
"language": "Язык", "language": "Язык",
"defaultProject": "Проект по умолчанию", "defaultProject": "Проект по умолчанию",
"defaultView": "Представление по умолчанию", "defaultView": "Представление по умолчанию",
@ -179,13 +133,7 @@
"taskAndNotifications": "Проекты и задачи", "taskAndNotifications": "Проекты и задачи",
"privacy": "Конфиденциальность", "privacy": "Конфиденциальность",
"localization": "Локализация", "localization": "Локализация",
"appearance": "Внешний вид и поведение", "appearance": "Внешний вид и поведение"
"desktop": "Настольное приложение"
},
"desktop": {
"quickEntryShortcut": "Ярлык быстрого входа",
"shortcutRecorderPlaceholder": "Нажмите, чтобы задать ярлык",
"shortcutRecorderRecording": "Нажмите комбинацию клавиш…"
}, },
"totp": { "totp": {
"title": "Двухфакторная аутентификация", "title": "Двухфакторная аутентификация",
@ -215,13 +163,6 @@
"usernameIs": "Имя пользователя для CalDAV: {0}", "usernameIs": "Имя пользователя для CalDAV: {0}",
"apiTokenHint": "Вы также можете использовать токен API с разрешением CalDAV. Создайте его в {link}." "apiTokenHint": "Вы также можете использовать токен API с разрешением CalDAV. Создайте его в {link}."
}, },
"feeds": {
"title": "Atom-лента",
"howTo": "Вы можете подписаться на уведомления Vikunja в любом приложении для чтения новостей, поддерживающем Atom-ленты. Используйте следующий URL:",
"usernameIs": "Имя пользователя для доступа к ленте: {0}",
"apiTokenHint": "Для аутентификации используйте токен API с разрешением {scope}. Создайте его на странице {link}.",
"tokenTitle": "Atom-лента"
},
"avatar": { "avatar": {
"title": "Аватар", "title": "Аватар",
"initials": "Инициалы", "initials": "Инициалы",
@ -344,7 +285,6 @@
"shared": "Общие проекты", "shared": "Общие проекты",
"noDescriptionAvailable": "Описание проекта отсутствует.", "noDescriptionAvailable": "Описание проекта отсутствует.",
"inboxTitle": "Входящие", "inboxTitle": "Входящие",
"myOpenTasksFilterTitle": "Мои открытые задачи",
"favorite": "Отметить проект как избранный", "favorite": "Отметить проект как избранный",
"unfavorite": "Удалить проект из избранного", "unfavorite": "Удалить проект из избранного",
"openSettingsMenu": "Открыть настройки проекта", "openSettingsMenu": "Открыть настройки проекта",
@ -389,7 +329,6 @@
"title": "Создание копии проекта", "title": "Создание копии проекта",
"label": "Создать копию", "label": "Создать копию",
"text": "Выберите родительский проект, в который поместить копию проекта:", "text": "Выберите родительский проект, в который поместить копию проекта:",
"shares": "Скопировать настройки доступа (пользователей, групп и ссылок для обмена)",
"success": "Копия проекта создана." "success": "Копия проекта создана."
}, },
"edit": { "edit": {
@ -468,6 +407,7 @@
"month": "Месяц", "month": "Месяц",
"day": "День", "day": "День",
"hour": "Час", "hour": "Час",
"range": "Диапазон",
"chartLabel": "Диаграмма Ганта", "chartLabel": "Диаграмма Ганта",
"taskBarsForRow": "Задачи в строке {rowId}", "taskBarsForRow": "Задачи в строке {rowId}",
"taskBarLabel": "Задача: {task}. С {startDate} по {endDate}. {dateType}. Нажмите для изменения, потяните для перемещения.", "taskBarLabel": "Задача: {task}. С {startDate} по {endDate}. {dateType}. Нажмите для изменения, потяните для перемещения.",
@ -486,8 +426,7 @@
"partialDatesStart": "Только дата начала (без окончания)", "partialDatesStart": "Только дата начала (без окончания)",
"partialDatesEnd": "Только дата окончания (без начала)", "partialDatesEnd": "Только дата окончания (без начала)",
"expandGroup": "Развернуть группу: {task}", "expandGroup": "Развернуть группу: {task}",
"collapseGroup": "Свернуть группу: {task}", "collapseGroup": "Свернуть группу: {task}"
"toggleRelationArrows": "Переключить стрелки связи"
}, },
"table": { "table": {
"title": "Таблица", "title": "Таблица",
@ -496,6 +435,7 @@
"kanban": { "kanban": {
"title": "Канбан", "title": "Канбан",
"limit": "Лимит: {limit}", "limit": "Лимит: {limit}",
"noLimit": "не установлен",
"doneBucket": "Колонка завершённых", "doneBucket": "Колонка завершённых",
"doneBucketHint": "Все задачи, помещённые в эту колонку, автоматически отмечаются как завершённые.", "doneBucketHint": "Все задачи, помещённые в эту колонку, автоматически отмечаются как завершённые.",
"doneBucketHintExtended": "Все задачи, перенесённые в колонку завершённых, будут помечены как завершённые. Все задачи, помеченные как завершённые, также будут перемещены в эту колонку.", "doneBucketHintExtended": "Все задачи, перенесённые в колонку завершённых, будут помечены как завершённые. Все задачи, помеченные как завершённые, также будут перемещены в эту колонку.",
@ -516,8 +456,7 @@
"bucketTitleSavedSuccess": "Название колонки сохранено.", "bucketTitleSavedSuccess": "Название колонки сохранено.",
"bucketLimitSavedSuccess": "Лимит колонки сохранён.", "bucketLimitSavedSuccess": "Лимит колонки сохранён.",
"collapse": "Свернуть эту колонку", "collapse": "Свернуть эту колонку",
"bucketLimitReached": "Вы достигли лимита колонки. Удалите какие-нибудь задачи или увеличьте лимит, чтобы добавить новые задачи.", "bucketLimitReached": "Вы достигли лимита колонки. Удалите какие-нибудь задачи или увеличьте лимит, чтобы добавить новые задачи."
"bucketOptions": "Настройки колонки"
}, },
"pseudo": { "pseudo": {
"favorites": { "favorites": {
@ -740,9 +679,7 @@
"upcoming": "Предстоящие задачи", "upcoming": "Предстоящие задачи",
"settings": "Настройки", "settings": "Настройки",
"imprint": "Отпечаток", "imprint": "Отпечаток",
"privacy": "Политика конфиденциальности", "privacy": "Политика конфиденциальности"
"closeSidebar": "Закрыть боковую панель",
"home": "Главная страница Vikunja"
}, },
"misc": { "misc": {
"loading": "Загрузка…", "loading": "Загрузка…",
@ -774,17 +711,9 @@
"createdBy": "Создатель {0}", "createdBy": "Создатель {0}",
"actions": "Действия", "actions": "Действия",
"cannotBeUndone": "Это действие отменить нельзя!", "cannotBeUndone": "Это действие отменить нельзя!",
"avatarOfUser": "Изображение профиля {user}", "avatarOfUser": "Изображение профиля {user}"
"closeBanner": "Закрыть баннер",
"closeDialog": "Закрыть диалог",
"closeQuickActions": "Закрыть быстрые действия",
"skipToContent": "Перейти к основному содержимому",
"dateRange": "Диапазон",
"notSet": "Не задано",
"user": "Пользователь"
}, },
"input": { "input": {
"projectColor": "Цвет проекта",
"resetColor": "Сбросить цвет", "resetColor": "Сбросить цвет",
"datepicker": { "datepicker": {
"today": "Сегодня", "today": "Сегодня",
@ -857,7 +786,6 @@
"date": "Дата", "date": "Дата",
"ranges": { "ranges": {
"today": "Сегодня", "today": "Сегодня",
"tomorrow": "Завтра",
"thisWeek": "Эта неделя", "thisWeek": "Эта неделя",
"restOfThisWeek": "Остаток этой недели", "restOfThisWeek": "Остаток этой недели",
"nextWeek": "Следующая неделя", "nextWeek": "Следующая неделя",
@ -965,8 +893,6 @@
"belongsToProject": "Задача принадлежит проекту «{project}»", "belongsToProject": "Задача принадлежит проекту «{project}»",
"back": "Вернуться к проекту", "back": "Вернуться к проекту",
"due": "Истекает {at}", "due": "Истекает {at}",
"closeTaskDetail": "Закрыть детали задачи",
"title": "Детали задачи",
"scrollToBottom": "Прокрутить до конца страницы", "scrollToBottom": "Прокрутить до конца страницы",
"organization": "Организация", "organization": "Организация",
"management": "Управление", "management": "Управление",
@ -1060,10 +986,7 @@
"addedSuccess": "Комментарий добавлен.", "addedSuccess": "Комментарий добавлен.",
"permalink": "Скопировать постоянную ссылку на комментарий", "permalink": "Скопировать постоянную ссылку на комментарий",
"sortNewestFirst": "Сначала новые", "sortNewestFirst": "Сначала новые",
"sortOldestFirst": "Сначала старые", "sortOldestFirst": "Сначала старые"
"reply": "Ответить",
"jumpToOriginal": "Перейти к исходному комментарию",
"deletedComment": "удалённый комментарий"
}, },
"mention": { "mention": {
"noUsersFound": "Пользователи не найдены" "noUsersFound": "Пользователи не найдены"
@ -1327,11 +1250,9 @@
"none": "Уведомлений нет. Хорошего дня!", "none": "Уведомлений нет. Хорошего дня!",
"explainer": "Здесь появятся уведомления, когда что-нибудь произойдёт с проектами или задачами, на которые вы подписаны.", "explainer": "Здесь появятся уведомления, когда что-нибудь произойдёт с проектами или задачами, на которые вы подписаны.",
"markAllRead": "Отметить всё как прочитанное", "markAllRead": "Отметить всё как прочитанное",
"markAllReadSuccess": "Все уведомления отмечены как прочитанные.", "markAllReadSuccess": "Все уведомления отмечены как прочитанные."
"subscribeFeed": "Подписаться на уведомления через Atom-ленту"
}, },
"quickActions": { "quickActions": {
"notLoggedIn": "Сначала войдите в главное окно Vikunja.",
"commands": "Команды", "commands": "Команды",
"placeholder": "Введите команду или поисковый запрос…", "placeholder": "Введите команду или поисковый запрос…",
"hint": "Используйте {project}, чтобы ограничить поиск проектом. Комбинируйте {project} и {label} (метки) с поисковым запросом для поиска задачи с этими метками или на этом проекте. Используйте {assignee} для поиска команд.", "hint": "Используйте {project}, чтобы ограничить поиск проектом. Комбинируйте {project} и {label} (метки) с поисковым запросом для поиска задачи с этими метками или на этом проекте. Используйте {assignee} для поиска команд.",
@ -1458,66 +1379,5 @@
"weeks": "неделя|недели|недель", "weeks": "неделя|недели|недель",
"years": "год|года|лет" "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", "default": "Privzeto",
"month": "Mesec", "month": "Mesec",
"day": "Dan", "day": "Dan",
"hour": "Ura" "hour": "Ura",
"range": "Datumski obseg"
}, },
"table": { "table": {
"title": "Tabela", "title": "Tabela",
@ -323,6 +324,7 @@
"kanban": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "Omejitev: {limit}", "limit": "Omejitev: {limit}",
"noLimit": "Ni nastavljeno",
"doneBucket": "Vedro končanih nalog", "doneBucket": "Vedro končanih nalog",
"doneBucketHint": "Vse naloge, premaknjene v to vedro, bodo samodejno označene kot opravljene.", "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.", "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", "month": "Månad",
"day": "Dag", "day": "Dag",
"hour": "Timme", "hour": "Timme",
"range": "Datumintervall",
"chartLabel": "Projektets Gantt-schema", "chartLabel": "Projektets Gantt-schema",
"taskBarsForRow": "Uppgiftsstaplar för rad {rowId}", "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.", "taskBarLabel": "Uppgift: {task}. Från {startDate} till {endDate}. {dateType}. Klicka för att redigera, dra för att flytta.",
@ -385,6 +386,7 @@
"kanban": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "Gräns: {limit}", "limit": "Gräns: {limit}",
"noLimit": "Ej inställt",
"doneBucket": "Färdigkolumn", "doneBucket": "Färdigkolumn",
"doneBucketHint": "Alla uppgifter som flyttas till den här kolumnen markeras automatiskt som klara.", "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.", "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", "month": "Ay",
"day": "Gün", "day": "Gün",
"hour": "Saat", "hour": "Saat",
"range": "Tarih Aralığı",
"chartLabel": "Proje Gantt Şeması", "chartLabel": "Proje Gantt Şeması",
"taskBarsForRow": "{rowId} satırı için görev çubukları", "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.", "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": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "Sınır: {limit}", "limit": "Sınır: {limit}",
"noLimit": "Belirlenmedi",
"doneBucket": "Tamamlananlar kutusu", "doneBucket": "Tamamlananlar kutusu",
"doneBucketHint": "Bu kutuya taşınan tüm görevler otomatik olarak tamamlandı olarak işaretlenir.", "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.", "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" "yyyy/mm/dd": "YYYY/MM/DD"
}, },
"timeFormat": "Формат часу", "timeFormat": "Формат часу",
"timeTrackingDefaultStart": "Початковий час для автоматичного заповнення обліку часу",
"timeFormatOptions": { "timeFormatOptions": {
"12h": "12-годинний (AM/PM)", "12h": "12-годинний (AM/PM)",
"24h": "24-годинний (HH:mm)" "24h": "24-годинний (HH:mm)"
@ -393,7 +392,6 @@
"title": "Дублювати цей проєкт", "title": "Дублювати цей проєкт",
"label": "Дублювати", "label": "Дублювати",
"text": "Оберіть батьківський проєкт, який повинен складатися з дубльованих проєктів:", "text": "Оберіть батьківський проєкт, який повинен складатися з дубльованих проєктів:",
"shares": "Скопіювати налаштування спільного доступу (користувачів, команди та посилання) до копії проєкту",
"success": "Проєкт дубльовано." "success": "Проєкт дубльовано."
}, },
"edit": { "edit": {
@ -472,6 +470,7 @@
"month": "Місяць", "month": "Місяць",
"day": "День", "day": "День",
"hour": "Година", "hour": "Година",
"range": "Проміжок днів",
"chartLabel": "Діаграма Ганта", "chartLabel": "Діаграма Ганта",
"taskBarsForRow": "Смуги завдань для рядка {rowId}", "taskBarsForRow": "Смуги завдань для рядка {rowId}",
"taskBarLabel": "Завдання: {task}. З {startDate} по {endDate}. {dateType}. Натисніть, щоб редагувати, перетягніть, щоб перемістити.", "taskBarLabel": "Завдання: {task}. З {startDate} по {endDate}. {dateType}. Натисніть, щоб редагувати, перетягніть, щоб перемістити.",
@ -500,6 +499,7 @@
"kanban": { "kanban": {
"title": "Дошка", "title": "Дошка",
"limit": "Межа: {limit}", "limit": "Межа: {limit}",
"noLimit": "Немає",
"doneBucket": "Колонка «Виконано»", "doneBucket": "Колонка «Виконано»",
"doneBucketHint": "Всі завдання, переміщені в цю колонку, будуть автоматично позначені як виконані.", "doneBucketHint": "Всі завдання, переміщені в цю колонку, будуть автоматично позначені як виконані.",
"doneBucketHintExtended": "Всі завдання, що потрапляють у колонку «Виконано», автоматично відмічаються як виконані. Завдання, позначені як виконані в інших місцях, також перемістяться сюди.", "doneBucketHintExtended": "Всі завдання, що потрапляють у колонку «Виконано», автоматично відмічаються як виконані. Завдання, позначені як виконані в інших місцях, також перемістяться сюди.",
@ -783,10 +783,7 @@
"closeDialog": "Закрити діалог", "closeDialog": "Закрити діалог",
"closeQuickActions": "Закрити швидкі дії", "closeQuickActions": "Закрити швидкі дії",
"skipToContent": "Перейти до основного вмісту", "skipToContent": "Перейти до основного вмісту",
"sortBy": "Сортувати за", "sortBy": "Сортувати за"
"dateRange": "Діапазон дат",
"notSet": "Не встановлено",
"user": "Користувач"
}, },
"input": { "input": {
"projectColor": "Колір проєкту", "projectColor": "Колір проєкту",
@ -989,14 +986,13 @@
"assign": "Доручити", "assign": "Доручити",
"label": "Позначки", "label": "Позначки",
"priority": "Встановити пріоритет", "priority": "Встановити пріоритет",
"dueDate": "Встановити термін виконання", "dueDate": "Встановити термін",
"startDate": "Почати", "startDate": "Почати",
"endDate": "Встановити дату завершення", "endDate": "Встановити дату завершення",
"reminders": "Нагадування", "reminders": "Нагадування",
"repeatAfter": "Повторювати", "repeatAfter": "Повторювати",
"percentDone": "Встановити прогрес", "percentDone": "Встановити прогрес",
"attachments": "Вкласти", "attachments": "Вкласти",
"timeTracking": "Відстежити час",
"relatedTasks": "Пов'язати", "relatedTasks": "Пов'язати",
"moveProject": "Перемістити", "moveProject": "Перемістити",
"duplicate": "Дублювати", "duplicate": "Дублювати",
@ -1063,7 +1059,7 @@
"edited": "змінено: {date}", "edited": "змінено: {date}",
"creating": "Створюю коментар…", "creating": "Створюю коментар…",
"placeholder": "Введіть коментар, натисніть '/' для додаткових опцій…", "placeholder": "Введіть коментар, натисніть '/' для додаткових опцій…",
"comment": "Зберегти коментар", "comment": "Залишити",
"delete": "Видалити коментар", "delete": "Видалити коментар",
"deleteText1": "Справді впровадити?", "deleteText1": "Справді впровадити?",
"deleteSuccess": "Коментар успішно видалено.", "deleteSuccess": "Коментар успішно видалено.",
@ -1152,11 +1148,11 @@
"repeat": { "repeat": {
"everyDay": "Щодня", "everyDay": "Щодня",
"everyWeek": "Щотижня", "everyWeek": "Щотижня",
"every30d": "Кожні 30 днів", "every30d": "Щомісяця",
"mode": "Спосіб", "mode": "Спосіб",
"monthly": "Щомісяця", "monthly": "Щомісяця",
"fromCurrentDate": "З дня закінчення", "fromCurrentDate": "Щодень закінчення",
"each": "Кожен", "each": "Що",
"specifyAmount": "Вкажіть величину…", "specifyAmount": "Вкажіть величину…",
"hours": "Години", "hours": "Години",
"days": "День", "days": "День",
@ -1222,8 +1218,8 @@
"success": "Вживача успішно видалено зі спільноти." "success": "Вживача успішно видалено зі спільноти."
}, },
"leave": { "leave": {
"title": "Залишити спільноту", "title": "Покинути спільноту",
"text1": "Ви впевнені, що хочете залишити цю спільноту?", "text1": "Справді покинути?",
"text2": "Ви втратите доступ до всіх проєктів, до яких має доступ ця команда. Якщо передумаєте, вам знадобиться адміністратор команди, щоб додати вас знову.", "text2": "Ви втратите доступ до всіх проєктів, до яких має доступ ця команда. Якщо передумаєте, вам знадобиться адміністратор команди, щоб додати вас знову.",
"success": "Ви покинули спільноту." "success": "Ви покинули спільноту."
} }
@ -1466,32 +1462,6 @@
"frontendVersion": "Версія інтерфейсу: {version}", "frontendVersion": "Версія інтерфейсу: {version}",
"apiVersion": "API версія: {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": { "time": {
"units": { "units": {
"seconds": "секунда|секунд(и)", "seconds": "секунда|секунд(и)",

View File

@ -319,7 +319,8 @@
"default": "Mặc định", "default": "Mặc định",
"month": "Tháng", "month": "Tháng",
"day": "Ngày", "day": "Ngày",
"hour": "Giờ" "hour": "Giờ",
"range": "Khoảng thời gian"
}, },
"table": { "table": {
"title": "Bảng", "title": "Bảng",
@ -328,6 +329,7 @@
"kanban": { "kanban": {
"title": "Kanban", "title": "Kanban",
"limit": "Giới hạn: {limit}", "limit": "Giới hạn: {limit}",
"noLimit": "Không giới hạn",
"doneBucket": "Cột hoàn thành", "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.", "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.", "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": "月", "month": "月",
"day": "日", "day": "日",
"hour": "时", "hour": "时",
"range": "日期范围",
"chartLabel": "项目甘特图", "chartLabel": "项目甘特图",
"scheduledDates": "预定日期", "scheduledDates": "预定日期",
"estimatedDates": "估计日期" "estimatedDates": "估计日期"
@ -349,6 +350,7 @@
"kanban": { "kanban": {
"title": "看板", "title": "看板",
"limit": "限制: {limit}", "limit": "限制: {limit}",
"noLimit": "未设置",
"doneBucket": "已完成的桶数", "doneBucket": "已完成的桶数",
"doneBucketHint": "移入此存储桶的所有任务将自动标记为已完成。", "doneBucketHint": "移入此存储桶的所有任务将自动标记为已完成。",
"doneBucketHintExtended": "所有移入已完成存储桶的任务都将自动标记为已完成。 从其他位置标记为已完成的任务也将被移动。", "doneBucketHintExtended": "所有移入已完成存储桶的任务都将自动标记为已完成。 从其他位置标记为已完成的任务也将被移动。",

View File

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

View File

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

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