Compare commits
2 Commits
main
...
docs-v2-qu
| Author | SHA1 | Date |
|---|---|---|
|
|
de5be2a7d3 | |
|
|
f856bf6318 |
|
|
@ -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 1–2 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0="
|
||||||
|
|
||||||
|
use devenv
|
||||||
|
|
@ -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/*
|
||||||
|
|
|
||||||
|
|
@ -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 }}/*
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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: |
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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,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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
85
devenv.lock
85
devenv.lock
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
24.18.0
|
24.13.0
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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('')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<{
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 === '') {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="task-time-tracking">
|
|
||||||
<XButton
|
|
||||||
v-if="entries.length > 0"
|
|
||||||
v-tooltip="$t('timeTracking.logTime')"
|
|
||||||
v-cy="'addTaskTimeEntry'"
|
|
||||||
class="is-pulled-right d-print-none"
|
|
||||||
:class="{'is-active': showForm}"
|
|
||||||
variant="secondary"
|
|
||||||
icon="plus"
|
|
||||||
:shadow="false"
|
|
||||||
@click="showForm = !showForm"
|
|
||||||
/>
|
|
||||||
<h3 class="title is-5">
|
|
||||||
{{ $t('timeTracking.title') }}
|
|
||||||
</h3>
|
|
||||||
<TimeEntryForm
|
|
||||||
v-if="formVisible"
|
|
||||||
:task-id="taskId"
|
|
||||||
:entry="editingEntry"
|
|
||||||
:recent-entries="entries"
|
|
||||||
@saved="onSaved"
|
|
||||||
@cancel="editingEntry = null"
|
|
||||||
/>
|
|
||||||
<TimeEntryList
|
|
||||||
class="mbs-4"
|
|
||||||
:entries="entries"
|
|
||||||
:card="false"
|
|
||||||
:empty-text="$t('timeTracking.list.emptyTask')"
|
|
||||||
hide-label-column
|
|
||||||
@edit="editingEntry = $event"
|
|
||||||
@delete="onDelete"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {ref, computed, watch} from 'vue'
|
|
||||||
|
|
||||||
import TimeEntryForm from '@/components/time-tracking/TimeEntryForm.vue'
|
|
||||||
import TimeEntryList from '@/components/time-tracking/TimeEntryList.vue'
|
|
||||||
|
|
||||||
import {useTimeEntryService} from '@/services/timeEntry'
|
|
||||||
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
|
||||||
|
|
||||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
taskId: number
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const timeTrackingStore = useTimeTrackingStore()
|
|
||||||
const entries = ref<ITimeEntry[]>([])
|
|
||||||
const editingEntry = ref<ITimeEntry | null>(null)
|
|
||||||
const showForm = ref(false)
|
|
||||||
|
|
||||||
// Like related tasks: the form is implicit when empty, otherwise behind the +.
|
|
||||||
const formVisible = computed(() => entries.value.length === 0 || showForm.value || editingEntry.value !== null)
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const {items} = await useTimeEntryService().getAll({
|
|
||||||
filter: `task_id = ${props.taskId}`,
|
|
||||||
perPage: 250,
|
|
||||||
})
|
|
||||||
entries.value = items
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSaved() {
|
|
||||||
editingEntry.value = null
|
|
||||||
showForm.value = false
|
|
||||||
await load()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDelete(id: number) {
|
|
||||||
await timeTrackingStore.removeEntry(id)
|
|
||||||
await load()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.taskId, load, {immediate: true})
|
|
||||||
// The header badge can start/stop the timer without going through this form;
|
|
||||||
// reload so the row reflects the stop (its new end time).
|
|
||||||
watch(() => timeTrackingStore.activeTimer, load)
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,353 +0,0 @@
|
||||||
<template>
|
|
||||||
<form
|
|
||||||
ref="formEl"
|
|
||||||
v-cy="'timeEntryForm'"
|
|
||||||
class="time-entry-form"
|
|
||||||
@submit.prevent="saveEntry"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="taskId === undefined"
|
|
||||||
class="field-columns"
|
|
||||||
>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">{{ $t('task.attributes.project') }}</label>
|
|
||||||
<ProjectSearch v-model="selectedProject" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">{{ $t('timeTracking.form.task') }}</label>
|
|
||||||
<Multiselect
|
|
||||||
v-model="selectedTask"
|
|
||||||
:placeholder="$t('timeTracking.form.taskSearch')"
|
|
||||||
:loading="taskService.loading"
|
|
||||||
:search-results="foundTasks"
|
|
||||||
label="title"
|
|
||||||
@search="findTasks"
|
|
||||||
>
|
|
||||||
<template #searchResult="{option}">
|
|
||||||
{{ option.title }}
|
|
||||||
</template>
|
|
||||||
</Multiselect>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">{{ $t('task.comment.comment') }}</label>
|
|
||||||
<input
|
|
||||||
v-model="comment"
|
|
||||||
v-cy="'timeEntryComment'"
|
|
||||||
class="input"
|
|
||||||
type="text"
|
|
||||||
:placeholder="$t('timeTracking.form.commentPlaceholder')"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field is-grouped from-to-row">
|
|
||||||
<div class="control is-expanded">
|
|
||||||
<label class="label">{{ $t('input.datepickerRange.from') }}</label>
|
|
||||||
<Datepicker
|
|
||||||
v-model="from"
|
|
||||||
:show-shortcuts="false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="control is-expanded">
|
|
||||||
<label class="label">{{ $t('input.datepickerRange.to') }}</label>
|
|
||||||
<Datepicker
|
|
||||||
v-model="to"
|
|
||||||
:show-shortcuts="false"
|
|
||||||
:empty-label="$t('misc.notSet')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="control">
|
|
||||||
<BaseButton
|
|
||||||
v-tooltip="$t('timeTracking.form.smartFill')"
|
|
||||||
v-cy="'smartFill'"
|
|
||||||
class="smart-fill"
|
|
||||||
:aria-label="$t('timeTracking.form.smartFill')"
|
|
||||||
@click="smartFill"
|
|
||||||
>
|
|
||||||
<Icon :icon="['far', 'clock']" />
|
|
||||||
</BaseButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field form-actions">
|
|
||||||
<template v-if="isEditing">
|
|
||||||
<XButton
|
|
||||||
v-cy="'updateTimeEntry'"
|
|
||||||
:disabled="!canSubmit"
|
|
||||||
:loading="isSaving"
|
|
||||||
@click="saveEntry"
|
|
||||||
>
|
|
||||||
{{ $t('timeTracking.form.update') }}
|
|
||||||
</XButton>
|
|
||||||
<XButton
|
|
||||||
variant="secondary"
|
|
||||||
:disabled="isSaving"
|
|
||||||
@click="cancelEdit"
|
|
||||||
>
|
|
||||||
{{ $t('misc.cancel') }}
|
|
||||||
</XButton>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<XButton
|
|
||||||
v-cy="'saveTimeEntry'"
|
|
||||||
:disabled="!canSubmit"
|
|
||||||
:loading="isSaving"
|
|
||||||
@click="saveEntry"
|
|
||||||
>
|
|
||||||
{{ $t('timeTracking.form.save') }}
|
|
||||||
</XButton>
|
|
||||||
<XButton
|
|
||||||
v-cy="'startTimer'"
|
|
||||||
variant="secondary"
|
|
||||||
:disabled="!canSubmit"
|
|
||||||
:loading="isSaving"
|
|
||||||
@click="startTimer"
|
|
||||||
>
|
|
||||||
{{ $t('timeTracking.form.startTimer') }}
|
|
||||||
</XButton>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {ref, computed, shallowReactive, watch, nextTick} from 'vue'
|
|
||||||
|
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
|
||||||
import Multiselect from '@/components/input/Multiselect.vue'
|
|
||||||
import Datepicker from '@/components/input/Datepicker.vue'
|
|
||||||
import ProjectSearch from '@/components/tasks/partials/ProjectSearch.vue'
|
|
||||||
|
|
||||||
import TaskService from '@/services/task'
|
|
||||||
import TaskModel from '@/models/task'
|
|
||||||
import {smartFillStart} from '@/helpers/time/smartFillStart'
|
|
||||||
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
|
||||||
import {useAuthStore} from '@/stores/auth'
|
|
||||||
import {useProjectStore} from '@/stores/projects'
|
|
||||||
|
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
|
||||||
import type {ITask} from '@/modelTypes/ITask'
|
|
||||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
// When set, the entry is locked to this task and the project/task pickers are hidden.
|
|
||||||
taskId?: number
|
|
||||||
// When set, the form edits this entry (Update + Cancel) instead of creating.
|
|
||||||
entry?: ITimeEntry | null
|
|
||||||
// Entries the smart-clock looks at to continue from the last one's end.
|
|
||||||
recentEntries?: ITimeEntry[]
|
|
||||||
}>(), {
|
|
||||||
taskId: undefined,
|
|
||||||
entry: undefined,
|
|
||||||
recentEntries: () => [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
saved: []
|
|
||||||
cancel: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const timeTrackingStore = useTimeTrackingStore()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const projectStore = useProjectStore()
|
|
||||||
|
|
||||||
const isEditing = computed(() => props.entry != null)
|
|
||||||
|
|
||||||
const formEl = ref<HTMLFormElement | null>(null)
|
|
||||||
const selectedProject = ref<IProject | null>(null)
|
|
||||||
const selectedTask = ref<ITask | null>(null)
|
|
||||||
const from = ref<Date | null>(new Date())
|
|
||||||
const to = ref<Date | null>(null)
|
|
||||||
const comment = ref('')
|
|
||||||
const isSaving = ref(false)
|
|
||||||
|
|
||||||
// Task and project are mutually exclusive (XOR) — selecting one clears the other,
|
|
||||||
// so applyTarget never picks a stale target the user has since changed.
|
|
||||||
watch(selectedTask, task => {
|
|
||||||
if (task !== null) {
|
|
||||||
selectedProject.value = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
watch(selectedProject, project => {
|
|
||||||
if (project !== null) {
|
|
||||||
selectedTask.value = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const taskService = shallowReactive(new TaskService())
|
|
||||||
const foundTasks = ref<ITask[]>([])
|
|
||||||
async function findTasks(query: string) {
|
|
||||||
if (query === '') {
|
|
||||||
foundTasks.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const result = await taskService.getAll({}, {s: query, sort_by: 'done'}) as ITask[]
|
|
||||||
foundTasks.value = selectedProject.value === null
|
|
||||||
? result
|
|
||||||
: result.filter(task => task.projectId === selectedProject.value?.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const canSubmit = computed(() =>
|
|
||||||
// In edit mode the entry already has a valid container; an update that sends
|
|
||||||
// neither keeps it, so don't block submit if the prefill lookup failed.
|
|
||||||
isEditing.value || props.taskId !== undefined || selectedTask.value !== null || selectedProject.value !== null,
|
|
||||||
)
|
|
||||||
|
|
||||||
function smartFill() {
|
|
||||||
from.value = smartFillStart(
|
|
||||||
props.recentEntries,
|
|
||||||
authStore.settings.frontendSettings.timeTrackingDefaultStart ?? '09:00',
|
|
||||||
new Date(),
|
|
||||||
)
|
|
||||||
to.value = new Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whichever of task / project is set lands on the payload (XOR — enforced by canSubmit).
|
|
||||||
function applyTarget(payload: Partial<ITimeEntry>) {
|
|
||||||
if (props.taskId !== undefined) {
|
|
||||||
payload.taskId = props.taskId
|
|
||||||
} else if (selectedTask.value !== null) {
|
|
||||||
payload.taskId = selectedTask.value.id
|
|
||||||
} else if (selectedProject.value !== null) {
|
|
||||||
payload.projectId = selectedProject.value.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPayload(includeEnd: boolean): Partial<ITimeEntry> {
|
|
||||||
const payload: Partial<ITimeEntry> = {
|
|
||||||
comment: comment.value,
|
|
||||||
startTime: from.value ?? new Date(),
|
|
||||||
}
|
|
||||||
applyTarget(payload)
|
|
||||||
// Saving a manual entry always has an end (an empty "To" means "until now");
|
|
||||||
// only the Start-timer path omits it to create a running timer.
|
|
||||||
if (includeEnd) {
|
|
||||||
payload.endTime = to.value ?? new Date()
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
selectedTask.value = null
|
|
||||||
selectedProject.value = null
|
|
||||||
comment.value = ''
|
|
||||||
from.value = new Date()
|
|
||||||
to.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefill from the entry being edited; a null entry returns the form to create mode.
|
|
||||||
watch(() => props.entry, async entry => {
|
|
||||||
if (entry == null) {
|
|
||||||
reset()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
comment.value = entry.comment
|
|
||||||
from.value = entry.startTime
|
|
||||||
to.value = entry.endTime
|
|
||||||
// Bring the form into view — the edit button may be far down the list.
|
|
||||||
await nextTick()
|
|
||||||
formEl.value?.scrollIntoView({behavior: 'smooth', block: 'center'})
|
|
||||||
if (props.taskId !== undefined) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (entry.taskId > 0) {
|
|
||||||
selectedProject.value = null
|
|
||||||
try {
|
|
||||||
selectedTask.value = await taskService.get(new TaskModel({id: entry.taskId})) as ITask
|
|
||||||
} catch {
|
|
||||||
selectedTask.value = null
|
|
||||||
}
|
|
||||||
} else if (entry.projectId > 0) {
|
|
||||||
selectedTask.value = null
|
|
||||||
selectedProject.value = (projectStore.projects[entry.projectId] as IProject) ?? null
|
|
||||||
}
|
|
||||||
}, {immediate: true})
|
|
||||||
|
|
||||||
async function submit(includeEnd: boolean) {
|
|
||||||
if (!canSubmit.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isSaving.value = true
|
|
||||||
try {
|
|
||||||
const payload = buildPayload(includeEnd)
|
|
||||||
// A started timer begins now (click time), not when the form first loaded.
|
|
||||||
if (!includeEnd) {
|
|
||||||
payload.startTime = new Date()
|
|
||||||
}
|
|
||||||
await timeTrackingStore.createEntry(payload)
|
|
||||||
reset()
|
|
||||||
emit('saved')
|
|
||||||
} finally {
|
|
||||||
isSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitUpdate() {
|
|
||||||
const entry = props.entry
|
|
||||||
if (!canSubmit.value || entry == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isSaving.value = true
|
|
||||||
try {
|
|
||||||
const payload: Partial<ITimeEntry> & {id: number} = {
|
|
||||||
id: entry.id,
|
|
||||||
comment: comment.value,
|
|
||||||
startTime: from.value ?? entry.startTime,
|
|
||||||
// A running entry stays running (null); a completed one can't be reopened,
|
|
||||||
// so keep its end if "To" was cleared (the API rejects clearing it).
|
|
||||||
endTime: entry.endTime === null ? to.value : (to.value ?? entry.endTime),
|
|
||||||
taskId: 0,
|
|
||||||
projectId: 0,
|
|
||||||
}
|
|
||||||
applyTarget(payload)
|
|
||||||
await timeTrackingStore.updateEntry(payload)
|
|
||||||
emit('saved')
|
|
||||||
} finally {
|
|
||||||
isSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveEntry = () => (isEditing.value ? submitUpdate() : submit(true))
|
|
||||||
const startTimer = () => submit(false)
|
|
||||||
function cancelEdit() {
|
|
||||||
emit('cancel')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.field-columns {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
|
|
||||||
> .field {
|
|
||||||
flex: 1;
|
|
||||||
min-inline-size: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.from-to-row {
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.smart-fill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
block-size: 2.5em;
|
|
||||||
inline-size: 2.5em;
|
|
||||||
border-radius: $radius;
|
|
||||||
color: var(--primary);
|
|
||||||
transition: background-color $transition;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--grey-100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: .5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,247 +0,0 @@
|
||||||
<template>
|
|
||||||
<p
|
|
||||||
v-if="rows.length === 0"
|
|
||||||
class="has-text-centered has-text-grey is-italic"
|
|
||||||
>
|
|
||||||
{{ emptyText }}
|
|
||||||
</p>
|
|
||||||
<component
|
|
||||||
:is="card ? Card : 'div'"
|
|
||||||
v-else
|
|
||||||
v-bind="card ? {padding: false, hasContent: false} : {}"
|
|
||||||
>
|
|
||||||
<div class="has-horizontal-overflow">
|
|
||||||
<table class="table has-actions is-hoverable is-fullwidth mbe-0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th v-if="!hideLabelColumn">
|
|
||||||
{{ $t('task.attributes.project') }}
|
|
||||||
</th>
|
|
||||||
<th v-if="!hideLabelColumn">
|
|
||||||
{{ $t('timeTracking.form.task') }}
|
|
||||||
</th>
|
|
||||||
<th>{{ $t('task.comment.comment') }}</th>
|
|
||||||
<th class="nowrap">
|
|
||||||
{{ $t('timeTracking.list.time') }}
|
|
||||||
</th>
|
|
||||||
<th class="nowrap has-text-right">
|
|
||||||
{{ $t('timeTracking.list.duration') }}
|
|
||||||
</th>
|
|
||||||
<th />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
v-for="row in rows"
|
|
||||||
:key="row.entry.id"
|
|
||||||
v-cy="'timeEntry'"
|
|
||||||
>
|
|
||||||
<td v-if="!hideLabelColumn">
|
|
||||||
<template
|
|
||||||
v-for="(project, i) in row.projectChain"
|
|
||||||
:key="project.id"
|
|
||||||
>
|
|
||||||
<RouterLink :to="{ name: 'project.index', params: { projectId: project.id } }">
|
|
||||||
{{ project.title }}
|
|
||||||
</RouterLink>
|
|
||||||
<span
|
|
||||||
v-if="i < row.projectChain.length - 1"
|
|
||||||
class="has-text-grey"
|
|
||||||
> > </span>
|
|
||||||
</template>
|
|
||||||
</td>
|
|
||||||
<td v-if="!hideLabelColumn">
|
|
||||||
<RouterLink
|
|
||||||
v-if="row.entry.taskId > 0"
|
|
||||||
:to="{ name: 'task.detail', params: { id: row.entry.taskId } }"
|
|
||||||
>
|
|
||||||
{{ row.taskIdentifier }}{{ row.taskTitle ? ` - ${row.taskTitle}` : '' }}
|
|
||||||
</RouterLink>
|
|
||||||
</td>
|
|
||||||
<td class="has-text-grey">
|
|
||||||
{{ row.entry.comment }}
|
|
||||||
</td>
|
|
||||||
<td class="nowrap has-text-grey">
|
|
||||||
{{ timeRange(row.entry) }}
|
|
||||||
</td>
|
|
||||||
<td class="nowrap has-text-right has-text-weight-semibold">
|
|
||||||
{{ row.seconds === null ? '' : formatDuration(row.seconds) }}
|
|
||||||
</td>
|
|
||||||
<td class="nowrap has-text-right">
|
|
||||||
<template v-if="row.entry.userId === currentUserId">
|
|
||||||
<BaseButton
|
|
||||||
v-tooltip="$t('menu.edit')"
|
|
||||||
v-cy="'editTimeEntry'"
|
|
||||||
class="entry-action"
|
|
||||||
:aria-label="$t('menu.edit')"
|
|
||||||
@click="emit('edit', row.entry)"
|
|
||||||
>
|
|
||||||
<Icon icon="pen" />
|
|
||||||
</BaseButton>
|
|
||||||
<BaseButton
|
|
||||||
v-tooltip="$t('misc.delete')"
|
|
||||||
v-cy="'deleteTimeEntry'"
|
|
||||||
class="entry-action entry-delete"
|
|
||||||
:aria-label="$t('misc.delete')"
|
|
||||||
@click="emit('delete', row.entry.id)"
|
|
||||||
>
|
|
||||||
<Icon icon="trash-alt" />
|
|
||||||
</BaseButton>
|
|
||||||
</template>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
:colspan="hideLabelColumn ? 2 : 4"
|
|
||||||
class="has-text-weight-bold"
|
|
||||||
>
|
|
||||||
{{ $t('timeTracking.list.total') }}
|
|
||||||
</td>
|
|
||||||
<td class="nowrap has-text-right has-text-weight-bold">
|
|
||||||
{{ formatDuration(totalSeconds) }}
|
|
||||||
</td>
|
|
||||||
<td />
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</component>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {ref, computed, watch} from 'vue'
|
|
||||||
|
|
||||||
import Card from '@/components/misc/Card.vue'
|
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
|
||||||
|
|
||||||
import TaskService from '@/services/task'
|
|
||||||
import TaskModel from '@/models/task'
|
|
||||||
import {useProjectStore} from '@/stores/projects'
|
|
||||||
import {useAuthStore} from '@/stores/auth'
|
|
||||||
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
|
||||||
import {formatDate} from '@/helpers/time/formatDate'
|
|
||||||
import {useTimeFormat} from '@/composables/useTimeFormat'
|
|
||||||
import {TIME_FORMAT} from '@/constants/timeFormat'
|
|
||||||
|
|
||||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
|
||||||
import type {ITask} from '@/modelTypes/ITask'
|
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
entries: ITimeEntry[]
|
|
||||||
// Drop the project + task columns when every entry belongs to the same task
|
|
||||||
// (e.g. the task-detail page).
|
|
||||||
hideLabelColumn?: boolean
|
|
||||||
// Wrap the table in a Card box; set false to render it inline (no card background).
|
|
||||||
card?: boolean
|
|
||||||
// Override the empty-state message (defaults to the per-day wording).
|
|
||||||
emptyText?: string
|
|
||||||
}>(), {
|
|
||||||
hideLabelColumn: false,
|
|
||||||
card: true,
|
|
||||||
emptyText: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
delete: [id: number]
|
|
||||||
edit: [entry: ITimeEntry]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
|
||||||
const {store: timeFormat} = useTimeFormat()
|
|
||||||
|
|
||||||
// Only the author can update/delete (enforced server-side); shared lists include
|
|
||||||
// others' entries, so hide the controls on rows the current user doesn't own.
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const currentUserId = computed(() => authStore.info?.id)
|
|
||||||
|
|
||||||
// Task entries carry only a task id; resolve the full task lazily (for its
|
|
||||||
// title, identifier, and parent project) and cache it.
|
|
||||||
const taskService = new TaskService()
|
|
||||||
const tasks = ref<Record<number, ITask>>({})
|
|
||||||
const inFlight = new Set<number>()
|
|
||||||
async function ensureTask(taskId: number) {
|
|
||||||
if (taskId === 0 || tasks.value[taskId] !== undefined || inFlight.has(taskId)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
inFlight.add(taskId)
|
|
||||||
try {
|
|
||||||
tasks.value[taskId] = await taskService.get(new TaskModel({id: taskId}))
|
|
||||||
} catch {
|
|
||||||
// Leave unresolved — the row falls back to #<id>.
|
|
||||||
} finally {
|
|
||||||
inFlight.delete(taskId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.entries, entries => {
|
|
||||||
entries.forEach(entry => ensureTask(entry.taskId))
|
|
||||||
}, {immediate: true})
|
|
||||||
|
|
||||||
function entrySeconds(entry: ITimeEntry): number {
|
|
||||||
const end = entry.endTime ?? new Date()
|
|
||||||
return Math.floor((end.getTime() - entry.startTime.getTime()) / 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = computed(() => props.entries.map(entry => {
|
|
||||||
const task = entry.taskId > 0 ? tasks.value[entry.taskId] : undefined
|
|
||||||
const projectId = task?.projectId ?? (entry.projectId > 0 ? entry.projectId : 0)
|
|
||||||
const project = projectId > 0 ? projectStore.projects[projectId] as IProject | undefined : undefined
|
|
||||||
const ancestors = project ? projectStore.getAncestors(project) : []
|
|
||||||
|
|
||||||
return {
|
|
||||||
entry,
|
|
||||||
// Full ancestor chain (root → leaf), each link-able.
|
|
||||||
projectChain: ancestors.map(p => ({id: p.id, title: getProjectTitle(p)})),
|
|
||||||
taskIdentifier: task ? (task.identifier || `#${task.index}`) : (entry.taskId > 0 ? `#${entry.taskId}` : ''),
|
|
||||||
taskTitle: task?.title ?? '',
|
|
||||||
// A running entry (no end) has no settled duration — leave it blank.
|
|
||||||
seconds: entry.endTime !== null ? entrySeconds(entry) : null,
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
const totalSeconds = computed(() => rows.value.reduce((sum, row) => sum + (row.seconds ?? 0), 0))
|
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
|
||||||
const hours = Math.floor(seconds / 3600)
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60)
|
|
||||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(date: Date): string {
|
|
||||||
return formatDate(date, timeFormat.value === TIME_FORMAT.HOURS_24 ? 'HH:mm' : 'hh:mm A')
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeRange(entry: ITimeEntry): string {
|
|
||||||
const start = formatTime(entry.startTime)
|
|
||||||
if (entry.endTime === null) {
|
|
||||||
return `${start} – …`
|
|
||||||
}
|
|
||||||
return `${start} – ${formatTime(entry.endTime)}`
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.nowrap {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-action {
|
|
||||||
color: var(--grey-400);
|
|
||||||
transition: color $transition;
|
|
||||||
|
|
||||||
& + & {
|
|
||||||
margin-inline-start: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-delete:hover {
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
v-if="timeTrackingStore.hasActiveTimer"
|
|
||||||
v-cy="'timerBadge'"
|
|
||||||
class="timer-badge"
|
|
||||||
>
|
|
||||||
<RouterLink
|
|
||||||
:to="{ name: 'time-tracking' }"
|
|
||||||
class="timer-badge__elapsed"
|
|
||||||
:title="$t('timeTracking.title')"
|
|
||||||
>
|
|
||||||
{{ elapsed }}
|
|
||||||
</RouterLink>
|
|
||||||
<BaseButton
|
|
||||||
v-tooltip="$t('timeTracking.stop')"
|
|
||||||
v-cy="'stopTimer'"
|
|
||||||
class="timer-badge__stop"
|
|
||||||
:aria-label="$t('timeTracking.stop')"
|
|
||||||
@click="stop"
|
|
||||||
>
|
|
||||||
<Icon icon="stop" />
|
|
||||||
</BaseButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {ref, computed, onMounted, onUnmounted} from 'vue'
|
|
||||||
|
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
|
||||||
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
|
||||||
import {useConfigStore} from '@/stores/config'
|
|
||||||
import {PRO_FEATURE} from '@/constants/proFeatures'
|
|
||||||
|
|
||||||
const timeTrackingStore = useTimeTrackingStore()
|
|
||||||
const configStore = useConfigStore()
|
|
||||||
|
|
||||||
const now = ref(new Date())
|
|
||||||
let interval: ReturnType<typeof setInterval> | undefined
|
|
||||||
|
|
||||||
const elapsed = computed(() => {
|
|
||||||
const timer = timeTrackingStore.activeTimer
|
|
||||||
if (timer === null) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const seconds = Math.max(0, Math.floor((now.value.getTime() - timer.startTime.getTime()) / 1000))
|
|
||||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
|
||||||
const hours = Math.floor(seconds / 3600)
|
|
||||||
const mmss = `${pad(Math.floor((seconds % 3600) / 60))}:${pad(seconds % 60)}`
|
|
||||||
return hours >= 1 ? `${hours}:${mmss}` : mmss
|
|
||||||
})
|
|
||||||
|
|
||||||
const isStopping = ref(false)
|
|
||||||
async function stop() {
|
|
||||||
if (isStopping.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isStopping.value = true
|
|
||||||
try {
|
|
||||||
await timeTrackingStore.stopTimer()
|
|
||||||
} finally {
|
|
||||||
isStopping.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// The badge lives in the always-mounted header, so it owns the app-wide timer
|
|
||||||
// sync. Subscribing is harmless when the feature is off (no events are emitted);
|
|
||||||
// only the hydrate hits the gated endpoint, so guard that.
|
|
||||||
timeTrackingStore.subscribeToTimerEvents()
|
|
||||||
if (configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING)) {
|
|
||||||
timeTrackingStore.hydrateActiveTimer()
|
|
||||||
}
|
|
||||||
interval = setInterval(() => {
|
|
||||||
now.value = new Date()
|
|
||||||
}, 1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
timeTrackingStore.unsubscribeFromTimerEvents()
|
|
||||||
if (interval !== undefined) {
|
|
||||||
clearInterval(interval)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.timer-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: .25rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timer-badge__elapsed {
|
|
||||||
padding-inline: .75rem .25rem;
|
|
||||||
color: var(--primary);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timer-badge__stop {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding-inline: .5rem;
|
|
||||||
color: var(--grey-400);
|
|
||||||
transition: color $transition;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getCurrentInstance, ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { createGlobalState, useIntervalFn } from '@vueuse/core'
|
import { 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,
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import {describe, it, expect} from 'vitest'
|
|
||||||
import {buildStoredQuery} from './useTaskList'
|
|
||||||
|
|
||||||
describe('buildStoredQuery', () => {
|
|
||||||
it('includes sort when set', () => {
|
|
||||||
expect(buildStoredQuery({sort: 'due_date:asc', filter: undefined, s: undefined, page: 1}))
|
|
||||||
.toEqual({sort: 'due_date:asc'})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('includes filter and search when set', () => {
|
|
||||||
expect(buildStoredQuery({sort: undefined, filter: 'done = false', s: 'foo', page: 1}))
|
|
||||||
.toEqual({filter: 'done = false', s: 'foo'})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('omits page when it equals the default of 1', () => {
|
|
||||||
expect(buildStoredQuery({sort: 'id:desc', filter: undefined, s: undefined, page: 1}))
|
|
||||||
.toEqual({sort: 'id:desc'})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('includes page when greater than 1', () => {
|
|
||||||
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 3}))
|
|
||||||
.toEqual({page: '3'})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns an empty object when nothing is set', () => {
|
|
||||||
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 1}))
|
|
||||||
.toEqual({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('skips empty strings', () => {
|
|
||||||
expect(buildStoredQuery({sort: '', filter: '', s: '', page: 1}))
|
|
||||||
.toEqual({})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
|
import {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}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import {watch} from 'vue'
|
|
||||||
import {createSharedComposable, tryOnMounted} from '@vueuse/core'
|
|
||||||
import {storeToRefs} from 'pinia'
|
|
||||||
|
|
||||||
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
|
||||||
import {getFullBaseUrl} from '@/helpers/getFullBaseUrl'
|
|
||||||
|
|
||||||
const TRACKING_FAVICON = `${getFullBaseUrl()}images/icons/favicon-tracking-32x32.png`
|
|
||||||
|
|
||||||
function getFaviconLink(): HTMLLinkElement | null {
|
|
||||||
return document.querySelector<HTMLLinkElement>('link[rel="icon"]')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Swaps in a favicon with a small red dot in the lower left corner while a timer
|
|
||||||
// is running, so an active time tracking session is visible even when the tab
|
|
||||||
// isn't focused.
|
|
||||||
export const useTimeTrackingFavicon = createSharedComposable(() => {
|
|
||||||
const {hasActiveTimer} = storeToRefs(useTimeTrackingStore())
|
|
||||||
|
|
||||||
const originalHref = getFaviconLink()?.getAttribute('href') ?? '/favicon.ico'
|
|
||||||
|
|
||||||
function update(active: boolean) {
|
|
||||||
const link = getFaviconLink()
|
|
||||||
if (link === null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
link.href = active ? TRACKING_FAVICON : originalHref
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(hasActiveTimer, update, {flush: 'post'})
|
|
||||||
tryOnMounted(() => update(hasActiveTimer.value))
|
|
||||||
})
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
// Licensed "pro" features the server may advertise via /info's enabled_pro_features.
|
|
||||||
// Use these instead of bare strings when calling configStore.isProFeatureEnabled.
|
|
||||||
export const PRO_FEATURE = {
|
|
||||||
ADMIN_PANEL: 'admin_panel',
|
|
||||||
TIME_TRACKING: 'time_tracking',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type ProFeature = typeof PRO_FEATURE[keyof typeof PRO_FEATURE]
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
/**
|
|
||||||
* Hash-fragment prefix used to carry a post-login destination in the URL.
|
|
||||||
*
|
|
||||||
* Unlike the localStorage redirect, this lives in the address bar so the URL
|
|
||||||
* stays copyable between browsers (needed for native OAuth clients that open
|
|
||||||
* /oauth/authorize, see #2654). It uses the hash – not a query param – so the
|
|
||||||
* embedded OAuth parameters never reach server or proxy access logs.
|
|
||||||
*
|
|
||||||
* Must stay distinct from LINK_SHARE_HASH_PREFIX, which router.beforeEach
|
|
||||||
* special-cases.
|
|
||||||
*/
|
|
||||||
export const REDIRECT_HASH_PREFIX = '#redirect='
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
|
|
||||||
|
|
||||||
import {refreshToken, removeToken} from './auth'
|
|
||||||
|
|
||||||
// Count how many times the refresh endpoint is actually POSTed. The whole point
|
|
||||||
// of the in-flight dedup is that concurrent refreshToken() calls share a single
|
|
||||||
// underlying POST, independent of the Web Locks API.
|
|
||||||
let postCallCount = 0
|
|
||||||
let resolvePost: ((value: unknown) => void) | null = null
|
|
||||||
|
|
||||||
vi.mock('@/helpers/fetcher', () => ({
|
|
||||||
HTTPFactory: () => ({
|
|
||||||
post: vi.fn(() => {
|
|
||||||
postCallCount++
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
resolvePost = resolve
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/helpers/desktopAuth', () => ({
|
|
||||||
isDesktopApp: () => false,
|
|
||||||
refreshDesktopToken: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const FAKE_TOKEN = 'header.payload.signature'
|
|
||||||
|
|
||||||
function settlePost() {
|
|
||||||
resolvePost?.({data: {token: FAKE_TOKEN}})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('refreshToken in-flight dedup', () => {
|
|
||||||
const originalLocks = navigator.locks
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
postCallCount = 0
|
|
||||||
resolvePost = null
|
|
||||||
removeToken()
|
|
||||||
localStorage.clear()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
Object.defineProperty(navigator, 'locks', {
|
|
||||||
value: originalLocks,
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('coalesces concurrent calls into a single POST when Web Locks is available', async () => {
|
|
||||||
// Stub a minimal Web Locks API: happy-dom leaves navigator.locks
|
|
||||||
// undefined, so without this the test would silently fall through to
|
|
||||||
// the insecure-HTTP branch and never exercise navigator.locks.request.
|
|
||||||
const requestSpy = vi.fn((_name: string, cb: () => unknown) => cb())
|
|
||||||
Object.defineProperty(navigator, 'locks', {
|
|
||||||
value: {request: requestSpy},
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const p1 = refreshToken(true)
|
|
||||||
const p2 = refreshToken(true)
|
|
||||||
|
|
||||||
// Both calls share one underlying request.
|
|
||||||
expect(postCallCount).toBe(1)
|
|
||||||
|
|
||||||
settlePost()
|
|
||||||
await Promise.all([p1, p2])
|
|
||||||
|
|
||||||
// The Web Locks branch actually ran...
|
|
||||||
expect(requestSpy).toHaveBeenCalledWith('vikunja-token-refresh', expect.any(Function))
|
|
||||||
// ...and the in-flight dedup still collapsed both calls into one POST.
|
|
||||||
expect(postCallCount).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('coalesces concurrent calls into a single POST on insecure HTTP (no Web Locks)', async () => {
|
|
||||||
// Simulate an insecure HTTP context where navigator.locks is undefined.
|
|
||||||
Object.defineProperty(navigator, 'locks', {
|
|
||||||
value: undefined,
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const p1 = refreshToken(true)
|
|
||||||
const p2 = refreshToken(true)
|
|
||||||
const p3 = refreshToken(true)
|
|
||||||
|
|
||||||
expect(postCallCount).toBe(1)
|
|
||||||
|
|
||||||
settlePost()
|
|
||||||
await Promise.all([p1, p2, p3])
|
|
||||||
|
|
||||||
expect(postCallCount).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('allows a fresh refresh after the previous one settled', async () => {
|
|
||||||
const p1 = refreshToken(true)
|
|
||||||
settlePost()
|
|
||||||
await p1
|
|
||||||
expect(postCallCount).toBe(1)
|
|
||||||
|
|
||||||
// The in-flight promise was reset, so a later refresh runs anew.
|
|
||||||
const p2 = refreshToken(true)
|
|
||||||
expect(postCallCount).toBe(2)
|
|
||||||
settlePost()
|
|
||||||
await p2
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not re-persist the token when logout happens during an in-flight refresh', async () => {
|
|
||||||
const p1 = refreshToken(true)
|
|
||||||
expect(postCallCount).toBe(1)
|
|
||||||
|
|
||||||
// User logs out while the refresh POST is still in flight.
|
|
||||||
removeToken()
|
|
||||||
|
|
||||||
// The in-flight POST resolves afterwards — it must not undo the logout.
|
|
||||||
settlePost()
|
|
||||||
await p1
|
|
||||||
|
|
||||||
expect(localStorage.getItem('token')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('an older refresh settling does not clobber a newer in-flight one', async () => {
|
|
||||||
// Refresh A starts and stays in flight.
|
|
||||||
const pA = refreshToken(true)
|
|
||||||
expect(postCallCount).toBe(1)
|
|
||||||
const resolveA = resolvePost
|
|
||||||
|
|
||||||
// User logs out, which drops the in-flight reference to A.
|
|
||||||
removeToken()
|
|
||||||
|
|
||||||
// Refresh B starts; it must claim the in-flight slot.
|
|
||||||
const pB = refreshToken(true)
|
|
||||||
expect(postCallCount).toBe(2)
|
|
||||||
const resolveB = resolvePost
|
|
||||||
|
|
||||||
// A settles after B started. Its cleanup must NOT null the in-flight
|
|
||||||
// slot, since that slot now belongs to B. Without the `=== p` guard,
|
|
||||||
// A's .finally would clobber B and let a concurrent caller fire a
|
|
||||||
// second parallel POST.
|
|
||||||
resolveA?.({data: {token: FAKE_TOKEN}})
|
|
||||||
await pA
|
|
||||||
|
|
||||||
// A concurrent caller while B is still in flight must dedup to B —
|
|
||||||
// no third POST.
|
|
||||||
const pB2 = refreshToken(true)
|
|
||||||
expect(postCallCount).toBe(2)
|
|
||||||
|
|
||||||
resolveB?.({data: {token: FAKE_TOKEN}})
|
|
||||||
await Promise.all([pB, pB2])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -33,53 +33,18 @@ export const removeToken = () => {
|
||||||
savedToken = null
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import {describe, it, expect} from 'vitest'
|
|
||||||
|
|
||||||
import {smartFillStart} from './smartFillStart'
|
|
||||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
|
||||||
|
|
||||||
function entry(startTime: Date, endTime: Date | null): ITimeEntry {
|
|
||||||
return {
|
|
||||||
id: 1,
|
|
||||||
userId: 1,
|
|
||||||
taskId: 0,
|
|
||||||
projectId: 0,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
comment: '',
|
|
||||||
created: startTime,
|
|
||||||
updated: startTime,
|
|
||||||
maxPermission: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('smartFillStart', () => {
|
|
||||||
const now = new Date('2026-06-07T15:30:00')
|
|
||||||
|
|
||||||
it('continues from the latest entry end time', () => {
|
|
||||||
const entries = [
|
|
||||||
entry(new Date('2026-06-07T09:00:00'), new Date('2026-06-07T10:00:00')),
|
|
||||||
entry(new Date('2026-06-07T11:00:00'), new Date('2026-06-07T12:30:00')),
|
|
||||||
]
|
|
||||||
expect(smartFillStart(entries, '09:00', now)).toEqual(new Date('2026-06-07T12:30:00'))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ignores still-running entries (no end) when picking the latest end', () => {
|
|
||||||
const entries = [
|
|
||||||
entry(new Date('2026-06-07T09:00:00'), new Date('2026-06-07T10:00:00')),
|
|
||||||
entry(new Date('2026-06-07T13:00:00'), null),
|
|
||||||
]
|
|
||||||
expect(smartFillStart(entries, '09:00', now)).toEqual(new Date('2026-06-07T10:00:00'))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to the default start time on the current day when there are no entries', () => {
|
|
||||||
expect(smartFillStart([], '08:15', now)).toEqual(new Date('2026-06-07T08:15:00'))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to 09:00 when no default is configured', () => {
|
|
||||||
expect(smartFillStart([], '', now)).toEqual(new Date('2026-06-07T09:00:00'))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('caps the default start at now when it would be in the future (before 09:00)', () => {
|
|
||||||
const beforeNine = new Date('2026-06-07T07:30:00')
|
|
||||||
expect(smartFillStart([], '09:00', beforeNine)).toEqual(beforeNine)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('caps a future last-entry end at now', () => {
|
|
||||||
const entries = [entry(new Date('2026-06-07T16:00:00'), new Date('2026-06-07T17:00:00'))]
|
|
||||||
expect(smartFillStart(entries, '09:00', now)).toEqual(now)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
|
||||||
|
|
||||||
// The smart-clock start time: continue from the most recent entry's end so
|
|
||||||
// consecutive entries don't overlap or leave gaps; with no completed entry to
|
|
||||||
// continue from, fall back to the user's configured default start (HH:MM) on
|
|
||||||
// the given day.
|
|
||||||
export function smartFillStart(recentEntries: ITimeEntry[], defaultStart: string, now: Date): Date {
|
|
||||||
// The filled range ends at now, so a start after now would be inverted (and
|
|
||||||
// rejected on save). Cap at now — e.g. the 09:00 fallback before 9am.
|
|
||||||
const cap = (start: Date) => (start.getTime() > now.getTime() ? new Date(now) : start)
|
|
||||||
|
|
||||||
const lastEnd = recentEntries
|
|
||||||
.map(entry => entry.endTime)
|
|
||||||
.filter((end): end is Date => end !== null)
|
|
||||||
.sort((a, b) => b.getTime() - a.getTime())[0]
|
|
||||||
if (lastEnd !== undefined) {
|
|
||||||
return cap(new Date(lastEnd))
|
|
||||||
}
|
|
||||||
|
|
||||||
const [hours, minutes] = (defaultStart || '09:00').split(':').map(Number)
|
|
||||||
const start = new Date(now)
|
|
||||||
start.setHours(hours || 0, minutes || 0, 0, 0)
|
|
||||||
return cap(start)
|
|
||||||
}
|
|
||||||
|
|
@ -30,7 +30,6 @@ export const SUPPORTED_LOCALES = {
|
||||||
'ja-JP': '日本語',
|
'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])
|
||||||
|
|
|
||||||
|
|
@ -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": "سيتم وضع علامة مكتمل على جميع المهام التي تم نقلها إلى حافظة المهام المكتملة. كما سيتم نقل جميع المهام المكتملة من أماكن أخرى.",
|
||||||
|
|
|
||||||
|
|
@ -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": "Всички задачи, преместени в колоната за завършени, ще бъдат автоматично маркирани като завършени. Всички задачи, маркирани като завършени от другаде, също ще бъдат преместени тук.",
|
||||||
|
|
|
||||||
|
|
@ -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é.",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "δευτερόλεπτο|δευτερόλεπτα",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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": "כל המטלות המוכנסות לדלי הגמורים יסומנו אוטומטית כגמורים. כל המטלות המסומנות כגמורים מבחוץ יוזזו גם.",
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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": "完了バケットに移動されたすべてのタスクは自動的に完了としてマークされます。他の場所にあるタスクも完了としてマークされるとこのバケットに移動されます。",
|
||||||
|
|
|
||||||
|
|
@ -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": "완료 버킷으로 이동된 모든 할 일은 자동으로 완료로 표시됩니다. 다른 곳에서 완료로 표시된 모든 할 일도 함께 이동됩니다.",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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": "Новый владелец"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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": "секунда|секунд(и)",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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": "所有移入已完成存储桶的任务都将自动标记为已完成。 从其他位置标记为已完成的任务也将被移动。",
|
||||||
|
|
|
||||||
|
|
@ -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": "所有移入已完成類別的任務將自動標記為已完成。 其他位置標記為已完成的任務也將被移動。",
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in New Issue