Compare commits

..

7 Commits

Author SHA1 Message Date
kolaente a2cb2826d0 feat(search): omit default sort while searching so results rank by relevance
The web client always sent sort_by/order_by (the view's default sort), so the
backend's userProvidedSort flag was always true and the new BM25 relevance
ranking never engaged from the web app. When a search is active and the user
has not explicitly chosen a sort, omit the sort entirely so the backend ranks
results by relevance. An explicit user sort still suppresses ranking and
non-search browsing is unchanged.
2026-06-21 19:36:03 +02:00
kolaente 6f6f91bd28 test(search): assert explicit sort_by disables relevance ranking
Lock the contract that a user-provided sort_by overrides BM25 relevance:
with sort_by=id order_by=desc the lowest-id task (which BM25 would rank
first) ranks last, proving pdb.score is not applied. ParadeDB-gated since
only its per-token search matches all three fixture tasks.
2026-06-21 19:24:07 +02:00
kolaente cefa42da86 refactor(search): limit BM25 relevance ranking to pure-text searches
Rank ParadeDB search results by BM25 relevance only for pure-text searches
over a plain project scope. Numeric searches (the `OR index = N` branch) and
the Favorites view (the `id IN (<subquery>)` scope) keep the default ordering
(unranked, as on main): pdb.score rejects both as unsupported query shapes, and
the contortions previously needed to score them (two-arm numeric merge with
in-memory pagination, a favorites LEFT JOIN) added far more complexity than the
ranking was worth. Neither path was ranked before this PR, so leaving them at
the default order is no regression.
2026-06-21 18:49:41 +02:00
kolaente 78dde2fb18 fix(search): derive userProvidedSort from the effective sort so relevance ranking applies in negative-id views 2026-06-19 23:14:55 +02:00
kolaente d93e98f76b fix(search): qualify the task index column to avoid ambiguity with the parent-task join 2026-06-19 23:14:51 +02:00
kolaente 116fb1e2e0 fix(search): rank exact task-index match before BM25 text relevance on ParadeDB
The BM25 relevance ranking added `pdb.score(tasks.id)` to the search SELECT
and ORDER BY. ParadeDB can only compute a score for a pure-ParadeDB query
shape, so two cases produced "pq: Unsupported query shape":

1. A numeric search (e.g. "#17") OR's the ParadeDB `|||` operators with a
   plain `"index" = N` equality in the same boolean group. Scoring that mixed
   group is unsupported.
2. When favorites are in scope, the `project_id IN (...) OR id IN (<favorites
   subquery>)` predicate is unsupported under pdb.score regardless of how the
   subquery is expressed (OR or UNION) - it just was never exercised because
   the ranking tests searched a single project with no favorites.

Both are now handled so each query ParadeDB scores is a supported shape:

- Numeric search runs as two arms: an exact `index = N` arm (no score, ranked
  first) and a text `|||` arm scored by pdb.score DESC. The arms are merged in
  Go (index matches first, deduped by task id) and paginated in memory; the
  count query keeps the combined `OR index = N` predicate (no score), which is
  a supported shape, so totalItems stays correct.
- The relevance arms reach favorites through a LEFT JOIN and scope on the
  joined column (`rank_favorites.entity_id IS NOT NULL`) instead of an
  id-IN-subquery, which ParadeDB can score.

Non-numeric (pure text) searches keep the single pdb.score-ordered query.
Non-ParadeDB databases are unchanged (no pdb.score, no ranking).

TestTaskSearchRelevanceRankingNumericIndex covers the numeric case: on
ParadeDB the exact-index task ranks first, then text matches by relevance; on
other databases it only asserts the matches are returned.

Validated against the CI-pinned ParadeDB image (paradedb 0.21.12): the full
pkg/models and pkg/webtests suites pass, including
TestTaskCollection_ReadAll/search_for_task_index and the HTTP search tests.
2026-06-19 22:52:26 +02:00
kolaente 9fb0d86c1b feat(search): rank ParadeDB search results by BM25 relevance (#2690)
When ParadeDB is in use and a search is run, results now keep the current
fuzzy/OR matching but are ordered by BM25 relevance so tasks matching all
query words rank above tasks matching only some.

Details:
- ParadeDB exposes the BM25 score via pdb.score(<key_field>); Vikunja's
  key_field is id, so we order by pdb.score(tasks.id) DESC, then the
  existing order-by (ending in a stable tasks.id tiebreak).
- Gating: relevance ordering only applies when ParadeDB is available, a
  search term is present, AND the user did not pass an explicit sort_by.
  An explicit user sort still wins; relevance only replaces the default
  (id / position) sort.
- DISTINCT requires every ORDER BY expression to appear in the SELECT
  list, so pdb.score(tasks.id) is added to the selected columns too (for
  both the plain and task_positions-join query shapes). Because xorm's
  Distinct() quotes each column and corrupts the function call, the
  ranking path uses Select(rawColumns).Distinct() instead.
- ParadeDB-only by nature: pdb.score is invalid SQL on sqlite, mysql and
  plain postgres, so those paths are completely unchanged.

A test (TestTaskSearchRelevanceRanking) creates a task matching all query
words plus tasks matching only one, then searches a multi-word query. On
ParadeDB it asserts the all-words task ranks first; on other databases it
only asserts the matching tasks are returned, so it stays green across the
whole CI database matrix. The CI ParadeDB matrix entry exercises the
ranking assertion.

Follow-up (not in this change): boosting results where the words appear in
order / in close proximity above plain all-words matches.

Fixes #2690
2026-06-19 20:46:28 +02:00
122 changed files with 2665 additions and 6913 deletions

3
.envrc Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1@sha256:87999aa3d42bdc6bea60565083ee17e86d1f3339802f543c0d03998580f9cb89 # syntax=docker/dockerfile:1@sha256:b6afd42430b15f2d2a4c5a02b919e98a525b785b1aaff16747d2f623364e39b6
FROM --platform=$BUILDPLATFORM node:24.18.0-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS frontendbuilder FROM --platform=$BUILDPLATFORM node:24.13.0-alpine@sha256:931d7d57f8c1fd0e2179dbff7cc7da4c9dd100998bc2b32afc85142d8efbc213 AS frontendbuilder
WORKDIR /build WORKDIR /build
@ -14,7 +14,7 @@ COPY frontend/ ./
ARG RELEASE_VERSION=dev ARG RELEASE_VERSION=dev
RUN echo "{\"VERSION\": \"${RELEASE_VERSION/-g/-}\"}" > src/version.json && pnpm run build RUN echo "{\"VERSION\": \"${RELEASE_VERSION/-g/-}\"}" > src/version.json && pnpm run build
FROM --platform=$BUILDPLATFORM ghcr.io/techknowlogick/xgo:go-1.26.x@sha256:57c62857168cee9213045d65044e990d8b181ed6df30ba7097d2dcddd42b9908 AS apibuilder FROM --platform=$BUILDPLATFORM ghcr.io/techknowlogick/xgo:go-1.25.x@sha256:11ac5e6cb8767caea0c62c420e053cb69554638ec255f9bbef8ed411e70c9eec AS apibuilder
RUN go install github.com/magefile/mage@latest && \ RUN go install github.com/magefile/mage@latest && \
mv /go/bin/mage /usr/local/go/bin mv /go/bin/mage /usr/local/go/bin

View File

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

View File

@ -849,11 +849,6 @@
"default_value": "(&(objectclass=*)(|(objectclass=group)(objectclass=groupOfNames)))", "default_value": "(&(objectclass=*)(|(objectclass=group)(objectclass=groupOfNames)))",
"comment": "The filter to search for group objects in the ldap directory. Only used when `groupsyncenabled` is set to `true`." "comment": "The filter to search for group objects in the ldap directory. Only used when `groupsyncenabled` is set to `true`."
}, },
{
"key": "groupsyncuseserviceaccount",
"default_value": "false",
"comment": "If true, Vikunja re-binds as the service account (binddn/bindpassword) before searching for groups during group sync. Enable this when the authenticating user does not have sufficient rights to enumerate group membership in the directory."
},
{ {
"key": "avatarsyncattribute", "key": "avatarsyncattribute",
"default_value": "", "default_value": "",

View File

@ -100,15 +100,10 @@ app.on('second-instance', (_event, argv) => {
return return
} }
// Reveal the main window. It may be hidden in the tray (not just minimized), // Focus the main window
// so show() is required — focus() alone won't surface a hidden window, which
// made the app look dead when relaunched while running in the tray.
if (mainWindow) { if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore() if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.show()
mainWindow.focus() mainWindow.focus()
} else if (serverPort) {
createMainWindow()
} }
// Find the deep link URL in argv // Find the deep link URL in argv
@ -241,11 +236,6 @@ function createMainWindow() {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1680, width: 1680,
height: 960, height: 960,
// Without an explicit window icon, X11/XWayland compositors (e.g. KDE
// Plasma) fall back to a generic placeholder when WM_CLASS doesn't match
// an installed .desktop file. icon.png lives at the app root because
// build/ is electron-builder's buildResources dir and isn't packaged.
icon: path.join(__dirname, 'icon.png'),
webPreferences: { webPreferences: {
...BASE_WEB_PREFERENCES, ...BASE_WEB_PREFERENCES,
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
@ -553,14 +543,3 @@ app.on('window-all-closed', () => {
app.quit() app.quit()
} }
}) })
// Quit on termination signals (DE/systemd shutdown, `kill`). Without an explicit
// handler the app ignores SIGTERM because the tray and express server keep the
// event loop alive — leaving users to `kill -9`. isQuitting must be set first so
// the hide-to-tray close handler doesn't swallow the quit.
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => {
isQuitting = true
app.quit()
})
}

View File

@ -5,7 +5,7 @@
"main": "main.js", "main": "main.js",
"repository": "https://code.vikunja.io/desktop", "repository": "https://code.vikunja.io/desktop",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"packageManager": "pnpm@10.34.4", "packageManager": "pnpm@10.28.1",
"author": { "author": {
"email": "maintainers@vikunja.io", "email": "maintainers@vikunja.io",
"name": "Vikunja Team" "name": "Vikunja Team"
@ -61,9 +61,9 @@
} }
}, },
"devDependencies": { "devDependencies": {
"electron": "40.10.5", "electron": "40.10.4",
"electron-builder": "26.15.3", "electron-builder": "26.15.3",
"unzipper": "0.12.5" "unzipper": "0.12.3"
}, },
"dependencies": { "dependencies": {
"express": "5.2.1" "express": "5.2.1"
@ -73,16 +73,14 @@
"electron" "electron"
], ],
"overrides": { "overrides": {
"minimatch": "10.2.5", "minimatch": "^10.2.3",
"tar": "7.5.17", "tar": ">=7.5.16",
"@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.7",
"ip-address": "10.2.0", "ip-address": ">=10.1.1",
"form-data": "4.0.6", "form-data": ">=4.0.6",
"js-yaml": "5.2.0", "js-yaml": ">=4.2.0"
"undici@6": "6.27.0",
"undici@7": "7.28.0"
} }
} }
} }

View File

@ -5,16 +5,14 @@ settings:
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides: overrides:
minimatch: 10.2.5 minimatch: ^10.2.3
tar: 7.5.17 tar: '>=7.5.16'
'@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.7'
ip-address: 10.2.0 ip-address: '>=10.1.1'
form-data: 4.0.6 form-data: '>=4.0.6'
js-yaml: 5.2.0 js-yaml: '>=4.2.0'
undici@6: 6.27.0
undici@7: 7.28.0
importers: importers:
@ -25,14 +23,14 @@ importers:
version: 5.2.1 version: 5.2.1
devDependencies: devDependencies:
electron: electron:
specifier: 40.10.5 specifier: 40.10.4
version: 40.10.5 version: 40.10.4
electron-builder: electron-builder:
specifier: 26.15.3 specifier: 26.15.3
version: 26.15.3(electron-builder-squirrel-windows@24.13.3) version: 26.15.3(electron-builder-squirrel-windows@24.13.3)
unzipper: unzipper:
specifier: 0.12.5 specifier: 0.12.3
version: 0.12.5 version: 0.12.3
packages: packages:
@ -537,8 +535,8 @@ packages:
electron-publish@26.15.3: electron-publish@26.15.3:
resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==}
electron@40.10.5: electron@40.10.4:
resolution: {integrity: sha512-VzTIvwOYXZZufT9B83GDQogR1TFqREygRYhm0LE++QhGPjvBeg+W7siOP9K5+9rHMUnRuCX4YU/0ivLekN/UZQ==} resolution: {integrity: sha512-ouNZrXXmdPL/wiTQ+xzXpb7B/BHg+j7XARig0SE7azFO3bjbYUd6lFjIAAiDQ02Pl/Oj7MUk+4C0hdf9yFtA1A==}
engines: {node: '>= 22.12.0'} engines: {node: '>= 22.12.0'}
hasBin: true hasBin: true
@ -620,7 +618,7 @@ packages:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
peerDependencies: peerDependencies:
picomatch: 4.0.4 picomatch: '>=4.0.4'
peerDependenciesMeta: peerDependenciesMeta:
picomatch: picomatch:
optional: true optional: true
@ -655,6 +653,10 @@ packages:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
fs-extra@11.2.0:
resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==}
engines: {node: '>=14.14'}
fs-extra@11.3.1: fs-extra@11.3.1:
resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==}
engines: {node: '>=14.14'} engines: {node: '>=14.14'}
@ -834,8 +836,8 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
js-yaml@5.2.0: js-yaml@4.2.0:
resolution: {integrity: sha512-YeLUMlvR4Ou1B119LIaM0r65JvbOBooJDc9yEu0dClb/uSC5P4FrLU8OCCz/HXWvtPoIrR0dRzABTjo1sTN9Bw==} resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
hasBin: true hasBin: true
json-buffer@3.0.1: json-buffer@3.0.1:
@ -858,6 +860,9 @@ packages:
jsonfile@4.0.0: jsonfile@4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
jsonfile@6.2.0: jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
@ -1299,8 +1304,8 @@ packages:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
tar@7.5.17: tar@7.5.16:
resolution: {integrity: sha512-wPEBwzapC+2PaTYPH6e2L+cNOEE227S47wUYFqlegcs8zlLLmeb9Fcff1HVZY4Fwku/1Eyv38n7GYwB2aaS71g==} resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==}
engines: {node: '>=18'} engines: {node: '>=18'}
temp-file@3.4.0: temp-file@3.4.0:
@ -1346,12 +1351,12 @@ packages:
undici-types@7.16.0: undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@6.27.0: undici@6.26.0:
resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==}
engines: {node: '>=18.17'} engines: {node: '>=18.17'}
undici@7.28.0: undici@7.27.2:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==}
engines: {node: '>=20.18.1'} engines: {node: '>=20.18.1'}
universalify@0.1.2: universalify@0.1.2:
@ -1366,8 +1371,8 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
unzipper@0.12.5: unzipper@0.12.3:
resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} resolution: {integrity: sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==}
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@ -1488,7 +1493,7 @@ snapshots:
semver: 7.8.1 semver: 7.8.1
sumchecker: 3.0.1 sumchecker: 3.0.1
optionalDependencies: optionalDependencies:
undici: 7.28.0 undici: 7.27.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -1735,13 +1740,13 @@ snapshots:
hosted-git-info: 4.1.0 hosted-git-info: 4.1.0
is-ci: 3.0.1 is-ci: 3.0.1
isbinaryfile: 5.0.7 isbinaryfile: 5.0.7
js-yaml: 5.2.0 js-yaml: 4.2.0
lazy-val: 1.0.5 lazy-val: 1.0.5
minimatch: 10.2.5 minimatch: 10.2.5
read-config-file: 6.3.2 read-config-file: 6.3.2
sanitize-filename: 1.6.4 sanitize-filename: 1.6.4
semver: 7.8.1 semver: 7.8.1
tar: 7.5.17 tar: 7.5.16
temp-file: 3.4.0 temp-file: 3.4.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -1777,7 +1782,7 @@ snapshots:
hosted-git-info: 4.1.0 hosted-git-info: 4.1.0
isbinaryfile: 5.0.7 isbinaryfile: 5.0.7
jiti: 2.6.1 jiti: 2.6.1
js-yaml: 5.2.0 js-yaml: 4.2.0
json5: 2.2.3 json5: 2.2.3
lazy-val: 1.0.5 lazy-val: 1.0.5
minimatch: 10.2.5 minimatch: 10.2.5
@ -1786,10 +1791,10 @@ snapshots:
proper-lockfile: 4.1.2 proper-lockfile: 4.1.2
resedit: 1.7.2 resedit: 1.7.2
semver: 7.7.4 semver: 7.7.4
tar: 7.5.17 tar: 7.5.16
temp-file: 3.4.0 temp-file: 3.4.0
tiny-async-pool: 1.3.0 tiny-async-pool: 1.3.0
unzipper: 0.12.5 unzipper: 0.12.3
which: 5.0.0 which: 5.0.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -1924,7 +1929,7 @@ snapshots:
http-proxy-agent: 5.0.0 http-proxy-agent: 5.0.0
https-proxy-agent: 5.0.1 https-proxy-agent: 5.0.1
is-ci: 3.0.1 is-ci: 3.0.1
js-yaml: 5.2.0 js-yaml: 4.2.0
source-map-support: 0.5.21 source-map-support: 0.5.21
stat-mode: 1.0.0 stat-mode: 1.0.0
temp-file: 3.4.0 temp-file: 3.4.0
@ -1941,7 +1946,7 @@ snapshots:
fs-extra: 10.1.0 fs-extra: 10.1.0
http-proxy-agent: 7.0.2 http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.5 https-proxy-agent: 7.0.5
js-yaml: 5.2.0 js-yaml: 4.2.0
sanitize-filename: 1.6.4 sanitize-filename: 1.6.4
source-map-support: 0.5.21 source-map-support: 0.5.21
stat-mode: 1.0.0 stat-mode: 1.0.0
@ -2094,7 +2099,7 @@ snapshots:
app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@24.13.3) app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@24.13.3)
builder-util: 26.15.3 builder-util: 26.15.3
fs-extra: 10.1.0 fs-extra: 10.1.0
js-yaml: 5.2.0 js-yaml: 4.2.0
transitivePeerDependencies: transitivePeerDependencies:
- electron-builder-squirrel-windows - electron-builder-squirrel-windows
- supports-color - supports-color
@ -2179,7 +2184,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
electron@40.10.5: electron@40.10.4:
dependencies: dependencies:
'@electron-internal/extract-zip': 1.0.2 '@electron-internal/extract-zip': 1.0.2
'@electron/get': 5.0.0 '@electron/get': 5.0.0
@ -2315,6 +2320,12 @@ snapshots:
jsonfile: 6.2.0 jsonfile: 6.2.0
universalify: 2.0.1 universalify: 2.0.1
fs-extra@11.2.0:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.1.0
universalify: 2.0.1
fs-extra@11.3.1: fs-extra@11.3.1:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@ -2533,7 +2544,7 @@ snapshots:
jiti@2.6.1: {} jiti@2.6.1: {}
js-yaml@5.2.0: js-yaml@4.2.0:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
@ -2552,6 +2563,12 @@ snapshots:
optionalDependencies: optionalDependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
jsonfile@6.1.0:
dependencies:
universalify: 2.0.1
optionalDependencies:
graceful-fs: 4.2.11
jsonfile@6.2.0: jsonfile@6.2.0:
dependencies: dependencies:
universalify: 2.0.1 universalify: 2.0.1
@ -2649,9 +2666,9 @@ snapshots:
nopt: 9.0.0 nopt: 9.0.0
proc-log: 6.1.0 proc-log: 6.1.0
semver: 7.8.1 semver: 7.8.1
tar: 7.5.17 tar: 7.5.16
tinyglobby: 0.2.15 tinyglobby: 0.2.15
undici: 6.27.0 undici: 6.26.0
which: 6.0.1 which: 6.0.1
node-int64@0.4.0: {} node-int64@0.4.0: {}
@ -2784,7 +2801,7 @@ snapshots:
config-file-ts: 0.2.6 config-file-ts: 0.2.6
dotenv: 9.0.2 dotenv: 9.0.2
dotenv-expand: 5.1.0 dotenv-expand: 5.1.0
js-yaml: 5.2.0 js-yaml: 4.2.0
json5: 2.2.3 json5: 2.2.3
lazy-val: 1.0.5 lazy-val: 1.0.5
@ -3001,7 +3018,7 @@ snapshots:
inherits: 2.0.4 inherits: 2.0.4
readable-stream: 3.6.2 readable-stream: 3.6.2
tar@7.5.17: tar@7.5.16:
dependencies: dependencies:
'@isaacs/fs-minipass': 4.0.1 '@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0 chownr: 3.0.0
@ -3050,9 +3067,9 @@ snapshots:
undici-types@7.16.0: {} undici-types@7.16.0: {}
undici@6.27.0: {} undici@6.26.0: {}
undici@7.28.0: undici@7.27.2:
optional: true optional: true
universalify@0.1.2: {} universalify@0.1.2: {}
@ -3061,11 +3078,11 @@ snapshots:
unpipe@1.0.0: {} unpipe@1.0.0: {}
unzipper@0.12.5: unzipper@0.12.3:
dependencies: dependencies:
bluebird: 3.7.2 bluebird: 3.7.2
duplexer2: 0.1.4 duplexer2: 0.1.4
fs-extra: 11.3.1 fs-extra: 11.2.0
graceful-fs: 4.2.11 graceful-fs: 4.2.11
node-int64: 0.4.0 node-int64: 0.4.0

View File

@ -3,11 +3,10 @@
"devenv": { "devenv": {
"locked": { "locked": {
"dir": "src/modules", "dir": "src/modules",
"lastModified": 1782492839, "lastModified": 1773012232,
"narHash": "sha256-j9wrcB4al5QhMelEghJ0Qs+RQPT+wyCcI4070NEgPLQ=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv", "repo": "devenv",
"rev": "3d39d0817d62069f7b18821c34a617b5141cb278", "rev": "46a4bd0299a26ad948b71d3053174ba7b90522f7",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -22,11 +21,10 @@
"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 +37,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 +53,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": {

View File

@ -1 +1 @@
24.18.0 24.13.0

View File

@ -13,7 +13,7 @@
}, },
"homepage": "https://vikunja.io/", "homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja", "funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@10.34.4", "packageManager": "pnpm@10.28.1",
"engines": { "engines": {
"node": ">=24.0.0" "node": ">=24.0.0"
}, },
@ -51,37 +51,37 @@
"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.11",
"fast-deep-equal": "3.1.3", "fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13", "flatpickr": "4.6.13",
@ -89,16 +89,16 @@
"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",
@ -108,7 +108,7 @@
"@faker-js/faker": "10.5.0", "@faker-js/faker": "10.5.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.1",
"@tsconfig/node24": "24.0.4", "@tsconfig/node24": "24.0.4",
@ -117,15 +117,15 @@
"@types/node": "24.13.2", "@types/node": "24.13.2",
"@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.61.1",
"@typescript-eslint/parser": "8.62.0", "@typescript-eslint/parser": "8.61.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.8.0",
"@vue/test-utils": "2.4.11", "@vue/test-utils": "2.4.11",
"@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.30001799",
"csstype": "3.2.3", "csstype": "3.2.3",
"esbuild": "0.28.1", "esbuild": "0.28.1",
@ -150,9 +150,9 @@
"tailwindcss": "4.3.1", "tailwindcss": "4.3.1",
"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.3",
"vite-svg-loader": "5.1.1", "vite-svg-loader": "5.1.1",
"vitest": "4.1.9", "vitest": "4.1.9",
"vue-tsc": "3.3.5", "vue-tsc": "3.3.5",
@ -169,20 +169,20 @@
"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.7",
"esbuild": "0.28.1", "esbuild": ">=0.28.1",
"form-data": "4.0.6", "form-data": ">=4.0.6",
"markdown-it": "14.2.0", "markdown-it": ">=14.2.0",
"launch-editor": "2.14.1", "launch-editor": ">=2.14.1",
"@babel/core": "8.0.1", "@babel/core": ">=7.29.6",
"js-yaml@4": "5.2.0" "js-yaml@4": ">=4.2.0"
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -13,14 +13,14 @@
<div class="gantt-chart-wrapper"> <div class="gantt-chart-wrapper">
<GanttTimelineHeader <GanttTimelineHeader
:timeline-data="timelineData" :timeline-data="timelineData"
:day-width-pixels="dayWidthPixels" :day-width-pixels="DAY_WIDTH_PIXELS"
/> />
<GanttVerticalGridLines <GanttVerticalGridLines
:timeline-data="timelineData" :timeline-data="timelineData"
:total-width="totalWidth" :total-width="totalWidth"
:height="ganttRows.length * 40" :height="ganttRows.length * 40"
:day-width-pixels="dayWidthPixels" :day-width-pixels="DAY_WIDTH_PIXELS"
/> />
<GanttChartBody <GanttChartBody
@ -57,7 +57,7 @@
:total-width="totalWidth" :total-width="totalWidth"
:date-from-date="dateFromDate" :date-from-date="dateFromDate"
:date-to-date="dateToDate" :date-to-date="dateToDate"
:day-width-pixels="dayWidthPixels" :day-width-pixels="DAY_WIDTH_PIXELS"
:is-dragging="isDragging" :is-dragging="isDragging"
:is-resizing="isResizing" :is-resizing="isResizing"
:drag-state="dragState" :drag-state="dragState"
@ -89,7 +89,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref, watch, toRefs, nextTick, onMounted, onBeforeUnmount, onUnmounted} from 'vue' import {computed, ref, watch, toRefs, onUnmounted} from 'vue'
import {useRouter} from 'vue-router' import {useRouter} from 'vue-router'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync' import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync'
@ -126,9 +126,7 @@ const emit = defineEmits<{
(e: 'update:task', task: ITaskPartialWithId): void (e: 'update:task', task: ITaskPartialWithId): void
}>() }>()
const DAY_WIDTH_PIXELS_MIN = 30 const DAY_WIDTH_PIXELS = 30
const dayWidthPixels = ref(0)
let resizeObserver: ResizeObserver
const {tasks, filters} = toRefs(props) const {tasks, filters} = toRefs(props)
@ -160,7 +158,7 @@ const dateToDate = computed(() => dayjs(filters.value.dateTo).endOf('day').toDat
const totalWidth = computed(() => { const totalWidth = computed(() => {
const dateDiff = Math.ceil((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY) const dateDiff = Math.ceil((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY)
return dateDiff * dayWidthPixels.value return dateDiff * DAY_WIDTH_PIXELS
}) })
const timelineData = computed(() => { const timelineData = computed(() => {
@ -299,55 +297,6 @@ function transformTaskToGanttBar(node: GanttTaskTreeNode): GanttBarModel {
} }
} }
function updateDayWidthPixels() {
const node = ganttContainer.value
if (!node) return
const rect = node.getBoundingClientRect()
const styles = window.getComputedStyle(node)
const marginLeft = parseFloat(styles.marginLeft) || 0
const marginRight = parseFloat(styles.marginRight) || 0
// max width without overflow
const maxWidth = rect.width - marginLeft - marginRight
const dayCount = Math.ceil(
(dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY,
)
dayWidthPixels.value = Math.max(
maxWidth / dayCount,
DAY_WIDTH_PIXELS_MIN,
)
}
onMounted(async () => {
await nextTick()
updateDayWidthPixels()
if (ganttContainer.value) {
resizeObserver = new ResizeObserver(updateDayWidthPixels)
resizeObserver.observe(ganttContainer.value)
}
window.addEventListener('resize', updateDayWidthPixels)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
window.removeEventListener('resize', updateDayWidthPixels)
})
watch(
[dateFromDate, dateToDate],
async () => {
await nextTick()
updateDayWidthPixels()
},
{flush: 'post'},
)
// Build the task tree when tasks change // Build the task tree when tasks change
watch( watch(
[tasks, filters], [tasks, filters],
@ -402,7 +351,7 @@ const ROW_HEIGHT = 40
const barPositions = computed(() => { const barPositions = computed(() => {
const positions = new Map<number, GanttBarPosition>() const positions = new Map<number, GanttBarPosition>()
const ds = dragState.value const ds = dragState.value
const dragPixelOffset = ds ? ds.currentDays * dayWidthPixels.value : 0 const dragPixelOffset = ds ? ds.currentDays * DAY_WIDTH_PIXELS : 0
ganttBars.value.forEach((rowBars, rowIndex) => { ganttBars.value.forEach((rowBars, rowIndex) => {
for (const bar of rowBars) { for (const bar of rowBars) {
@ -437,7 +386,7 @@ function computeBarX(date: Date): number {
(roundToNaturalDayBoundary(date, true).getTime() - dateFromDate.value.getTime()) / (roundToNaturalDayBoundary(date, true).getTime() - dateFromDate.value.getTime()) /
MILLISECONDS_A_DAY, MILLISECONDS_A_DAY,
) )
return diff * dayWidthPixels.value return diff * DAY_WIDTH_PIXELS
} }
function computeBarWidth(bar: GanttBarModel): number { function computeBarWidth(bar: GanttBarModel): number {
@ -445,7 +394,7 @@ function computeBarWidth(bar: GanttBarModel): number {
(roundToNaturalDayBoundary(bar.end).getTime() - roundToNaturalDayBoundary(bar.start, true).getTime()) / (roundToNaturalDayBoundary(bar.end).getTime() - roundToNaturalDayBoundary(bar.start, true).getTime()) /
MILLISECONDS_A_DAY, MILLISECONDS_A_DAY,
) )
return diff * dayWidthPixels.value return diff * DAY_WIDTH_PIXELS
} }
// Compute relation arrows // Compute relation arrows
@ -641,7 +590,7 @@ function startDrag(bar: GanttBarModel, event: PointerEvent) {
if (!dragState.value || !isDragging.value) return if (!dragState.value || !isDragging.value) return
const diff = e.clientX - dragState.value.startX const diff = e.clientX - dragState.value.startX
const days = Math.round(diff / dayWidthPixels.value) const days = Math.round(diff / DAY_WIDTH_PIXELS)
if (days !== dragState.value.currentDays) { if (days !== dragState.value.currentDays) {
dragState.value.currentDays = days dragState.value.currentDays = days
@ -703,7 +652,7 @@ function startResize(bar: GanttBarModel, edge: 'start' | 'end', event: PointerEv
if (!dragState.value || !isResizing.value) return if (!dragState.value || !isResizing.value) return
const diff = e.clientX - dragState.value.startX const diff = e.clientX - dragState.value.startX
const days = Math.round(diff / dayWidthPixels.value) const days = Math.round(diff / DAY_WIDTH_PIXELS)
if (edge === 'start') { if (edge === 'start') {
const newStart = new Date(dragState.value.originalStart) const newStart = new Date(dragState.value.originalStart)

View File

@ -722,7 +722,7 @@ async function addImage(event: Event) {
return return
} }
const url = await inputPrompt(event.target.getBoundingClientRect(), '', editor.value) const url = await inputPrompt(event.target.getBoundingClientRect())
if (url) { if (url) {
editor.value?.chain().focus().setImage({src: url}).run() editor.value?.chain().focus().setImage({src: url}).run()

View File

@ -5,7 +5,6 @@ import {PluginKey, type EditorState} from '@tiptap/pm/state'
import EmojiList from './EmojiList.vue' import EmojiList from './EmojiList.vue'
import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData' import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData'
import {getPopupContainer} from '../popupContainer'
export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion') export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion')
@ -79,7 +78,7 @@ export default function emojiSuggestionSetup() {
popupElement.style.left = '0' popupElement.style.left = '0'
popupElement.style.zIndex = '4700' popupElement.style.zIndex = '4700'
popupElement.appendChild(component.element!) popupElement.appendChild(component.element!)
getPopupContainer(props.editor).appendChild(popupElement) document.body.appendChild(popupElement)
const rect = props.clientRect() const rect = props.clientRect()
if (!rect) { if (!rect) {
@ -109,7 +108,7 @@ export default function emojiSuggestionSetup() {
cleanupFloating = null cleanupFloating = null
} }
if (popupElement) { if (popupElement) {
popupElement.remove() document.body.removeChild(popupElement)
popupElement = null popupElement = null
} }
component?.destroy() component?.destroy()

View File

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

View File

@ -123,7 +123,7 @@
</XButton> </XButton>
<!-- Dropzone --> <!-- Dropzone -->
<Teleport :to="dropzoneTeleportTarget"> <Teleport to="body">
<div <div
v-if="editEnabled" v-if="editEnabled"
:class="{hidden: !showDropzone}" :class="{hidden: !showDropzone}"
@ -185,7 +185,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {ref, shallowReactive, computed, watch, onMounted, onBeforeUnmount} from 'vue' import {ref, shallowReactive, computed, watch} from 'vue'
import {useDropZone} from '@vueuse/core' import {useDropZone} from '@vueuse/core'
import User from '@/components/misc/User.vue' import User from '@/components/misc/User.vue'
@ -322,34 +322,6 @@ const showDropzone = computed(() =>
props.editEnabled && isDraggingFiles.value && !isDragOverEditor.value, props.editEnabled && isDraggingFiles.value && !isDragOverEditor.value,
) )
// A <dialog> opened with showModal() (e.g. the Kanban task detail) renders in
// the browser's top layer, so the full-screen dropzone overlay teleported to
// <body> would paint behind it regardless of z-index. Teleport it into the
// topmost open dialog instead, mirroring Notification.vue.
const dropzoneTeleportTarget = ref<string | HTMLElement>('body')
let dialogObserver: MutationObserver | null = null
function syncDropzoneTeleportTarget() {
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.modal-dialog[open]')
dropzoneTeleportTarget.value = dialogs.item(dialogs.length - 1) ?? 'body'
}
onMounted(() => {
syncDropzoneTeleportTarget()
dialogObserver = new MutationObserver(syncDropzoneTeleportTarget)
dialogObserver.observe(document.body, {
attributes: true,
attributeFilter: ['open'],
childList: true,
subtree: true,
})
})
onBeforeUnmount(() => {
dialogObserver?.disconnect()
dialogObserver = null
})
watch(() => props.editEnabled, enabled => { watch(() => props.editEnabled, enabled => {
if (!enabled) { if (!enabled) {
resetDragState() resetDragState()
@ -506,7 +478,7 @@ defineExpose({
inset-inline-start: 0; inset-inline-start: 0;
inset-block-end: 0; inset-block-end: 0;
inset-inline-end: 0; inset-inline-end: 0;
z-index: 4001; // above app chrome when teleported to body (no modal open) z-index: 4001; // modal z-index is 4000
text-align: center; text-align: center;
&.hidden { &.hidden {

View File

@ -326,17 +326,9 @@ const isOverdue = computed(() => (
let oldTask let oldTask
async function markAsDone(checked: boolean, wasReverted: boolean = false) { async function markAsDone(checked: boolean, wasReverted: boolean = false) {
oldTask = {...task.value} const updateFunc = async () => {
oldTask = {...task.value}
// Fire the request immediately and with the intended done value snapshotted, so a re-render or const newTask = await taskStore.update(task.value)
// teardown during the animation delay can neither drop the save nor make it send a stale state.
const updatePromise = taskStore.update({
...task.value,
done: checked,
})
const finish = async () => {
const newTask = await updatePromise
task.value = newTask task.value = newTask
updateDueDate() updateDueDate()
@ -362,9 +354,9 @@ async function markAsDone(checked: boolean, wasReverted: boolean = false) {
} }
if (checked) { if (checked) {
setTimeout(finish, 300) // Delay only the follow-up to show the animation when marking a task as done setTimeout(updateFunc, 300) // Delay it to show the animation when marking a task as done
} else { } else {
await finish() // Don't delay it when un-marking it as it doesn't have an animation the other way around await updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
} }
} }

View File

@ -1,34 +1,83 @@
import {describe, it, expect} from 'vitest' import {describe, it, expect, beforeEach, vi} from 'vitest'
import {buildStoredQuery} from './useTaskList' import {defineComponent, h, nextTick} from 'vue'
import {mount, flushPromises} from '@vue/test-utils'
import {setActivePinia, createPinia} from 'pinia'
import {createRouter, createMemoryHistory, type Router} from 'vue-router'
describe('buildStoredQuery', () => { const getAll = vi.fn(async () => [])
it('includes sort when set', () => { vi.mock('@/services/taskCollection', async (importOriginal) => {
expect(buildStoredQuery({sort: 'due_date:asc', filter: undefined, s: undefined, page: 1})) const actual = await importOriginal<typeof import('@/services/taskCollection')>()
.toEqual({sort: 'due_date:asc'}) return {
...actual,
default: class {
loading = false
totalPages = 1
getAll = getAll
},
}
})
import {useTaskList} from './useTaskList'
// The second positional argument passed to TaskCollectionService.getAll carries
// the sort_by/order_by the backend uses to decide whether to rank by relevance.
function lastRequestParams(): Record<string, unknown> {
return getAll.mock.calls.at(-1)?.[1] as Record<string, unknown>
}
async function mountTaskList(query: Record<string, string>): Promise<Router> {
const router = createRouter({
history: createMemoryHistory(),
routes: [{path: '/', name: 'home', component: {render: () => null}}],
})
await router.push({path: '/', query})
await router.isReady()
const TestComponent = defineComponent({
setup() {
useTaskList(() => 1, () => 1)
return () => h('div')
},
}) })
it('includes filter and search when set', () => { mount(TestComponent, {global: {plugins: [router]}})
expect(buildStoredQuery({sort: undefined, filter: 'done = false', s: 'foo', page: 1})) await flushPromises()
.toEqual({filter: 'done = false', s: 'foo'}) await nextTick()
return router
}
describe('useTaskList sort handling for relevance ranking', () => {
beforeEach(() => {
setActivePinia(createPinia())
getAll.mockClear()
}) })
it('omits page when it equals the default of 1', () => { it('omits the sort while searching with the default sort so the backend ranks by relevance', async () => {
expect(buildStoredQuery({sort: 'id:desc', filter: undefined, s: undefined, page: 1})) await mountTaskList({s: 'find me'})
.toEqual({sort: 'id:desc'})
const params = lastRequestParams()
expect(params.s).toBe('find me')
expect(params.sort_by).toEqual([])
expect(params.order_by).toEqual([])
}) })
it('includes page when greater than 1', () => { it('keeps an explicit user sort while searching so the user sort is respected', async () => {
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 3})) await mountTaskList({s: 'find me', sort: 'title:asc'})
.toEqual({page: '3'})
const params = lastRequestParams()
expect(params.s).toBe('find me')
expect(params.sort_by).toEqual(['title'])
expect(params.order_by).toEqual(['asc'])
}) })
it('returns an empty object when nothing is set', () => { it('sends the default sort when not searching', async () => {
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 1})) await mountTaskList({})
.toEqual({})
})
it('skips empty strings', () => { const params = lastRequestParams()
expect(buildStoredQuery({sort: '', filter: '', s: '', page: 1})) expect(params.s).toBe('')
.toEqual({}) expect(params.sort_by).not.toHaveLength(0)
// id always sorts last so other sort columns take precedence.
expect(params.sort_by).toEqual(['id'])
expect(params.order_by).toEqual(['desc'])
}) })
}) })

View File

@ -1,6 +1,4 @@
import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue' import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
import {useRouter, isNavigationFailure} from 'vue-router'
import type {LocationQueryRaw} from 'vue-router'
import {useRouteQuery} from '@vueuse/router' import {useRouteQuery} from '@vueuse/router'
import TaskCollectionService, { import TaskCollectionService, {
@ -12,7 +10,6 @@ import type {ITask} from '@/modelTypes/ITask'
import {error} from '@/message' import {error} from '@/message'
import type {IProject} from '@/modelTypes/IProject' import type {IProject} from '@/modelTypes/IProject'
import {useAuthStore} from '@/stores/auth' import {useAuthStore} from '@/stores/auth'
import {useViewFiltersStore} from '@/stores/viewFilters'
import type {IProjectView} from '@/modelTypes/IProjectView' import type {IProjectView} from '@/modelTypes/IProjectView'
export type Order = 'asc' | 'desc' | 'none' export type Order = 'asc' | 'desc' | 'none'
@ -62,22 +59,6 @@ const SORT_BY_DEFAULT: SortBy = {
id: 'desc', id: 'desc',
} }
interface TaskListQueryState {
sort: string | undefined
filter: string | undefined
s: string | undefined
page: number
}
export function buildStoredQuery(state: TaskListQueryState): LocationQueryRaw {
const query: LocationQueryRaw = {}
if (state.sort) query.sort = state.sort
if (state.filter) query.filter = state.filter
if (state.s) query.s = state.s
if (state.page > 1) query.page = String(state.page)
return query
}
// This makes sure an id sort order is always sorted last. // This makes sure an id sort order is always sorted last.
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes // When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
// precedence over everything else, making any other sort columns pretty useless. // precedence over everything else, making any other sort columns pretty useless.
@ -113,9 +94,6 @@ export function useTaskList(
const projectId = computed(() => projectIdGetter()) const projectId = computed(() => projectIdGetter())
const projectViewId = computed(() => projectViewIdGetter()) const projectViewId = computed(() => projectViewIdGetter())
const router = useRouter()
const viewFiltersStore = useViewFiltersStore()
const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()}) const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()})
const page = useRouteQuery('page', '1', { transform: Number }) const page = useRouteQuery('page', '1', { transform: Number })
@ -141,58 +119,17 @@ 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}
// Relevance ranking only engages when no sort is sent, so omit the default
// sort while searching and let an explicit user sort still take precedence.
if (loadParams.s && !sortQuery.value) {
loadParams.sort_by = []
loadParams.order_by = []
return loadParams
}
return formatSortOrder(sortBy.value, loadParams) return formatSortOrder(sortBy.value, loadParams)
}) })

View File

@ -1,153 +0,0 @@
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
import {refreshToken, removeToken} from './auth'
// Count how many times the refresh endpoint is actually POSTed. The whole point
// of the in-flight dedup is that concurrent refreshToken() calls share a single
// underlying POST, independent of the Web Locks API.
let postCallCount = 0
let resolvePost: ((value: unknown) => void) | null = null
vi.mock('@/helpers/fetcher', () => ({
HTTPFactory: () => ({
post: vi.fn(() => {
postCallCount++
return new Promise((resolve) => {
resolvePost = resolve
})
}),
}),
}))
vi.mock('@/helpers/desktopAuth', () => ({
isDesktopApp: () => false,
refreshDesktopToken: vi.fn(),
}))
const FAKE_TOKEN = 'header.payload.signature'
function settlePost() {
resolvePost?.({data: {token: FAKE_TOKEN}})
}
describe('refreshToken in-flight dedup', () => {
const originalLocks = navigator.locks
beforeEach(() => {
postCallCount = 0
resolvePost = null
removeToken()
localStorage.clear()
})
afterEach(() => {
Object.defineProperty(navigator, 'locks', {
value: originalLocks,
configurable: true,
writable: true,
})
})
it('coalesces concurrent calls into a single POST when Web Locks is available', async () => {
// Stub a minimal Web Locks API: happy-dom leaves navigator.locks
// undefined, so without this the test would silently fall through to
// the insecure-HTTP branch and never exercise navigator.locks.request.
const requestSpy = vi.fn((_name: string, cb: () => unknown) => cb())
Object.defineProperty(navigator, 'locks', {
value: {request: requestSpy},
configurable: true,
writable: true,
})
const p1 = refreshToken(true)
const p2 = refreshToken(true)
// Both calls share one underlying request.
expect(postCallCount).toBe(1)
settlePost()
await Promise.all([p1, p2])
// The Web Locks branch actually ran...
expect(requestSpy).toHaveBeenCalledWith('vikunja-token-refresh', expect.any(Function))
// ...and the in-flight dedup still collapsed both calls into one POST.
expect(postCallCount).toBe(1)
})
it('coalesces concurrent calls into a single POST on insecure HTTP (no Web Locks)', async () => {
// Simulate an insecure HTTP context where navigator.locks is undefined.
Object.defineProperty(navigator, 'locks', {
value: undefined,
configurable: true,
writable: true,
})
const p1 = refreshToken(true)
const p2 = refreshToken(true)
const p3 = refreshToken(true)
expect(postCallCount).toBe(1)
settlePost()
await Promise.all([p1, p2, p3])
expect(postCallCount).toBe(1)
})
it('allows a fresh refresh after the previous one settled', async () => {
const p1 = refreshToken(true)
settlePost()
await p1
expect(postCallCount).toBe(1)
// The in-flight promise was reset, so a later refresh runs anew.
const p2 = refreshToken(true)
expect(postCallCount).toBe(2)
settlePost()
await p2
})
it('does not re-persist the token when logout happens during an in-flight refresh', async () => {
const p1 = refreshToken(true)
expect(postCallCount).toBe(1)
// User logs out while the refresh POST is still in flight.
removeToken()
// The in-flight POST resolves afterwards — it must not undo the logout.
settlePost()
await p1
expect(localStorage.getItem('token')).toBeNull()
})
it('an older refresh settling does not clobber a newer in-flight one', async () => {
// Refresh A starts and stays in flight.
const pA = refreshToken(true)
expect(postCallCount).toBe(1)
const resolveA = resolvePost
// User logs out, which drops the in-flight reference to A.
removeToken()
// Refresh B starts; it must claim the in-flight slot.
const pB = refreshToken(true)
expect(postCallCount).toBe(2)
const resolveB = resolvePost
// A settles after B started. Its cleanup must NOT null the in-flight
// slot, since that slot now belongs to B. Without the `=== p` guard,
// A's .finally would clobber B and let a concurrent caller fire a
// second parallel POST.
resolveA?.({data: {token: FAKE_TOKEN}})
await pA
// A concurrent caller while B is still in flight must dedup to B —
// no third POST.
const pB2 = refreshToken(true)
expect(postCallCount).toBe(2)
resolveB?.({data: {token: FAKE_TOKEN}})
await Promise.all([pB, pB2])
})
})

View File

@ -33,53 +33,18 @@ export const removeToken = () => {
savedToken = null savedToken = null
localStorage.removeItem('token') localStorage.removeItem('token')
localStorage.removeItem('desktopOAuthRefreshToken') localStorage.removeItem('desktopOAuthRefreshToken')
// Bump the epoch and drop the in-flight refresh so a refresh that started
// before this logout can't re-persist a token after we cleared it.
authEpoch++
inFlightRefresh = null
} }
// Coalesces concurrent same-tab refreshes into one POST. Web Locks (below) is
// secure-context-only, so on insecure HTTP there's no cross-tab coordination —
// without this guard, refreshes firing close together each spend the single-use
// cookie and all but one get a 401.
let inFlightRefresh: Promise<void> | null = null
// Incremented on every removeToken()/logout. A refresh captures the epoch when
// it starts and only persists its result if the epoch is unchanged, so a
// refresh that resolves after a logout can't undo it.
let authEpoch = 0
/** /**
* Refreshes an auth token while ensuring it is updated everywhere. * Refreshes an auth token while ensuring it is updated everywhere.
* The refresh token is sent automatically as an HttpOnly cookie. * The refresh token is sent automatically as an HttpOnly cookie.
* The server rotates the cookie on every call. * The server rotates the cookie on every call.
* *
* Same-tab concurrent calls share one in-flight refresh (always-on dedup); the * Uses the Web Locks API to coordinate across browser tabs. Only one tab
* Web Locks API inside adds cross-tab coordination only in secure contexts. * performs the actual refresh; other tabs waiting for the lock detect that
* the token in localStorage was already updated and adopt it directly.
*/ */
export async function refreshToken(persist: boolean): Promise<void> { export async function refreshToken(persist: boolean): Promise<void> {
if (inFlightRefresh) {
return inFlightRefresh
}
const p = doRefresh(persist)
inFlightRefresh = p
// Only clear if it still points to this promise — a logout (or a newer
// refresh started after it) may have replaced inFlightRefresh meanwhile.
p.finally(() => {
if (inFlightRefresh === p) {
inFlightRefresh = null
}
})
return p
}
async function doRefresh(persist: boolean): Promise<void> {
// Snapshot the epoch so we can tell if a logout happened while we awaited.
const epochAtStart = authEpoch
const loggedOutSinceStart = () => authEpoch !== epochAtStart
// In desktop mode, refresh via IPC to the Electron main process // In desktop mode, refresh via IPC to the Electron main process
if (isDesktopApp()) { if (isDesktopApp()) {
const storedRefreshToken = localStorage.getItem('desktopOAuthRefreshToken') const storedRefreshToken = localStorage.getItem('desktopOAuthRefreshToken')
@ -88,9 +53,6 @@ async function doRefresh(persist: boolean): Promise<void> {
} }
try { try {
const tokens = await refreshDesktopToken(window.API_URL, storedRefreshToken) const tokens = await refreshDesktopToken(window.API_URL, storedRefreshToken)
if (loggedOutSinceStart()) {
return
}
saveToken(tokens.access_token, persist) saveToken(tokens.access_token, persist)
localStorage.setItem('desktopOAuthRefreshToken', tokens.refresh_token) localStorage.setItem('desktopOAuthRefreshToken', tokens.refresh_token)
} catch (e) { } catch (e) {
@ -103,13 +65,7 @@ async function doRefresh(persist: boolean): Promise<void> {
// if another tab refreshed while we were queued. // if another tab refreshed while we were queued.
const tokenBeforeLock = localStorage.getItem('token') const tokenBeforeLock = localStorage.getItem('token')
const refreshUnderLock = async () => { const doRefresh = async () => {
// A logout may have happened while we waited for the lock — don't
// re-adopt or re-fetch a token after the user signed out.
if (loggedOutSinceStart()) {
return
}
// If the token in localStorage changed while waiting for the lock, // If the token in localStorage changed while waiting for the lock,
// another tab already refreshed. Just adopt the new token. // another tab already refreshed. Just adopt the new token.
const currentToken = localStorage.getItem('token') const currentToken = localStorage.getItem('token')
@ -122,9 +78,6 @@ async function doRefresh(persist: boolean): Promise<void> {
const HTTP = HTTPFactory() const HTTP = HTTPFactory()
try { try {
const response = await HTTP.post('user/token/refresh') const response = await HTTP.post('user/token/refresh')
if (loggedOutSinceStart()) {
return
}
saveToken(response.data.token, persist) saveToken(response.data.token, persist)
} catch (e) { } catch (e) {
throw new Error('Error renewing token: ', {cause: e}) throw new Error('Error renewing token: ', {cause: e})
@ -132,10 +85,10 @@ async function doRefresh(persist: boolean): Promise<void> {
} }
if (navigator.locks) { if (navigator.locks) {
await navigator.locks.request('vikunja-token-refresh', refreshUnderLock) await navigator.locks.request('vikunja-token-refresh', doRefresh)
} else { } else {
// Fallback for environments without Web Locks (e.g. insecure HTTP) // Fallback for environments without Web Locks (e.g. insecure HTTP)
await refreshUnderLock() await doRefresh()
} }
} }

View File

@ -10,9 +10,5 @@ export function getProjectTitle(project: IProject) {
return i18n.global.t('project.inboxTitle') return i18n.global.t('project.inboxTitle')
} }
if (project.title === 'My Open Tasks') {
return i18n.global.t('project.myOpenTasksFilterTitle')
}
return project.title return project.title
} }

View File

@ -2,17 +2,10 @@ import {createRandomID} from '@/helpers/randomId'
import {computePosition, flip, shift, offset} from '@floating-ui/dom' import {computePosition, flip, shift, offset} from '@floating-ui/dom'
import {nextTick} from 'vue' import {nextTick} from 'vue'
import {eventToShortcutString} from '@/helpers/shortcut' import {eventToShortcutString} from '@/helpers/shortcut'
import type {Editor} from '@tiptap/core'
import {getPopupContainer} from '@/components/input/editor/popupContainer'
export default function inputPrompt(pos: ClientRect, oldValue: string = '', editor?: Editor): Promise<string> { export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Promise<string> {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = 'link-input-' + createRandomID() const id = 'link-input-' + createRandomID()
// Append inside the open task <dialog> (top-layer) when present, otherwise
// document.body. A body-level popup is painted behind a showModal() dialog
// and unfocusable through its focus trap, breaking the link prompt in the
// Kanban task popup (#2940).
const container = getPopupContainer(editor)
// Create popup element // Create popup element
const popupElement = document.createElement('div') const popupElement = document.createElement('div')
@ -33,7 +26,7 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = '', edit
inputElement.value = oldValue inputElement.value = oldValue
wrapperDiv.appendChild(inputElement) wrapperDiv.appendChild(inputElement)
popupElement.appendChild(wrapperDiv) popupElement.appendChild(wrapperDiv)
container.appendChild(popupElement) document.body.appendChild(popupElement)
// Create a local mutable copy of the position for scroll tracking // Create a local mutable copy of the position for scroll tracking
let currentRect = new DOMRect(pos.left, pos.top, pos.width, pos.height) let currentRect = new DOMRect(pos.left, pos.top, pos.width, pos.height)
@ -89,41 +82,15 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = '', edit
nextTick(() => document.getElementById(id)?.focus()) nextTick(() => document.getElementById(id)?.focus())
// The prompt is a sub-modal of the enclosing task <dialog>. Native modal
// dialogs close themselves on Escape ("cancel"); swallow that while the
// prompt is open so Escape only dismisses the prompt, not the task dialog.
const dialog = container.closest('dialog') as HTMLDialogElement | null
const handleDialogCancel = (event: Event) => event.preventDefault()
dialog?.addEventListener('cancel', handleDialogCancel)
const handleClickOutside = (event: MouseEvent) => {
if (!popupElement.contains(event.target as Node)) {
resolve('')
cleanup()
}
}
const cleanup = () => { const cleanup = () => {
window.removeEventListener('scroll', handleScroll, true) window.removeEventListener('scroll', handleScroll, true)
document.removeEventListener('click', handleClickOutside) if (document.body.contains(popupElement)) {
dialog?.removeEventListener('cancel', handleDialogCancel) document.body.removeChild(popupElement)
if (container.contains(popupElement)) {
container.removeChild(popupElement)
} }
} }
document.getElementById(id)?.addEventListener('keydown', event => { document.getElementById(id)?.addEventListener('keydown', event => {
const shortcutString = eventToShortcutString(event) const shortcutString = eventToShortcutString(event)
if (shortcutString === 'Escape') {
// Stop the native <dialog> from closing on Escape; cancel the prompt only.
event.preventDefault()
event.stopPropagation()
resolve('')
cleanup()
return
}
if (shortcutString !== 'Enter') { if (shortcutString !== 'Enter') {
return return
} }
@ -138,6 +105,15 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = '', edit
cleanup() cleanup()
}) })
// Close on click outside
const handleClickOutside = (event: MouseEvent) => {
if (!popupElement.contains(event.target as Node)) {
resolve('')
cleanup()
document.removeEventListener('click', handleClickOutside)
}
}
// Add slight delay to prevent immediate closing // Add slight delay to prevent immediate closing
setTimeout(() => { setTimeout(() => {
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)

View File

@ -24,10 +24,8 @@ export const redirectToProvider = (provider: IProvider) => {
window.location.href = `${provider.authUrl}?client_id=${provider.clientId}&redirect_uri=${redirectUrl}&response_type=code&scope=${scope}&state=${state}` window.location.href = `${provider.authUrl}?client_id=${provider.clientId}&redirect_uri=${redirectUrl}&response_type=code&scope=${scope}&state=${state}`
} }
export const redirectToProviderOnLogout = (provider: IProvider): boolean => { export const redirectToProviderOnLogout = (provider: IProvider) => {
if (provider.logoutUrl.length > 0) { if (provider.logoutUrl.length > 0) {
window.location.href = `${provider.logoutUrl}` window.location.href = `${provider.logoutUrl}`
return true
} }
return false
} }

View File

@ -349,7 +349,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 +393,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": {

View File

@ -349,7 +349,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 +393,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": {

View File

@ -393,7 +393,6 @@
"title": "Αντιγραφή του έργου", "title": "Αντιγραφή του έργου",
"label": "Αντιγραφή", "label": "Αντιγραφή",
"text": "Επιλέξτε ένα γονικό έργο που θα περιλαμβάνει το αντίγραφο του έργου:", "text": "Επιλέξτε ένα γονικό έργο που θα περιλαμβάνει το αντίγραφο του έργου:",
"shares": "Αντιγραφή διαμοιρασμών (χρήστες, ομάδες και σύνδεσμοι διαμοιρασμού) στο αντίγραφο",
"success": "Το έργο αντιγράφηκε με επιτυχία." "success": "Το έργο αντιγράφηκε με επιτυχία."
}, },
"edit": { "edit": {

View File

@ -349,7 +349,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",

View File

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

View File

@ -393,7 +393,6 @@
"title": "Дублювати цей проєкт", "title": "Дублювати цей проєкт",
"label": "Дублювати", "label": "Дублювати",
"text": "Оберіть батьківський проєкт, який повинен складатися з дубльованих проєктів:", "text": "Оберіть батьківський проєкт, який повинен складатися з дубльованих проєктів:",
"shares": "Скопіювати налаштування спільного доступу (користувачів, команди та посилання) до копії проєкту",
"success": "Проєкт дубльовано." "success": "Проєкт дубльовано."
}, },
"edit": { "edit": {
@ -989,7 +988,7 @@
"assign": "Доручити", "assign": "Доручити",
"label": "Позначки", "label": "Позначки",
"priority": "Встановити пріоритет", "priority": "Встановити пріоритет",
"dueDate": "Встановити термін виконання", "dueDate": "Встановити термін",
"startDate": "Почати", "startDate": "Почати",
"endDate": "Встановити дату завершення", "endDate": "Встановити дату завершення",
"reminders": "Нагадування", "reminders": "Нагадування",

View File

@ -1,139 +0,0 @@
import {describe, it, expect, beforeEach, vi} from 'vitest'
import {setActivePinia, createPinia} from 'pinia'
import {useAuthStore} from './auth'
import {AUTH_TYPES} from '@/modelTypes/IUser'
const {refreshTokenMock, routerPushMock, getTokenMock} = vi.hoisted(() => ({
refreshTokenMock: vi.fn(),
routerPushMock: vi.fn(),
getTokenMock: vi.fn(() => null as string | null),
}))
vi.mock('@/helpers/auth', () => ({
refreshToken: refreshTokenMock,
getToken: getTokenMock,
saveToken: vi.fn(),
removeToken: vi.fn(),
}))
vi.mock('@/router', () => ({
default: {push: routerPushMock},
}))
vi.mock('@/composables/useWebSocket', () => ({
useWebSocket: () => ({disconnect: vi.fn(), connect: vi.fn()}),
}))
function fakeHttp() {
return {
post: vi.fn().mockResolvedValue({data: {}}),
get: vi.fn().mockResolvedValue({data: {}}),
request: vi.fn().mockResolvedValue({data: {}}),
interceptors: {
request: {use: vi.fn()},
response: {use: vi.fn()},
},
}
}
vi.mock('@/helpers/fetcher', () => ({
HTTPFactory: () => fakeHttp(),
AuthenticatedHTTPFactory: () => fakeHttp(),
getApiBaseUrl: () => 'http://localhost/api/v1/',
}))
vi.mock('@/helpers/redirectToProvider', () => ({
getRedirectUrlFromCurrentFrontendPath: vi.fn(),
redirectToProvider: vi.fn(),
redirectToProviderOnLogout: vi.fn(),
}))
// A refresh failure that looks like a real network/HTTP error so renewToken's
// "is this a genuine logout?" check (it inspects the error cause's status) fires.
function refreshError() {
return new Error('Error renewing token: ', {
cause: {response: {status: 401}},
})
}
// A JWT carrying a not-yet-expired user session, so the checkAuth() call that
// renewToken() runs after a successful refresh treats the session as live.
function freshUserJwt() {
const payload = {
id: 1,
type: AUTH_TYPES.USER,
exp: Math.floor(Date.now() / 1000) + 3600,
}
const encoded = btoa(JSON.stringify(payload))
return `header.${encoded}.signature`
}
describe('auth store renewToken retry (issue #2863)', () => {
beforeEach(() => {
setActivePinia(createPinia())
refreshTokenMock.mockReset()
routerPushMock.mockReset()
getTokenMock.mockReset().mockReturnValue(null)
})
function setupExpiredUserSession(store: ReturnType<typeof useAuthStore>) {
store.setAuthenticated(true)
// Expired exp so renewToken treats a refresh failure as a real logout.
store.setUser({
id: 1,
type: AUTH_TYPES.USER,
exp: Math.floor(Date.now() / 1000) - 60,
} as never, false)
}
it('does NOT log out when the first refresh fails but the retry succeeds', async () => {
const store = useAuthStore()
setupExpiredUserSession(store)
// The retry "succeeds" only if it actually leaves a usable token behind:
// renewToken() runs checkAuth() afterwards, which reads getToken(). Start
// with no token, then hand back a fresh JWT once the refresh resolves.
getTokenMock.mockReturnValue(null)
refreshTokenMock
.mockRejectedValueOnce(refreshError())
.mockImplementationOnce(async () => {
getTokenMock.mockReturnValue(freshUserJwt())
})
await store.renewToken()
// Two refresh attempts: the initial one and the single retry.
expect(refreshTokenMock).toHaveBeenCalledTimes(2)
// The retry recovered the session: the user is still authenticated...
expect(store.authenticated).toBe(true)
// ...and was not bounced to login.
expect(routerPushMock).not.toHaveBeenCalledWith({name: 'user.login'})
})
it('logs out when BOTH the refresh and its retry fail', async () => {
const store = useAuthStore()
setupExpiredUserSession(store)
refreshTokenMock
.mockRejectedValueOnce(refreshError())
.mockRejectedValueOnce(refreshError())
await store.renewToken()
expect(refreshTokenMock).toHaveBeenCalledTimes(2)
expect(routerPushMock).toHaveBeenCalledWith({name: 'user.login'})
})
it('retries exactly once (no infinite loop) when the session is genuinely dead', async () => {
const store = useAuthStore()
setupExpiredUserSession(store)
refreshTokenMock.mockRejectedValue(refreshError())
await store.renewToken()
// Initial attempt + exactly one retry — never more.
expect(refreshTokenMock).toHaveBeenCalledTimes(2)
})
})

View File

@ -28,11 +28,6 @@ import {TIME_FORMAT} from '@/constants/timeFormat'
import {RELATION_KIND} from '@/types/IRelationKind' import {RELATION_KIND} from '@/types/IRelationKind'
import type {IProvider} from '@/types/IProvider' import type {IProvider} from '@/types/IProvider'
// Set on explicit logout so the login page won't immediately bounce the user
// back to the OIDC provider. Lives in sessionStorage so it survives the
// round-trip to the IdP within the tab and isn't wiped by localStorage.clear().
export const JUST_LOGGED_OUT_KEY = 'justLoggedOut'
function redirectToSpecifiedProvider() { function redirectToSpecifiedProvider() {
const {auth} = useConfigStore() const {auth} = useConfigStore()
@ -60,17 +55,6 @@ function redirectToSpecifiedProvider() {
} }
} }
// A race-loser's refresh fails but the rotated cookie is already valid, so a
// second attempt succeeds — recovering what would otherwise be a spurious
// logout. Exactly one retry: a genuinely dead session still logs out, no loop.
async function refreshTokenWithRetry(persist: boolean): Promise<void> {
try {
await refreshToken(persist)
} catch {
await refreshToken(persist)
}
}
function getLoggedInVia(): string | null { function getLoggedInVia(): string | null {
return localStorage.getItem('loggedInViaProvider') return localStorage.getItem('loggedInViaProvider')
} }
@ -368,7 +352,7 @@ export const useAuthStore = defineStore('auth', () => {
// refresh before giving up. This lets users who reopen the app // refresh before giving up. This lets users who reopen the app
// after the short JWT TTL seamlessly resume their session. // after the short JWT TTL seamlessly resume their session.
try { try {
await refreshTokenWithRetry(true) await refreshToken(true)
const freshJwt = getToken() const freshJwt = getToken()
if (freshJwt) { if (freshJwt) {
const b64 = freshJwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/') const b64 = freshJwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
@ -528,7 +512,7 @@ export const useAuthStore = defineStore('auth', () => {
saveToken(response.data.token, false) saveToken(response.data.token, false)
} else { } else {
// User sessions renew via the refresh-token cookie. // User sessions renew via the refresh-token cookie.
await refreshTokenWithRetry(true) await refreshToken(true)
} }
await checkAuth() await checkAuth()
} catch (e) { } catch (e) {
@ -562,25 +546,19 @@ export const useAuthStore = defineStore('auth', () => {
const loggedInVia = getLoggedInVia() const loggedInVia = getLoggedInVia()
window.localStorage.clear() // Clear all settings and history we might have saved in local storage. window.localStorage.clear() // Clear all settings and history we might have saved in local storage.
lastUserInfoRefresh.value = null lastUserInfoRefresh.value = null
await router.push({name: 'user.login'})
sessionStorage.setItem(JUST_LOGGED_OUT_KEY, 'true') await checkAuth()
// Redirect to the OIDC provider to end its session too. Prefer the // Redirect to the OIDC provider to end its session too. Prefer the
// server-built RP-Initiated Logout URL, falling back to the static one. // server-built RP-Initiated Logout URL, falling back to the static one.
// These full-page redirects return the user to the login page, so we
// must not router.push there first — that would consume
// JUST_LOGGED_OUT_KEY before the round-trip lands.
if (oidcLogoutUrl) { if (oidcLogoutUrl) {
window.location.href = oidcLogoutUrl window.location.href = oidcLogoutUrl
return return
} }
const fullProvider: IProvider|undefined = configStore.auth.openidConnect.providers?.find((p: IProvider) => p.key === loggedInVia) const fullProvider: IProvider|undefined = configStore.auth.openidConnect.providers?.find((p: IProvider) => p.key === loggedInVia)
if (fullProvider && redirectToProviderOnLogout(fullProvider)) { if (fullProvider) {
return redirectToProviderOnLogout(fullProvider)
} }
await router.push({name: 'user.login'})
await checkAuth()
} }
return { return {

View File

@ -136,7 +136,7 @@ import {redirectToProvider} from '@/helpers/redirectToProvider'
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited' import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
import {isDesktopApp} from '@/helpers/desktopAuth' import {isDesktopApp} from '@/helpers/desktopAuth'
import {useAuthStore, JUST_LOGGED_OUT_KEY} from '@/stores/auth' import {useAuthStore} from '@/stores/auth'
import {useConfigStore} from '@/stores/config' import {useConfigStore} from '@/stores/config'
import {useTitle} from '@/composables/useTitle' import {useTitle} from '@/composables/useTitle'
@ -181,25 +181,6 @@ onBeforeMount(() => {
// route before the submit() handler gets a chance to use it. // route before the submit() handler gets a chance to use it.
if (authenticated.value) { if (authenticated.value) {
router.push({name: 'home'}) router.push({name: 'home'})
return
}
// Don't auto-redirect right after an explicit logout, otherwise we'd
// immediately re-authenticate the user we just logged out.
if (sessionStorage.getItem(JUST_LOGGED_OUT_KEY)) {
sessionStorage.removeItem(JUST_LOGGED_OUT_KEY)
return
}
// When the login page offers nothing but a single OIDC provider, skip it
// and send the user straight there.
if (
!localAuthEnabled.value &&
!ldapAuthEnabled.value &&
hasOpenIdProviders.value &&
openidConnect.value.providers.length === 1
) {
redirectToProvider(openidConnect.value.providers[0])
} }
}) })

View File

@ -1,124 +0,0 @@
import {test, expect} from '../../support/fixtures'
import {ProjectFactory} from '../../factories/project'
import {ProjectViewFactory} from '../../factories/project_view'
import {BucketFactory} from '../../factories/bucket'
import {TaskFactory} from '../../factories/task'
import {TaskBucketFactory} from '../../factories/task_buckets'
// Regression test for #2940: in the Kanban task popup the description editor is
// rendered inside a native <dialog> opened via showModal() (browser top-layer).
// The link prompt used to be appended to document.body, so it was painted behind
// the dialog and unfocusable through its focus trap, making "set link" a no-op.
test.describe('Editor link prompt inside the Kanban task popup', () => {
test('creates a link in the description when opened as the Kanban popup', async ({authenticatedPage: page}) => {
const projects = await ProjectFactory.create(1)
const views = await ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
})
const buckets = await BucketFactory.create(1, {
project_view_id: views[0].id,
})
const tasks = await TaskFactory.create(1, {
project_id: projects[0].id,
description: 'link me',
index: 1,
})
await TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
project_view_id: views[0].id,
})
await page.goto(`/projects/${projects[0].id}/${views[0].id}`)
const card = page.locator('.kanban .bucket .tasks .task').filter({hasText: tasks[0].title})
await expect(card).toBeVisible()
await card.click()
// The task popup must be a native <dialog> in the top layer.
const dialog = page.locator('dialog[open]')
await expect(dialog).toBeVisible()
await expect(dialog.locator('.task-view')).toBeVisible()
const editButton = dialog.locator('.details.content.description .tiptap button.done-edit').filter({hasText: 'Edit'})
await expect(editButton).toBeVisible({timeout: 10000})
await editButton.click()
const description = dialog.locator('.details.content.description')
const editor = description.locator('[contenteditable="true"]').first()
await expect(editor).toBeVisible({timeout: 10000})
await editor.click()
await page.keyboard.press('ControlOrMeta+a')
await description.locator('.editor-toolbar__button').filter({hasText: 'Link'}).click()
const urlInput = dialog.locator('input.input[placeholder="URL"]')
await expect(urlInput).toBeVisible()
await urlInput.fill('https://vikunja.io')
await urlInput.press('Enter')
const link = editor.locator('a[href="https://vikunja.io"]')
await expect(link).toBeVisible()
await expect(link).toHaveText('link me')
})
// The link prompt is a sub-modal of the task <dialog>: pressing Escape while
// it is open must cancel only the prompt and leave the task dialog open,
// instead of falling through to the native <dialog>'s Escape-to-close.
test('Escape cancels the link prompt without closing the task dialog', async ({authenticatedPage: page}) => {
const projects = await ProjectFactory.create(1)
const views = await ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
})
const buckets = await BucketFactory.create(1, {
project_view_id: views[0].id,
})
const tasks = await TaskFactory.create(1, {
project_id: projects[0].id,
description: 'link me',
index: 1,
})
await TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
project_view_id: views[0].id,
})
await page.goto(`/projects/${projects[0].id}/${views[0].id}`)
const card = page.locator('.kanban .bucket .tasks .task').filter({hasText: tasks[0].title})
await expect(card).toBeVisible()
await card.click()
const dialog = page.locator('dialog[open]')
await expect(dialog).toBeVisible()
await expect(dialog.locator('.task-view')).toBeVisible()
const editButton = dialog.locator('.details.content.description .tiptap button.done-edit').filter({hasText: 'Edit'})
await expect(editButton).toBeVisible({timeout: 10000})
await editButton.click()
const description = dialog.locator('.details.content.description')
const editor = description.locator('[contenteditable="true"]').first()
await expect(editor).toBeVisible({timeout: 10000})
await editor.click()
await page.keyboard.press('ControlOrMeta+a')
await description.locator('.editor-toolbar__button').filter({hasText: 'Link'}).click()
const urlInput = dialog.locator('input.input[placeholder="URL"]')
await expect(urlInput).toBeVisible()
await urlInput.press('Escape')
// The prompt is gone, but the task dialog stays open.
await expect(urlInput).toBeHidden()
await expect(dialog).toBeVisible()
await expect(dialog.locator('.task-view')).toBeVisible()
})
})

View File

@ -1,55 +0,0 @@
import {type Page} from '@playwright/test'
import {test, expect} from '../../support/fixtures'
import {TaskFactory} from '../../factories/task'
import {createProjects} from './prepareProjects'
async function selectSortInList(page: Page, optionLabel: string) {
await page.locator('.filter-container').getByRole('button', {name: 'Sort', exact: true}).click()
await page.getByLabel('Sort by').selectOption({label: optionLabel})
await page.getByRole('button', {name: 'Apply sort'}).click()
}
async function navigateViaSidebar(page: Page, projectTitle: string) {
await page.locator('.menu-list .list-menu-link', {
has: page.locator('.project-menu-title', {hasText: new RegExp(`^${projectTitle}$`)}),
}).first().click()
}
test.describe('Sort persistence across sidebar navigation (#2753)', () => {
test('List view: sort persists after navigating to another project and back', async ({authenticatedPage: page}) => {
const projects = await createProjects(2)
const [projectA, projectB] = projects
await TaskFactory.create(3, {
id: '{increment}',
project_id: projectA.id,
title: 'Task {increment}',
})
const listViewA = projectA.views[0].id
await page.goto(`/projects/${projectA.id}/${listViewA}`)
await expect(page).not.toHaveURL(/sort=/)
await selectSortInList(page, 'Due date (Earliest first)')
await expect(page).toHaveURL(/sort=due_date:asc/)
await navigateViaSidebar(page, projectB.title)
await expect(page).toHaveURL(new RegExp(`/projects/${projectB.id}/`))
await navigateViaSidebar(page, projectA.title)
await expect(page).toHaveURL(new RegExp(`/projects/${projectA.id}/`))
await expect(page).toHaveURL(/sort=due_date:asc/)
})
test('List view: explicit URL sort wins over stored sort', async ({authenticatedPage: page}) => {
const projects = await createProjects(1)
const listView = projects[0].views[0].id
// Seed the store with one sort by visiting with it set.
await page.goto(`/projects/${projects[0].id}/${listView}?sort=due_date:asc`)
await expect(page).toHaveURL(/sort=due_date:asc/)
// Visit a URL that explicitly sets a different sort — that should win.
await page.goto(`/projects/${projects[0].id}/${listView}?sort=priority:desc`)
await expect(page).toHaveURL(/sort=priority:desc/)
})
})

123
go.mod
View File

@ -16,58 +16,56 @@
module code.vikunja.io/api module code.vikunja.io/api
go 1.26.4 go 1.25.7
require ( require (
code.dny.dev/ssrf v0.2.0 code.dny.dev/ssrf v0.2.0
dario.cat/mergo v1.0.2 dario.cat/mergo v1.0.2
github.com/JohannesKaufmann/dom v0.3.1 github.com/ThreeDotsLabs/watermill v1.5.1
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
github.com/ThreeDotsLabs/watermill v1.5.2
github.com/adlio/trello v1.12.0 github.com/adlio/trello v1.12.0
github.com/arran4/golang-ical v0.3.5 github.com/arran4/golang-ical v0.3.2
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
github.com/aws/aws-sdk-go-v2 v1.42.0 github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.32.26 github.com/aws/aws-sdk-go-v2/config v1.32.10
github.com/aws/aws-sdk-go-v2/credentials v1.19.25 github.com/aws/aws-sdk-go-v2/credentials v1.19.10
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.1 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
github.com/aws/smithy-go v1.27.3 github.com/aws/smithy-go v1.24.2
github.com/bbrks/go-blurhash v1.2.0 github.com/bbrks/go-blurhash v1.1.1
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500
github.com/coder/websocket v1.8.15 github.com/coder/websocket v1.8.14
github.com/coreos/go-oidc/v3 v3.19.0 github.com/coreos/go-oidc/v3 v3.17.0
github.com/d4l3k/messagediff v1.2.1 github.com/d4l3k/messagediff v1.2.1
github.com/danielgtaylor/huma/v2 v2.38.0 github.com/danielgtaylor/huma/v2 v2.37.3
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b
github.com/fatih/color v1.19.0 github.com/fatih/color v1.18.0
github.com/gabriel-vasile/mimetype v1.4.13 github.com/gabriel-vasile/mimetype v1.4.13
github.com/ganigeorgiev/fexpr v0.5.0 github.com/ganigeorgiev/fexpr v0.5.0
github.com/getsentry/sentry-go v0.41.0 github.com/getsentry/sentry-go v0.41.0
github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-sql-driver/mysql v1.10.0 github.com/go-sql-driver/mysql v1.9.3
github.com/go-testfixtures/testfixtures/v3 v3.19.0 github.com/go-testfixtures/testfixtures/v3 v3.19.0
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/feeds v1.2.0 github.com/gorilla/feeds v1.2.0
github.com/hashicorp/go-version v1.9.0 github.com/hashicorp/go-version v1.8.0
github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346 github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346
github.com/huandu/go-clone/generic v1.7.3 github.com/huandu/go-clone/generic v1.7.3
github.com/iancoleman/strcase v0.3.0 github.com/iancoleman/strcase v0.3.0
github.com/jaswdr/faker/v2 v2.9.1 github.com/jaswdr/faker/v2 v2.9.1
github.com/jinzhu/copier v0.4.0 github.com/jinzhu/copier v0.4.0
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6
github.com/labstack/echo-jwt/v5 v5.0.1 github.com/labstack/echo-jwt/v5 v5.0.0
github.com/labstack/echo/v5 v5.2.1 github.com/labstack/echo/v5 v5.0.3
github.com/lib/pq v1.12.3 github.com/lib/pq v1.10.9
github.com/magefile/mage v1.17.2 github.com/magefile/mage v1.15.0
github.com/mattn/go-sqlite3 v1.14.47 github.com/mattn/go-sqlite3 v1.14.33
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/olekukonko/tablewriter v1.1.4 github.com/olekukonko/tablewriter v1.1.3
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.21.0 github.com/redis/go-redis/v9 v9.17.3
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/samedi/caldav-go v3.0.0+incompatible github.com/samedi/caldav-go v3.0.0+incompatible
github.com/schollz/progressbar/v3 v3.19.0 github.com/schollz/progressbar/v3 v3.19.0
@ -78,43 +76,43 @@ require (
github.com/tkuchiki/go-timezone v0.2.3 github.com/tkuchiki/go-timezone v0.2.3
github.com/traefik/yaegi v0.16.1 github.com/traefik/yaegi v0.16.1
github.com/ulule/limiter/v3 v3.11.2 github.com/ulule/limiter/v3 v3.11.2
github.com/wneessen/go-mail v0.7.3 github.com/wneessen/go-mail v0.7.2
github.com/yuin/goldmark v1.8.2 github.com/yuin/goldmark v1.7.16
golang.org/x/crypto v0.53.0 golang.org/x/crypto v0.48.0
golang.org/x/image v0.38.0 golang.org/x/image v0.38.0
golang.org/x/net v0.55.0 golang.org/x/net v0.50.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.34.0
golang.org/x/sync v0.21.0 golang.org/x/sync v0.20.0
golang.org/x/sys v0.46.0 golang.org/x/sys v0.41.0
golang.org/x/term v0.44.0 golang.org/x/term v0.40.0
golang.org/x/text v0.38.0 golang.org/x/text v0.35.0
gopkg.in/d4l3k/messagediff.v1 v1.2.1 gopkg.in/d4l3k/messagediff.v1 v1.2.1
mvdan.cc/xurls/v2 v2.6.0 mvdan.cc/xurls/v2 v2.6.0
src.techknowlogick.com/xormigrate v1.7.1 src.techknowlogick.com/xormigrate v1.7.1
xorm.io/builder v0.3.13 xorm.io/builder v0.3.13
xorm.io/xorm v1.4.1 xorm.io/xorm v1.3.11
) )
require ( require (
filippo.io/edwards25519 v1.2.0 // indirect filippo.io/edwards25519 v1.1.1 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.30 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.2.1 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.31.4 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.7 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.43.4 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/beevik/etree v1.1.0 // indirect github.com/beevik/etree v1.1.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
@ -136,7 +134,7 @@ require (
github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect
@ -147,7 +145,7 @@ require (
github.com/go-openapi/spec v0.20.4 // indirect github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
@ -158,8 +156,8 @@ require (
github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/moby/api v1.53.0 // indirect github.com/moby/moby/api v1.53.0 // indirect
@ -168,18 +166,18 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oklog/ulid v1.3.1 // indirect github.com/oklog/ulid v1.3.1 // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.2.0 // indirect github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.1.6 // indirect github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 // indirect
github.com/onsi/ginkgo v1.16.4 // indirect github.com/onsi/ginkgo v1.16.4 // indirect
github.com/onsi/gomega v1.16.0 // indirect github.com/onsi/gomega v1.16.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.20.1 // indirect github.com/prometheus/procfs v0.17.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect
@ -199,13 +197,12 @@ require (
go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9 // indirect golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9 // indirect
golang.org/x/mod v0.36.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.45.0 // indirect golang.org/x/tools v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

179
go.sum
View File

@ -4,8 +4,6 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
gitee.com/travelliu/dm v1.8.11192/go.mod h1:DHTzyhCrM843x9VdKVbZ+GKXGRbKM2sJ4LxihRxShkE= gitee.com/travelliu/dm v1.8.11192/go.mod h1:DHTzyhCrM843x9VdKVbZ+GKXGRbKM2sJ4LxihRxShkE=
@ -17,10 +15,6 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg6
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0=
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
@ -30,116 +24,56 @@ github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY=
github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4=
github.com/ThreeDotsLabs/watermill v1.5.2 h1:0ES33Eq1jEsP/pWvtE4n8bE0bs+9Jq7boT7wGBCVY6Q=
github.com/ThreeDotsLabs/watermill v1.5.2/go.mod h1:i9/968UriGphWfEbfMuYSD1qFbYRjb0mE0r+rV0FPp4=
github.com/adlio/trello v1.12.0 h1:JqOE2GFHQ9YtEviRRRSnicSxPbt4WFOxhqXzjMOw8lw= github.com/adlio/trello v1.12.0 h1:JqOE2GFHQ9YtEviRRRSnicSxPbt4WFOxhqXzjMOw8lw=
github.com/adlio/trello v1.12.0/go.mod h1:I4Lti4jf2KxjTNgTqs5W3lLuE78QZZdYbbPnQQGwjOo= github.com/adlio/trello v1.12.0/go.mod h1:I4Lti4jf2KxjTNgTqs5W3lLuE78QZZdYbbPnQQGwjOo=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/arran4/golang-ical v0.3.2 h1:MGNjcXJFSuCXmYX/RpZhR2HDCYoFuK8vTPFLEdFC3JY= github.com/arran4/golang-ical v0.3.2 h1:MGNjcXJFSuCXmYX/RpZhR2HDCYoFuK8vTPFLEdFC3JY=
github.com/arran4/golang-ical v0.3.2/go.mod h1:xblDGxxIUMWwFZk9dlECUlc1iXNV65LJZOTHLVwu8bo= github.com/arran4/golang-ical v0.3.2/go.mod h1:xblDGxxIUMWwFZk9dlECUlc1iXNV65LJZOTHLVwu8bo=
github.com/arran4/golang-ical v0.3.5 h1:bbz6ld4dC+MmCKiFfOd6SkmIGnhNMBACZ485ULh7p9A=
github.com/arran4/golang-ical v0.3.5/go.mod h1:OnguFgjN0Hmx8jzpmWcC+AkHio94ujmLHKoaef7xQh8=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls=
github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI= github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI=
github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw= github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw=
github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
github.com/aws/aws-sdk-go-v2/config v1.32.26 h1:JI+W5B3jUA8UBz2ggbICGd9UCR6/+SB21G8EFl0SFTQ=
github.com/aws/aws-sdk-go-v2/config v1.32.26/go.mod h1:RLE2Ls/wRstvdSz1GPrIWNnXcKZ/znDdWyMuiQxdBoY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8= github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE= github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.25 h1:TzPVjfUZ1hsKafvYE+DIzKXIik2KufQxsPHanlkttbo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.25/go.mod h1:K4hw0buguVvtC74HnVfTRr0LzQQHAWPqJbBU9QGk2Pg=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.30 h1:4HbXxyipSYxexU0juMIpdS05dilL6dbB2VQHxxN2vGU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.30/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo= github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 h1:ta8csKy5vN91F3i5gGR85lFV0srBqySEji7Jroes6rE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.1 h1:yb03KevaOAG5e8suo79Af74vjIQvoeKmjl79WQchLrs=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.1/go.mod h1:mreYODw0Y4yv7xeczvqC6vciwFao8lPE9k1l1ulfY6E=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ= github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g= github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.1 h1:BeJmkm5YOZs6lGRGcNoIuLSoTTtGLLCEqlSiRKYodfM=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.1/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o= github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo= github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.4 h1:i465b/3c7xJd++pobNIDOggouekCuiWOnB0goQJy+94=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.4/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.7 h1:xbmJAnBbyYPkTzoCNCF/bpJ6ymQHRdXX1vquYfDIGYk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.7/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c= github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs= github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.4 h1:Np0vmL7op0Zs5xGacYMMX3v5O5pvZ46xhb5LwDgPj8M=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.4/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bbrks/go-blurhash v1.1.1 h1:uoXOxRPDca9zHYabUTwvS4KnY++KKUbwFo+Yxb8ME4M= github.com/bbrks/go-blurhash v1.1.1 h1:uoXOxRPDca9zHYabUTwvS4KnY++KKUbwFo+Yxb8ME4M=
github.com/bbrks/go-blurhash v1.1.1/go.mod h1:lkAsdyXp+EhARcUo85yS2G1o+Sh43I2ebF5togC4bAY= github.com/bbrks/go-blurhash v1.1.1/go.mod h1:lkAsdyXp+EhARcUo85yS2G1o+Sh43I2ebF5togC4bAY=
github.com/bbrks/go-blurhash v1.2.0 h1:99w0YT50b/B7uoZyM79Nqy+UemMOh8fO/ONyyxmr9MU=
github.com/bbrks/go-blurhash v1.2.0/go.mod h1:r4N4/ViVMa2h6Ex6e1aoCWMTkykYWS/VXvYMCrbkRpw=
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@ -169,16 +103,12 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
@ -192,8 +122,6 @@ github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt
github.com/d4l3k/messagediff v1.2.1/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= github.com/d4l3k/messagediff v1.2.1/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
github.com/danielgtaylor/huma/v2 v2.37.3 h1:6Av0Vj45Vk5lDxRVfoO2iPlEdvCvwLc7pl5nbqGOkYM= github.com/danielgtaylor/huma/v2 v2.37.3 h1:6Av0Vj45Vk5lDxRVfoO2iPlEdvCvwLc7pl5nbqGOkYM=
github.com/danielgtaylor/huma/v2 v2.37.3/go.mod h1:OeHHtCEAaNiuVbAVdYu4IQ0UOmnb4x3yMUOShNlZ53g= github.com/danielgtaylor/huma/v2 v2.37.3/go.mod h1:OeHHtCEAaNiuVbAVdYu4IQ0UOmnb4x3yMUOShNlZ53g=
github.com/danielgtaylor/huma/v2 v2.38.0 h1:fb0WZCatnaiHLphMQDDWDjygNxfMkX/ENma3QsRl7vY=
github.com/danielgtaylor/huma/v2 v2.38.0/go.mod h1:k9hwjlgWFt1t2jsmQGlsgXAG2FBTZa4kkjV581qAtfo=
github.com/danielgtaylor/mexpr v1.9.1 h1:nA9bsGRmNlJeVCPFgGf7WhrLuKag/+iWfOaJ03iKFPI= github.com/danielgtaylor/mexpr v1.9.1 h1:nA9bsGRmNlJeVCPFgGf7WhrLuKag/+iWfOaJ03iKFPI=
github.com/danielgtaylor/mexpr v1.9.1/go.mod h1:kAivYNRnBeE/IJinqBvVFvLrX54xX//9zFYwADo4Bc8= github.com/danielgtaylor/mexpr v1.9.1/go.mod h1:kAivYNRnBeE/IJinqBvVFvLrX54xX//9zFYwADo4Bc8=
github.com/danielgtaylor/shorthand/v2 v2.2.0 h1:hVsemdRq6v3JocP6YRTfu9rOoghZI9PFmkngdKqzAVQ= github.com/danielgtaylor/shorthand/v2 v2.2.0 h1:hVsemdRq6v3JocP6YRTfu9rOoghZI9PFmkngdKqzAVQ=
@ -224,8 +152,6 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@ -236,8 +162,6 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk= github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
@ -255,8 +179,6 @@ github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
@ -279,8 +201,6 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-testfixtures/testfixtures/v3 v3.19.0 h1:/Y0bars250zggm+1A2PvwaJQsJel7/tS4D/Hhwt66Bc= github.com/go-testfixtures/testfixtures/v3 v3.19.0 h1:/Y0bars250zggm+1A2PvwaJQsJel7/tS4D/Hhwt66Bc=
@ -293,15 +213,11 @@ github.com/goccy/go-json v0.8.1/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGF
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
@ -343,8 +259,6 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346 h1:Odeq5rB6OZSkib5gqTG+EM1iF0bUVjYYd33XB1ULv00= github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346 h1:Odeq5rB6OZSkib5gqTG+EM1iF0bUVjYYd33XB1ULv00=
github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346/go.mod h1:4ggHM2qnyyZjenBb7RpwVzIj+JMsu9kHCVxMjB30hGs= github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346/go.mod h1:4ggHM2qnyyZjenBb7RpwVzIj+JMsu9kHCVxMjB30hGs=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
@ -442,14 +356,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo-jwt/v5 v5.0.0 h1:uPp+FpkI/PKpMPPygtnK3RQOpg5a2wlM04UgfpWLVyI= github.com/labstack/echo-jwt/v5 v5.0.0 h1:uPp+FpkI/PKpMPPygtnK3RQOpg5a2wlM04UgfpWLVyI=
github.com/labstack/echo-jwt/v5 v5.0.0/go.mod h1:RYF2ojWXbaY09QQ5J9vVtPUtkyI5UztS0gJotmCRz/U= github.com/labstack/echo-jwt/v5 v5.0.0/go.mod h1:RYF2ojWXbaY09QQ5J9vVtPUtkyI5UztS0gJotmCRz/U=
github.com/labstack/echo-jwt/v5 v5.0.1 h1:uIpCHCiDPN3jA8Jb47i4EViToUl1uypMiPvVAAgKpIw=
github.com/labstack/echo-jwt/v5 v5.0.1/go.mod h1:kcHmJPzrVSEJa1FRheVoi9EJrBLLUqr1ntlil6uPe1Q=
github.com/labstack/echo/v5 v5.0.3 h1:Jql8sDtCYXrhh2Mbs6jKwjR6r7X8FSQQmch+w6QS7kc= github.com/labstack/echo/v5 v5.0.3 h1:Jql8sDtCYXrhh2Mbs6jKwjR6r7X8FSQQmch+w6QS7kc=
github.com/labstack/echo/v5 v5.0.3/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo= github.com/labstack/echo/v5 v5.0.3/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
github.com/labstack/echo/v5 v5.0.4 h1:ll3I/O8BifjMztj9dD1vx/peZQv8cR2CTUdQK6QxGGc=
github.com/labstack/echo/v5 v5.0.4/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
github.com/labstack/echo/v5 v5.2.1 h1:TzpIksY6zLMzV0T0ycYbvTEoj9w6o6AcL5twg182VTY=
github.com/labstack/echo/v5 v5.2.1/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
github.com/laurent22/ical-go v0.1.1-0.20181107184520-7e5d6ade8eef h1:RZnRnSID1skF35j/15KJ6hKZkdIC/teQClJK5wP5LU4= github.com/laurent22/ical-go v0.1.1-0.20181107184520-7e5d6ade8eef h1:RZnRnSID1skF35j/15KJ6hKZkdIC/teQClJK5wP5LU4=
github.com/laurent22/ical-go v0.1.1-0.20181107184520-7e5d6ade8eef/go.mod h1:4LATl0uhhtytR6p9n1AlktDyIz4u2iUnWEdI3L/hXiw= github.com/laurent22/ical-go v0.1.1-0.20181107184520-7e5d6ade8eef/go.mod h1:4LATl0uhhtytR6p9n1AlktDyIz4u2iUnWEdI3L/hXiw=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@ -459,14 +367,10 @@ github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
@ -482,18 +386,14 @@ github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
@ -524,16 +424,10 @@ github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM= github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM=
github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew=
github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA=
github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88=
github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA=
github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
@ -550,8 +444,6 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
@ -569,16 +461,10 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4= github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
@ -600,10 +486,6 @@ github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeH
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
@ -660,16 +542,14 @@ github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8= github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k= github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 h1:0sw0nJM544SpsihWx1bkXdYLQDlzRflMgFJQ4Yih9ts= github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 h1:0sw0nJM544SpsihWx1bkXdYLQDlzRflMgFJQ4Yih9ts=
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE= github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@ -690,8 +570,6 @@ go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
@ -703,8 +581,6 @@ go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@ -720,10 +596,8 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9 h1:yZNXmy+j/JpX19vZkVktWqAo7Gny4PBWYYK3zskGpx4= golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9 h1:yZNXmy+j/JpX19vZkVktWqAo7Gny4PBWYYK3zskGpx4=
golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@ -734,10 +608,8 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@ -752,20 +624,16 @@ golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -793,18 +661,15 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -812,10 +677,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
@ -831,10 +694,8 @@ golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapK
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -938,5 +799,3 @@ xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.3.3/go.mod h1:qFJGFoVYbbIdnz2vaL5OxSQ2raleMpyRRalnq3n9OJo= xorm.io/xorm v1.3.3/go.mod h1:qFJGFoVYbbIdnz2vaL5OxSQ2raleMpyRRalnq3n9OJo=
xorm.io/xorm v1.3.11 h1:i4tlVUASogb0ZZFJHA7dZqoRU2pUpUsutnNdaOlFyMI= xorm.io/xorm v1.3.11 h1:i4tlVUASogb0ZZFJHA7dZqoRU2pUpUsutnNdaOlFyMI=
xorm.io/xorm v1.3.11/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q= xorm.io/xorm v1.3.11/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
xorm.io/xorm v1.4.1 h1:m7QlNd0eBGb31IV4Q/ow0Du83rtdC1CiwlvJZGvYde8=
xorm.io/xorm v1.4.1/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=

View File

@ -1,4 +1,4 @@
[tools] [tools]
node = "24.18.0" # keep in sync with frontend/.nvmrc node = "24.13.0" # keep in sync with frontend/.nvmrc
pnpm = "10.34.4" # keep in sync with frontend/package.json#packageManager pnpm = "10.28.1" # keep in sync with frontend/package.json#packageManager
go = "1.26.4" # keep in sync with go.mod go = "1.25.7" # keep in sync with go.mod

View File

@ -21,9 +21,7 @@ import (
"strings" "strings"
"time" "time"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/richtext"
"code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils" "code.vikunja.io/api/pkg/utils"
) )
@ -181,18 +179,8 @@ DURATION:PT` + formatDuration(t.Duration)
DTEND:` + makeCalDavTimeFromTimeStamp(t.End) DTEND:` + makeCalDavTimeFromTimeStamp(t.End)
} }
if t.Description != "" { if t.Description != "" {
// CalDAV clients show plain text, so emit markdown. On the near-impossible caldavtodos += `
// conversion error, log it and keep the stored value (GetContent can't DESCRIPTION:` + escapeICalText(t.Description)
// return an error) rather than drop the description.
description, err := richtext.HTMLToMarkdown(t.Description)
if err != nil {
log.Errorf("[CALDAV] Failed to convert description to markdown for task %q: %v", t.UID, err)
description = t.Description
}
if description != "" {
caldavtodos += `
DESCRIPTION:` + escapeICalText(description)
}
} }
if t.Completed.Unix() > 0 { if t.Completed.Unix() > 0 {
caldavtodos += ` caldavtodos += `

View File

@ -47,11 +47,12 @@ func TestParseTodos(t *testing.T) {
}, },
todos: []*Todo{ todos: []*Todo{
{ {
Summary: "Todo #1", Summary: "Todo #1",
Description: `<p>Lorem Ipsum</p><p>Dolor sit amet</p>`, Description: `Lorem Ipsum
UID: "randommduid", Dolor sit amet`,
Timestamp: time.Unix(1543626724, 0).In(config.GetTimeZone()), UID: "randommduid",
Color: "affffe", Timestamp: time.Unix(1543626724, 0).In(config.GetTimeZone()),
Color: "affffe",
}, },
}, },
}, },
@ -72,7 +73,7 @@ X-APPLE-CALENDAR-COLOR:#affffeFF
X-OUTLOOK-COLOR:#affffeFF X-OUTLOOK-COLOR:#affffeFF
X-FUNAMBOL-COLOR:#affffeFF X-FUNAMBOL-COLOR:#affffeFF
COLOR:#affffeFF COLOR:#affffeFF
DESCRIPTION:Lorem Ipsum\n\nDolor sit amet DESCRIPTION:Lorem Ipsum\nDolor sit amet
LAST-MODIFIED:00010101T000000Z LAST-MODIFIED:00010101T000000Z
END:VTODO END:VTODO
END:VCALENDAR`, END:VCALENDAR`,
@ -437,33 +438,6 @@ END:VCALENDAR`,
} }
} }
func TestParseTodosRichTextDescription(t *testing.T) {
cfg := &Config{Name: "test", ProdID: "Vikunja"}
ts := time.Unix(1543626724, 0).In(config.GetTimeZone())
t.Run("rich html serializes as markdown", func(t *testing.T) {
out := ParseTodos(cfg, []*Todo{{
Summary: "Todo",
UID: "uid",
Timestamp: ts,
Description: `<p>Hello <strong>bold</strong> and <mention-user data-id="user1" data-label="User One">@User One</mention-user></p>` +
`<ul data-type="taskList"><li data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p>done</p></div></li></ul>`,
}})
// iCal escapes the markdown's newlines as "\n".
assert.Contains(t, out, `DESCRIPTION:Hello **bold** and @user1\n\n- [x] done`)
})
t.Run("empty html omits the description line", func(t *testing.T) {
out := ParseTodos(cfg, []*Todo{{Summary: "Todo", UID: "uid", Timestamp: ts, Description: "<p></p>"}})
assert.NotContains(t, out, "DESCRIPTION:")
})
t.Run("plain text description is unaffected", func(t *testing.T) {
out := ParseTodos(cfg, []*Todo{{Summary: "Todo", UID: "uid", Timestamp: ts, Description: "just plain text"}})
assert.Contains(t, out, "DESCRIPTION:just plain text")
})
}
func TestGetCaldavColor(t *testing.T) { func TestGetCaldavColor(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@ -92,15 +92,14 @@ const (
AuthLdapVerifyTLS Key = `auth.ldap.verifytls` AuthLdapVerifyTLS Key = `auth.ldap.verifytls`
AuthLdapBindDN Key = `auth.ldap.binddn` AuthLdapBindDN Key = `auth.ldap.binddn`
// #nosec G101 // #nosec G101
AuthLdapBindPassword Key = `auth.ldap.bindpassword` AuthLdapBindPassword Key = `auth.ldap.bindpassword`
AuthLdapGroupSyncEnabled Key = `auth.ldap.groupsyncenabled` AuthLdapGroupSyncEnabled Key = `auth.ldap.groupsyncenabled`
AuthLdapGroupSyncFilter Key = `auth.ldap.groupsyncfilter` AuthLdapGroupSyncFilter Key = `auth.ldap.groupsyncfilter`
AuthLdapGroupSyncUseServiceAccount Key = `auth.ldap.groupsyncuseserviceaccount` AuthLdapAvatarSyncAttribute Key = `auth.ldap.avatarsyncattribute`
AuthLdapAvatarSyncAttribute Key = `auth.ldap.avatarsyncattribute` AuthLdapAttributeUsername Key = `auth.ldap.attribute.username`
AuthLdapAttributeUsername Key = `auth.ldap.attribute.username` AuthLdapAttributeEmail Key = `auth.ldap.attribute.email`
AuthLdapAttributeEmail Key = `auth.ldap.attribute.email` AuthLdapAttributeDisplayname Key = `auth.ldap.attribute.displayname`
AuthLdapAttributeDisplayname Key = `auth.ldap.attribute.displayname` AuthLdapAttributeMemberID Key = `auth.ldap.attribute.memberid`
AuthLdapAttributeMemberID Key = `auth.ldap.attribute.memberid`
LegalImprintURL Key = `legal.imprinturl` LegalImprintURL Key = `legal.imprinturl`
LegalPrivacyURL Key = `legal.privacyurl` LegalPrivacyURL Key = `legal.privacyurl`
@ -390,7 +389,6 @@ func InitDefaultConfig() {
AuthLdapVerifyTLS.setDefault(true) AuthLdapVerifyTLS.setDefault(true)
AuthLdapGroupSyncEnabled.setDefault(false) AuthLdapGroupSyncEnabled.setDefault(false)
AuthLdapGroupSyncFilter.setDefault("(&(objectclass=*)(|(objectclass=group)(objectclass=groupOfNames)))") AuthLdapGroupSyncFilter.setDefault("(&(objectclass=*)(|(objectclass=group)(objectclass=groupOfNames)))")
AuthLdapGroupSyncUseServiceAccount.setDefault(false)
AuthLdapAttributeUsername.setDefault("uid") AuthLdapAttributeUsername.setDefault("uid")
AuthLdapAttributeEmail.setDefault("mail") AuthLdapAttributeEmail.setDefault("mail")
AuthLdapAttributeDisplayname.setDefault("displayName") AuthLdapAttributeDisplayname.setDefault("displayName")

View File

@ -74,13 +74,6 @@ func stripAPIVersion(path string) string {
return path return path
} }
// canonicalAPITokenGroup snake_cases a permission group name. The frontend
// snake_cases request payloads, so a hyphenated group slug (e.g. from
// /api/v2/time-entries) can't round-trip and fails validation on save.
func canonicalAPITokenGroup(group string) string {
return strings.ReplaceAll(group, "-", "_")
}
func getRouteGroupName(path string) (finalName string, filteredParts []string) { func getRouteGroupName(path string) (finalName string, filteredParts []string) {
parts := strings.Split(stripAPIVersion(path), "/") parts := strings.Split(stripAPIVersion(path), "/")
filteredParts = []string{} filteredParts = []string{}
@ -89,7 +82,7 @@ func getRouteGroupName(path string) (finalName string, filteredParts []string) {
continue continue
} }
filteredParts = append(filteredParts, canonicalAPITokenGroup(part)) filteredParts = append(filteredParts, part)
} }
finalName = strings.Join(filteredParts, "_") finalName = strings.Join(filteredParts, "_")
@ -190,7 +183,7 @@ func isStandardCRUDRoute(routeGroupName string, routeParts []string, _ string) b
"comments": true, "comments": true,
"relations": true, "relations": true,
"attachments": true, "attachments": true,
"time_entries": true, "time-entries": true,
"projects_views": true, "projects_views": true,
"projects_teams": true, "projects_teams": true,
"projects_users": true, "projects_users": true,
@ -410,8 +403,7 @@ func CanDoAPIRoute(c *echo.Context, token *APIToken) (can bool) {
} }
method := c.Request().Method method := c.Request().Method
for rawGroup, perms := range token.APIPermissions { for group, perms := range token.APIPermissions {
group := canonicalAPITokenGroup(rawGroup)
tables := []APITokenRoute{apiTokenRoutes[group], apiTokenRoutesV2[group]} tables := []APITokenRoute{apiTokenRoutes[group], apiTokenRoutesV2[group]}
for _, routes := range tables { for _, routes := range tables {
if routes == nil { if routes == nil {
@ -435,8 +427,7 @@ func CanDoAPIRoute(c *echo.Context, token *APIToken) (can bool) {
// Two list endpoints share tasks.read_all but only one // Two list endpoints share tasks.read_all but only one
// survives collection, so allow either explicitly. // survives collection, so allow either explicitly.
if group == "tasks" && p == "read_all" && method == http.MethodGet && if group == "tasks" && p == "read_all" && method == http.MethodGet &&
(path == "/api/v1/tasks" || path == "/api/v1/projects/:project/tasks" || (path == "/api/v1/tasks" || path == "/api/v1/projects/:project/tasks") {
path == "/api/v2/tasks" || path == "/api/v2/projects/:project/tasks") {
return true return true
} }
} }
@ -456,9 +447,8 @@ func PermissionsAreValid(permissions APIPermissions) (err error) {
// resources (no v1 counterpart) live solely in apiTokenRoutesV2, so // resources (no v1 counterpart) live solely in apiTokenRoutesV2, so
// validating against the union lets tokens grant them. CanDoAPIRoute // validating against the union lets tokens grant them. CanDoAPIRoute
// already consults both tables when authorising. // already consults both tables when authorising.
group := canonicalAPITokenGroup(key) v1Routes := apiTokenRoutes[key]
v1Routes := apiTokenRoutes[group] v2Routes := apiTokenRoutesV2[key]
v2Routes := apiTokenRoutesV2[group]
if v1Routes == nil && v2Routes == nil { if v1Routes == nil && v2Routes == nil {
return &ErrInvalidAPITokenPermission{ return &ErrInvalidAPITokenPermission{
Group: key, Group: key,

View File

@ -121,9 +121,9 @@ func TestCollectRoutesV2(t *testing.T) {
assert.Equal(t, "DELETE", labels["delete"].Method) assert.Equal(t, "DELETE", labels["delete"].Method)
} }
// TestCollectRoutes_TimeEntriesV2 pins the v2-only time-entries resource to a // TestCollectRoutes_TimeEntriesV2 verifies the v2-only time-entries resource
// snake_case "time_entries" group (not the "other" catch-all, not a hyphenated // lands under a clean "time-entries" group rather than the "other" catch-all,
// key the frontend's snake_case transform would mangle on save). // so its scopes read sensibly for token clients.
func TestCollectRoutes_TimeEntriesV2(t *testing.T) { func TestCollectRoutes_TimeEntriesV2(t *testing.T) {
apiTokenRoutes = make(map[string]APITokenRoute) apiTokenRoutes = make(map[string]APITokenRoute)
apiTokenRoutesV2 = make(map[string]APITokenRoute) apiTokenRoutesV2 = make(map[string]APITokenRoute)
@ -137,11 +137,8 @@ func TestCollectRoutes_TimeEntriesV2(t *testing.T) {
_, isOther := apiTokenRoutesV2["other"] _, isOther := apiTokenRoutesV2["other"]
assert.False(t, isOther, "time-entries CRUD must not fall into the 'other' bucket") assert.False(t, isOther, "time-entries CRUD must not fall into the 'other' bucket")
_, hyphenated := apiTokenRoutesV2["time-entries"] te, has := apiTokenRoutesV2["time-entries"]
assert.False(t, hyphenated, "group key must be canonicalised to snake_case") require.True(t, has, "time-entries group should exist in the v2 table")
te, has := apiTokenRoutesV2["time_entries"]
require.True(t, has, "time_entries group should exist in the v2 table")
assert.Equal(t, "GET", te["read_all"].Method) assert.Equal(t, "GET", te["read_all"].Method)
assert.Equal(t, "/api/v2/time-entries", te["read_all"].Path) assert.Equal(t, "/api/v2/time-entries", te["read_all"].Path)
assert.Equal(t, "GET", te["read_one"].Method) assert.Equal(t, "GET", te["read_one"].Method)
@ -151,7 +148,7 @@ func TestCollectRoutes_TimeEntriesV2(t *testing.T) {
} }
// TestGetAPITokenRoutes_ExposesV2Only verifies the /routes payload merges // TestGetAPITokenRoutes_ExposesV2Only verifies the /routes payload merges
// v2-only groups (time_entries has no v1 counterpart) so token clients can // v2-only groups (time-entries has no v1 counterpart) so token clients can
// discover and grant them, without mutating the v1 table itself. // discover and grant them, without mutating the v1 table itself.
func TestGetAPITokenRoutes_ExposesV2Only(t *testing.T) { func TestGetAPITokenRoutes_ExposesV2Only(t *testing.T) {
apiTokenRoutes = make(map[string]APITokenRoute) apiTokenRoutes = make(map[string]APITokenRoute)
@ -165,35 +162,14 @@ func TestGetAPITokenRoutes_ExposesV2Only(t *testing.T) {
_, hasLabels := routes["labels"] _, hasLabels := routes["labels"]
assert.True(t, hasLabels, "v1 groups stay exposed") assert.True(t, hasLabels, "v1 groups stay exposed")
te, hasTE := routes["time_entries"] te, hasTE := routes["time-entries"]
require.True(t, hasTE, "v2-only time_entries must be exposed via /routes") require.True(t, hasTE, "v2-only time-entries must be exposed via /routes")
assert.Equal(t, "GET", te["read_all"].Method) assert.Equal(t, "GET", te["read_all"].Method)
_, v1HasTE := apiTokenRoutes["time_entries"] _, v1HasTE := apiTokenRoutes["time-entries"]
assert.False(t, v1HasTE, "the merge must not mutate the v1 table") assert.False(t, v1HasTE, "the merge must not mutate the v1 table")
} }
// TestCanDoAPIRoute_TimeEntriesHyphenLegacy proves a token stored under the old
// hyphenated "time-entries" key still validates and authorises — no migration.
func TestCanDoAPIRoute_TimeEntriesHyphenLegacy(t *testing.T) {
apiTokenRoutes = make(map[string]APITokenRoute)
apiTokenRoutesV2 = make(map[string]APITokenRoute)
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/time-entries"}, true)
for _, key := range []string{"time_entries", "time-entries"} {
t.Run(key, func(t *testing.T) {
perms := APIPermissions{key: []string{"read_all"}}
require.NoError(t, PermissionsAreValid(perms), "%s must validate", key)
token := &APIToken{APIPermissions: perms}
req := httptest.NewRequest("GET", "/api/v2/time-entries", nil)
c := echo.New().NewContext(req, httptest.NewRecorder())
assert.True(t, CanDoAPIRoute(c, token), "%s must authorise", key)
})
}
}
// TestGetRouteDetail_V2Verbs verifies the v2 verb mapping: POST→create, // TestGetRouteDetail_V2Verbs verifies the v2 verb mapping: POST→create,
// PUT/PATCH→update. v1 inverts POST and PUT so we need a separate mapping // PUT/PATCH→update. v1 inverts POST and PUT so we need a separate mapping
// path. // path.
@ -270,40 +246,6 @@ func TestCanDoAPIRoute_V2PatchAliasesPut(t *testing.T) {
}) })
} }
// TestCanDoAPIRoute_V2TasksReadAll verifies that tasks.read_all authorises
// both the global /api/v2/tasks and project-scoped /api/v2/projects/:project/tasks
// endpoints. Both normalise to tasks.read_all via getRouteGroupName, but only
// one RouteDetail survives in the map — the special case in CanDoAPIRoute must
// accept either path.
func TestCanDoAPIRoute_V2TasksReadAll(t *testing.T) {
apiTokenRoutes = make(map[string]APITokenRoute)
apiTokenRoutesV2 = make(map[string]APITokenRoute)
apiTokenRoutes["caldav"] = APITokenRoute{
"access": &RouteDetail{Path: "/dav/*", Method: "ANY"},
}
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/tasks"}, true)
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/projects/:project/tasks"}, true)
token := &APIToken{
APIPermissions: APIPermissions{"tasks": []string{"read_all"}},
}
e := echo.New()
t.Run("global /api/v2/tasks", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/v2/tasks", nil)
c := e.NewContext(req, httptest.NewRecorder())
assert.True(t, CanDoAPIRoute(c, token))
})
t.Run("project-scoped /api/v2/projects/:project/tasks", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/v2/projects/:project/tasks", nil)
c := e.NewContext(req, httptest.NewRecorder())
assert.True(t, CanDoAPIRoute(c, token))
})
}
// End-to-end CanDoAPIRoute coverage for /api/v2 is provided by the Label // End-to-end CanDoAPIRoute coverage for /api/v2 is provided by the Label
// integration test in pkg/webtests/huma_label_test.go (see the token-auth // integration test in pkg/webtests/huma_label_test.go (see the token-auth
// scenarios in that file) which exercises the full auth pipeline. // scenarios in that file) which exercises the full auth pipeline.

View File

@ -69,7 +69,7 @@ func (r *Permission) UnmarshalJSON(data []byte) error {
case 2: case 2:
*r = PermissionAdmin *r = PermissionAdmin
default: default:
return fmt.Errorf("invalid Permission %d", s) return fmt.Errorf("invalid Permission %q", s)
} }
return nil return nil
} }

View File

@ -760,15 +760,6 @@ func getRawProjectsForUser(s *xorm.Session, opts *projectOptions) (projects []*P
return allProjects, len(allProjects), totalItems, err return allProjects, len(allProjects), totalItems, err
} }
func CreateDefaultSavedFiltersForUser(s *xorm.Session, u *user.User) error {
sf := &SavedFilter{
Title: "My Open Tasks",
Filters: &TaskCollection{Filter: fmt.Sprintf("done = false && assignees = %s", u.Username)},
}
return sf.Create(s, u)
}
func getSavedFilterProjects(s *xorm.Session, doer *user.User, search string) (savedFiltersProjects []*Project, err error) { func getSavedFilterProjects(s *xorm.Session, doer *user.User, search string) (savedFiltersProjects []*Project, err error) {
savedFilters, err := getSavedFiltersForUser(s, doer, search) savedFilters, err := getSavedFiltersForUser(s, doer, search)
if err != nil { if err != nil {
@ -1117,10 +1108,6 @@ func RegisterUser(s *xorm.Session, u *user.User) (*user.User, error) {
return nil, err return nil, err
} }
if err := CreateDefaultSavedFiltersForUser(s, newUser); err != nil {
return nil, err
}
return newUser, nil return newUser, nil
} }

View File

@ -142,6 +142,7 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection, projectView *ProjectVie
opts = &taskSearchOptions{ opts = &taskSearchOptions{
sortby: sort, sortby: sort,
userProvidedSort: len(tf.SortBy) > 0,
filterIncludeNulls: tf.FilterIncludeNulls, filterIncludeNulls: tf.FilterIncludeNulls,
filter: tf.Filter, filter: tf.Filter,
filterTimezone: tf.FilterTimezone, filterTimezone: tf.FilterTimezone,

View File

@ -321,12 +321,12 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
// Then return all tasks for that projects // Then return all tasks for that projects
var where builder.Cond var where builder.Cond
searchIndex := getTaskIndexFromSearchString(opts.search)
if opts.search != "" { if opts.search != "" {
where = db.MultiFieldSearchWithTableAlias([]string{"title", "description"}, opts.search, "tasks") where = db.MultiFieldSearchWithTableAlias([]string{"title", "description"}, opts.search, "tasks")
searchIndex := getTaskIndexFromSearchString(opts.search)
if searchIndex > 0 { if searchIndex > 0 {
where = builder.Or(where, builder.Eq{"`index`": searchIndex}) where = builder.Or(where, builder.Eq{"tasks.`index`": searchIndex})
} }
} }
@ -374,9 +374,32 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
)) ))
} }
query := d.s. // ParadeDB exposes the BM25 relevance score via pdb.score(tasks.id) for a query
Distinct(distinct). // containing a ParadeDB operator (the ||| from MultiFieldSearch qualifies). When
Where(cond) // searching without an explicit user sort, order by relevance so tasks matching
// all query words rank above tasks matching only some.
//
// This is limited to pure-text searches over a plain project scope: numeric
// searches add an `OR index = N` branch and the Favorites view scopes on an
// `id IN (<subquery>)`, both of which pdb.score rejects as unsupported query
// shapes. Those keep the default ordering (unranked). pdb.score is also invalid
// SQL on sqlite/mysql/plain postgres, hence the ParadeDBAvailable() gate.
rankByRelevance := db.ParadeDBAvailable() &&
opts.search != "" &&
!opts.userProvidedSort &&
searchIndex == 0 &&
!d.hasFavoritesProject
query := d.s.Where(cond)
if rankByRelevance {
// Select() passes the raw column list through untouched while Distinct()
// (no args) still emits DISTINCT. Distinct("tasks.*, pdb.score(tasks.id)")
// would quote-corrupt the function call into "pdb"."score(tasks"."id)".
query = query.Select(distinct + ", pdb.score(tasks.id)").Distinct()
orderby = "pdb.score(tasks.id) DESC, " + orderby
} else {
query = query.Distinct(distinct)
}
if limit > 0 { if limit > 0 {
query = query.Limit(limit, start) query = query.Limit(limit, start)
} }

View File

@ -54,3 +54,91 @@ func TestKanbanViewBucketFiltering(t *testing.T) {
assert.NotContains(t, taskBuckets, id) assert.NotContains(t, taskBuckets, id)
} }
} }
// TestTaskSearchRelevanceRanking verifies that a multi-word search ranks the task
// matching all words above tasks matching only some. The ranking is BM25-based and
// therefore only enforced on ParadeDB; on other databases we only assert that the
// matching tasks are returned (no order guarantee), keeping the test green across
// the whole CI database matrix.
func TestTaskSearchRelevanceRanking(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
usr := &user.User{ID: 1}
allWords := &Task{Title: "Backup server migration", ProjectID: 1}
require.NoError(t, allWords.Create(s, usr))
oneWordA := &Task{Title: "Backup of old files", ProjectID: 1}
require.NoError(t, oneWordA.Create(s, usr))
oneWordB := &Task{Title: "server room booking", ProjectID: 1}
require.NoError(t, oneWordB.Create(s, usr))
assertRelevanceRanked := func(t *testing.T, tc *TaskCollection) {
got, _, _, err := tc.ReadAll(s, usr, "backup server", 0, 50)
require.NoError(t, err)
gotTasks, is := got.([]*Task)
require.True(t, is)
gotIDs := make([]int64, len(gotTasks))
for i, tsk := range gotTasks {
gotIDs[i] = tsk.ID
}
require.Contains(t, gotIDs, allWords.ID, "the task matching all words should be returned")
if db.ParadeDBAvailable() {
require.NotEmpty(t, gotTasks)
assert.Equal(t, allWords.ID, gotTasks[0].ID, "task matching all query words should rank first by BM25 relevance")
}
}
// Without a view: plain "tasks.*, pdb.score(tasks.id)" select.
t.Run("no view", func(t *testing.T) {
assertRelevanceRanked(t, &TaskCollection{ProjectID: 1})
})
// With a view: exercises the task_positions LEFT JOIN, which adds
// task_positions.position to the DISTINCT select alongside pdb.score(tasks.id).
t.Run("list view", func(t *testing.T) {
assertRelevanceRanked(t, &TaskCollection{ProjectID: 1, ProjectViewID: 1})
})
// An explicit sort_by must win over relevance: with `id desc` the lowest-id
// task (allWords) ranks last, the opposite of what BM25 relevance would do.
// This locks the contract that user-provided sorting disables relevance
// ranking even on ParadeDB. Only ParadeDB's per-token search matches all
// three tasks, so the ordering contract is only asserted there (other
// databases ILIKE the whole phrase and match a different subset).
t.Run("explicit sort disables relevance ranking", func(t *testing.T) {
if !db.ParadeDBAvailable() {
t.Skip("relevance ranking only applies on ParadeDB")
}
tc := &TaskCollection{
ProjectID: 1,
SortBy: []string{"id"},
OrderBy: []string{"desc"},
}
got, _, _, err := tc.ReadAll(s, usr, "backup server", 0, 50)
require.NoError(t, err)
gotTasks, is := got.([]*Task)
require.True(t, is)
created := map[int64]bool{allWords.ID: true, oneWordA.ID: true, oneWordB.ID: true}
var orderedIDs []int64
for _, tsk := range gotTasks {
if created[tsk.ID] {
orderedIDs = append(orderedIDs, tsk.ID)
}
}
require.Len(t, orderedIDs, len(created), "all created tasks should match the search")
for i := 1; i < len(orderedIDs); i++ {
assert.Greater(t, orderedIDs[i-1], orderedIDs[i], "tasks must follow the explicit id-desc sort, not relevance")
}
assert.Equal(t, allWords.ID, orderedIDs[len(orderedIDs)-1], "the all-words match (lowest id) ranks last under id-desc, proving relevance was not applied")
})
}

View File

@ -214,6 +214,10 @@ type taskSearchOptions struct {
projectIDs []int64 projectIDs []int64
expand []TaskCollectionExpandable expand []TaskCollectionExpandable
projectViewID int64 projectViewID int64
// userProvidedSort distinguishes an explicit sort_by from the id/position
// defaults appended later, so relevance ordering only replaces the default sort.
userProvidedSort bool
} }
// ReadAll is a dummy function to still have that endpoint documented // ReadAll is a dummy function to still have that endpoint documented

View File

@ -250,23 +250,6 @@ func AuthenticateUserInLDAP(s *xorm.Session, username, password string, syncGrou
return return
} }
// After verifying the user's password above the connection is bound as the
// end user. Many directories restrict group searches to service accounts, so
// re-bind as the service account before enumerating groups when configured.
if config.AuthLdapGroupSyncUseServiceAccount.GetBool() {
bindDN := config.AuthLdapBindDN.GetString()
bindPassword := config.AuthLdapBindPassword.GetString()
if bindDN != "" && bindPassword != "" {
if err = l.Bind(bindDN, bindPassword); err != nil {
return nil, fmt.Errorf("could not re-bind service account for group sync: %w", err)
}
} else {
if err = l.UnauthenticatedBind(""); err != nil {
return nil, fmt.Errorf("could not re-bind anonymously for group sync: %w", err)
}
}
}
err = syncUserGroups(s, l, u, userdn) err = syncUserGroups(s, l, u, userdn)
return u, err return u, err

View File

@ -104,64 +104,6 @@ func TestLdapLogin(t *testing.T) {
}, false) }, false)
}) })
t.Run("should sync groups using service account rebind", func(t *testing.T) {
// Verifies that re-binding as the service account before the group
// search works correctly — the fix for directories where regular users
// cannot enumerate group membership.
origFlag := config.AuthLdapGroupSyncUseServiceAccount.GetBool()
config.AuthLdapGroupSyncUseServiceAccount.Set(true)
defer config.AuthLdapGroupSyncUseServiceAccount.Set(origFlag)
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
user, err := AuthenticateUserInLDAP(s, "professor", "professor", true, "")
require.NoError(t, err)
assert.Equal(t, "professor", user.Username)
require.NoError(t, s.Commit())
db.AssertExists(t, "teams", map[string]interface{}{
"name": "admin_staff (LDAP)",
"issuer": "ldap",
"external_id": "cn=admin_staff,ou=people,dc=planetexpress,dc=com",
}, false)
db.AssertExists(t, "teams", map[string]interface{}{
"name": "git (LDAP)",
"issuer": "ldap",
"external_id": "cn=git,ou=people,dc=planetexpress,dc=com",
}, false)
})
t.Run("should sync groups using user binding", func(t *testing.T) {
// Verifies the flag=false path where the connection stays bound as the
// authenticated user during the group search. Works on directories that
// grant regular users read access to group objects.
origFlag := config.AuthLdapGroupSyncUseServiceAccount.GetBool()
config.AuthLdapGroupSyncUseServiceAccount.Set(false)
defer config.AuthLdapGroupSyncUseServiceAccount.Set(origFlag)
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
user, err := AuthenticateUserInLDAP(s, "professor", "professor", true, "")
require.NoError(t, err)
assert.Equal(t, "professor", user.Username)
require.NoError(t, s.Commit())
db.AssertExists(t, "teams", map[string]interface{}{
"name": "admin_staff (LDAP)",
"issuer": "ldap",
"external_id": "cn=admin_staff,ou=people,dc=planetexpress,dc=com",
}, false)
db.AssertExists(t, "teams", map[string]interface{}{
"name": "git (LDAP)",
"issuer": "ldap",
"external_id": "cn=git,ou=people,dc=planetexpress,dc=com",
}, false)
})
t.Run("should sync avatar when enabled", func(t *testing.T) { t.Run("should sync avatar when enabled", func(t *testing.T) {
db.LoadAndAssertFixtures(t) db.LoadAndAssertFixtures(t)
s := db.NewSession() s := db.NewSession()

View File

@ -377,46 +377,6 @@ func syncUserAvatarFromOpenID(s *xorm.Session, u *user.User, pictureURL string)
return nil return nil
} }
// fallbackSearchUsers builds the ordered list of local-user lookups used to link an OIDC
// login to an existing account when the provider has email and/or username fallback enabled.
// GetUserWithEmail ANDs all non-zero fields, so the email (when set) is combined with each
// username candidate.
func fallbackSearchUsers(cl *claims, provider *Provider, idToken *oidc.IDToken) []*user.User {
fallbackEmail := ""
if provider.EmailFallback {
// Used alone, allow for someone to connect from various provider to the same account.
// Discouraged for untrusted providers where someone can set email without verification.
// Note: mapping on email prevents auto-updating the user email.
fallbackEmail = cl.Email
}
// Try the subject first (keeps working for IdPs where sub == username), then the
// preferred_username. The latter lets providers with an opaque sub (e.g. a random
// UUID, like PocketID) still link to an existing local account.
var searches []*user.User
if provider.UsernameFallback {
// Skip empty username candidates: GetUserWithEmail ANDs only non-zero fields, so a
// {Issuer, Username:"", Email:""} would degenerate to an issuer-only lookup and link
// an arbitrary local user. idToken.Subject is non-empty per OIDC, but guard anyway.
if idToken.Subject != "" {
searches = append(searches, &user.User{Issuer: user.IssuerLocal, Username: idToken.Subject, Email: fallbackEmail})
}
preferred := strings.ReplaceAll(cl.PreferredUsername, " ", "-")
if preferred != "" && preferred != idToken.Subject {
searches = append(searches, &user.User{Issuer: user.IssuerLocal, Username: preferred, Email: fallbackEmail})
}
}
// EmailFallback without UsernameFallback: a single email-only lookup (the caller only
// runs this when at least one fallback is enabled, so EmailFallback is guaranteed here).
// Only add it when there is a real email — an empty email would degenerate to an
// issuer-only lookup and link an arbitrary local user.
if len(searches) == 0 && cl.Email != "" {
searches = append(searches, &user.User{Issuer: user.IssuerLocal, Email: cl.Email})
}
return searches
}
func getOrCreateUser(s *xorm.Session, cl *claims, provider *Provider, idToken *oidc.IDToken) (u *user.User, err error) { func getOrCreateUser(s *xorm.Session, cl *claims, provider *Provider, idToken *oidc.IDToken) (u *user.User, err error) {
// set defaults // set defaults
@ -442,21 +402,33 @@ func getOrCreateUser(s *xorm.Session, cl *claims, provider *Provider, idToken *o
if !alreadyCreatedFromIssuer && (provider.EmailFallback || provider.UsernameFallback) { if !alreadyCreatedFromIssuer && (provider.EmailFallback || provider.UsernameFallback) {
// try finding the user on fallback mapping properties // try finding the user on fallback mappingproperties
for _, searchUser := range fallbackSearchUsers(cl, provider, idToken) {
u, err = user.GetUserWithEmail(s, searchUser)
if err != nil && !user.IsErrUserDoesNotExist(err) && !user.IsErrUserStatusError(err) {
return nil, err
}
fallbackMatchFound = err == nil || user.IsErrUserStatusError(err)
// Same as above: disabled/locked user found via fallback — return early. searchUser := &user.User{
if fallbackMatchFound && user.IsErrUserStatusError(err) { Issuer: user.IssuerLocal,
return u, nil }
} if provider.UsernameFallback {
if fallbackMatchFound { // Match oidc subject on username as each is unique identifier in its own referential
break // Discouraged if multiple account providers are used.
} searchUser.Username = idToken.Subject
}
if provider.EmailFallback {
// Used alone, allow for someone to connect from various provider to the same account
// Discouraged for untrusted provider where someone can set email without verification
// Note : mapping on email prevent from auto-updating user email
searchUser.Email = cl.Email
}
// Check if the user exists for the given fallback matching options
u, err = user.GetUserWithEmail(s, searchUser)
if err != nil && !user.IsErrUserDoesNotExist(err) && !user.IsErrUserStatusError(err) {
return nil, err
}
fallbackMatchFound = err == nil || user.IsErrUserStatusError(err)
// Same as above: disabled/locked user found via fallback — return early.
if fallbackMatchFound && user.IsErrUserStatusError(err) {
return u, nil
} }
} }

View File

@ -254,61 +254,11 @@ func TestGetOrCreateUser(t *testing.T) {
assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one") assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
assert.Equal(t, 11, int(u.ID), "user id 11 expected") assert.Equal(t, 11, int(u.ID), "user id 11 expected")
}) })
t.Run("ProviderFallback: Match to existing local user on preferred_username when sub differs", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
cl := &claims{
PreferredUsername: "user11",
}
provider := &Provider{
UsernameFallback: true,
}
// PocketID-style: the subject is an opaque UUID that does not match any local username.
idToken := &oidc.IDToken{Issuer: "https://some.issuer", Subject: "c0ffee00-dead-beef-cafe-000000000011"}
u, err := getOrCreateUser(s, cl, provider, idToken)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
assert.Equal(t, "user11", u.Username, "should link to the local user matching preferred_username")
assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
assert.Equal(t, 11, int(u.ID), "user id 11 expected")
// No duplicate user must be created for the opaque subject.
db.AssertMissing(t, "users", map[string]interface{}{
"subject": idToken.Subject,
})
})
t.Run("ProviderFallback: Falls back to sub when preferred_username is empty", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
cl := &claims{
PreferredUsername: "",
}
provider := &Provider{
UsernameFallback: true,
}
idToken := &oidc.IDToken{Issuer: "https://some.issuer", Subject: "user11"}
u, err := getOrCreateUser(s, cl, provider, idToken)
require.NoError(t, err)
assert.Equal(t, idToken.Subject, u.Username, "subject should match username")
assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
assert.Equal(t, 11, int(u.ID), "user id 11 expected")
})
t.Run("ProviderFallback: Match to existing local user on email", func(t *testing.T) { t.Run("ProviderFallback: Match to existing local user on email", func(t *testing.T) {
db.LoadAndAssertFixtures(t) db.LoadAndAssertFixtures(t)
s := db.NewSession() s := db.NewSession()
defer s.Close() defer s.Close()
usersBefore, err := s.Count(&user.User{})
require.NoError(t, err)
cl := &claims{ cl := &claims{
Email: "user11@example.com", Email: "user11@example.com",
} }
@ -322,42 +272,6 @@ func TestGetOrCreateUser(t *testing.T) {
assert.Equal(t, cl.Email, u.Email, "email should match") assert.Equal(t, cl.Email, u.Email, "email should match")
assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one") assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
assert.Equal(t, 11, int(u.ID), "user id 11 expected") assert.Equal(t, 11, int(u.ID), "user id 11 expected")
// The email-only fallback must link the existing user, not create a duplicate.
usersAfter, err := s.Count(&user.User{})
require.NoError(t, err)
assert.Equal(t, usersBefore, usersAfter, "no new user should have been created")
})
t.Run("ProviderFallback: empty email claim does not link to an arbitrary local user", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
usersBefore, err := s.Count(&user.User{})
require.NoError(t, err)
// EmailFallback on, no username fallback, and the IdP sent no email claim. The
// email-only search must not degenerate to an issuer-only lookup matching an
// arbitrary local user. With no email there is nothing safe to match on, so the
// flow falls through to user creation (which then errors because an email is
// required) rather than silently linking an existing local account.
cl := &claims{
Email: "",
PreferredUsername: "brandNewOidcUser",
}
provider := &Provider{
EmailFallback: true,
}
idToken := &oidc.IDToken{Issuer: "https://some.issuer", Subject: "opaque-subject-no-email"}
u, err := getOrCreateUser(s, cl, provider, idToken)
// Must not have linked an existing local user.
require.Error(t, err, "an empty email must not silently link an existing local user")
assert.Nil(t, u, "no existing local user should be returned for an empty email claim")
usersAfter, err := s.Count(&user.User{})
require.NoError(t, err)
assert.Equal(t, usersBefore, usersAfter, "no user should have been linked or created from an empty email claim")
}) })
t.Run("ProviderFallback: Match to existing local user on username and email", func(t *testing.T) { t.Run("ProviderFallback: Match to existing local user on username and email", func(t *testing.T) {

View File

@ -22,10 +22,8 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings"
"time" "time"
"code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/config"
@ -255,72 +253,6 @@ func parseDate(dateString string) (date time.Time, err error) {
return date, err return date, err
} }
// Matching the existing migration importers, months are treated as 30 days and years as 365.
const (
secondsPerDay int64 = 60 * 60 * 24
secondsPerWeek = secondsPerDay * 7
secondsPerMonth = secondsPerDay * 30
secondsPerYear = secondsPerDay * 365
)
var repeatUnitSeconds = map[string]int64{
"day": secondsPerDay,
"week": secondsPerWeek,
"month": secondsPerMonth,
"year": secondsPerYear,
}
var (
todoistRepeatRegex = regexp.MustCompile(`^(?:every\s+)?(?:(\d+)\s+|(other)\s+)?(day|week|month|year)s?$`)
todoistRepeatTimeRegex = regexp.MustCompile(`\s+(?:at|@)\s+.*$`)
)
// parseTodoistRepeat translates Todoist's recurrence into a repeat interval in seconds.
// Todoist exposes recurrence only as free text (e.g. "every 3 weeks"), so we parse the
// common, unambiguous interval phrases. Patterns we can't represent (specific weekdays,
// days of the month, non-English strings) return 0, leaving the task non-repeating. Only
// the cadence is kept - the due date already anchors the actual day and time.
func parseTodoistRepeat(due *dueDate) int64 {
if due == nil || !due.IsRecurring {
return 0
}
s := strings.ToLower(strings.TrimSpace(due.String))
// The time of day is already on the due date, drop it so "every day at 9am" still matches.
s = todoistRepeatTimeRegex.ReplaceAllString(s, "")
switch s {
case "daily":
return secondsPerDay
case "weekly":
return secondsPerWeek
case "monthly":
return secondsPerMonth
case "yearly", "annually":
return secondsPerYear
}
matches := todoistRepeatRegex.FindStringSubmatch(s)
if matches == nil {
log.Debugf("[Todoist Migration] Could not parse recurrence %q, leaving task non-repeating", due.String)
return 0
}
interval := int64(1)
switch {
case matches[1] != "":
n, err := strconv.ParseInt(matches[1], 10, 64)
if err != nil || n < 1 {
return 0
}
interval = n
case matches[2] == "other":
interval = 2
}
return interval * repeatUnitSeconds[matches[3]]
}
func convertTodoistToVikunja(sync *sync, doneItems map[string]*doneItem) (fullVikunjaHierachie []*models.ProjectWithTasksAndBuckets, err error) { func convertTodoistToVikunja(sync *sync, doneItems map[string]*doneItem) (fullVikunjaHierachie []*models.ProjectWithTasksAndBuckets, err error) {
var pseudoParentID int64 = 1 var pseudoParentID int64 = 1
@ -426,7 +358,6 @@ func convertTodoistToVikunja(sync *sync, doneItems map[string]*doneItem) (fullVi
return nil, err return nil, err
} }
task.DueDate = dueDate.In(config.GetTimeZone()) task.DueDate = dueDate.In(config.GetTimeZone())
task.RepeatAfter = parseTodoistRepeat(i.Due)
} }
// Put all labels together from earlier // Put all labels together from earlier

View File

@ -651,47 +651,3 @@ func TestConvertTodoistToVikunja(t *testing.T) {
t.Errorf("converted todoist data = %v, want %v, diff: %v", hierachie, expectedHierachie, diff) t.Errorf("converted todoist data = %v, want %v, diff: %v", hierachie, expectedHierachie, diff)
} }
} }
func TestParseTodoistRepeat(t *testing.T) {
tests := []struct {
name string
due *dueDate
want int64
}{
{name: "nil due", due: nil, want: 0},
{name: "not recurring", due: &dueDate{String: "every day", IsRecurring: false}, want: 0},
{name: "every day", due: &dueDate{String: "every day", IsRecurring: true}, want: secondsPerDay},
{name: "daily", due: &dueDate{String: "daily", IsRecurring: true}, want: secondsPerDay},
{name: "every other day", due: &dueDate{String: "every other day", IsRecurring: true}, want: 2 * secondsPerDay},
{name: "every 3 days", due: &dueDate{String: "every 3 days", IsRecurring: true}, want: 3 * secondsPerDay},
{name: "every week", due: &dueDate{String: "every week", IsRecurring: true}, want: secondsPerWeek},
{name: "weekly", due: &dueDate{String: "weekly", IsRecurring: true}, want: secondsPerWeek},
{name: "every other week", due: &dueDate{String: "every other week", IsRecurring: true}, want: 2 * secondsPerWeek},
{name: "every 2 weeks", due: &dueDate{String: "every 2 weeks", IsRecurring: true}, want: 2 * secondsPerWeek},
{name: "every month", due: &dueDate{String: "every month", IsRecurring: true}, want: secondsPerMonth},
{name: "monthly", due: &dueDate{String: "monthly", IsRecurring: true}, want: secondsPerMonth},
{name: "every 3 months", due: &dueDate{String: "every 3 months", IsRecurring: true}, want: 3 * secondsPerMonth},
{name: "every year", due: &dueDate{String: "every year", IsRecurring: true}, want: secondsPerYear},
{name: "yearly", due: &dueDate{String: "yearly", IsRecurring: true}, want: secondsPerYear},
{name: "annually", due: &dueDate{String: "annually", IsRecurring: true}, want: secondsPerYear},
{name: "case insensitive", due: &dueDate{String: "Every Day", IsRecurring: true}, want: secondsPerDay},
{name: "time of day stripped", due: &dueDate{String: "every day at 9am", IsRecurring: true}, want: secondsPerDay},
// Tier 1 doesn't understand these, so the task stays non-repeating.
{name: "specific weekday", due: &dueDate{String: "every monday", IsRecurring: true}, want: 0},
{name: "day of month", due: &dueDate{String: "every 27th", IsRecurring: true}, want: 0},
{name: "non-english", due: &dueDate{String: "cada día", IsRecurring: true}, want: 0},
{name: "gibberish", due: &dueDate{String: "whenever", IsRecurring: true}, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, parseTodoistRepeat(tt.due))
})
}
}

View File

@ -1,64 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package richtext
import "strings"
// Changed reports whether inbound markdown differs semantically from stored
// rich-text HTML, so callers can skip rewriting unchanged fields (avoids CalDAV
// read-modify-write churning the HTML and bumping Updated). Both sides are
// canonicalized to markdown before comparing: HTML→markdown isn't an identity, so
// an HTML-domain compare would always report "changed". Errs to true.
func Changed(storedHTML, incomingMarkdown string) bool {
stored, err := HTMLToMarkdown(storedHTML)
if err != nil {
return true
}
incoming, err := canonicalMarkdown(incomingMarkdown)
if err != nil {
return true
}
return normalizeMarkdown(stored) != normalizeMarkdown(incoming)
}
// HTMLIsEmpty treats "", "<p></p>" and whitespace-only markup as empty.
func HTMLIsEmpty(htmlInput string) bool {
md, err := HTMLToMarkdown(htmlInput)
if err != nil {
return false
}
return md == ""
}
// canonicalMarkdown round-trips markdown through HTML so it matches the shape
// HTMLToMarkdown yields from stored HTML. No session needed: a <mention-user> tag
// and an inbound "@username" both reduce to "@username".
func canonicalMarkdown(md string) (string, error) {
h, err := MarkdownToHTML(md)
if err != nil {
return "", err
}
return HTMLToMarkdown(h)
}
func normalizeMarkdown(md string) string {
md = strings.ReplaceAll(md, "\r\n", "\n")
md = strings.ReplaceAll(md, "\r", "\n")
return strings.TrimSpace(md)
}

View File

@ -1,101 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package richtext
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestChanged(t *testing.T) {
tests := []struct {
name string
stored string
incoming string
want bool
}{
{
name: "markdown projection equals incoming",
stored: "<p>Hello <strong>world</strong></p>",
incoming: "Hello **world**",
want: false,
},
{
name: "genuinely edited",
stored: "<p>Hello <strong>world</strong></p>",
incoming: "Hello **mars**",
want: true,
},
{
name: "line ending only difference",
stored: "<p>line one</p><p>line two</p>",
incoming: "line one\r\n\r\nline two",
want: false,
},
{
name: "trailing whitespace only difference",
stored: "<p>same</p>",
incoming: "same\n\n ",
want: false,
},
{
name: "equivalent markdown flavors compare equal",
stored: "<p><em>x</em></p>",
incoming: "_x_",
want: false,
},
{
name: "empty stored vs empty incoming",
stored: "<p></p>",
incoming: "",
want: false,
},
{
name: "empty stored vs new content",
stored: "",
incoming: "now has text",
want: true,
},
{
name: "task list round trip unchanged",
stored: `<ul data-type="taskList"><li data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p>done</p></div></li></ul>`,
incoming: "- [x] done",
want: false,
},
{
name: "mention round trip unchanged",
stored: `<p>cc <mention-user data-id="user1" data-label="User One">@User One</mention-user></p>`,
incoming: "cc @user1",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, Changed(tt.stored, tt.incoming))
})
}
}
func TestHTMLIsEmpty(t *testing.T) {
assert.True(t, HTMLIsEmpty(""))
assert.True(t, HTMLIsEmpty("<p></p>"))
assert.True(t, HTMLIsEmpty(" "))
assert.True(t, HTMLIsEmpty("<p> </p>"))
assert.False(t, HTMLIsEmpty("<p>content</p>"))
}

View File

@ -1,59 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package richtext converts Vikunja's canonical rich-text HTML to and from
// Markdown at the API/CalDAV boundaries. Storage stays HTML; only the wire
// representation changes.
package richtext
import (
"fmt"
"strings"
"github.com/JohannesKaufmann/html-to-markdown/v2/converter"
"github.com/JohannesKaufmann/html-to-markdown/v2/plugin/base"
"github.com/JohannesKaufmann/html-to-markdown/v2/plugin/commonmark"
"github.com/JohannesKaufmann/html-to-markdown/v2/plugin/strikethrough"
"github.com/JohannesKaufmann/html-to-markdown/v2/plugin/table"
)
// HTMLToMarkdown converts rich-text HTML to GFM Markdown. Trimmed, so an empty
// document ("<p></p>") yields "".
func HTMLToMarkdown(htmlInput string) (string, error) {
md, err := newHTMLToMarkdownConverter().ConvertString(htmlInput)
if err != nil {
return "", fmt.Errorf("converting html to markdown: %w", err)
}
return strings.TrimSpace(md), nil
}
// newHTMLToMarkdownConverter builds a GFM converter. Per call: the registered
// handlers aren't safe for concurrent reuse, and conversion is cheap.
func newHTMLToMarkdownConverter() *converter.Converter {
conv := converter.NewConverter(
converter.WithPlugins(
base.NewBasePlugin(),
commonmark.NewCommonmarkPlugin(),
table.NewTablePlugin(),
strikethrough.NewStrikethroughPlugin(),
),
)
registerTipTapRules(conv)
return conv
}

View File

@ -1,111 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package richtext
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHTMLToMarkdown(t *testing.T) {
tests := []struct {
name string
html string
want string
}{
{
name: "heading",
html: "<h1>Title</h1>",
want: "# Title",
},
{
name: "bold and italic",
html: "<p><strong>bold</strong> and <em>italic</em></p>",
want: "**bold** and *italic*",
},
{
name: "link",
html: `<p>See <a href="https://vikunja.io">the site</a></p>`,
want: "See [the site](https://vikunja.io)",
},
{
name: "inline code",
html: "<p>run <code>mage build</code> first</p>",
want: "run `mage build` first",
},
{
name: "fenced code block keeps language",
html: `<pre><code class="language-go">fmt.Println("hi")</code></pre>`,
want: "```go\nfmt.Println(\"hi\")\n```",
},
{
name: "blockquote",
html: "<blockquote><p>quoted text</p></blockquote>",
want: "> quoted text",
},
{
name: "unordered list",
html: "<ul><li>one</li><li>two</li></ul>",
want: "- one\n- two",
},
{
name: "ordered list",
html: "<ol><li>one</li><li>two</li></ol>",
want: "1. one\n2. two",
},
{
name: "nested list",
html: "<ul><li>one<ul><li>nested</li></ul></li><li>two</li></ul>",
want: "- one\n \n - nested\n- two",
},
{
name: "gfm table",
html: "<table><thead><tr><th>a</th><th>b</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>",
want: "| a | b |\n|---|---|\n| 1 | 2 |",
},
{
name: "strikethrough",
html: "<p><del>gone</del></p>",
want: "~~gone~~",
},
{
name: "empty paragraph is empty string",
html: "<p></p>",
want: "",
},
{
name: "whitespace only is empty string",
html: "<p> </p>",
want: "",
},
{
name: "unknown element degrades without leaking tags",
html: "<p>hello <unknowntag>world</unknowntag></p>",
want: "hello world",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := HTMLToMarkdown(tt.html)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}

View File

@ -1,47 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package richtext
import (
"os"
"testing"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/user"
)
// TestMain bootstraps a test DB with user fixtures so the mention-resolution
// tests can look up real users. The pure converter tests don't touch the DB.
func TestMain(m *testing.M) {
log.InitLogger()
x, err := db.CreateTestEngine()
if err != nil {
log.Fatal(err)
}
if err := x.Sync2(user.GetTables()...); err != nil {
log.Fatal(err)
}
if err := db.InitTestFixtures("users"); err != nil {
log.Fatal(err)
}
os.Exit(m.Run())
}

View File

@ -1,76 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package richtext
import (
"bytes"
"fmt"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"xorm.io/xorm"
)
// markdownConverter renders GFM but never enables html.WithUnsafe() — raw HTML in
// the markdown stays inert, so the only active markup is what goldmark emits. This
// is what stops user-supplied markdown from smuggling in scripts.
var markdownConverter = goldmark.New(
goldmark.WithExtensions(extension.GFM),
)
// MarkdownToHTML converts GFM Markdown to canonical rich-text HTML, rewriting task
// lists into TipTap's <ul data-type="taskList"> form. Mentions are left as literal
// "@username" — see MarkdownToHTMLWithMentions to resolve them.
func MarkdownToHTML(md string) (string, error) {
return markdownToHTML(md, nil)
}
// MarkdownToHTMLWithMentions is MarkdownToHTML plus mention resolution: "@username"
// matching an existing user becomes a <mention-user> tag. Needs a session.
func MarkdownToHTMLWithMentions(s *xorm.Session, md string) (string, error) {
return markdownToHTML(md, s)
}
func markdownToHTML(md string, s *xorm.Session) (string, error) {
var buf bytes.Buffer
if err := markdownConverter.Convert([]byte(md), &buf); err != nil {
return "", fmt.Errorf("converting markdown to html: %w", err)
}
nodes, err := parseHTMLFragment(buf.Bytes())
if err != nil {
return "", err
}
for _, n := range nodes {
convertTaskListItems(n)
}
if s != nil {
if err := rebuildMentions(s, nodes); err != nil {
return "", err
}
}
out, err := renderHTMLNodes(nodes)
if err != nil {
return "", err
}
return strings.TrimSpace(out), nil
}

View File

@ -1,100 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package richtext
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMarkdownToHTML(t *testing.T) {
tests := []struct {
name string
md string
want string
}{
{
name: "heading and bold",
md: "# Title\n\nsome **bold** text",
want: "<h1>Title</h1>\n<p>some <strong>bold</strong> text</p>",
},
{
name: "link",
md: "see [the site](https://vikunja.io)",
want: `<p>see <a href="https://vikunja.io">the site</a></p>`,
},
{
name: "task list becomes tiptap dom",
md: "- [x] done\n- [ ] todo",
want: "<ul data-type=\"taskList\">\n<li data-type=\"taskItem\" data-checked=\"true\"><p>done</p></li>\n<li data-type=\"taskItem\" data-checked=\"false\"><p>todo</p></li>\n</ul>",
},
{
name: "nested task list",
md: "- [ ] parent\n - [x] child",
want: "<ul data-type=\"taskList\">\n<li data-type=\"taskItem\" data-checked=\"false\"><p>parent</p><ul data-type=\"taskList\">\n<li data-type=\"taskItem\" data-checked=\"true\"><p>child</p></li>\n</ul>\n</li>\n</ul>",
},
{
name: "task list keeps inline formatting",
md: "- [x] task with **bold** and a [link](https://x.io)",
want: "<ul data-type=\"taskList\">\n<li data-type=\"taskItem\" data-checked=\"true\"><p>task with <strong>bold</strong> and a <a href=\"https://x.io\">link</a></p></li>\n</ul>",
},
{
name: "plain list is not a task list",
md: "- one\n- two",
want: "<ul>\n<li>one</li>\n<li>two</li>\n</ul>",
},
{
name: "pipe table",
md: "| a | b |\n|---|---|\n| 1 | 2 |",
want: "<table>\n<thead>\n<tr>\n<th>a</th>\n<th>b</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>1</td>\n<td>2</td>\n</tr>\n</tbody>\n</table>",
},
{
name: "strikethrough",
md: "~~gone~~",
want: "<p><del>gone</del></p>",
},
{
name: "empty markdown is empty",
md: "",
want: "",
},
{
name: "whitespace markdown is empty",
md: " \n ",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := MarkdownToHTML(tt.md)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
// TestMarkdownToHTML_NoUnsafe proves goldmark runs without html.WithUnsafe():
// raw HTML in the markdown must never become active markup.
func TestMarkdownToHTML_NoUnsafe(t *testing.T) {
got, err := MarkdownToHTML("text with <script>alert(1)</script> raw html")
require.NoError(t, err)
assert.NotContains(t, got, "<script>")
assert.NotContains(t, got, "</script>")
}

View File

@ -1,178 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package richtext
import (
"fmt"
"regexp"
"unicode"
"unicode/utf8"
"code.vikunja.io/api/pkg/user"
"golang.org/x/net/html"
"xorm.io/xorm"
)
// mentionTokenRegex matches "@username". The username starts/ends with a word
// char so trailing prose punctuation ("@jane.") isn't swallowed. RE2 has no
// look-behind, so the boundary before "@" is checked in code (to reject "a@b").
var mentionTokenRegex = regexp.MustCompile(`@([\p{L}\p{N}_](?:[\p{L}\p{N}._-]*[\p{L}\p{N}_])?)`)
// rebuildMentions replaces "@username" tokens with <mention-user> tags, resolving
// against real users in one batched query. Unknown handles and tokens inside
// code/links are left untouched.
func rebuildMentions(s *xorm.Session, nodes []*html.Node) error {
var textNodes []*html.Node
for _, n := range nodes {
collectMentionTextNodes(n, false, &textNodes)
}
if len(textNodes) == 0 {
return nil
}
candidates := map[string]struct{}{}
for _, tn := range textNodes {
for _, name := range findMentionCandidates(tn.Data) {
candidates[name] = struct{}{}
}
}
if len(candidates) == 0 {
return nil
}
usernames := make([]string, 0, len(candidates))
for name := range candidates {
usernames = append(usernames, name)
}
usersByID, err := user.GetUsersByUsername(s, usernames, false)
if err != nil {
return fmt.Errorf("looking up mentioned users: %w", err)
}
usersByName := make(map[string]*user.User, len(usersByID))
for _, u := range usersByID {
usersByName[u.Username] = u
}
if len(usersByName) == 0 {
return nil
}
for _, tn := range textNodes {
replaceMentionsInTextNode(tn, usersByName)
}
return nil
}
// collectMentionTextNodes gathers text nodes outside <code>, <pre>, <a> and
// <mention-user>.
func collectMentionTextNodes(n *html.Node, inSkip bool, out *[]*html.Node) {
if n.Type == html.TextNode {
if !inSkip {
*out = append(*out, n)
}
return
}
skip := inSkip
if n.Type == html.ElementNode {
switch n.Data {
case "code", "pre", "a", "mention-user":
skip = true
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
collectMentionTextNodes(c, skip, out)
}
}
// findMentionCandidates returns the usernames mentioned in text (word-boundary
// "@" only).
func findMentionCandidates(text string) []string {
var names []string
for _, m := range mentionTokenRegex.FindAllStringSubmatchIndex(text, -1) {
if mentionPrecededByWordChar(text, m[0]) {
continue
}
names = append(names, text[m[2]:m[3]])
}
return names
}
// replaceMentionsInTextNode splits tn, swapping known @mentions for <mention-user> nodes.
func replaceMentionsInTextNode(tn *html.Node, users map[string]*user.User) {
text := tn.Data
var newNodes []*html.Node
cursor := 0
for _, m := range mentionTokenRegex.FindAllStringSubmatchIndex(text, -1) {
start, end := m[0], m[1]
if mentionPrecededByWordChar(text, start) {
continue
}
u, ok := users[text[m[2]:m[3]]]
if !ok {
continue
}
if start > cursor {
newNodes = append(newNodes, &html.Node{Type: html.TextNode, Data: text[cursor:start]})
}
newNodes = append(newNodes, newMentionNode(u))
cursor = end
}
if len(newNodes) == 0 {
return
}
if cursor < len(text) {
newNodes = append(newNodes, &html.Node{Type: html.TextNode, Data: text[cursor:]})
}
parent := tn.Parent
for _, nn := range newNodes {
parent.InsertBefore(nn, tn)
}
parent.RemoveChild(tn)
}
// newMentionNode builds <mention-user data-id="username" data-label="Name">@Name</mention-user>.
// data-id carries the username so extractMentionedUsernames can re-resolve it.
func newMentionNode(u *user.User) *html.Node {
n := &html.Node{
Type: html.ElementNode,
Data: "mention-user",
Attr: []html.Attribute{
{Key: "data-id", Val: u.Username},
{Key: "data-label", Val: u.GetName()},
},
}
n.AppendChild(&html.Node{Type: html.TextNode, Data: "@" + u.GetName()})
return n
}
// mentionPrecededByWordChar reports whether the rune just before atIndex is a
// letter, digit or underscore — i.e. the "@" is mid-token (an email), not a mention.
func mentionPrecededByWordChar(text string, atIndex int) bool {
if atIndex == 0 {
return false
}
r, _ := utf8.DecodeLastRuneInString(text[:atIndex])
return unicode.IsLetter(r) || unicode.IsNumber(r) || r == '_'
}

View File

@ -1,107 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package richtext
import (
"testing"
"code.vikunja.io/api/pkg/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMarkdownToHTMLWithMentions(t *testing.T) {
t.Run("known mention is rebuilt", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
got, err := MarkdownToHTMLWithMentions(s, "hi @user1")
require.NoError(t, err)
assert.Equal(t, `<p>hi <mention-user data-id="user1" data-label="user1">@user1</mention-user></p>`, got)
})
t.Run("unknown mention stays literal text", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
got, err := MarkdownToHTMLWithMentions(s, "hi @nosuchuser")
require.NoError(t, err)
assert.Equal(t, "<p>hi @nosuchuser</p>", got)
})
t.Run("mention next to punctuation", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
got, err := MarkdownToHTMLWithMentions(s, "cc @user1, please review")
require.NoError(t, err)
assert.Equal(t, `<p>cc <mention-user data-id="user1" data-label="user1">@user1</mention-user>, please review</p>`, got)
})
t.Run("multiple mentions resolve in one pass", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
got, err := MarkdownToHTMLWithMentions(s, "ping @user1 and @user2")
require.NoError(t, err)
assert.Contains(t, got, `<mention-user data-id="user1" data-label="user1">@user1</mention-user>`)
assert.Contains(t, got, `<mention-user data-id="user2" data-label="user2">@user2</mention-user>`)
})
t.Run("email is not a mention", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
got, err := MarkdownToHTMLWithMentions(s, "reach me at user1@example.com please")
require.NoError(t, err)
assert.NotContains(t, got, "mention-user")
})
t.Run("mention inside code span is ignored", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
got, err := MarkdownToHTMLWithMentions(s, "use `@user1` literally")
require.NoError(t, err)
assert.NotContains(t, got, "mention-user")
assert.Contains(t, got, "<code>@user1</code>")
})
t.Run("mention inside task list item", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
got, err := MarkdownToHTMLWithMentions(s, "- [ ] ping @user1")
require.NoError(t, err)
assert.Contains(t, got, `data-type="taskItem"`)
assert.Contains(t, got, `<mention-user data-id="user1" data-label="user1">@user1</mention-user>`)
})
t.Run("no session leaves mention as text", func(t *testing.T) {
got, err := MarkdownToHTML("hi @user1")
require.NoError(t, err)
assert.Equal(t, "<p>hi @user1</p>", got)
})
}

View File

@ -1,156 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package richtext
import (
"bytes"
"fmt"
"strings"
"github.com/JohannesKaufmann/dom"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// parseHTMLFragment parses an HTML fragment in a <body> context (so tables/lists parse).
func parseHTMLFragment(in []byte) ([]*html.Node, error) {
context := &html.Node{Type: html.ElementNode, Data: "body", DataAtom: atom.Body}
nodes, err := html.ParseFragment(bytes.NewReader(in), context)
if err != nil {
return nil, fmt.Errorf("parsing converted html: %w", err)
}
return nodes, nil
}
func renderHTMLNodes(nodes []*html.Node) (string, error) {
var buf bytes.Buffer
for _, n := range nodes {
if err := html.Render(&buf, n); err != nil {
return "", fmt.Errorf("rendering converted html: %w", err)
}
}
return buf.String(), nil
}
// convertTaskListItems rewrites goldmark's GFM task-list output
// (<li><input type="checkbox"> text</li>) into the TipTap
// <ul data-type="taskList"><li data-type="taskItem" data-checked="…"><p>text</p></li>
// shape the web editor and resetDescriptionChecklist (recurring-task reset) expect.
func convertTaskListItems(n *html.Node) {
for c := n.FirstChild; c != nil; c = c.NextSibling {
convertTaskListItems(c)
}
if n.Type != html.ElementNode || n.Data != "li" {
return
}
input := leadingCheckbox(n)
if input == nil {
return
}
_, checked := dom.GetAttribute(input, "checked")
dom.RemoveNode(input)
setAttribute(n, "data-type", "taskItem")
setAttribute(n, "data-checked", boolString(checked))
wrapLeadingInlineInParagraph(n)
if p := n.Parent; p != nil && p.Type == html.ElementNode && (p.Data == "ul" || p.Data == "ol") {
setAttribute(p, "data-type", "taskList")
}
}
// leadingCheckbox returns the <input type="checkbox"> at the start of li (after
// skipping insignificant whitespace), or nil if li isn't a task item.
func leadingCheckbox(li *html.Node) *html.Node {
for c := li.FirstChild; c != nil; c = c.NextSibling {
if isWhitespaceText(c) {
continue
}
if c.Type == html.ElementNode && c.Data == "input" && dom.GetAttributeOr(c, "type", "") == "checkbox" {
return c
}
return nil
}
return nil
}
// wrapLeadingInlineInParagraph moves li's leading inline content (everything up
// to the first nested list) into a <p>, matching TipTap's taskItem shape.
func wrapLeadingInlineInParagraph(li *html.Node) {
var inline []*html.Node
for c := li.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.ElementNode && (c.Data == "ul" || c.Data == "ol") {
break
}
inline = append(inline, c)
}
allWhitespace := true
for _, c := range inline {
if !isWhitespaceText(c) {
allWhitespace = false
break
}
}
if len(inline) == 0 || allWhitespace {
return
}
p := &html.Node{Type: html.ElementNode, Data: "p", DataAtom: atom.P}
for _, c := range inline {
li.RemoveChild(c)
p.AppendChild(c)
}
li.InsertBefore(p, li.FirstChild)
trimEdgeWhitespace(p)
}
// trimEdgeWhitespace trims leading/trailing whitespace from the first and last
// text nodes of n so the wrapped paragraph doesn't keep goldmark's "<input> "
// spacing or trailing newline.
func trimEdgeWhitespace(n *html.Node) {
if first := n.FirstChild; first != nil && first.Type == html.TextNode {
first.Data = strings.TrimLeft(first.Data, " \t\n\r")
}
if last := n.LastChild; last != nil && last.Type == html.TextNode {
last.Data = strings.TrimRight(last.Data, " \t\n\r")
}
}
func setAttribute(n *html.Node, key, val string) {
for i, a := range n.Attr {
if a.Key == key {
n.Attr[i].Val = val
return
}
}
n.Attr = append(n.Attr, html.Attribute{Key: key, Val: val})
}
func boolString(b bool) string {
if b {
return "true"
}
return "false"
}
func isWhitespaceText(n *html.Node) bool {
return n.Type == html.TextNode && strings.TrimSpace(n.Data) == ""
}

View File

@ -1,153 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package richtext
import (
"github.com/JohannesKaufmann/dom"
"github.com/JohannesKaufmann/html-to-markdown/v2/converter"
"golang.org/x/net/html"
)
// registerTipTapRules teaches the HTML→Markdown converter about the two
// Vikunja-specific nodes that standard GFM doesn't model: TipTap mentions and
// TipTap task lists.
func registerTipTapRules(conv *converter.Converter) {
// Empty mention elements (the common stored form is <mention-user data-id data-label></mention-user>)
// would otherwise be treated as content-less by the whitespace collapser, eating the
// following space. Giving them a text child before collapse (PriorityLate) preserves it.
conv.Register.PreRenderer(ensureMentionContent, converter.PriorityEarly)
conv.Register.RendererFor("mention-user", converter.TagTypeInline, renderMentionUser, converter.PriorityEarly)
// Normalize TipTap task-list items to a single <input type="checkbox"> that
// renderTaskCheckbox turns into the GFM "[x]"/"[ ]" marker. We drive off the
// <li data-checked> attribute (the same source of truth resetDescriptionChecklist
// uses) rather than TipTap's <label><input> chrome, which may not always be present.
conv.Register.PreRenderer(normalizeTaskListItems, converter.PriorityEarly)
conv.Register.RendererFor("input", converter.TagTypeInline, renderTaskCheckbox, converter.PriorityEarly)
}
// renderMentionUser converts <mention-user data-id="username"> to "@username"
// (label and inner text dropped). Tags without data-id fall through to the
// default renderer, keeping their inner text.
func renderMentionUser(_ converter.Context, w converter.Writer, n *html.Node) converter.RenderStatus {
username := dom.GetAttributeOr(n, "data-id", "")
if username == "" {
return converter.RenderTryNext
}
// Written directly to the writer so the username isn't markdown-escaped;
// the inbound side re-tokenizes "@username" verbatim. The writer is
// buffer-backed and never errors.
_, _ = w.WriteString("@" + username)
return converter.RenderSuccess
}
// ensureMentionContent gives every mention with a data-id a text child if it has
// none, so the whitespace collapser keeps it (and the surrounding spaces). The
// child is never rendered — renderMentionUser writes "@data-id" and stops.
func ensureMentionContent(_ converter.Context, doc *html.Node) {
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "mention-user" && n.FirstChild == nil {
if username := dom.GetAttributeOr(n, "data-id", ""); username != "" {
n.AppendChild(&html.Node{Type: html.TextNode, Data: "@" + username})
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
}
// renderTaskCheckbox emits the GFM task-list marker for the normalized checkbox
// input. The trailing space separates it from the item text ("- [x] text").
func renderTaskCheckbox(_ converter.Context, w converter.Writer, n *html.Node) converter.RenderStatus {
if dom.GetAttributeOr(n, "type", "") != "checkbox" {
return converter.RenderTryNext
}
marker := "[ ] "
if _, checked := dom.GetAttribute(n, "checked"); checked {
marker = "[x] "
}
_, _ = w.WriteString(marker)
return converter.RenderSuccess
}
// normalizeTaskListItems rewrites every <li data-checked="…"> so its checkbox
// state is carried by a single leading <input type="checkbox">, removing
// TipTap's <label> chrome. This makes the marker independent of whether the
// stored HTML used the full TipTap form or the bare data-checked form.
func normalizeTaskListItems(_ converter.Context, doc *html.Node) {
var items []*html.Node
var collect func(*html.Node)
collect = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "li" {
if _, ok := dom.GetAttribute(n, "data-checked"); ok {
items = append(items, n)
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
collect(c)
}
}
collect(doc)
for _, li := range items {
checked := dom.GetAttributeOr(li, "data-checked", "false") == "true"
// Drop the existing checkbox chrome (<label><input><span>) so we don't
// render a duplicate or stale marker.
for _, child := range dom.AllChildNodes(li) {
if child.Type == html.ElementNode && (child.Data == "label" || child.Data == "input") {
dom.RemoveNode(child)
}
}
input := &html.Node{
Type: html.ElementNode,
Data: "input",
Attr: []html.Attribute{{Key: "type", Val: "checkbox"}},
}
if checked {
input.Attr = append(input.Attr, html.Attribute{Key: "checked", Val: "checked"})
}
// Insert the marker inside the item's first paragraph so it stays inline
// with the text ("- [x] text"). TipTap wraps task text in <div><p>…</p></div>;
// inserting at the <li> level instead would put a block boundary between
// the marker and the text.
host := firstParagraph(li)
if host == nil {
host = li
}
host.InsertBefore(input, host.FirstChild)
}
}
func firstParagraph(n *html.Node) *html.Node {
for _, c := range dom.AllChildNodes(n) {
if c.Type == html.ElementNode && c.Data == "p" {
return c
}
if found := firstParagraph(c); found != nil {
return found
}
}
return nil
}

View File

@ -1,86 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package richtext
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHTMLToMarkdown_TipTap(t *testing.T) {
tests := []struct {
name string
html string
want string
}{
{
name: "mention uses data-id and drops label",
html: `<p><mention-user data-id="actualuser" data-label="Different Label">@differentlabel</mention-user></p>`,
want: "@actualuser",
},
{
name: "empty mention keeps following space",
html: `<p><mention-user data-id="frederick" data-label="Frederick"></mention-user> hello</p>`,
want: "@frederick hello",
},
{
name: "mention next to punctuation stays intact",
html: `<p>cc <mention-user data-id="jane">@jane</mention-user>, please review</p>`,
want: "cc @jane, please review",
},
{
name: "multiple mentions in one block",
html: `<p>ping <mention-user data-id="user1">@user1</mention-user> and <mention-user data-id="user2">@user2</mention-user></p>`,
want: "ping @user1 and @user2",
},
{
name: "mention without data-id keeps inner text",
html: `<p><mention-user>@someuser</mention-user> hi</p>`,
want: "@someuser hi",
},
{
name: "tiptap task list checked and unchecked",
html: `<ul data-type="taskList"><li data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p>done item</p></div></li><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>todo item</p></div></li></ul>`,
want: "- [x] done item\n- [ ] todo item",
},
{
name: "task list bare data-checked form",
html: `<ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p>Item 1</p></li></ul>`,
want: "- [ ] Item 1",
},
{
name: "nested task list items",
html: `<ul data-type="taskList"><li data-checked="false" data-type="taskItem"><div><p>parent</p></div><ul data-type="taskList"><li data-checked="true" data-type="taskItem"><div><p>child</p></div></li></ul></li></ul>`,
want: "- [ ] parent\n \n - [x] child",
},
{
name: "mention inside task list item",
html: `<ul data-type="taskList"><li data-checked="false" data-type="taskItem"><div><p>ask <mention-user data-id="bob">@bob</mention-user></p></div></li></ul>`,
want: "- [ ] ask @bob",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := HTMLToMarkdown(tt.html)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}

View File

@ -1,155 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package apiv2
import (
"context"
"fmt"
"net/http"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/web/handler"
"github.com/danielgtaylor/huma/v2"
)
// bucketListBody is the list-response envelope. models.Bucket.ReadAll returns
// []*models.Bucket, so that's the element type.
type bucketListBody struct {
Body Paginated[*models.Bucket]
}
// RegisterBucketRoutes wires the nested kanban-bucket CRUD onto the Huma API.
// Buckets live under /projects/{project}/views/{view}/buckets; every operation
// binds {project} → ProjectID and {view} → ProjectViewID, the write operations
// additionally {bucket} → ID. There is intentionally no read-one route
// (mirroring v1: the Bucket model has no ReadOne/CanRead), so AutoPatch
// synthesises no PATCH either.
func RegisterBucketRoutes(api huma.API) {
tags := []string{"projects"}
Register(api, huma.Operation{
OperationID: "buckets-list",
Summary: "List the buckets of a kanban view",
Description: "Returns all kanban buckets of a project view, ordered by position. Requires read access to the project. The list is not paginated by the server but is returned in the standard list envelope. To get the buckets together with their tasks, use the buckets/tasks endpoint instead.",
Method: http.MethodGet,
Path: "/projects/{project}/views/{view}/buckets",
Tags: tags,
}, bucketsList)
Register(api, huma.Operation{
OperationID: "buckets-create",
Summary: "Create a bucket in a kanban view",
Description: "Creates a kanban bucket in the given project view. The project and view come from the URL, not the body. Requires write access to the project.",
Method: http.MethodPost,
Path: "/projects/{project}/views/{view}/buckets",
Tags: tags,
}, bucketsCreate)
Register(api, huma.Operation{
OperationID: "buckets-update",
Summary: "Update a bucket of a kanban view",
Description: "Replaces a kanban bucket's title, limit and position. The bucket is identified by the URL, which also scopes it to the project and view. Requires write access to the project.",
Method: http.MethodPut,
Path: "/projects/{project}/views/{view}/buckets/{bucket}",
Tags: tags,
}, bucketsUpdate)
Register(api, huma.Operation{
OperationID: "buckets-delete",
Summary: "Delete a bucket of a kanban view",
Description: "Deletes a kanban bucket and moves its tasks to the view's default bucket; no tasks are deleted. You cannot delete the last bucket of a view (rejected with 412). Requires write access to the project.",
Method: http.MethodDelete,
Path: "/projects/{project}/views/{view}/buckets/{bucket}",
Tags: tags,
}, bucketsDelete)
}
func init() { AddRouteRegistrar(RegisterBucketRoutes) }
func bucketsList(ctx context.Context, in *struct {
ProjectID int64 `path:"project"`
ViewID int64 `path:"view"`
ListParams
}) (*bucketListBody, error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
result, _, total, err := handler.DoReadAll(ctx, &models.Bucket{ProjectID: in.ProjectID, ProjectViewID: in.ViewID}, a, in.Q, in.Page, in.PerPage)
if err != nil {
return nil, translateDomainError(err)
}
buckets, ok := result.([]*models.Bucket)
if !ok {
return nil, fmt.Errorf("buckets.ReadAll returned unexpected type %T (expected []*models.Bucket)", result)
}
return &bucketListBody{Body: NewPaginated(buckets, total, in.Page, in.PerPage)}, nil
}
func bucketsCreate(ctx context.Context, in *struct {
ProjectID int64 `path:"project"`
ViewID int64 `path:"view"`
Body models.Bucket
}) (*singleBody[models.Bucket], error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
b := &in.Body
b.ProjectID = in.ProjectID // URL wins over body
b.ProjectViewID = in.ViewID // URL wins over body
if err := handler.DoCreate(ctx, b, a); err != nil {
return nil, translateDomainError(err)
}
return &singleBody[models.Bucket]{Body: b}, nil
}
func bucketsUpdate(ctx context.Context, in *struct {
ProjectID int64 `path:"project"`
ViewID int64 `path:"view"`
BucketID int64 `path:"bucket"`
Body models.Bucket
}) (*singleBody[models.Bucket], error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
b := &in.Body
b.ID = in.BucketID // URL wins over body
b.ProjectID = in.ProjectID // URL wins over body
b.ProjectViewID = in.ViewID // URL wins over body
if err := handler.DoUpdate(ctx, b, a); err != nil {
return nil, translateDomainError(err)
}
return &singleBody[models.Bucket]{Body: b}, nil
}
func bucketsDelete(ctx context.Context, in *struct {
ProjectID int64 `path:"project"`
ViewID int64 `path:"view"`
BucketID int64 `path:"bucket"`
}) (*emptyBody, error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
if err := handler.DoDelete(ctx, &models.Bucket{ID: in.BucketID, ProjectID: in.ProjectID, ProjectViewID: in.ViewID}, a); err != nil {
return nil, translateDomainError(err)
}
return &emptyBody{}, nil
}

View File

@ -47,24 +47,15 @@ func RegisterBulkTaskRoutes(api huma.API) {
func init() { AddRouteRegistrar(RegisterBulkTaskRoutes) } func init() { AddRouteRegistrar(RegisterBulkTaskRoutes) }
func tasksBulkUpdate(ctx context.Context, in *struct { func tasksBulkUpdate(ctx context.Context, in *struct {
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` Body models.BulkTask
Body models.BulkTask
}) (*singleBody[models.BulkTask], error) { }) (*singleBody[models.BulkTask], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
bt := &in.Body bt := &in.Body
if bt.Values != nil {
if err := convertToHTML(ctx, &bt.Values.Description); err != nil {
return nil, translateDomainError(err)
}
}
if err := handler.DoUpdate(ctx, bt, a); err != nil { if err := handler.DoUpdate(ctx, bt, a); err != nil {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
// Echo values + updated tasks back in the requested format (values.description
// was converted to HTML above for persistence).
convertTasksToMarkdown(ctx, append([]*models.Task{bt.Values}, bt.Tasks...)...)
return &singleBody[models.BulkTask]{Body: bt}, nil return &singleBody[models.BulkTask]{Body: bt}, nil
} }

View File

@ -88,9 +88,6 @@ func NewAPI(e *echo.Echo, g *echo.Group) huma.API {
api := humaecho5.NewWithGroup(e, g, GroupPrefix, cfg) api := humaecho5.NewWithGroup(e, g, GroupPrefix, cfg)
oapi := api.OpenAPI() oapi := api.OpenAPI()
if oapi.Info != nil {
oapi.Info.Description = richTextFormatAPIDescription
}
if oapi.Components.SecuritySchemes == nil { if oapi.Components.SecuritySchemes == nil {
oapi.Components.SecuritySchemes = map[string]*huma.SecurityScheme{} oapi.Components.SecuritySchemes = map[string]*huma.SecurityScheme{}
} }

View File

@ -87,10 +87,7 @@ func RegisterLabelRoutes(api huma.API) {
func init() { AddRouteRegistrar(RegisterLabelRoutes) } func init() { AddRouteRegistrar(RegisterLabelRoutes) }
func labelsList(ctx context.Context, in *struct { func labelsList(ctx context.Context, in *ListParams) (*labelListBody, error) {
ListParams
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
}) (*labelListBody, error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@ -103,9 +100,6 @@ func labelsList(ctx context.Context, in *struct {
if !ok { if !ok {
return nil, fmt.Errorf("labels.ReadAll returned unexpected type %T (expected []*models.LabelWithTaskID)", result) return nil, fmt.Errorf("labels.ReadAll returned unexpected type %T (expected []*models.LabelWithTaskID)", result)
} }
for _, l := range items {
convertToMarkdown(ctx, &l.Description)
}
return &labelListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil return &labelListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
} }
@ -115,8 +109,7 @@ type labelReadBody struct {
} }
func labelsRead(ctx context.Context, in *struct { func labelsRead(ctx context.Context, in *struct {
ID int64 `path:"id"` ID int64 `path:"id"`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
conditional.Params conditional.Params
}) (*singleReadBody[labelReadBody], error) { }) (*singleReadBody[labelReadBody], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
@ -129,33 +122,26 @@ func labelsRead(ctx context.Context, in *struct {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
body := &labelReadBody{Label: *label, MaxPermission: models.Permission(maxPermission)} body := &labelReadBody{Label: *label, MaxPermission: models.Permission(maxPermission)}
convertToMarkdown(ctx, &body.Description)
return conditionalReadResponse(&in.Params, body, label.Updated, maxPermission) return conditionalReadResponse(&in.Params, body, label.Updated, maxPermission)
} }
func labelsCreate(ctx context.Context, in *struct { func labelsCreate(ctx context.Context, in *struct {
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` Body models.Label
Body models.Label
}) (*singleBody[models.Label], error) { }) (*singleBody[models.Label], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := convertToHTML(ctx, &in.Body.Description); err != nil {
return nil, translateDomainError(err)
}
if err := handler.DoCreate(ctx, &in.Body, a); err != nil { if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
convertToMarkdown(ctx, &in.Body.Description)
return &singleBody[models.Label]{Body: &in.Body}, nil return &singleBody[models.Label]{Body: &in.Body}, nil
} }
// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates. // Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates.
func labelsUpdate(ctx context.Context, in *struct { func labelsUpdate(ctx context.Context, in *struct {
ID int64 `path:"id"` ID int64 `path:"id"`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` Body labelReadBody
Body labelReadBody
}) (*singleBody[models.Label], error) { }) (*singleBody[models.Label], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
if err != nil { if err != nil {
@ -163,13 +149,9 @@ func labelsUpdate(ctx context.Context, in *struct {
} }
label := &in.Body.Label label := &in.Body.Label
label.ID = in.ID // URL wins over body label.ID = in.ID // URL wins over body
if err := convertToHTML(ctx, &label.Description); err != nil {
return nil, translateDomainError(err)
}
if err := handler.DoUpdate(ctx, label, a); err != nil { if err := handler.DoUpdate(ctx, label, a); err != nil {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
convertToMarkdown(ctx, &label.Description)
return &singleBody[models.Label]{Body: label}, nil return &singleBody[models.Label]{Body: label}, nil
} }

View File

@ -89,7 +89,6 @@ func projectsList(ctx context.Context, in *struct {
ListParams ListParams
Expand string `query:"expand" enum:"permissions" doc:"If set to \"permissions\", each returned project includes the max permission the requesting user has on it (max_permission). Currently only \"permissions\" is supported."` Expand string `query:"expand" enum:"permissions" doc:"If set to \"permissions\", each returned project includes the max permission the requesting user has on it (max_permission). Currently only \"permissions\" is supported."`
IsArchived bool `query:"is_archived" doc:"If true, also returns archived projects."` IsArchived bool `query:"is_archived" doc:"If true, also returns archived projects."`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
}) (*projectListBody, error) { }) (*projectListBody, error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
if err != nil { if err != nil {
@ -107,9 +106,6 @@ func projectsList(ctx context.Context, in *struct {
if !ok { if !ok {
return nil, fmt.Errorf("projects.ReadAll returned unexpected type %T (expected []*models.Project)", result) return nil, fmt.Errorf("projects.ReadAll returned unexpected type %T (expected []*models.Project)", result)
} }
for _, p := range items {
convertToMarkdown(ctx, &p.Description)
}
return &projectListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil return &projectListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
} }
@ -121,8 +117,7 @@ type projectReadBody struct {
} }
func projectsRead(ctx context.Context, in *struct { func projectsRead(ctx context.Context, in *struct {
ID int64 `path:"id"` ID int64 `path:"id"`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
}) (*singleBody[projectReadBody], error) { }) (*singleBody[projectReadBody], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
if err != nil { if err != nil {
@ -137,29 +132,22 @@ func projectsRead(ctx context.Context, in *struct {
// the Favorites pseudo-project and saved-filter-backed ones), so the field // the Favorites pseudo-project and saved-filter-backed ones), so the field
// is always meaningful here — surfaced unconditionally like labels/views. // is always meaningful here — surfaced unconditionally like labels/views.
project.MaxPermission = models.Permission(maxPermission) project.MaxPermission = models.Permission(maxPermission)
body := &projectReadBody{Project: *project}
convertToMarkdown(ctx, &body.Description)
// No ETag/conditional read: a project response carries user-scoped, derived // No ETag/conditional read: a project response carries user-scoped, derived
// state (subscription, favorite, views, computed archived state) that // state (subscription, favorite, views, computed archived state) that
// changes without bumping project.Updated, so it's always served fresh. // changes without bumping project.Updated, so it's always served fresh.
return &singleBody[projectReadBody]{Body: body}, nil return &singleBody[projectReadBody]{Body: &projectReadBody{Project: *project}}, nil
} }
func projectsCreate(ctx context.Context, in *struct { func projectsCreate(ctx context.Context, in *struct {
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` Body models.Project
Body models.Project
}) (*singleBody[models.Project], error) { }) (*singleBody[models.Project], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := convertToHTML(ctx, &in.Body.Description); err != nil {
return nil, translateDomainError(err)
}
if err := handler.DoCreate(ctx, &in.Body, a); err != nil { if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
convertToMarkdown(ctx, &in.Body.Description)
// Create/Update don't compute the caller's permission; null says "read it" // Create/Update don't compute the caller's permission; null says "read it"
// rather than echoing the zero value (0 = read), misleading for the owner. // rather than echoing the zero value (0 = read), misleading for the owner.
in.Body.MaxPermission = models.PermissionUnknown in.Body.MaxPermission = models.PermissionUnknown
@ -168,9 +156,8 @@ func projectsCreate(ctx context.Context, in *struct {
// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates. // Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates.
func projectsUpdate(ctx context.Context, in *struct { func projectsUpdate(ctx context.Context, in *struct {
ID int64 `path:"id"` ID int64 `path:"id"`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` Body projectReadBody
Body projectReadBody
}) (*singleBody[models.Project], error) { }) (*singleBody[models.Project], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
if err != nil { if err != nil {
@ -178,13 +165,9 @@ func projectsUpdate(ctx context.Context, in *struct {
} }
project := &in.Body.Project project := &in.Body.Project
project.ID = in.ID // URL wins over body project.ID = in.ID // URL wins over body
if err := convertToHTML(ctx, &project.Description); err != nil {
return nil, translateDomainError(err)
}
if err := handler.DoUpdate(ctx, project, a); err != nil { if err := handler.DoUpdate(ctx, project, a); err != nil {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
convertToMarkdown(ctx, &project.Description)
project.MaxPermission = models.PermissionUnknown // see projectsCreate project.MaxPermission = models.PermissionUnknown // see projectsCreate
return &singleBody[models.Project]{Body: project}, nil return &singleBody[models.Project]{Body: project}, nil
} }

View File

@ -42,5 +42,4 @@ func RegisterAll(api huma.API) {
r(api) r(api)
} }
EnableAutoPatch(api) EnableAutoPatch(api)
stripPatchFormatQuery(api)
} }

View File

@ -1,166 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package apiv2
import (
"context"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/humaecho5"
"code.vikunja.io/api/pkg/richtext"
"github.com/danielgtaylor/huma/v2"
"github.com/labstack/echo/v5"
)
const (
// "markdown" converts rich-text fields on read and write; anything else keeps HTML.
richTextFormatQuery = "format"
richTextFormatHeader = "X-Vikunja-Format"
markdownFormat = "markdown"
)
// requestWantsMarkdown reports whether the request asked for markdown. The per-op
// `format` query field on the input structs only documents the param; the value is
// read here so this also catches the X-Vikunja-Format header — the only channel
// that survives AutoPatch's PATCH re-dispatch (it strips the query).
func requestWantsMarkdown(ctx context.Context) bool {
ec, ok := ctx.Value(humaecho5.EchoContextKey).(*echo.Context)
if !ok {
return false
}
return ec.QueryParam(richTextFormatQuery) == markdownFormat ||
ec.Request().Header.Get(richTextFormatHeader) == markdownFormat
}
// richTextFormatAPIDescription documents the cross-cutting markdown behavior at
// the top of the OpenAPI spec (Scalar renders it on the docs landing page).
const richTextFormatAPIDescription = "## Rich-text fields\n\n" +
"Descriptions (task, project, label, team, saved filter) and task comments are stored as HTML. " +
"Add `?format=markdown` to read and write them as GFM Markdown instead; on write it is converted " +
"to HTML and `@mentions` resolved to existing users. On `PATCH`, send the `X-Vikunja-Format: markdown` " +
"header instead (merge-patch drops query parameters). CalDAV always exchanges task descriptions as " +
"Markdown.\n\n" +
"Writing is lossy: Markdown can't express every HTML construct (e.g. underline), so a field you send " +
"as Markdown is stored as its converted HTML — formatting Markdown can't represent is dropped. Omit a " +
"field, or use `format=html`, to leave it untouched (note a full `PUT` and `PATCH` round-trip the " +
"whole resource, so send `format=html` unless you actually edited the rich-text fields). Unknown " +
"`@mentions` stay as plain text."
// stripPatchFormatQuery removes the `format` query param AutoPatch copies onto
// each synthesised PATCH. The query doesn't survive AutoPatch's re-dispatch, so
// advertising it on PATCH would be a trap (markdown silently stored as HTML);
// PATCH uses the X-Vikunja-Format header instead. Call after EnableAutoPatch.
func stripPatchFormatQuery(api huma.API) {
for _, item := range api.OpenAPI().Paths {
if item == nil || item.Patch == nil {
continue
}
kept := item.Patch.Parameters[:0]
for _, p := range item.Patch.Parameters {
if p.Name == richTextFormatQuery && p.In == "query" {
continue
}
kept = append(kept, p)
}
item.Patch.Parameters = kept
}
}
// convertToMarkdown converts the given HTML fields to Markdown in place when the
// request asked for markdown. Read handlers call it on returned fields; write
// handlers after persisting, to echo back in the requested format. Best effort: a
// conversion error leaves the HTML untouched.
func convertToMarkdown(ctx context.Context, fields ...*string) {
if !requestWantsMarkdown(ctx) {
return
}
for _, field := range fields {
if field == nil {
continue
}
if md, err := richtext.HTMLToMarkdown(*field); err == nil {
*field = md
}
}
}
// convertTasksToMarkdown converts each task's description plus any expanded
// rich-text children (comments, related tasks) to markdown when requested. Dedups
// by field pointer so a task reachable twice (e.g. as another's relation) isn't
// converted twice — a second HTML→markdown pass would escape the markdown.
func convertTasksToMarkdown(ctx context.Context, tasks ...*models.Task) {
if !requestWantsMarkdown(ctx) {
return
}
seen := map[*string]struct{}{}
toMarkdown := func(field *string) {
if field == nil {
return
}
if _, done := seen[field]; done {
return
}
seen[field] = struct{}{}
if md, err := richtext.HTMLToMarkdown(*field); err == nil {
*field = md
}
}
for _, task := range tasks {
if task == nil {
continue
}
toMarkdown(&task.Description)
for _, comment := range task.Comments {
if comment != nil {
toMarkdown(&comment.Comment)
}
}
for _, related := range task.RelatedTasks {
for _, rel := range related {
if rel != nil {
toMarkdown(&rel.Description)
}
}
}
}
}
// convertToHTML converts the given Markdown fields to canonical HTML in place,
// rebuilding @mentions, when the request asked for markdown (no-op otherwise).
// Write handlers call it on the request body before persisting.
func convertToHTML(ctx context.Context, fields ...*string) error {
if !requestWantsMarkdown(ctx) {
return nil
}
s := db.NewSession()
defer s.Close()
for _, field := range fields {
if field == nil {
continue
}
htmlDesc, err := richtext.MarkdownToHTMLWithMentions(s, *field)
if err != nil {
return err
}
*field = htmlDesc
}
return nil
}

View File

@ -77,8 +77,7 @@ type savedFilterReadBody struct {
} }
func savedFiltersRead(ctx context.Context, in *struct { func savedFiltersRead(ctx context.Context, in *struct {
ID int64 `path:"filter"` ID int64 `path:"filter"`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
conditional.Params conditional.Params
}) (*singleReadBody[savedFilterReadBody], error) { }) (*singleReadBody[savedFilterReadBody], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
@ -91,33 +90,26 @@ func savedFiltersRead(ctx context.Context, in *struct {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
body := &savedFilterReadBody{SavedFilter: *filter, MaxPermission: models.Permission(maxPermission)} body := &savedFilterReadBody{SavedFilter: *filter, MaxPermission: models.Permission(maxPermission)}
convertToMarkdown(ctx, &body.Description)
return conditionalReadResponse(&in.Params, body, filter.Updated, maxPermission) return conditionalReadResponse(&in.Params, body, filter.Updated, maxPermission)
} }
func savedFiltersCreate(ctx context.Context, in *struct { func savedFiltersCreate(ctx context.Context, in *struct {
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` Body models.SavedFilter
Body models.SavedFilter
}) (*singleBody[models.SavedFilter], error) { }) (*singleBody[models.SavedFilter], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := convertToHTML(ctx, &in.Body.Description); err != nil {
return nil, translateDomainError(err)
}
if err := handler.DoCreate(ctx, &in.Body, a); err != nil { if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
convertToMarkdown(ctx, &in.Body.Description)
return &singleBody[models.SavedFilter]{Body: &in.Body}, nil return &singleBody[models.SavedFilter]{Body: &in.Body}, nil
} }
// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates. // Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates.
func savedFiltersUpdate(ctx context.Context, in *struct { func savedFiltersUpdate(ctx context.Context, in *struct {
ID int64 `path:"filter"` ID int64 `path:"filter"`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` Body savedFilterReadBody
Body savedFilterReadBody
}) (*singleBody[models.SavedFilter], error) { }) (*singleBody[models.SavedFilter], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
if err != nil { if err != nil {
@ -125,13 +117,9 @@ func savedFiltersUpdate(ctx context.Context, in *struct {
} }
filter := &in.Body.SavedFilter filter := &in.Body.SavedFilter
filter.ID = in.ID // URL wins over body filter.ID = in.ID // URL wins over body
if err := convertToHTML(ctx, &filter.Description); err != nil {
return nil, translateDomainError(err)
}
if err := handler.DoUpdate(ctx, filter, a); err != nil { if err := handler.DoUpdate(ctx, filter, a); err != nil {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
convertToMarkdown(ctx, &filter.Description)
return &singleBody[models.SavedFilter]{Body: filter}, nil return &singleBody[models.SavedFilter]{Body: filter}, nil
} }

View File

@ -61,7 +61,6 @@ type TaskListQueryParams struct {
SortBy []string `query:"sort_by,explode" doc:"Fields to sort by (e.g. done, priority). Repeatable; pair positionally with order_by."` SortBy []string `query:"sort_by,explode" doc:"Fields to sort by (e.g. done, priority). Repeatable; pair positionally with order_by."`
OrderBy []string `query:"order_by,explode" doc:"Sort order per sort_by field, asc or desc. Repeatable; defaults to asc."` OrderBy []string `query:"order_by,explode" doc:"Sort order per sort_by field, asc or desc. Repeatable; defaults to asc."`
Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra, more expensive data per task. Repeatable."` Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra, more expensive data per task. Repeatable."`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
} }
type taskListAllInput struct { type taskListAllInput struct {
@ -202,7 +201,6 @@ func readFlatTasks(ctx context.Context, f taskListFilters, page, perPage int, pr
if !ok { if !ok {
return nil, fmt.Errorf("taskCollection.ReadAll returned unexpected type %T (expected []*models.Task)", result) return nil, fmt.Errorf("taskCollection.ReadAll returned unexpected type %T (expected []*models.Task)", result)
} }
convertTasksToMarkdown(ctx, tasks...)
return &taskListBody{Body: NewPaginated(tasks, total, page, perPage)}, nil return &taskListBody{Body: NewPaginated(tasks, total, page, perPage)}, nil
} }
@ -230,11 +228,6 @@ func projectViewBucketsTasksList(ctx context.Context, in *taskListViewInput) (*b
} }
return nil, fmt.Errorf("taskCollection.ReadAll returned unexpected type %T (expected []*models.Bucket)", result) return nil, fmt.Errorf("taskCollection.ReadAll returned unexpected type %T (expected []*models.Bucket)", result)
} }
var bucketTasks []*models.Task
for _, bucket := range buckets {
bucketTasks = append(bucketTasks, bucket.Tasks...)
}
convertTasksToMarkdown(ctx, bucketTasks...)
out := &bucketsWithTasksBody{} out := &bucketsWithTasksBody{}
out.Body.Items = buckets out.Body.Items = buckets
out.Body.Total = total out.Body.Total = total

View File

@ -96,7 +96,6 @@ func init() { AddRouteRegistrar(RegisterTaskCommentRoutes) }
func taskCommentsList(ctx context.Context, in *struct { func taskCommentsList(ctx context.Context, in *struct {
TaskID int64 `path:"task"` TaskID int64 `path:"task"`
OrderBy string `query:"order_by" enum:"asc,desc" default:"asc" doc:"Sort order by creation time: 'asc' (oldest first, default) or 'desc' (newest first)."` OrderBy string `query:"order_by" enum:"asc,desc" default:"asc" doc:"Sort order by creation time: 'asc' (oldest first, default) or 'desc' (newest first)."`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
ListParams ListParams
}) (*taskCommentListBody, error) { }) (*taskCommentListBody, error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
@ -111,9 +110,6 @@ func taskCommentsList(ctx context.Context, in *struct {
if !ok { if !ok {
return nil, fmt.Errorf("taskComments.ReadAll returned unexpected type %T (expected []*models.TaskComment)", result) return nil, fmt.Errorf("taskComments.ReadAll returned unexpected type %T (expected []*models.TaskComment)", result)
} }
for _, c := range items {
convertToMarkdown(ctx, &c.Comment)
}
return &taskCommentListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil return &taskCommentListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
} }
@ -125,9 +121,8 @@ type taskCommentReadBody struct {
} }
func taskCommentsRead(ctx context.Context, in *struct { func taskCommentsRead(ctx context.Context, in *struct {
TaskID int64 `path:"task"` TaskID int64 `path:"task"`
ID int64 `path:"commentid"` ID int64 `path:"commentid"`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
conditional.Params conditional.Params
}) (*singleReadBody[taskCommentReadBody], error) { }) (*singleReadBody[taskCommentReadBody], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
@ -142,13 +137,11 @@ func taskCommentsRead(ctx context.Context, in *struct {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
body := &taskCommentReadBody{TaskComment: *comment, MaxPermission: models.Permission(maxPermission)} body := &taskCommentReadBody{TaskComment: *comment, MaxPermission: models.Permission(maxPermission)}
convertToMarkdown(ctx, &body.Comment)
return conditionalReadResponse(&in.Params, body, comment.Updated, maxPermission) return conditionalReadResponse(&in.Params, body, comment.Updated, maxPermission)
} }
func taskCommentsCreate(ctx context.Context, in *struct { func taskCommentsCreate(ctx context.Context, in *struct {
TaskID int64 `path:"task"` TaskID int64 `path:"task"`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
Body models.TaskComment Body models.TaskComment
}) (*singleBody[models.TaskComment], error) { }) (*singleBody[models.TaskComment], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
@ -156,21 +149,16 @@ func taskCommentsCreate(ctx context.Context, in *struct {
return nil, err return nil, err
} }
in.Body.TaskID = in.TaskID // URL wins over body in.Body.TaskID = in.TaskID // URL wins over body
if err := convertToHTML(ctx, &in.Body.Comment); err != nil {
return nil, translateDomainError(err)
}
if err := handler.DoCreate(ctx, &in.Body, a); err != nil { if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
convertToMarkdown(ctx, &in.Body.Comment)
return &singleBody[models.TaskComment]{Body: &in.Body}, nil return &singleBody[models.TaskComment]{Body: &in.Body}, nil
} }
// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates. // Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates.
func taskCommentsUpdate(ctx context.Context, in *struct { func taskCommentsUpdate(ctx context.Context, in *struct {
TaskID int64 `path:"task"` TaskID int64 `path:"task"`
ID int64 `path:"commentid"` ID int64 `path:"commentid"`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
Body taskCommentReadBody Body taskCommentReadBody
}) (*singleBody[models.TaskComment], error) { }) (*singleBody[models.TaskComment], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
@ -180,13 +168,9 @@ func taskCommentsUpdate(ctx context.Context, in *struct {
comment := &in.Body.TaskComment comment := &in.Body.TaskComment
comment.ID = in.ID // URL wins over body comment.ID = in.ID // URL wins over body
comment.TaskID = in.TaskID // parent from the path scopes the update comment.TaskID = in.TaskID // parent from the path scopes the update
if err := convertToHTML(ctx, &comment.Comment); err != nil {
return nil, translateDomainError(err)
}
if err := handler.DoUpdate(ctx, comment, a); err != nil { if err := handler.DoUpdate(ctx, comment, a); err != nil {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
convertToMarkdown(ctx, &comment.Comment)
return &singleBody[models.TaskComment]{Body: comment}, nil return &singleBody[models.TaskComment]{Body: comment}, nil
} }

View File

@ -112,7 +112,6 @@ type taskReadOneBody struct {
func tasksRead(ctx context.Context, in *struct { func tasksRead(ctx context.Context, in *struct {
ID int64 `path:"projecttask" doc:"The numeric id of the task."` ID int64 `path:"projecttask" doc:"The numeric id of the task."`
Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra data per task. Repeatable."` Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra data per task. Repeatable."`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
conditional.Params conditional.Params
}) (*singleReadBody[taskReadOneBody], error) { }) (*singleReadBody[taskReadOneBody], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
@ -129,7 +128,6 @@ func tasksRead(ctx context.Context, in *struct {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
body := &taskReadOneBody{Task: *task, MaxPermission: models.Permission(maxPermission)} body := &taskReadOneBody{Task: *task, MaxPermission: models.Permission(maxPermission)}
convertTasksToMarkdown(ctx, &body.Task)
return conditionalReadResponse(&in.Params, body, task.Updated, maxPermission) return conditionalReadResponse(&in.Params, body, task.Updated, maxPermission)
} }
@ -137,7 +135,6 @@ func tasksReadByIndex(ctx context.Context, in *struct {
Project string `path:"project" doc:"A numeric project id or a textual project identifier (e.g. \"PROJ\")."` Project string `path:"project" doc:"A numeric project id or a textual project identifier (e.g. \"PROJ\")."`
Index int64 `path:"index" doc:"The per-project task index."` Index int64 `path:"index" doc:"The per-project task index."`
Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra data per task. Repeatable."` Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra data per task. Repeatable."`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
conditional.Params conditional.Params
}) (*singleReadBody[taskReadOneBody], error) { }) (*singleReadBody[taskReadOneBody], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
@ -161,13 +158,11 @@ func tasksReadByIndex(ctx context.Context, in *struct {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
body := &taskReadOneBody{Task: *task, MaxPermission: models.Permission(maxPermission)} body := &taskReadOneBody{Task: *task, MaxPermission: models.Permission(maxPermission)}
convertTasksToMarkdown(ctx, &body.Task)
return conditionalReadResponse(&in.Params, body, task.Updated, maxPermission) return conditionalReadResponse(&in.Params, body, task.Updated, maxPermission)
} }
func tasksCreate(ctx context.Context, in *struct { func tasksCreate(ctx context.Context, in *struct {
Project int64 `path:"project" doc:"The numeric id of the project to create the task in."` Project int64 `path:"project" doc:"The numeric id of the project to create the task in."`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
Body models.Task Body models.Task
}) (*singleBody[models.Task], error) { }) (*singleBody[models.Task], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
@ -176,21 +171,16 @@ func tasksCreate(ctx context.Context, in *struct {
} }
task := &in.Body task := &in.Body
task.ProjectID = in.Project // URL wins over body task.ProjectID = in.Project // URL wins over body
if err := convertToHTML(ctx, &task.Description); err != nil {
return nil, translateDomainError(err)
}
if err := handler.DoCreate(ctx, task, a); err != nil { if err := handler.DoCreate(ctx, task, a); err != nil {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
convertTasksToMarkdown(ctx, task)
return &singleBody[models.Task]{Body: task}, nil return &singleBody[models.Task]{Body: task}, nil
} }
// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates. // Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates.
func tasksUpdate(ctx context.Context, in *struct { func tasksUpdate(ctx context.Context, in *struct {
ID int64 `path:"projecttask"` ID int64 `path:"projecttask"`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` Body taskReadOneBody
Body taskReadOneBody
}) (*singleBody[models.Task], error) { }) (*singleBody[models.Task], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
if err != nil { if err != nil {
@ -198,13 +188,9 @@ func tasksUpdate(ctx context.Context, in *struct {
} }
task := &in.Body.Task task := &in.Body.Task
task.ID = in.ID // URL wins over body task.ID = in.ID // URL wins over body
if err := convertToHTML(ctx, &task.Description); err != nil {
return nil, translateDomainError(err)
}
if err := handler.DoUpdate(ctx, task, a); err != nil { if err := handler.DoUpdate(ctx, task, a); err != nil {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
convertTasksToMarkdown(ctx, task)
return &singleBody[models.Task]{Body: task}, nil return &singleBody[models.Task]{Body: task}, nil
} }

View File

@ -90,8 +90,7 @@ func teamsList(ctx context.Context, in *struct {
// IncludePublic mirrors the model's include_public query param; bound // IncludePublic mirrors the model's include_public query param; bound
// onto the model below so ReadAll can honor it (gated by the instance // onto the model below so ReadAll can honor it (gated by the instance
// public-teams setting). // public-teams setting).
IncludePublic bool `query:"include_public" doc:"Also include public teams the user is not a member of. Only honored when public teams are enabled on the instance."` IncludePublic bool `query:"include_public" doc:"Also include public teams the user is not a member of. Only honored when public teams are enabled on the instance."`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
}) (*teamListBody, error) { }) (*teamListBody, error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
if err != nil { if err != nil {
@ -105,9 +104,6 @@ func teamsList(ctx context.Context, in *struct {
if !ok { if !ok {
return nil, fmt.Errorf("teams.ReadAll returned unexpected type %T (expected []*models.Team)", result) return nil, fmt.Errorf("teams.ReadAll returned unexpected type %T (expected []*models.Team)", result)
} }
for _, team := range items {
convertToMarkdown(ctx, &team.Description)
}
return &teamListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil return &teamListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
} }
@ -117,8 +113,7 @@ type teamReadBody struct {
} }
func teamsRead(ctx context.Context, in *struct { func teamsRead(ctx context.Context, in *struct {
ID int64 `path:"id"` ID int64 `path:"id"`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."`
conditional.Params conditional.Params
}) (*singleReadBody[teamReadBody], error) { }) (*singleReadBody[teamReadBody], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
@ -131,33 +126,26 @@ func teamsRead(ctx context.Context, in *struct {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
body := &teamReadBody{Team: *team, MaxPermission: models.Permission(maxPermission)} body := &teamReadBody{Team: *team, MaxPermission: models.Permission(maxPermission)}
convertToMarkdown(ctx, &body.Description)
return conditionalReadResponse(&in.Params, body, team.Updated, maxPermission) return conditionalReadResponse(&in.Params, body, team.Updated, maxPermission)
} }
func teamsCreate(ctx context.Context, in *struct { func teamsCreate(ctx context.Context, in *struct {
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` Body models.Team
Body models.Team
}) (*singleBody[models.Team], error) { }) (*singleBody[models.Team], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := convertToHTML(ctx, &in.Body.Description); err != nil {
return nil, translateDomainError(err)
}
if err := handler.DoCreate(ctx, &in.Body, a); err != nil { if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
convertToMarkdown(ctx, &in.Body.Description)
return &singleBody[models.Team]{Body: &in.Body}, nil return &singleBody[models.Team]{Body: &in.Body}, nil
} }
// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates. // Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates.
func teamsUpdate(ctx context.Context, in *struct { func teamsUpdate(ctx context.Context, in *struct {
ID int64 `path:"id"` ID int64 `path:"id"`
Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` Body teamReadBody
Body teamReadBody
}) (*singleBody[models.Team], error) { }) (*singleBody[models.Team], error) {
a, err := authFromCtx(ctx) a, err := authFromCtx(ctx)
if err != nil { if err != nil {
@ -165,13 +153,9 @@ func teamsUpdate(ctx context.Context, in *struct {
} }
team := &in.Body.Team team := &in.Body.Team
team.ID = in.ID // URL wins over body team.ID = in.ID // URL wins over body
if err := convertToHTML(ctx, &team.Description); err != nil {
return nil, translateDomainError(err)
}
if err := handler.DoUpdate(ctx, team, a); err != nil { if err := handler.DoUpdate(ctx, team, a); err != nil {
return nil, translateDomainError(err) return nil, translateDomainError(err)
} }
convertToMarkdown(ctx, &team.Description)
return &singleBody[models.Team]{Body: team}, nil return &singleBody[models.Team]{Body: team}, nil
} }

View File

@ -1,88 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package caldav
import (
"testing"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApplyDescriptionFromMarkdown(t *testing.T) {
t.Run("unchanged round trip keeps stored html verbatim", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
const stored = `<p>Hello <strong>world</strong></p>`
vTask := &models.Task{Description: "Hello **world**"}
require.NoError(t, applyDescriptionFromMarkdown(s, vTask, stored))
assert.Equal(t, stored, vTask.Description)
})
t.Run("edited markdown is converted to html", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
const stored = `<p>Hello <strong>world</strong></p>`
vTask := &models.Task{Description: "Hello **mars**"}
require.NoError(t, applyDescriptionFromMarkdown(s, vTask, stored))
assert.Equal(t, "<p>Hello <strong>mars</strong></p>", vTask.Description)
})
t.Run("mention is rebuilt from markdown", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
vTask := &models.Task{Description: "ping @user1"}
require.NoError(t, applyDescriptionFromMarkdown(s, vTask, ""))
assert.Contains(t, vTask.Description, `<mention-user data-id="user1"`)
})
t.Run("new task markdown description becomes html", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
vTask := &models.Task{Description: "- [x] done"}
require.NoError(t, applyDescriptionFromMarkdown(s, vTask, ""))
assert.Contains(t, vTask.Description, `data-type="taskList"`)
assert.Contains(t, vTask.Description, `data-checked="true"`)
assert.Contains(t, vTask.Description, "<p>done</p>")
})
t.Run("emptying a description is honoured", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
vTask := &models.Task{Description: ""}
require.NoError(t, applyDescriptionFromMarkdown(s, vTask, "<p>was here</p>"))
assert.Empty(t, vTask.Description)
})
}

View File

@ -28,7 +28,6 @@ import (
"code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/richtext"
user2 "code.vikunja.io/api/pkg/user" user2 "code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/web" "code.vikunja.io/api/pkg/web"
"github.com/samedi/caldav-go/data" "github.com/samedi/caldav-go/data"
@ -365,14 +364,6 @@ func (vcls *VikunjaCaldavProjectStorage) CreateResource(rpath, content string) (
return nil, errs.ForbiddenError return nil, errs.ForbiddenError
} }
// Inbound CalDAV descriptions are markdown; store them as canonical HTML.
if err := applyDescriptionFromMarkdown(s, vTask, ""); err != nil {
log.Errorf("[CALDAV] Failed to convert description in CreateResource: %v", err)
_ = s.Rollback()
events.CleanupPending(s)
return nil, err
}
// Create the task // Create the task
err = vTask.Create(s, vcls.user) err = vTask.Create(s, vcls.user)
if err != nil { if err != nil {
@ -417,23 +408,6 @@ func (vcls *VikunjaCaldavProjectStorage) CreateResource(rpath, content string) (
return &r, nil return &r, nil
} }
// applyDescriptionFromMarkdown converts a task's inbound CalDAV description
// (markdown) to canonical HTML, rebuilding @mentions. Unchanged markdown keeps the
// stored HTML verbatim, so a no-op read-modify-write doesn't churn it or move Updated.
func applyDescriptionFromMarkdown(s *xorm.Session, vTask *models.Task, storedHTML string) error {
if !richtext.Changed(storedHTML, vTask.Description) {
vTask.Description = storedHTML
return nil
}
htmlDesc, err := richtext.MarkdownToHTMLWithMentions(s, vTask.Description)
if err != nil {
return err
}
vTask.Description = htmlDesc
return nil
}
// UpdateResource updates a resource // UpdateResource updates a resource
func (vcls *VikunjaCaldavProjectStorage) UpdateResource(rpath, content string) (*data.Resource, error) { func (vcls *VikunjaCaldavProjectStorage) UpdateResource(rpath, content string) (*data.Resource, error) {
@ -469,14 +443,6 @@ func (vcls *VikunjaCaldavProjectStorage) UpdateResource(rpath, content string) (
return nil, errs.ForbiddenError return nil, errs.ForbiddenError
} }
// Inbound markdown → canonical HTML, kept verbatim when unchanged.
if err := applyDescriptionFromMarkdown(s, vTask, vcls.task.Description); err != nil {
log.Errorf("[CALDAV] Failed to convert description in UpdateResource: %v", err)
_ = s.Rollback()
events.CleanupPending(s)
return nil, err
}
// Update the task // Update the task
err = vTask.Update(s, vcls.user) err = vTask.Update(s, vcls.user)
if err != nil { if err != nil {

View File

@ -1,265 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package webtests
import (
"encoding/json"
"net/http"
"testing"
"code.vikunja.io/api/pkg/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestBucket covers the nested kanban-bucket CRUD on /api/v2. Buckets live under
// /projects/{project}/views/{view}/buckets, so the harness binds the project and
// view in basePath and idParam picks {bucket}.
//
// Permission model — Bucket.Can{Create,Update,Delete} all delegate to
// Project.CanUpdate, which resolves to write access (not admin). Bucket.ReadAll
// only needs the view's read access. So write is the boundary for mutation,
// unlike project views where admin is required.
//
// Fixture topology (see pkg/db/fixtures):
// - project 1 (owned by testuser1), kanban view 4: buckets 1, 2, 3.
// - project 2 (owned by user3, no share to testuser1), kanban view 8:
// buckets 4, 40 — the forbidden / non-member negatives.
// - projects 9/10/11 are owned by user6 and shared to testuser1 read/write/admin;
// their kanban views 36/40/44 carry buckets {9,25}/{10,26}/{11,27}. The same
// user exercises every rung by switching the parent path.
func TestHumaBucket(t *testing.T) {
// project 1 is owned by testuser1.
owned := webHandlerTestV2{
user: &testuser1,
basePath: "/api/v2/projects/1/views/4/buckets",
idParam: "bucket",
t: t,
}
require.NoError(t, owned.ensureEnv())
// project 2 is owned by user3; testuser1 has no access. Share owned's Echo
// instance: each setupTestEnv() regenerates the global JWT signing secret,
// so two independent harnesses would invalidate each other's tokens.
forbidden := webHandlerTestV2{
user: &testuser1,
basePath: "/api/v2/projects/2/views/8/buckets",
idParam: "bucket",
t: t,
e: owned.e,
}
// project 9 is shared to testuser1 read-only — enough to list, below the
// write bar mutation requires.
readShared := webHandlerTestV2{
user: &testuser1,
basePath: "/api/v2/projects/9/views/36/buckets",
idParam: "bucket",
t: t,
e: owned.e,
}
// project 10 is shared with write — the rung that clears Project.CanUpdate,
// so it can create/update/delete buckets.
writeShared := webHandlerTestV2{
user: &testuser1,
basePath: "/api/v2/projects/10/views/40/buckets",
idParam: "bucket",
t: t,
e: owned.e,
}
// project 11 is shared with admin — write access is a subset, so it can do
// everything too.
adminShared := webHandlerTestV2{
user: &testuser1,
basePath: "/api/v2/projects/11/views/44/buckets",
idParam: "bucket",
t: t,
e: owned.e,
}
t.Run("ReadAll", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := owned.testReadAllWithUser(nil, nil)
require.NoError(t, err)
// view 4 has exactly buckets 1, 2, 3 in position order.
ids, viewIDs := bucketsFromReadAll(t, rec.Body.Bytes())
assert.ElementsMatch(t, []int64{1, 2, 3}, ids)
for _, vid := range viewIDs {
assert.Equal(t, int64(4), vid, "every returned bucket must belong to view 4")
}
assert.Contains(t, rec.Body.String(), `"total":3`)
})
t.Run("Read-only share can list", func(t *testing.T) {
// ReadAll only needs the view's read access; a read share suffices.
rec, err := readShared.testReadAllWithUser(nil, nil)
require.NoError(t, err)
ids, _ := bucketsFromReadAll(t, rec.Body.Bytes())
assert.ElementsMatch(t, []int64{9, 25}, ids)
})
t.Run("Write share can list", func(t *testing.T) {
rec, err := writeShared.testReadAllWithUser(nil, nil)
require.NoError(t, err)
ids, _ := bucketsFromReadAll(t, rec.Body.Bytes())
assert.ElementsMatch(t, []int64{10, 26}, ids)
})
t.Run("Forbidden", func(t *testing.T) {
_, err := forbidden.testReadAllWithUser(nil, nil)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
})
t.Run("Create", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := owned.testCreateWithUser(nil, nil, `{"title":"New bucket","limit":5}`)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Contains(t, rec.Body.String(), `"title":"New bucket"`)
assert.Contains(t, rec.Body.String(), `"limit":5`)
// ownership: the view from the URL wins over the body.
assert.Contains(t, rec.Body.String(), `"project_view_id":4`)
})
t.Run("Write share can create", func(t *testing.T) {
// write access clears Project.CanUpdate → Bucket.CanCreate passes.
rec, err := writeShared.testCreateWithUser(nil, nil, `{"title":"Write made"}`)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Contains(t, rec.Body.String(), `"title":"Write made"`)
assert.Contains(t, rec.Body.String(), `"project_view_id":40`)
})
t.Run("Admin share can create", func(t *testing.T) {
rec, err := adminShared.testCreateWithUser(nil, nil, `{"title":"Admin made"}`)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Contains(t, rec.Body.String(), `"title":"Admin made"`)
assert.Contains(t, rec.Body.String(), `"project_view_id":44`)
})
t.Run("Read share cannot create", func(t *testing.T) {
// read share is below the write bar Bucket.CanCreate enforces.
_, err := readShared.testCreateWithUser(nil, nil, `{"title":"Nope"}`)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
t.Run("Forbidden", func(t *testing.T) {
_, err := forbidden.testCreateWithUser(nil, nil, `{"title":"Nope"}`)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
t.Run("Empty title", func(t *testing.T) {
// Title has valid:"required" / minLength:"1" → 422 before the model.
_, err := owned.testCreateWithUser(nil, nil, `{"title":""}`)
require.Error(t, err)
assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err))
})
})
t.Run("Update", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := owned.testUpdateWithUser(nil, map[string]string{"bucket": "1"}, `{"title":"Renamed bucket"}`)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"Renamed bucket"`)
assert.Contains(t, rec.Body.String(), `"id":1`)
// Only the sent fields are written: the server-managed creator and the
// view scoping from the URL are preserved, not clobbered to zero.
db.AssertExists(t, "buckets", map[string]interface{}{
"id": 1,
"title": "Renamed bucket",
"project_view_id": 4,
"created_by_id": 1,
}, false)
})
t.Run("Write share can update", func(t *testing.T) {
// bucket 10 belongs to view 40 (project 10, write share).
rec, err := writeShared.testUpdateWithUser(nil, map[string]string{"bucket": "10"}, `{"title":"Write renamed"}`)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"Write renamed"`)
assert.Contains(t, rec.Body.String(), `"id":10`)
})
t.Run("Read share cannot update", func(t *testing.T) {
// bucket 9 belongs to view 36 (project 9, read share) → needs write.
_, err := readShared.testUpdateWithUser(nil, map[string]string{"bucket": "9"}, `{"title":"x"}`)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
t.Run("Nonexisting", func(t *testing.T) {
_, err := owned.testUpdateWithUser(nil, map[string]string{"bucket": "9999"}, `{"title":"x"}`)
require.Error(t, err)
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
})
t.Run("Forbidden", func(t *testing.T) {
// bucket 4 belongs to view 8 (project 2) — testuser1 has no access.
_, err := forbidden.testUpdateWithUser(nil, map[string]string{"bucket": "4"}, `{"title":"x"}`)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
})
t.Run("Delete", func(t *testing.T) {
t.Run("Read share cannot delete", func(t *testing.T) {
// bucket 25 belongs to view 36 (project 9, read share) → needs write.
_, err := readShared.testDeleteWithUser(nil, map[string]string{"bucket": "25"})
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
t.Run("Write share can delete", func(t *testing.T) {
// bucket 26 belongs to view 40 (project 10, write share); view 40 still
// has bucket 10 (plus the one created above), so it isn't the last.
rec, err := writeShared.testDeleteWithUser(nil, map[string]string{"bucket": "26"})
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, rec.Code)
assert.Empty(t, rec.Body.String())
})
t.Run("Forbidden", func(t *testing.T) {
_, err := forbidden.testDeleteWithUser(nil, map[string]string{"bucket": "40"})
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
t.Run("Normal", func(t *testing.T) {
// view 4 has buckets 1, 2, 3 (plus the one created above), so deleting
// bucket 2 leaves more than one behind.
rec, err := owned.testDeleteWithUser(nil, map[string]string{"bucket": "2"})
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, rec.Code)
assert.Empty(t, rec.Body.String())
db.AssertMissing(t, "buckets", map[string]interface{}{"id": 2})
})
t.Run("Nonexisting", func(t *testing.T) {
_, err := owned.testDeleteWithUser(nil, map[string]string{"bucket": "9999"})
require.Error(t, err)
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
})
})
}
// bucketsFromReadAll extracts the bucket ids and their project_view_ids from a v2
// paginated list body so the visible set can be asserted exactly.
func bucketsFromReadAll(t *testing.T, body []byte) (ids []int64, viewIDs []int64) {
t.Helper()
var resp struct {
Items []struct {
ID int64 `json:"id"`
ProjectViewID int64 `json:"project_view_id"`
} `json:"items"`
}
require.NoError(t, json.Unmarshal(body, &resp), "ReadAll body must be a paginated envelope: %s", string(body))
ids = make([]int64, 0, len(resp.Items))
viewIDs = make([]int64, 0, len(resp.Items))
for _, it := range resp.Items {
ids = append(ids, it.ID)
viewIDs = append(viewIDs, it.ProjectViewID)
}
return ids, viewIDs
}

View File

@ -1,335 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package webtests
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func mustJSON(s string) string {
b, err := json.Marshal(s)
if err != nil {
panic(err)
}
return string(b)
}
func decodeLabel(t *testing.T, raw []byte) (id int64, description string) {
t.Helper()
var l struct {
ID int64 `json:"id"`
Description string `json:"description"`
}
require.NoError(t, json.Unmarshal(raw, &l))
return l.ID, l.Description
}
func TestHumaRichText_FormatDocumented(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
rec := humaRequest(t, e, http.MethodGet, "/api/v2/openapi.json", "", "", "")
require.Equal(t, http.StatusOK, rec.Code)
type param struct {
Name string `json:"name"`
In string `json:"in"`
}
var spec struct {
Info struct {
Description string `json:"description"`
} `json:"info"`
Paths map[string]map[string]struct {
Parameters []param `json:"parameters"`
} `json:"paths"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec))
hasParam := func(path, method, name, in string) bool {
op, ok := spec.Paths[path][method]
if !ok {
return false
}
for _, p := range op.Parameters {
if p.Name == name && p.In == in {
return true
}
}
return false
}
// Query param on the ops where it works (GET/POST/PUT), per entity.
assert.True(t, hasParam("/labels/{id}", "get", "format", "query"), "labels read must document ?format")
assert.True(t, hasParam("/labels", "post", "format", "query"), "labels create must document ?format")
assert.True(t, hasParam("/tasks/{projecttask}", "put", "format", "query"), "tasks update must document ?format")
// PATCH must NOT advertise ?format — AutoPatch strips the query at runtime, so
// it would be a trap (markdown stored as HTML). Stripped by stripPatchFormatQuery.
assert.False(t, hasParam("/labels/{id}", "patch", "format", "query"), "PATCH must not advertise ?format")
// The X-Vikunja-Format header is documented centrally, not as a per-op param.
assert.False(t, hasParam("/labels/{id}", "get", "X-Vikunja-Format", "header"))
assert.False(t, hasParam("/labels/{id}", "patch", "X-Vikunja-Format", "header"))
// Non-rich-text ops carry no format param.
assert.False(t, hasParam("/tasks/{task}/comments/{commentid}", "delete", "format", "query"))
// The cross-cutting behavior, including the PATCH header, is in the API description.
assert.Contains(t, spec.Info.Description, "Rich-text fields")
assert.Contains(t, spec.Info.Description, "CalDAV always exchanges")
assert.Contains(t, spec.Info.Description, "X-Vikunja-Format")
}
func TestHumaRichText_Read(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
// Store a label with HTML directly (no format → verbatim).
rec := humaRequest(t, e, http.MethodPost, "/api/v2/labels",
`{"title":"rt","description":"<p>Hello <strong>world</strong></p>","hex_color":"112233"}`, token, "")
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
id, _ := decodeLabel(t, rec.Body.Bytes())
t.Run("read as markdown converts html", func(t *testing.T) {
rec := humaRequest(t, e, http.MethodGet, fmt.Sprintf("/api/v2/labels/%d?format=markdown", id), "", token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
_, desc := decodeLabel(t, rec.Body.Bytes())
assert.Equal(t, "Hello **world**", desc)
})
t.Run("read without param keeps html", func(t *testing.T) {
rec := humaRequest(t, e, http.MethodGet, fmt.Sprintf("/api/v2/labels/%d", id), "", token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
_, desc := decodeLabel(t, rec.Body.Bytes())
assert.Equal(t, "<p>Hello <strong>world</strong></p>", desc)
})
t.Run("list converts every item", func(t *testing.T) {
rec := humaRequest(t, e, http.MethodGet, "/api/v2/labels?format=markdown", "", token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
// The freshly created label's HTML must not appear; its markdown must.
assert.NotContains(t, rec.Body.String(), "<strong>world</strong>")
assert.Contains(t, rec.Body.String(), "Hello **world**")
})
}
func decodeField(t *testing.T, raw []byte, field string) (id int64, value string) {
t.Helper()
var m map[string]json.RawMessage
require.NoError(t, json.Unmarshal(raw, &m))
if v, ok := m["id"]; ok {
_ = json.Unmarshal(v, &id)
}
if v, ok := m[field]; ok {
_ = json.Unmarshal(v, &value)
}
return id, value
}
// TestHumaRichText_EveryEntity drives every rich-text entity through the real v2
// API: each is created with a markdown body and read back as both HTML and
// markdown. A handler that stops converting fails its row here.
func TestHumaRichText_EveryEntity(t *testing.T) {
const md = "a **bold** note"
const html = "<p>a <strong>bold</strong> note</p>"
entities := []struct {
name string
createPath string
createBody string
readPath string // fmt verb %d for the created id
field string
}{
{"label", "/api/v2/labels", `{"title":"e-label","description":"a **bold** note"}`, "/api/v2/labels/%d", "description"},
{"project", "/api/v2/projects", `{"title":"e-project","description":"a **bold** note"}`, "/api/v2/projects/%d", "description"},
{"team", "/api/v2/teams", `{"name":"e-team","description":"a **bold** note"}`, "/api/v2/teams/%d", "description"},
{"saved filter", "/api/v2/filters", `{"title":"e-filter","description":"a **bold** note","filters":{"filter":"done = true"}}`, "/api/v2/filters/%d", "description"},
{"task", "/api/v2/projects/1/tasks", `{"title":"e-task","description":"a **bold** note"}`, "/api/v2/tasks/%d", "description"},
{"task comment", "/api/v2/tasks/1/comments", `{"comment":"a **bold** note"}`, "/api/v2/tasks/1/comments/%d", "comment"},
}
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
for _, ent := range entities {
t.Run(ent.name, func(t *testing.T) {
// Markdown body converted to HTML on create.
rec := humaRequest(t, e, http.MethodPost, ent.createPath+"?format=markdown", ent.createBody, token, "")
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
id, _ := decodeField(t, rec.Body.Bytes(), ent.field)
require.NotZero(t, id)
// Stored as canonical HTML (default read).
rec = humaRequest(t, e, http.MethodGet, fmt.Sprintf(ent.readPath, id), "", token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
_, stored := decodeField(t, rec.Body.Bytes(), ent.field)
assert.Equal(t, html, stored, "%s write seam did not convert markdown to HTML", ent.name)
// Read back as markdown.
rec = humaRequest(t, e, http.MethodGet, fmt.Sprintf(ent.readPath, id)+"?format=markdown", "", token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
_, asMarkdown := decodeField(t, rec.Body.Bytes(), ent.field)
assert.Equal(t, md, asMarkdown, "%s read transformer did not convert HTML to markdown", ent.name)
})
}
}
// TestHumaRichText_KanbanNested proves the read conversion reaches tasks nested
// inside kanban buckets (Body.Items[].Tasks[].Description), which the explicit
// handler converts by looping the buckets.
func TestHumaRichText_KanbanNested(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
// Store a task with HTML directly (no format → verbatim) in project 1.
rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/tasks",
`{"title":"kanban task","description":"<p>kanban <strong>md</strong></p>"}`, token, "")
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
// View 4 is project 1's kanban view; its buckets/tasks response nests tasks.
rec = humaRequest(t, e, http.MethodGet, "/api/v2/projects/1/views/4/buckets/tasks?format=markdown", "", token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Contains(t, rec.Body.String(), "kanban **md**", "nested task description must be converted to markdown")
assert.NotContains(t, rec.Body.String(), "<strong>md</strong>", "no HTML should leak from a nested task")
}
// TestHumaRichText_TaskExpandedNested proves expanded comments and related tasks
// are converted too, not just the top-level task description.
func TestHumaRichText_TaskExpandedNested(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
// A comment with HTML on task 1.
rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/comments",
`{"comment":"<p>a <strong>bold</strong> comment</p>"}`, token, "")
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
// A subtask (related task) with an HTML description.
rec = humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/tasks",
`{"title":"sub","description":"<p>sub <strong>desc</strong></p>"}`, token, "")
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
subID, _ := decodeField(t, rec.Body.Bytes(), "title")
rec = humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations",
fmt.Sprintf(`{"other_task_id":%d,"relation_kind":"subtask"}`, subID), token, "")
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
rec = humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1?expand=comments&expand=subtasks&format=markdown", "", token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
body := rec.Body.String()
assert.Contains(t, body, "a **bold** comment", "expanded comment must be markdown")
assert.Contains(t, body, "sub **desc**", "related task description must be markdown")
assert.NotContains(t, body, "<strong>", "no nested HTML should leak")
}
func TestHumaRichText_Write(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
t.Run("markdown write is stored as html", func(t *testing.T) {
rec := humaRequest(t, e, http.MethodPost, "/api/v2/labels?format=markdown",
`{"title":"w1","description":"Hello **world**","hex_color":"112233"}`, token, "")
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
id, _ := decodeLabel(t, rec.Body.Bytes())
// Read back without format → canonical HTML.
rec = humaRequest(t, e, http.MethodGet, fmt.Sprintf("/api/v2/labels/%d", id), "", token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
_, desc := decodeLabel(t, rec.Body.Bytes())
assert.Equal(t, "<p>Hello <strong>world</strong></p>", desc)
})
t.Run("default write stores body verbatim", func(t *testing.T) {
rec := humaRequest(t, e, http.MethodPost, "/api/v2/labels",
`{"title":"w2","description":"Hello **world**","hex_color":"112233"}`, token, "")
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
id, _ := decodeLabel(t, rec.Body.Bytes())
rec = humaRequest(t, e, http.MethodGet, fmt.Sprintf("/api/v2/labels/%d", id), "", token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
_, desc := decodeLabel(t, rec.Body.Bytes())
assert.Equal(t, "Hello **world**", desc, "without the param the body is stored unconverted")
})
t.Run("mention is rebuilt on markdown write", func(t *testing.T) {
rec := humaRequest(t, e, http.MethodPost, "/api/v2/labels?format=markdown",
`{"title":"w3","description":"ping @user1","hex_color":"112233"}`, token, "")
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
id, _ := decodeLabel(t, rec.Body.Bytes())
rec = humaRequest(t, e, http.MethodGet, fmt.Sprintf("/api/v2/labels/%d", id), "", token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
_, desc := decodeLabel(t, rec.Body.Bytes())
assert.Contains(t, desc, `<mention-user data-id="user1"`)
})
t.Run("markdown round trip is stable", func(t *testing.T) {
rec := humaRequest(t, e, http.MethodPost, "/api/v2/labels?format=markdown",
`{"title":"w4","description":"- [x] done\n- [ ] todo","hex_color":"112233"}`, token, "")
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
id, _ := decodeLabel(t, rec.Body.Bytes())
// GET as markdown → PUT it back as markdown → GET as markdown must match.
rec = humaRequest(t, e, http.MethodGet, fmt.Sprintf("/api/v2/labels/%d?format=markdown", id), "", token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
_, md1 := decodeLabel(t, rec.Body.Bytes())
put := fmt.Sprintf(`{"title":"w4","description":%s,"hex_color":"112233"}`, mustJSON(md1))
rec = humaRequest(t, e, http.MethodPut, fmt.Sprintf("/api/v2/labels/%d?format=markdown", id), put, token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
rec = humaRequest(t, e, http.MethodGet, fmt.Sprintf("/api/v2/labels/%d?format=markdown", id), "", token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
_, md2 := decodeLabel(t, rec.Body.Bytes())
assert.Equal(t, md1, md2, "markdown projection must be stable across a round trip")
})
t.Run("patch honours markdown via header", func(t *testing.T) {
rec := humaRequest(t, e, http.MethodPost, "/api/v2/labels",
`{"title":"w5","description":"<p>old</p>","hex_color":"112233"}`, token, "")
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
id, _ := decodeLabel(t, rec.Body.Bytes())
// AutoPatch strips the query string but forwards headers, so PATCH markdown
// support rides on X-Vikunja-Format.
req := httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/v2/labels/%d", id),
strings.NewReader(`{"description":"new **bold**"}`))
req.Header.Set("Content-Type", "application/merge-patch+json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Vikunja-Format", "markdown")
rec = httptest.NewRecorder()
e.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
rec = humaRequest(t, e, http.MethodGet, fmt.Sprintf("/api/v2/labels/%d", id), "", token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
_, desc := decodeLabel(t, rec.Body.Bytes())
assert.Equal(t, "<p>new <strong>bold</strong></p>", desc)
})
}

View File

@ -16,7 +16,6 @@ func init() {
"BucketConfigurationModeNone": reflect.ValueOf(models.BucketConfigurationModeNone), "BucketConfigurationModeNone": reflect.ValueOf(models.BucketConfigurationModeNone),
"CanDoAPIRoute": reflect.ValueOf(models.CanDoAPIRoute), "CanDoAPIRoute": reflect.ValueOf(models.CanDoAPIRoute),
"CollectRoutesForAPITokenUsage": reflect.ValueOf(models.CollectRoutesForAPITokenUsage), "CollectRoutesForAPITokenUsage": reflect.ValueOf(models.CollectRoutesForAPITokenUsage),
"CreateDefaultSavedFiltersForUser": reflect.ValueOf(models.CreateDefaultSavedFiltersForUser),
"CreateDefaultViewsForProject": reflect.ValueOf(models.CreateDefaultViewsForProject), "CreateDefaultViewsForProject": reflect.ValueOf(models.CreateDefaultViewsForProject),
"CreateNewProjectForUser": reflect.ValueOf(models.CreateNewProjectForUser), "CreateNewProjectForUser": reflect.ValueOf(models.CreateNewProjectForUser),
"CreateProject": reflect.ValueOf(models.CreateProject), "CreateProject": reflect.ValueOf(models.CreateProject),

View File

@ -46,36 +46,9 @@ this file is veans-specific.
## Vikunja wire-format gotchas ## Vikunja wire-format gotchas
veans targets the Huma-backed **`/api/v2`** exclusively (`apiBasePath` in Most failures surface when crossing the JSON boundary. The list below is
`internal/client/client.go`). v1 is frozen, and the kanban-bucket CRUD veans what's bitten me; if a new endpoint behaves oddly, suspect one of these:
relies on only exists on v2. Most failures surface when crossing the JSON
boundary. The list below is what's bitten me; if a new endpoint behaves
oddly, suspect one of these:
- **Lists come wrapped in the standard envelope.** Every v2 list returns
`{"items":[...],"total":N,"page":N,"per_page":N,"total_pages":N}`, not a
bare array, and there is no `x-pagination-total-pages` header anymore.
Decode with the generic `Paginated[T]` helper. **Most lists are
server-paginated** — their model's `ReadAll` applies a 50-item page limit:
tasks, projects, labels, comments and bots. Page through those with
`doListAll` until `page >= total_pages`; returning only page 1 silently
truncates (>50 comments on a task is realistic). **Buckets and project
views are the exception**: their `ReadAll` takes `_ int, _ int` and returns
every row in one page, so fetch them with a single `doList` and unwrap
`.items` — paging those would re-fetch the full set and duplicate it.
Single-object responses (create/update/read of one entity) stay UNWRAPPED.
- **v2 flips the create/update verbs.** Creates are **POST** (v1 used PUT):
projects, labels, tokens, bot users, project shares, task create,
comments, relations, assignees, label-attach, bucket create. Task update
is **PATCH** (see below). The bucket-task move is **PUT**.
- **Task update is `PATCH /tasks/{id}` with `application/merge-patch+json`**
(`client.DoMerge` → `UpdateTask(*TaskPatch)`). Only the fields present in
the body are written; absent fields are left intact. Build the body from
`TaskPatch` (pointer fields, omitempty) — never a whole `client.Task`,
whose no-omitempty `done`/`title` would clobber those columns on every
call (this was issue #2962).
- **List search is `q`**, not v1's `s` (`ListParams.Q`). Task-list
`filter`/`expand`/`page`/`per_page` keep their names.
- **`ProjectView.view_kind` and `bucket_configuration_mode` are - **`ProjectView.view_kind` and `bucket_configuration_mode` are
strings**, not ints. The parent enums (`ProjectViewKind`, strings**, not ints. The parent enums (`ProjectViewKind`,
`BucketConfigurationModeKind`) have custom `MarshalJSON` that emits `BucketConfigurationModeKind`) have custom `MarshalJSON` that emits
@ -85,12 +58,11 @@ oddly, suspect one of these:
`xorm:"-"` on it — the actual bucket lives in a separate `xorm:"-"` on it — the actual bucket lives in a separate
`task_buckets` table. Fetch with `?expand=buckets` and use `task_buckets` table. Fetch with `?expand=buckets` and use
`task.CurrentBucketID(viewID)` to read it. `task.CurrentBucketID(viewID)` to read it.
- **Task updates do NOT move tasks between buckets.** The task↔bucket - **`POST /tasks/{id}` does NOT move tasks between buckets.** The
relation is row-shaped; use `client.MoveTaskToBucket()` which hits task↔bucket relation is row-shaped; use `client.MoveTaskToBucket()`
**`PUT /projects/{p}/views/{v}/buckets/{b}/tasks`** with a `{"task_id":N}` which hits `POST /projects/{p}/views/{v}/buckets/{b}/tasks`. The
body (project/view/bucket all come from the URL). The Update path on the Update path on the server only auto-moves on `done` flips.
server only auto-moves on `done` flips. - **Bot user creation is `PUT /user/bots`**, not `/bots` — the routes
- **Bot user creation is `POST /user/bots`**, not `/bots` — the routes
are registered under the `/user` subgroup. Same prefix for are registered under the `/user` subgroup. Same prefix for
`GET /user/bots`. `GET /user/bots`.
- **`APIToken.expires_at` is required.** The struct field has - **`APIToken.expires_at` is required.** The struct field has
@ -116,16 +88,6 @@ oddly, suspect one of these:
- `/projects/:project/views/:view/buckets/:bucket/tasks` - `/projects/:project/views/:view/buckets/:bucket/tasks`
group `projects`, action `views_buckets_tasks` group `projects`, action `views_buckets_tasks`
- `/tasks/:task/comments` → group `tasks_comments`, action `create` - `/tasks/:task/comments` → group `tasks_comments`, action `create`
- v1 and v2 deliberately share `(group, permission)` keys:
`pkg/models/api_routes.go` normalizes the inverted verbs (v2 POST-create
and v1 PUT-create both → `create`; v2 PUT/PATCH-update and v1 POST-update
both → `update`), and `CanDoAPIRoute` consults both route tables, treating
PATCH as an alias for the stored PUT. So `PermissionsForBot`'s scope map
authorizes the v2 calls unchanged, including the PATCH task update.
- The bucket-task MOVE (`PUT …/buckets/:bucket/tasks`) and the
buckets-with-tasks LIST (`GET …/buckets/tasks`) collide on subkey
`views_buckets_tasks`; which one gets the bare key vs `views_buckets_tasks_put`
depends on unspecified route-init order, so the bot requests **both**.
- `client.PermissionsForBot()` calls `GET /routes` at runtime and - `client.PermissionsForBot()` calls `GET /routes` at runtime and
grants only the intersection of what we want and what the server grants only the intersection of what we want and what the server
exposes. **Don't hard-code permission group names** — they drift exposes. **Don't hard-code permission group names** — they drift
@ -134,9 +96,9 @@ oddly, suspect one of these:
## Bot ownership and token minting ## Bot ownership and token minting
- Creating a bot via `POST /user/bots` automatically sets the bot's - Creating a bot via `PUT /user/bots` automatically sets the bot's
`bot_owner_id` to the calling user. Only the owner can mint tokens `bot_owner_id` to the calling user. Only the owner can mint tokens
for the bot via `POST /tokens` with `owner_id=<bot_id>`. The init for the bot via `PUT /tokens` with `owner_id=<bot_id>`. The init
flow does these as a single human-JWT-authenticated batch. flow does these as a single human-JWT-authenticated batch.
- Bots have no password and **cannot** authenticate via `POST /login`. - Bots have no password and **cannot** authenticate via `POST /login`.
After init, `veans login` re-authenticates as the human (not the After init, `veans login` re-authenticates as the human (not the
@ -153,11 +115,9 @@ oddly, suspect one of these:
browser, and captures the callback. The `Shutdown` defer uses browser, and captures the callback. The `Shutdown` defer uses
`context.WithoutCancel(ctx)` so cancellation at the outer scope `context.WithoutCancel(ctx)` so cancellation at the outer scope
still drains the loopback server cleanly. still drains the loopback server cleanly.
- Token exchange goes out as **JSON**. v2's `/oauth/token` accepts both JSON - Token exchange is **JSON only**. Form-encoded POSTs to `/oauth/token`
and form-encoded bodies (Huma picks the decoder off the `Content-Type` fail; the standard `golang.org/x/oauth2` client speaks form encoding,
header), but the standard `golang.org/x/oauth2` client hard-codes form which is why we have a hand-rolled `client.ExchangeOAuthCode`.
encoding and its own response shape, so we keep the hand-rolled
`client.ExchangeOAuthCode` that speaks JSON.
## Credential store ## Credential store

View File

@ -102,14 +102,11 @@ func TestInit_HappyPath(t *testing.T) {
t.Fatalf("bot %q not found on server", ws.BotUsername) t.Fatalf("bot %q not found on server", ws.BotUsername)
} }
// Project shared with the bot at write permission. v2 lists come wrapped // Project shared with the bot at write permission.
// in the standard {items,...} envelope. var shares []map[string]any
var shares struct {
Items []map[string]any `json:"items"`
}
_ = h.AdminClient.Do(t.Context(), "GET", fmt.Sprintf("/projects/%d/users", project.ID), nil, nil, &shares) _ = h.AdminClient.Do(t.Context(), "GET", fmt.Sprintf("/projects/%d/users", project.ID), nil, nil, &shares)
shareFound := false shareFound := false
for _, s := range shares.Items { for _, s := range shares {
if u, _ := s["username"].(string); u == ws.BotUsername { if u, _ := s["username"].(string); u == ws.BotUsername {
if p, _ := s["permission"].(float64); int(p) >= 1 { if p, _ := s["permission"].(float64); int(p) >= 1 {
shareFound = true shareFound = true

View File

@ -1,6 +1,6 @@
module code.vikunja.io/veans module code.vikunja.io/veans
go 1.26.4 go 1.25.0
require ( require (
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
@ -8,11 +8,11 @@ require (
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b
github.com/magefile/mage v1.17.2 github.com/magefile/mage v1.17.2
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/sahilm/fuzzy v0.1.3 github.com/sahilm/fuzzy v0.1.2
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8 github.com/zalando/go-keyring v0.2.8
golang.org/x/sys v0.46.0 golang.org/x/sys v0.43.0
golang.org/x/term v0.44.0 golang.org/x/term v0.42.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )

View File

@ -53,8 +53,6 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sahilm/fuzzy v0.1.2 h1:kdSkz23lx1meNjEl+SLJULeSbjTI4Dn14K/YxdGrIww= github.com/sahilm/fuzzy v0.1.2 h1:kdSkz23lx1meNjEl+SLJULeSbjTI4Dn14K/YxdGrIww=
github.com/sahilm/fuzzy v0.1.2/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= github.com/sahilm/fuzzy v0.1.2/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=
github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU=
github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
@ -75,12 +73,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

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